All checks were successful
CI — Website / Website — typecheck, build (pull_request) Successful in 1m2s
Give Google unique snippets and FAQ rich results, and keep robots/sitemap from being cached as HTML. Co-authored-by: Cursor <cursoragent@cursor.com>
153 lines
4.9 KiB
JavaScript
153 lines
4.9 KiB
JavaScript
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = dirname(fileURLToPath(import.meta.url));
|
|
const distHtml = join(root, 'dist', 'index.html');
|
|
const { origin, routes } = JSON.parse(
|
|
readFileSync(join(root, 'src', 'seo.routes.json'), 'utf8'),
|
|
);
|
|
const faq = JSON.parse(readFileSync(join(root, 'src', 'faq.json'), 'utf8'));
|
|
|
|
const CHROME_STORE =
|
|
'https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk';
|
|
const VSCODE_MARKETPLACE =
|
|
'https://marketplace.visualstudio.com/items?itemName=JuanKibin.lexai-vscode';
|
|
const REPO = 'https://git.juankibin.space/kibin/LexAI';
|
|
|
|
const homeDescription = routes.find((r) => r.path === '/')?.description ?? '';
|
|
|
|
function softwareGraph() {
|
|
return {
|
|
'@type': 'SoftwareApplication',
|
|
name: 'LexAI',
|
|
applicationCategory: 'BrowserApplication',
|
|
applicationSubCategory: 'Writing assistance',
|
|
operatingSystem: 'Chrome, VS Code, Windows, macOS, Linux',
|
|
url: `${origin}/`,
|
|
downloadUrl: [CHROME_STORE, VSCODE_MARKETPLACE],
|
|
license: `${REPO}/src/branch/main/LICENSE`,
|
|
sameAs: [REPO, CHROME_STORE, VSCODE_MARKETPLACE],
|
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
};
|
|
}
|
|
|
|
function jsonLd(route) {
|
|
const url = route.path === '/' ? `${origin}/` : `${origin}${route.path}`;
|
|
const graph = [
|
|
{
|
|
'@type': 'WebSite',
|
|
name: 'LexAI',
|
|
url: `${origin}/`,
|
|
description: homeDescription,
|
|
},
|
|
softwareGraph(),
|
|
];
|
|
if (route.path === '/') {
|
|
graph.push({
|
|
'@type': 'FAQPage',
|
|
mainEntity: faq.map((item) => ({
|
|
'@type': 'Question',
|
|
name: item.q,
|
|
acceptedAnswer: { '@type': 'Answer', text: item.a },
|
|
})),
|
|
});
|
|
} else {
|
|
graph.push({
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: [
|
|
{ '@type': 'ListItem', position: 1, name: 'Home', item: `${origin}/` },
|
|
{ '@type': 'ListItem', position: 2, name: route.title, item: url },
|
|
],
|
|
});
|
|
}
|
|
return JSON.stringify({ '@context': 'https://schema.org', '@graph': graph });
|
|
}
|
|
|
|
function apply(html, { title, description, url, robots, ld }) {
|
|
let out = html;
|
|
out = out.replace(/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`);
|
|
out = out.replace(
|
|
/(<meta\s+name="description"\s+content=")[^"]*(")/,
|
|
`$1${description}$2`,
|
|
);
|
|
out = out.replace(/(<meta\s+name="robots"\s+content=")[^"]*(")/, `$1${robots}$2`);
|
|
out = out.replace(/(<meta\s+property="og:title"\s+content=")[^"]*(")/, `$1${title}$2`);
|
|
out = out.replace(
|
|
/(<meta\s+property="og:description"\s+content=")[^"]*(")/,
|
|
`$1${description}$2`,
|
|
);
|
|
out = out.replace(/(<meta\s+property="og:url"\s+content=")[^"]*(")/, `$1${url}$2`);
|
|
out = out.replace(/(<meta\s+name="twitter:title"\s+content=")[^"]*(")/, `$1${title}$2`);
|
|
out = out.replace(
|
|
/(<meta\s+name="twitter:description"\s+content=")[^"]*(")/,
|
|
`$1${description}$2`,
|
|
);
|
|
out = out.replace(/(<link\s+rel="canonical"\s+href=")[^"]*(")/, `$1${url}$2`);
|
|
out = out.replace(
|
|
/<script type="application\/ld\+json">[\s\S]*?<\/script>/,
|
|
`<script type="application/ld+json">${ld}</script>`,
|
|
);
|
|
return out;
|
|
}
|
|
|
|
const template = readFileSync(distHtml, 'utf8');
|
|
if (!template.includes('rel="canonical"') || !template.includes('og:title')) {
|
|
console.error('dist/index.html is missing SEO tags — check index.html');
|
|
process.exit(1);
|
|
}
|
|
|
|
const written = [];
|
|
for (const route of routes) {
|
|
const url = route.path === '/' ? `${origin}/` : `${origin}${route.path}`;
|
|
const file = route.path === '/' ? 'index.html' : `${route.path.replace(/^\//, '')}/index.html`;
|
|
const dest = join(root, 'dist', file);
|
|
mkdirSync(dirname(dest), { recursive: true });
|
|
writeFileSync(
|
|
dest,
|
|
apply(template, {
|
|
title: route.title,
|
|
description: route.description,
|
|
url,
|
|
robots: 'index, follow',
|
|
ld: jsonLd(route),
|
|
}),
|
|
);
|
|
written.push(file);
|
|
}
|
|
|
|
writeFileSync(
|
|
join(root, 'dist', '404.html'),
|
|
apply(template, {
|
|
title: 'Page not found — LexAI',
|
|
description: 'That address is not a LexAI page.',
|
|
url: `${origin}/`,
|
|
robots: 'noindex, follow',
|
|
ld: JSON.stringify({
|
|
'@context': 'https://schema.org',
|
|
'@graph': [{ '@type': 'WebSite', name: 'LexAI', url: `${origin}/` }, softwareGraph()],
|
|
}),
|
|
}),
|
|
);
|
|
written.push('404.html');
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
${routes
|
|
.map(
|
|
(r, i) => ` <url>
|
|
<loc>${r.path === '/' ? `${origin}/` : `${origin}${r.path}`}</loc>
|
|
<lastmod>${today}</lastmod>
|
|
<changefreq>${i < 2 ? 'weekly' : 'monthly'}</changefreq>
|
|
<priority>${r.path === '/' ? '1.0' : r.path === '/docs' ? '0.8' : '0.5'}</priority>
|
|
</url>`,
|
|
)
|
|
.join('\n')}
|
|
</urlset>
|
|
`;
|
|
writeFileSync(join(root, 'dist', 'sitemap.xml'), sitemap);
|
|
written.push('sitemap.xml');
|
|
|
|
console.log('SEO HTML written for', written.join(', '));
|