velxio/frontend/scripts/generate-sitemap.mjs

141 lines
4.9 KiB
JavaScript

/**
* Auto-generates public/sitemap.xml by parsing seoRoutes.ts
* Pure Node.js — no tsx/ts-node needed.
* Run: node scripts/generate-sitemap.mjs [--ping]
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DOMAIN = 'https://velxio.dev';
const TODAY = new Date().toISOString().slice(0, 10);
// Extract top-level ExampleProject ids from examples.ts.
// Top-level ids are indented with exactly 4 spaces; component/wire ids use 8+.
function parseExampleIds(source) {
const ids = [];
const re = /^ {4}id: '([^']+)'/gm;
let m;
while ((m = re.exec(source)) !== null) {
ids.push(m[1]);
}
return ids;
}
// Parse seoRoutes.ts to extract the route objects
const seoRoutesPath = resolve(__dirname, '../src/seoRoutes.ts');
const source = readFileSync(seoRoutesPath, 'utf-8');
// Extract the array content between SEO_ROUTES = [ ... ];
const match = source.match(/SEO_ROUTES[^=]*=\s*\[([\s\S]*?)\];/);
if (!match) {
console.error('Could not parse SEO_ROUTES from seoRoutes.ts');
process.exit(1);
}
// Evaluate the array (safe: only contains string/number/boolean literals)
// Convert TS-style comments and trailing commas to valid JSON-ish
const arrayStr = match[1]
.replace(/\/\/.*$/gm, '') // remove line comments
.replace(/\/\*[\s\S]*?\*\//g, ''); // remove block comments
// Use Function constructor to evaluate the JS array literal.
// Inject DOMAIN so template literals like `${DOMAIN}/path` resolve correctly.
const routes = new Function('DOMAIN', `return [${arrayStr}]`)(DOMAIN);
const indexable = routes.filter((r) => !r.noindex);
// Parse example project IDs and add /examples/:id URLs.
// Reads both examples.ts (legacy) and examples-circuits.ts (analog/digital/
// electromech examples added in circuitExamples).
const examplesSource = readFileSync(resolve(__dirname, '../src/data/examples.ts'), 'utf-8');
const circuitSource = readFileSync(resolve(__dirname, '../src/data/examples-circuits.ts'), 'utf-8');
const exampleIds = [
...parseExampleIds(examplesSource),
...parseExampleIds(circuitSource),
];
// Pro overlay examples (e.g. the Pico W WiFi showcase) live in
// <PRO_OVERLAY_PATH>/data/proExamples.ts and are spread into examples.ts via the
// `@pro` alias at build time. This script parses example IDs from source TEXT
// (it never executes the module), so it can't follow the alias — read the
// overlay file directly when building with the overlay. OSS builds skip this.
if (process.env.VITE_PRO_BUILD && process.env.PRO_OVERLAY_PATH) {
try {
const proSource = readFileSync(
resolve(process.env.PRO_OVERLAY_PATH, 'data/proExamples.ts'),
'utf-8',
);
exampleIds.push(...parseExampleIds(proSource));
} catch {
// No overlay examples file — nothing to add.
}
}
const exampleUrls = exampleIds.map((id) => ({
loc: `${DOMAIN}/examples/${id}`,
lastmod: TODAY,
changefreq: 'monthly',
priority: 0.6,
}));
// Every <loc> carries a trailing slash. nginx serves the prerendered route
// files as `<route>/index.html` and 301-redirects the slash-less form to add
// the slash, so a slash-less sitemap URL is fetched as a redirect and filed
// under "Page with redirect" in Search Console. Matching the served (slash)
// form keeps the sitemap URL == canonical == 200, with no redirect hop.
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
${indexable
.map(
(r) => `
<url>
<loc>${DOMAIN}${r.path}${r.path.endsWith('/') ? '' : '/'}</loc>
<lastmod>${TODAY}</lastmod>
<changefreq>${r.changefreq ?? 'monthly'}</changefreq>
<priority>${r.priority ?? 0.5}</priority>
</url>`
)
.join('')}
${exampleUrls
.map(
(u) => `
<url>
<loc>${u.loc}/</loc>
<lastmod>${u.lastmod}</lastmod>
<changefreq>${u.changefreq}</changefreq>
<priority>${u.priority}</priority>
</url>`
)
.join('')}
</urlset>
`;
const outPath = resolve(__dirname, '../public/sitemap.xml');
writeFileSync(outPath, xml.trimStart(), 'utf-8');
console.log(`sitemap.xml generated → ${indexable.length + exampleIds.length} URLs (${TODAY}) [${indexable.length} routes + ${exampleIds.length} examples]`);
// Ping search engines (optional)
if (process.argv.includes('--ping')) {
const sitemapUrl = `${DOMAIN}/sitemap.xml`;
const pings = [
`https://www.google.com/ping?sitemap=${encodeURIComponent(sitemapUrl)}`,
`https://www.bing.com/ping?sitemap=${encodeURIComponent(sitemapUrl)}`,
];
console.log('Pinging search engines...');
await Promise.allSettled(
pings.map(async (url) => {
try {
const res = await fetch(url);
console.log(` ${res.ok ? 'OK' : 'FAIL'} ${url.split('?')[0]}`);
} catch (e) {
console.log(` FAIL ${url.split('?')[0]}: ${e.message}`);
}
})
);
}