feat(website): add search-intent titles, FAQ schema, and breadcrumbs
All checks were successful
CI — Website / Website — typecheck, build (pull_request) Successful in 1m2s
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>
This commit is contained in:
@@ -7,8 +7,64 @@ 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'));
|
||||
|
||||
function apply(html, { title, description, url, robots }) {
|
||||
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(
|
||||
@@ -28,6 +84,10 @@ function apply(html, { title, description, url, robots }) {
|
||||
`$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;
|
||||
}
|
||||
|
||||
@@ -50,6 +110,7 @@ for (const route of routes) {
|
||||
description: route.description,
|
||||
url,
|
||||
robots: 'index, follow',
|
||||
ld: jsonLd(route),
|
||||
}),
|
||||
);
|
||||
written.push(file);
|
||||
@@ -62,8 +123,30 @@ writeFileSync(
|
||||
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(', '));
|
||||
|
||||
Reference in New Issue
Block a user