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 });