diff --git a/frontend/public/og-image.png b/frontend/public/og-image.png
new file mode 100644
index 0000000..8d1270c
Binary files /dev/null and b/frontend/public/og-image.png differ
diff --git a/frontend/src/plugins/vite-seo.ts b/frontend/src/plugins/vite-seo.ts
index c9eef1a..16166d9 100644
--- a/frontend/src/plugins/vite-seo.ts
+++ b/frontend/src/plugins/vite-seo.ts
@@ -48,6 +48,52 @@ function buildTarget(): BuildTarget {
return 'public-website';
}
+function buildJsonLd(meta: RouteMeta, routePath: string, site: SiteId): string {
+ const defaults = SITE_DEFAULTS[site];
+ const url = `${defaults.baseUrl}${routePath === '/' ? '' : routePath}`;
+ const siteName = defaults.siteName;
+
+ const organization = {
+ '@type': 'Organization',
+ name: siteName,
+ url: defaults.baseUrl,
+ logo: {
+ '@type': 'ImageObject',
+ url: `${defaults.baseUrl}/og-image.png`,
+ },
+ };
+
+ const graph: object[] = routePath === '/'
+ ? [{
+ '@type': 'WebSite',
+ name: siteName,
+ url: defaults.baseUrl,
+ description: meta.description,
+ publisher: organization,
+ }]
+ : [
+ {
+ '@type': 'WebPage',
+ name: meta.title,
+ url,
+ description: meta.description,
+ isPartOf: { '@type': 'WebSite', name: siteName, url: defaults.baseUrl },
+ publisher: organization,
+ },
+ {
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: 'Home', item: defaults.baseUrl },
+ { '@type': 'ListItem', position: 2, name: meta.title.split(' — ')[0], item: url },
+ ],
+ },
+ ];
+
+ // Escape `<` so the serialized JSON can never close the script tag.
+ const json = JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/${json}`;
+}
+
function buildMetaTags(meta: RouteMeta, routePath: string, site: SiteId): string {
const defaults = SITE_DEFAULTS[site];
const url = `${defaults.baseUrl}${routePath === '/' ? '' : routePath}`;
@@ -68,6 +114,7 @@ function buildMetaTags(meta: RouteMeta, routePath: string, site: SiteId): string
``,
``,
``,
+ buildJsonLd(meta, routePath, site),
].join('\n ');
}
@@ -83,11 +130,12 @@ function generateSitemapXml(site: SiteId): string {
const baseUrl = SITE_DEFAULTS[site].baseUrl;
const routes = SITEMAP_ROUTES[site];
const totalRoutes = routes.length;
+ const buildDate = new Date().toISOString().slice(0, 10);
const urls = routes.map((route, i) => {
const loc = route === '/' ? baseUrl + '/' : baseUrl + route;
const priority = Math.max(0.4, 1.0 - (i / totalRoutes) * 0.6).toFixed(1);
- return ` \n ${loc}\n ${priority}\n `;
+ return ` \n ${loc}\n ${buildDate}\n ${priority}\n `;
});
return [
@@ -208,6 +256,12 @@ export default function viteSeoPlugin(): Plugin {
routeHtml = routeHtml.replace(/(.*?<\/script>/,
+ buildJsonLd(meta, route, site),
+ );
+
// Write to route subdirectory
const routeDir = path.join(outDir, route.slice(1)); // Remove leading /
fs.mkdirSync(routeDir, { recursive: true });