#!/usr/bin/env node // Generates public/sitemap.xml from the static route list + every doc slug // in the docs registry, so it can never drift out of sync with the app. // Run via `npm run gen:sitemap`, or automatically as part of `prebuild`. import { writeFileSync, readdirSync, readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import path from 'node:path' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const root = path.resolve(__dirname, '..') const SITE_URL = 'https://gctrl.tech' // Static marketing routes (see src/App.tsx). Auth-gated app routes // (dashboard, login, settings, etc.) are intentionally excluded — they are // not meant to be indexed. const STATIC_ROUTES = [ { path: '/', priority: '1.0', changefreq: 'weekly' }, { path: '/docs', priority: '0.8', changefreq: 'weekly' }, { path: '/pricing', priority: '0.8', changefreq: 'monthly' }, { path: '/blog', priority: '0.7', changefreq: 'weekly' }, { path: '/use-cases', priority: '0.7', changefreq: 'monthly' }, { path: '/integrations', priority: '0.7', changefreq: 'monthly' }, { path: '/imprint', priority: '0.2', changefreq: 'yearly' }, { path: '/privacy', priority: '0.2', changefreq: 'yearly' }, { path: '/skill.md', priority: '0.5', changefreq: 'monthly' }, ] // Pull every doc slug straight out of the content directory rather than // parsing registry.ts (which is TS) — avoids a build step just to read a list. const contentDir = path.join(root, 'src/pages/docs/content') const docSlugs = readdirSync(contentDir) .filter((f) => f.endsWith('.md')) .map((f) => f.replace(/\.md$/, '')) .sort() // A post's own frontmatter `date:` is its real publication date. Stamping the // build date on every blog URL instead would tell crawlers that all posts // changed on every deploy, which devalues the lastmod signal entirely. function frontmatterDate(file) { const raw = readFileSync(file, 'utf8') const block = raw.match(/^---\n([\s\S]*?)\n---/)?.[1] const date = block?.match(/^date:\s*(\S+)/m)?.[1] return date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null } // Blog posts are directory-driven too: dropping one .md into the blog content // dir is the whole publishing contract (see src/pages/blog/registry.ts). const blogDir = path.join(root, 'src/pages/blog/content') let blogPosts = [] try { blogPosts = readdirSync(blogDir) .filter((f) => f.endsWith('.md')) .map((f) => ({ slug: f.replace(/\.md$/, ''), date: frontmatterDate(path.join(blogDir, f)) })) .sort((a, b) => a.slug.localeCompare(b.slug)) } catch { // no blog content yet - fine } // Fallback for routes with no content date of their own (static + docs pages). const lastmod = process.env.SITEMAP_LASTMOD || new Date().toISOString().slice(0, 10) const urls = [ ...STATIC_ROUTES.map((r) => ({ loc: `${SITE_URL}${r.path}`, lastmod, priority: r.priority, changefreq: r.changefreq })), ...docSlugs.map((slug) => ({ loc: `${SITE_URL}/docs/${slug}`, lastmod, priority: '0.6', changefreq: 'monthly' })), ...blogPosts.map((p) => ({ loc: `${SITE_URL}/blog/${p.slug}`, lastmod: p.date || lastmod, priority: '0.6', changefreq: 'monthly', })), ] const body = urls .map( (u) => ` ${u.loc} ${u.lastmod} ${u.changefreq} ${u.priority} `, ) .join('\n') const xml = ` ${body} ` const outPath = path.join(root, 'public/sitemap.xml') writeFileSync(outPath, xml) console.log(`[gen-sitemap] wrote ${outPath} with ${urls.length} URLs (${docSlugs.length} doc pages)`) // Sanity check: make sure we didn't silently produce an empty/broken sitemap. if (urls.length < STATIC_ROUTES.length + 5) { console.error('[gen-sitemap] suspiciously few URLs — check content dir path') process.exit(1) }