先日の記事にあった、約半年間での Next.js のプロジェクト 4 本は、合計 100P ほどだったんです。
このプロジェクトは完全納品型で、納品後は先方が触るので、たぶん先方は楽に編集できるかなと思って、変更を想定できる場所については YAML 管理にしたんです。
YAML はコメントも書けるし、カンマ忘れとかも無いし、改行もそのままできるので、好きで使っています。
Metadata も YAML 管理にしており、例えば description を「書き方を変えたい」とかで一斉に編集する際にも、ひとつの Metadata 内で完結できるようにしてみました。
納品後に言及されていないので、便利だったかどうかは不明ですが...ぼくが構築するときには便利かなと思いました。
このプロジェクトは、ぼくにとって初めて Next.js で構築したので、一般的なやり方とは違うかもしれません。ご了承ください。
なお、こちらは SSG ビルドによる納品です。
型定義
共通定義や、後ほど出てくる画像格納用のためなどの定義です。
export type ImageData = {
src: StaticImageData;
alt: string;
ogImage?: boolean;
};
export type ImageDataList = DeepGuard<Record<string, ImageData>>;
export type ImageDataOmittedOg = Omit<ImageData, 'ogImage'>;
export type ImageDataOmittedOgList = DeepGuard<Record<string, ImageDataOmittedOg>>;
export type LinkItem = {
href: string;
text: string;
target?: string;
};
export type DeepGuard<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepGuard<T[K]>
: T[K];
};
リンク定義
リンクパスやリンクのラベルになり得るページタイトルは、Metadata よりも強固な関係なので、別途 TypeScript ファイルとして定義しました。
import * as v from "valibot";
import type { DeepGuard, LinkItem } from "@/types/types";
export const LINKS: DeepGuard<Record<string, LinkItem>> = {
TOP: {
href: '/',
text: 'サンプル株式会社',
},
COMPANY: {
href: '/company/',
text: '企業情報',
},
PRIVACY: {
href: '/privacy/',
text: '個人情報の取扱い',
},
_YOUTUBE: {
href: 'https://www.youtube.com/***,
text: 'サンプル株式会社 YouTube チャンネル',
target: '_blank',
},
};
export type LinkKeys = keyof typeof LINKS;
export const LinkKeysList = v.picklist(Object.keys(LINKS) as [LinkKeys, ...LinkKeys[]]);
description や keywords は、個別に設定が無ければ、TOP の内容を自動的に継承します。
キーの部分はリンク定義でのキーと一致させます。
TOP:
english: Top
description: TOP の description の内容です。
keywords: TOP の keywords の内容です。
COMPANY:
english: Company Profile
keywords: ページ固有の keywords の内容です。
PRIVACY:
english: Privacy Policy
description: ページ固有の description の内容です。
ogImage を指定できる画像格納用
ページ内で使用する画像の定義の中に、ogImage の設定をできるようにしました。
YAML に項目を設けてファイル名を入れることもできましたが、「ogImage であっても、画像は画像」としてページ用の画像格納ファイルに入れる方がいいかなと思ったのです。
import { LINKS } from "@/lib/links";
import type { ImageDataList } from '@/types/types';
import picKv from "@/assets/images/company/message/kv.webp";
import president from "@/assets/images/company/message/president.webp";
export const images: ImageDataList = {
picKv: { src: picKv, alt: LINKS.COMPANY_MESSAGE.text, ogImage: true },
president: { src: president, alt: 'サンプル代表取締役社長' },
}
各ページやディレクトリ用の設定ファイル
上記の画像格納設定に加え、title に入れるディレクトリ共通のタイトル(「ページタイトル | ディレクトリ共通のタイトル | サイトタイトル」みたいな感じ)用のリンク設定のキーを含めます。
import type { LinkKeys } from "@/lib/links";
export const rootLinkKey: LinkKeys = 'COMPANY';
export { images } from "./images";
YAML 読み込み用
読み込む際に Valibot での検査を注入し、型要塞にして返しています。
import fs from "fs";
import path from "path";
import * as v from "valibot";
import yaml from "js-yaml";
import type { DeepGuard } from "@/types/types";
export const loadYaml = <T>(Schema: v.GenericSchema<T>, yamlPath: string): DeepGuard<T> => {
const filePath = path.join(process.cwd(), 'src', `${yamlPath}.yaml`);
const fileContent = fs.readFileSync(filePath, 'utf8');
const rawData = yaml.load(fileContent);
return v.parse(Schema, rawData);
};
各ページから buildMeta() を使って Metadata を整形し、YAML にあるデータを保険としながら、各ページで渡される個別の内容(記事のレスポンスに着いてきた内容など YAML 管理外など)を元に生成し、Metadata として返しています。
import { cache } from "react";
import * as v from "valibot";
import { LinkKeysList, LINKS } from "./links";
import { loadYaml } from "./yaml";
import { images as topImages } from "@/app/_config";
import type { LinkItem, ImageDataList, DeepGuard } from "@/types/types";
import type { LinkKeys } from "./links";
import type { Metadata } from "next";
import type { OpenGraph } from "next/dist/lib/metadata/types/opengraph-types";
const MetaItemSchema = v.object({
description: v.optional(v.string()),
keywords: v.optional(v.string()),
english: v.optional(v.string()),
});
export type SiteMeta = v.InferOutput<typeof MetaItemSchema>;
const MetaSchema = v.record(
LinkKeysList,
MetaItemSchema
);
export type SiteMetaData = v.InferOutput<typeof MetaSchema>;
type LinksWithMeta = Record<LinkKeys, LinkItem & SiteMeta>;
export const getLinksWithMeta = cache((): DeepGuard<LinksWithMeta> => {
const meta = loadYaml<SiteMetaData>(MetaSchema, 'Metadata の YAML の場所');
const metaData: LinksWithMeta = {};
Object.entries(LINKS).forEach(([key, value]) => {
metaData[key] = {
...value,
...meta[key],
};
});
return metaData;
});
const favicon = '/images/favicon.ico';
type TitleItem = string | LinkKeys;
type Props = {
linkKey?: LinkKeys;
titles?: TitleItem[];
description?: string;
keywords?: string;
images?: ImageDataList | string;
};
export const buildMeta = (props: Props): Metadata => {
const {
linkKey,
titles,
description,
keywords,
images,
} = props;
const link = linkKey ? LINKS[linkKey] : null;
const pageTitles: string[] = link ? [link.text] : [];
const meta = getLinksWithMeta();
const metaLink = link && linkKey && meta[linkKey] ? meta[linkKey] : null;
titles?.forEach((text) => {
if (typeof text === 'string' && LINKS[text]) {
pageTitles.push(LINKS[text].text);
} else {
pageTitles.push(text);
}
});
if (linkKey !== 'TOP') pageTitles.push(LINKS.TOP?.text);
const separator = ' | ';
const pageTitle = pageTitles.join(separator);
let pageDescription: string = meta.TOP?.description ?? '';
if (description) {
pageDescription = description;
} else if (metaLink?.description) {
pageDescription = metaLink.description;
}
let pageKeywords: string = meta.TOP?.keywords ?? '';
if (keywords) {
pageKeywords = keywords;
} else if (metaLink?.keywords) {
pageKeywords = metaLink.keywords;
}
let ogImage: string = '';
if (typeof images === 'string') {
ogImage = images;
} else if (images) {
const ogps = Object.values(images).filter((img) => img.ogImage === true);
if (ogps.length) ogImage = ogps[0].src.src;
}
if (!ogImage) {
const ogps = Object.values(topImages).filter((img) => img.ogImage === true);
if (ogps.length) ogImage = ogps[0].src.src;
}
const SocialMetaData: OpenGraph = {
title: pageTitle,
description: pageDescription,
};
if (ogImage) SocialMetaData.images = ogImage;
const data: Metadata = {
title: pageTitle,
description: pageDescription,
keywords: pageKeywords,
openGraph: SocialMetaData,
twitter: {
...SocialMetaData,
card: 'summary_large_image',
},
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'),
formatDetection: { telephone: false, address: false, email: false },
icons: {
icon: favicon,
shortcut: favicon,
apple: favicon,
},
};
return data;
}
通常は単純に内容を渡せば Metadata が吐き出されます。
import { buildMeta } from "@/lib/meta";
import { images, rootLinkKey } from "./_config";
export const metadata = buildMeta({ linkKey: rootLinkKey, images });
News ページなどでヘッドレス CMS を使っているなど、レスポンス由来の Metadata を入れる場合です。
export async function generateMetadata(props: PageProps<"/news/[id]">) {
const { id } = await props.params;
const news = await getNewsDetail(id);
if (!news?.data) return {};
return buildMeta({ titles: [news.data.subject, rootLinkKey], description: news.data.description, images });
}
この方法はあくまでも一例であるとご理解いただければ幸甚です。