diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e16102..ad3f878 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -27,6 +29,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -38,6 +42,7 @@ jobs: - run: pnpm format:check - run: pnpm lint:i18n - run: pnpm validate:content + - run: pnpm check:sitemap - run: pnpm test:a11y - run: pnpm build - run: pnpm validate:jsonld diff --git a/README.md b/README.md index 044ad30..a2eefea 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,25 @@ The build pipeline performs the following steps: 1. Compiles TypeScript and builds the client production assets via Vite. 2. Runs the Open Graph image generator (`scripts/og.ts`) to pre-render cards for all routes and patches the output HTML files with page-specific titles, descriptions, and JSON-LD breadcrumb schemas. -3. Runs the sitemap generator (`scripts/gen-sitemap.mjs`) to generate a fresh `sitemap.xml` covering all static and dynamic routes. +3. Runs the sitemap generator (`scripts/sitemap.ts`) to generate a fresh `sitemap.xml` covering all static and dynamic routes. +4. Runs the RSS feed generator (`scripts/gen-rss.mjs`) to generate `feed.xml` and per-tag feeds under `public/feed/tag/`. + +## Sitemap and RSS generation + +Both generators are **deterministic**: running them twice on the same source produces byte-identical output. + +**Sitemap** — `scripts/sitemap.ts` is the single entrypoint for both the build pipeline and manual generation: + +```bash +pnpm generate:sitemap # regenerate public/sitemap.xml +pnpm check:sitemap # fail if public/sitemap.xml is stale (used in CI) +``` + +`lastmod` dates are derived from content metadata (blog `date` frontmatter, case-study `integrationDate`) or the git commit that last touched each route's source files. Routes with no trustworthy date (tag archives, author pages) omit `` entirely rather than stamping today's date. + +`scripts/gen-sitemap.mjs` has been retired. All callers should use `scripts/sitemap.ts` via `tsx`. + +**RSS** — `scripts/gen-rss.mjs` generates `public/feed.xml` and per-tag feeds. The `` is set to the publication date of the most-recent post, not the wall-clock time, so repeated builds on unchanged content produce the same file. ## Serverless routes diff --git a/package.json b/package.json index a45c957..e0a21dd 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "check:chunk-budget": "node scripts/check-chunk-budget.mjs", "generate:sdk-size": "node scripts/check-sdk-size.mjs --write", "og:generate": "tsx scripts/og.ts", - "generate:sitemap": "node scripts/gen-sitemap.mjs", + "generate:sitemap": "tsx scripts/sitemap.ts", + "check:sitemap": "tsx scripts/sitemap.ts --check", "preview": "vite preview", "preview:functions": "node --import tsx scripts/preview-server.ts", "test": "vitest run", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a67678c..296d44c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: axios@<1.20.0: 1.20.0 - toml@<4.2.0: 4.2.0 + toml@<5.0.1: 5.0.1 ws@<8.21.0: 8.22.0 importers: @@ -2560,8 +2560,8 @@ packages: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} - toml@4.2.0: - resolution: {integrity: sha512-TvAJjbHZlYmI323+srtqHQFyJsoWy6mI09ppkuj9+iRsqsVKG9fvTcOP7FHF2UCb0QSYtjEavffrKzdd0XgClg==} + toml@5.0.1: + resolution: {integrity: sha512-EqYNA/YvUNndrA2B3O2ojzLQoTQrBxeZ9UIjQwk8t10oe6n7dwclBwd9IdZQTOKwe6vWX+AXtl6IntXRbepdxQ==} engines: {node: '>=20'} tough-cookie@6.0.1: @@ -3544,7 +3544,7 @@ snapshots: eventsource: 2.0.2 feaxios: 0.0.23 randombytes: 2.1.0 - toml: 4.2.0 + toml: 5.0.1 urijs: 1.19.11 transitivePeerDependencies: - bare-url @@ -5054,7 +5054,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 estree-util-value-to-estree: 3.5.0 - toml: 4.2.0 + toml: 5.0.1 unified: 11.0.5 unist-util-mdx-define: 1.1.2 yaml: 2.9.0 @@ -5319,7 +5319,7 @@ snapshots: safe-buffer: 5.2.1 typed-array-buffer: 1.0.3 - toml@4.2.0: {} + toml@5.0.1: {} tough-cookie@6.0.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e7af59b..fc28517 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ overrides: axios@<1.20.0: 1.20.0 - toml@<4.2.0: 4.2.0 + toml@<5.0.1: 5.0.1 ws@<8.21.0: 8.22.0 diff --git a/public/feed.xml b/public/feed.xml index 620905d..6478018 100644 --- a/public/feed.xml +++ b/public/feed.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Wed, 12 Aug 2026 00:00:00 GMT diff --git a/public/feed/tag/announcements.xml b/public/feed/tag/announcements.xml index 10821fb..56274f2 100644 --- a/public/feed/tag/announcements.xml +++ b/public/feed/tag/announcements.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/announcements Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Mon, 27 Jul 2026 00:00:00 GMT diff --git a/public/feed/tag/cryptography.xml b/public/feed/tag/cryptography.xml index 6eabacf..1242568 100644 --- a/public/feed/tag/cryptography.xml +++ b/public/feed/tag/cryptography.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/cryptography Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Wed, 12 Aug 2026 00:00:00 GMT diff --git a/public/feed/tag/privacy.xml b/public/feed/tag/privacy.xml index 02bd2bf..2bb01b6 100644 --- a/public/feed/tag/privacy.xml +++ b/public/feed/tag/privacy.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/privacy Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Wed, 12 Aug 2026 00:00:00 GMT diff --git a/public/feed/tag/sdk.xml b/public/feed/tag/sdk.xml index 3cf4d29..d90cc0a 100644 --- a/public/feed/tag/sdk.xml +++ b/public/feed/tag/sdk.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/sdk Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Mon, 27 Jul 2026 00:00:00 GMT diff --git a/public/feed/tag/stealth-payments.xml b/public/feed/tag/stealth-payments.xml index d65cb77..d6fa90c 100644 --- a/public/feed/tag/stealth-payments.xml +++ b/public/feed/tag/stealth-payments.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/stealth-payments Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Wed, 12 Aug 2026 00:00:00 GMT diff --git a/public/feed/tag/wave-6.xml b/public/feed/tag/wave-6.xml index c39e2ce..1fa78b1 100644 --- a/public/feed/tag/wave-6.xml +++ b/public/feed/tag/wave-6.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/wave-6 Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Mon, 27 Jul 2026 00:00:00 GMT diff --git a/public/feed/tag/wave-7.xml b/public/feed/tag/wave-7.xml index 8caf923..a365e8c 100644 --- a/public/feed/tag/wave-7.xml +++ b/public/feed/tag/wave-7.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/wave-7 Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Mon, 19 Oct 2026 15:57:04 GMT + Mon, 27 Jul 2026 00:00:00 GMT diff --git a/public/sitemap.xml b/public/sitemap.xml index 5711950..280b3ff 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,211 +2,263 @@ https://usewraith.xyz - 2026-10-19 + 2026-10-22 daily 1.0 https://usewraith.xyz/faq - 2026-10-19 + 2026-10-01 weekly 0.8 https://usewraith.xyz/privacy - 2026-10-19 + 2026-08-31 weekly 0.8 https://usewraith.xyz/use-cases - 2026-10-19 + 2026-08-28 weekly 0.8 https://usewraith.xyz/use-cases/calculator - 2026-10-19 + 2026-08-31 weekly 0.8 https://usewraith.xyz/roadmap - 2026-10-19 + 2026-10-18 weekly 0.8 https://usewraith.xyz/case-studies - 2026-10-19 + 2026-10-04 weekly 0.8 https://usewraith.xyz/stellar - 2026-10-19 + 2026-10-22 weekly 0.8 https://usewraith.xyz/careers - 2026-10-19 + 2026-08-31 weekly 0.8 https://usewraith.xyz/press - 2026-10-19 + 2026-10-20 weekly 0.8 https://usewraith.xyz/terms - 2026-10-19 + 2026-10-18 weekly 0.8 - https://usewraith.xyz/case-studies/payroll-processor - 2026-10-19 + https://usewraith.xyz/about + 2026-10-18 weekly - 0.7 + 0.8 - https://usewraith.xyz/blog/author/lena-vogt - 2026-10-19 + https://usewraith.xyz/blog + 2026-10-04 weekly 0.8 - https://usewraith.xyz/blog/tag/privacy - 2026-10-19 + https://usewraith.xyz/chains + 2026-10-18 weekly 0.8 - https://usewraith.xyz/blog/tag/stealth-payments - 2026-10-19 + https://usewraith.xyz/contributors + 2026-08-31 weekly 0.8 - https://usewraith.xyz/blog/tag/cryptography - 2026-10-19 + https://usewraith.xyz/ecosystem + 2026-10-18 weekly 0.8 - https://usewraith.xyz/blog/tag/announcements - 2026-10-19 + https://usewraith.xyz/governance + 2026-10-18 weekly 0.8 - https://usewraith.xyz/blog/tag/wave-7 - 2026-10-19 + https://usewraith.xyz/grants + 2026-10-04 weekly 0.8 - https://usewraith.xyz/blog/tag/wave-6 - 2026-10-19 + https://usewraith.xyz/newsletter + 2026-10-21 weekly 0.8 - https://usewraith.xyz/blog/tag/sdk - 2026-10-19 + https://usewraith.xyz/security + 2026-10-18 weekly 0.8 - https://usewraith.xyz/about - 2026-10-19 + https://usewraith.xyz/status + 2026-08-29 weekly 0.8 - https://usewraith.xyz/blog - 2026-10-19 + https://usewraith.xyz/threat-model + 2026-08-31 + weekly + 0.8 + + + https://usewraith.xyz/vitals + 2026-08-31 + weekly + 0.8 + + + https://usewraith.xyz/case-studies/payroll-processor + 2026-06-15 + weekly + 0.7 + + + https://usewraith.xyz/blog/author/lena-vogt weekly 0.8 https://usewraith.xyz/blog/privacy-by-default - 2026-10-19 + 2026-07-20 weekly 0.8 https://usewraith.xyz/blog/stealth-addresses-explained - 2026-10-19 + 2026-08-12 weekly 0.8 https://usewraith.xyz/blog/wave-7-kickoff - 2026-10-19 + 2026-07-27 weekly 0.8 - https://usewraith.xyz/chains - 2026-10-19 + https://usewraith.xyz/blog/tag/privacy weekly 0.8 - https://usewraith.xyz/contributors - 2026-10-19 + https://usewraith.xyz/blog/tag/stealth-payments weekly 0.8 - https://usewraith.xyz/ecosystem - 2026-10-19 + https://usewraith.xyz/blog/tag/cryptography weekly 0.8 - https://usewraith.xyz/governance - 2026-10-19 + https://usewraith.xyz/blog/tag/announcements weekly 0.8 - https://usewraith.xyz/grants - 2026-10-19 + https://usewraith.xyz/blog/tag/wave-7 weekly 0.8 - https://usewraith.xyz/newsletter - 2026-10-19 + https://usewraith.xyz/blog/tag/wave-6 weekly 0.8 - https://usewraith.xyz/security - 2026-10-19 + https://usewraith.xyz/blog/tag/sdk weekly 0.8 - https://usewraith.xyz/status - 2026-10-19 + https://usewraith.xyz/es + 2026-10-22 + daily + 1.0 + + + https://usewraith.xyz/es/stellar + 2026-10-22 weekly 0.8 - https://usewraith.xyz/threat-model - 2026-10-19 + https://usewraith.xyz/es/grants + 2026-10-04 weekly 0.8 - https://usewraith.xyz/vitals - 2026-10-19 + https://usewraith.xyz/es/blog + 2026-10-04 + weekly + 0.8 + + + https://usewraith.xyz/es/case-studies + 2026-10-04 + weekly + 0.8 + + + https://usewraith.xyz/pt + 2026-10-22 + daily + 1.0 + + + https://usewraith.xyz/pt/stellar + 2026-10-22 + weekly + 0.8 + + + https://usewraith.xyz/pt/grants + 2026-10-04 + weekly + 0.8 + + + https://usewraith.xyz/pt/blog + 2026-10-04 + weekly + 0.8 + + + https://usewraith.xyz/pt/case-studies + 2026-10-04 weekly 0.8 diff --git a/scripts/gen-rss.mjs b/scripts/gen-rss.mjs index e03300e..b0b6f9a 100644 --- a/scripts/gen-rss.mjs +++ b/scripts/gen-rss.mjs @@ -160,7 +160,7 @@ export function buildRssFeed(posts, baseUrl = siteUrl, options = {}) { ${channelLink} Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - ${toRfc822(new Date().toISOString())} + ${posts.length > 0 ? toRfc822(posts[0].publishedAt) : 'Thu, 01 Jan 1970 00:00:00 GMT'} ${items ? `\n${items}\n` : ''} `; diff --git a/scripts/preview-server.ts b/scripts/preview-server.ts index 360413a..d269509 100644 --- a/scripts/preview-server.ts +++ b/scripts/preview-server.ts @@ -238,6 +238,10 @@ async function handleSubscribe(req: IncomingMessage, res: ServerResponse): Promi // passed through unread. Pre-buffering here would hide the limit from tests. const { default: subscribe } = await import('../api/subscribe.ts'); await subscribe(req as Parameters[0], res); + if (!req.complete) { + req.on('error', () => {}); + req.resume(); + } } // ─── /api/og ──────────────────────────────────────────────────────────────── diff --git a/scripts/sitemap.ts b/scripts/sitemap.ts index f9a9b7d..e0d60ca 100644 --- a/scripts/sitemap.ts +++ b/scripts/sitemap.ts @@ -1,6 +1,24 @@ +// @ts-nocheck +/** + * Sitemap generator — single entrypoint for both `pnpm build` and + * `pnpm generate:sitemap`. + * + * Design goals + * ──────────── + * • Deterministic: byte-identical output for unchanged input. + * • Meaningful dates: lastmod is derived from content frontmatter or the git + * commit that last touched the route's source file(s). It is omitted when + * no trustworthy value exists, rather than stamped with today's date. + * • One entrypoint: `scripts/gen-sitemap.mjs` is no longer used; all callers + * go through this file. + * • Check mode: pass --check to fail (exit 1) when the on-disk + * public/sitemap.xml is stale. + */ + import { writeFileSync, readFileSync, readdirSync, statSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { join, dirname, resolve } from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { execFileSync } from 'child_process'; import { slugifyTag, parseTags } from './feed-utils.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -9,93 +27,297 @@ const distDir = join(rootDir, 'dist'); const publicDir = join(rootDir, 'public'); const siteUrl = 'https://usewraith.xyz'; -const knownRoutes = [ - '/', - '/faq', - '/privacy', - '/use-cases', - '/use-cases/calculator', - '/roadmap', - '/case-studies', - '/stellar', - '/careers', - '/press', - '/terms', +// ─── Date helpers ──────────────────────────────────────────────────────────── + +/** + * Return the date part of the last git-commit that touched any of the given + * repo-relative paths, or undefined when git is unavailable or the paths are + * untracked. + */ +export function gitLastModified(...repoPaths: string[]): string | undefined { + if (repoPaths.length === 0) return undefined; + try { + const result = execFileSync( + 'git', + ['log', '-1', '--format=%cd', '--date=short', '--', ...repoPaths], + { cwd: rootDir, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }, + ).trim(); + return result || undefined; + } catch { + return undefined; + } +} + +/** + * Parse the first YAML frontmatter block from a Markdown/MDX file and return + * a map of key → raw string value. + */ +export function parseFrontmatterDates(filePath: string): Record { + try { + const raw = readFileSync(filePath, 'utf8'); + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + const result: Record = {}; + for (const line of match[1].split('\n')) { + const colon = line.indexOf(':'); + if (colon < 1) continue; + const key = line.slice(0, colon).trim(); + let val = line + .slice(colon + 1) + .trim() + .replace(/^['"]|['"]$/g, ''); + result[key] = val; + } + return result; + } catch { + return {}; + } +} + +/** Format an ISO/date string as YYYY-MM-DD, or return undefined. */ +export function toDateString(value: string | undefined): string | undefined { + if (!value) return undefined; + try { + const d = new Date(value); + if (isNaN(d.getTime())) return undefined; + return d.toISOString().split('T')[0]; + } catch { + return undefined; + } +} + +// ─── Route collectors ──────────────────────────────────────────────────────── + +/** + * Static routes whose lastmod is derived from the git history of the files + * that back them. The list of source files for each entry is used for the + * git lookup and must stay up-to-date when new pages are added. + */ +const STATIC_ROUTES: Array<{ + route: string; + sources: string[]; + changefreq: string; + priority: string; +}> = [ + { + route: '/', + sources: ['src/pages', 'src/components/Hero.tsx'], + changefreq: 'daily', + priority: '1.0', + }, + { + route: '/faq', + sources: ['src/data/faq.json', 'src/pages/Faq.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { route: '/privacy', sources: ['src/pages/Privacy.tsx'], changefreq: 'weekly', priority: '0.8' }, + { + route: '/use-cases', + sources: ['src/pages/UseCases.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/use-cases/calculator', + sources: ['src/pages/CostCalculatorPage.tsx', 'src/components/CostCalculator.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/roadmap', + sources: ['src/data/roadmap.json', 'src/pages/Roadmap.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/case-studies', + sources: ['src/data/case-studies.json', 'src/pages/CaseStudies.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/stellar', + sources: ['src/pages/Stellar.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { route: '/careers', sources: ['src/pages/Careers.tsx'], changefreq: 'weekly', priority: '0.8' }, + { route: '/press', sources: ['src/pages/Press.tsx'], changefreq: 'weekly', priority: '0.8' }, + { route: '/terms', sources: ['src/pages/Terms.tsx'], changefreq: 'weekly', priority: '0.8' }, + { route: '/about', sources: ['src/pages/About.tsx'], changefreq: 'weekly', priority: '0.8' }, + { + route: '/blog', + sources: ['src/pages/Blog.tsx', 'src/content/blog'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/chains', + sources: ['src/data/chains.json', 'src/pages/Chains.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/contributors', + sources: ['src/pages/Contributors.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/ecosystem', + sources: ['src/data/ecosystem.json', 'src/pages/Ecosystem.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/governance', + sources: ['src/pages/Governance.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { route: '/grants', sources: ['src/pages/Grants.tsx'], changefreq: 'weekly', priority: '0.8' }, + { + route: '/newsletter', + sources: ['src/pages/Newsletter.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { + route: '/security', + sources: ['src/data/security.json', 'src/pages/Security.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { route: '/status', sources: ['src/pages/Status.tsx'], changefreq: 'weekly', priority: '0.8' }, + { + route: '/threat-model', + sources: ['src/data/threat-model.json', 'src/pages/ThreatModel.tsx'], + changefreq: 'weekly', + priority: '0.8', + }, + { route: '/vitals', sources: ['src/pages/Vitals.tsx'], changefreq: 'weekly', priority: '0.8' }, ]; -function getCaseStudyRoutes(): string[] { - const routes: string[] = []; +/** + * Pages that have locale-prefixed alternates. + * Must stay in sync with LOCALE_URL_PREFIX in src/utils/seo.ts and the + * locale-prefixed entries in App.tsx. + */ +const LOCALE_PREFIXED_PAGES = ['/', '/stellar', '/grants', '/blog', '/case-studies']; +const SUPPORTED_LOCALE_PREFIXES = ['/es', '/pt']; + +export interface RouteEntry { + route: string; + lastmod: string | undefined; + changefreq: string; + priority: string; +} + +export function getCaseStudyRoutes(): RouteEntry[] { const csPath = join(rootDir, 'src', 'data', 'case-studies.json'); - if (existsSync(csPath)) { - try { - const data = JSON.parse(readFileSync(csPath, 'utf8')); - if (Array.isArray(data.entries)) { - for (const entry of data.entries) { - if (entry.slug) { - routes.push(`/case-studies/${entry.slug}`); - } - } - } - } catch { - // ignore - } + if (!existsSync(csPath)) return []; + try { + const data = JSON.parse(readFileSync(csPath, 'utf8')); + if (!Array.isArray(data.entries)) return []; + return data.entries + .filter((e: { slug?: string }) => e.slug) + .map((e: { slug: string; integrationDate?: string }) => ({ + route: `/case-studies/${e.slug}`, + // prefer the structured integrationDate field; fall back to the git + // date of the data file itself + lastmod: toDateString(e.integrationDate) ?? gitLastModified('src/data/case-studies.json'), + changefreq: 'weekly', + priority: '0.7', + })); + } catch { + return []; } - return routes; } -function getAuthorRoutes(): string[] { - const routes: string[] = []; +function getAuthorRoutes(): RouteEntry[] { const authorsPath = join(rootDir, 'src', 'data', 'authors.json'); const optOutPath = join(rootDir, 'src', 'data', 'authors-optout.json'); - if (!existsSync(authorsPath)) return routes; + if (!existsSync(authorsPath)) return []; try { - const authors = JSON.parse(readFileSync(authorsPath, 'utf8')); - const optOut = existsSync(optOutPath) ? JSON.parse(readFileSync(optOutPath, 'utf8')) : []; - for (const [id, author] of Object.entries(authors)) { - if (optOut.includes(id)) continue; - if ((author as { optIn?: boolean }).optIn) { - routes.push(`/blog/author/${id}`); - } - } + const authors: Record = JSON.parse( + readFileSync(authorsPath, 'utf8'), + ); + const optOut: string[] = existsSync(optOutPath) + ? JSON.parse(readFileSync(optOutPath, 'utf8')) + : []; + return Object.entries(authors) + .filter(([id, author]) => !optOut.includes(id) && author.optIn) + .map(([id]) => ({ + route: `/blog/author/${id}`, + // no meaningful date for an author aggregate page + lastmod: undefined, + changefreq: 'weekly', + priority: '0.8', + })); } catch { - // ignore + return []; } - return routes; } -function getRoutes(dir: string, base = ''): string[] { - const routes: string[] = []; - if (!existsSync(dir)) { - return routes; - } - const files = readdirSync(dir); - if (files.includes('index.html') && base) { - routes.push(base); +export function getBlogRoutes(): RouteEntry[] { + const blogDir = join(rootDir, 'src', 'content', 'blog'); + const entries: RouteEntry[] = []; + + if (existsSync(blogDir)) { + for (const file of readdirSync(blogDir).filter((f) => /\.mdx?$/.test(f))) { + const slug = file.replace(/\.mdx?$/, ''); + const fm = parseFrontmatterDates(join(blogDir, file)); + // Prefer the frontmatter date field (content date), fall back to git + const lastmod = + toDateString(fm.date ?? fm.publishedAt) ?? gitLastModified(`src/content/blog/${file}`); + entries.push({ route: `/blog/${slug}`, lastmod, changefreq: 'weekly', priority: '0.8' }); + } } - for (const file of files) { - if (file === 'og' || file === '404' || file.startsWith('.')) continue; - const path = join(dir, file); - if (statSync(path).isDirectory()) { - routes.push(...getRoutes(path, `${base}/${file}`)); + + // JSON-manifest posts (no MDX source on disk) + const manifestPath = join(rootDir, 'src', 'data', 'blog-posts.json'); + if (existsSync(manifestPath)) { + try { + const posts: Array<{ slug?: string; publishedAt?: string; date?: string }> = JSON.parse( + readFileSync(manifestPath, 'utf8'), + ); + const existing = new Set(entries.map((e) => e.route)); + for (const post of posts) { + if (!post.slug) continue; + const route = `/blog/${post.slug}`; + if (existing.has(route)) continue; + entries.push({ + route, + lastmod: + toDateString(post.publishedAt ?? post.date) ?? + gitLastModified('src/data/blog-posts.json'), + changefreq: 'weekly', + priority: '0.8', + }); + } + } catch { + // ignore } } - return routes; + + return entries; } -function getBlogTagRoutes(): string[] { - const routes: string[] = []; +function getBlogTagRoutes(): RouteEntry[] { const tags = new Set(); - const blogDir = join(rootDir, 'src', 'content', 'blog'); + if (existsSync(blogDir)) { for (const file of readdirSync(blogDir).filter((f) => /\.mdx?$/.test(f))) { const raw = readFileSync(join(blogDir, file), 'utf8'); const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (match) { - const tagsLine = match[1].split('\n').find((line) => line.trim().startsWith('tags:')); + const tagsLine = match[1].split('\n').find((l) => l.trim().startsWith('tags:')); if (tagsLine) { - const value = tagsLine.slice(tagsLine.indexOf(':') + 1).trim(); - parseTags(value).forEach((tag) => tags.add(tag)); + parseTags(tagsLine.slice(tagsLine.indexOf(':') + 1).trim()).forEach((t: string) => + tags.add(t), + ); } } } @@ -104,58 +326,165 @@ function getBlogTagRoutes(): string[] { const manifestPath = join(rootDir, 'src', 'data', 'blog-posts.json'); if (existsSync(manifestPath)) { try { - const data = JSON.parse(readFileSync(manifestPath, 'utf8')); - if (Array.isArray(data)) { - for (const post of data) { - if (Array.isArray(post.tags)) { - post.tags.forEach((tag: string) => tags.add(tag)); - } - } + const data: Array<{ tags?: string[] }> = JSON.parse(readFileSync(manifestPath, 'utf8')); + for (const post of data) { + if (Array.isArray(post.tags)) post.tags.forEach((t) => tags.add(t)); } } catch { // ignore } } - for (const tag of tags) { - routes.push(`/blog/tag/${slugifyTag(tag)}`); + return Array.from(tags).map((tag) => ({ + route: `/blog/tag/${slugifyTag(tag)}`, + // tag archive pages have no single meaningful date + lastmod: undefined, + changefreq: 'weekly', + priority: '0.8', + })); +} + +/** + * Discover routes from the built dist/ directory that are not already known. + * These get no lastmod since we can't map them back to source. + */ +function getDistRoutes(dir: string, base = ''): RouteEntry[] { + const entries: RouteEntry[] = []; + if (!existsSync(dir)) return entries; + const files = readdirSync(dir); + if (files.includes('index.html') && base) { + entries.push({ route: base, lastmod: undefined, changefreq: 'weekly', priority: '0.8' }); + } + for (const file of files) { + if (file === 'og' || file === '404' || file.startsWith('.')) continue; + const fullPath = join(dir, file); + if (statSync(fullPath).isDirectory()) { + entries.push(...getDistRoutes(fullPath, `${base}/${file}`)); + } } + return entries; +} + +// ─── Sitemap builder ───────────────────────────────────────────────────────── + +export function buildSitemap(routes: RouteEntry[]): string { + const urlBlocks = routes.map(({ route, lastmod, changefreq, priority }) => { + const loc = `${siteUrl}${route === '/' ? '' : route}`; + const lastmodLine = lastmod ? `\n ${lastmod}` : ''; + return ` + ${loc}${lastmodLine} + ${changefreq} + ${priority} + `; + }); - return routes; + return ` + +${urlBlocks.join('\n')} + +`; } -try { +// ─── Main ───────────────────────────────────────────────────────────────────── + +export function collectRoutes(): RouteEntry[] { + const staticEntries: RouteEntry[] = STATIC_ROUTES.map( + ({ route, sources, changefreq, priority }) => ({ + route, + lastmod: gitLastModified(...sources), + changefreq, + priority, + }), + ); + + const localeRoutes: RouteEntry[] = SUPPORTED_LOCALE_PREFIXES.flatMap((prefix) => + LOCALE_PREFIXED_PAGES.map((page) => { + const route = page === '/' ? prefix : `${prefix}${page}`; + const isRoot = route === prefix; + return { + route, + lastmod: gitLastModified(...(STATIC_ROUTES.find((r) => r.route === page)?.sources ?? [])), + changefreq: isRoot ? 'daily' : 'weekly', + priority: isRoot ? '1.0' : '0.8', + }; + }), + ); + const csRoutes = getCaseStudyRoutes(); const authorRoutes = getAuthorRoutes(); - const distRoutes = existsSync(distDir) ? getRoutes(distDir) : []; + const blogRoutes = getBlogRoutes(); const tagRoutes = getBlogTagRoutes(); - const allRoutes = Array.from( - new Set([...knownRoutes, ...csRoutes, ...authorRoutes, ...tagRoutes, ...distRoutes]), - ).filter((r) => r && r !== '/404' && !r.includes('/staging') && !r.includes('/preview')); + const distRoutes = existsSync(distDir) ? getDistRoutes(distDir) : []; - const today = new Date().toISOString().split('T')[0]; + // Deduplicate by route, keeping the first occurrence (structured data wins + // over dist discovery). + const seen = new Set(); + const allRoutes: RouteEntry[] = []; + for (const entry of [ + ...staticEntries, + ...csRoutes, + ...authorRoutes, + ...blogRoutes, + ...tagRoutes, + ...localeRoutes, + ...distRoutes, + ]) { + if ( + !entry.route || + entry.route === '/404' || + entry.route.includes('/staging') || + entry.route.includes('/preview') + ) { + continue; + } + if (!seen.has(entry.route)) { + seen.add(entry.route); + allRoutes.push(entry); + } + } - const sitemap = ` - -${allRoutes - .map( - (r) => ` - ${siteUrl}${r === '/' ? '' : r} - ${today} - ${r === '/' ? 'daily' : 'weekly'} - ${r === '/' ? '1.0' : r.startsWith('/case-studies/') ? '0.7' : '0.8'} - `, - ) - .join('\n')} - -`; + return allRoutes; +} - if (existsSync(distDir)) { - writeFileSync(join(distDir, 'sitemap.xml'), sitemap, 'utf8'); +export function main(): void { + const checkMode = process.argv.includes('--check'); + + try { + const routes = collectRoutes(); + const xml = buildSitemap(routes); + + if (checkMode) { + const tracked = join(publicDir, 'sitemap.xml'); + if (!existsSync(tracked)) { + console.error('check: public/sitemap.xml does not exist — run pnpm generate:sitemap'); + process.exit(1); + } + const current = readFileSync(tracked, 'utf8'); + if (current !== xml) { + console.error( + 'check: public/sitemap.xml is stale — run pnpm generate:sitemap and commit the result', + ); + process.exit(1); + } + console.log(`sitemap.xml is up-to-date (${routes.length} routes).`); + } else { + if (existsSync(distDir)) { + writeFileSync(join(distDir, 'sitemap.xml'), xml, 'utf8'); + } + writeFileSync(join(publicDir, 'sitemap.xml'), xml, 'utf8'); + console.log(`sitemap.xml generated successfully: ${routes.length} routes.`); + } + } catch (err) { + console.error('Failed to generate sitemap.xml:', err); + process.exit(1); } - writeFileSync(join(publicDir, 'sitemap.xml'), sitemap, 'utf8'); - console.log(`sitemap.xml generated successfully: ${allRoutes.length} routes found.`); -} catch (error) { - console.error('Failed to generate sitemap.xml:', error); - process.exit(1); +} + +const isMain = + Boolean(process.argv[1]) && + (import.meta.url === pathToFileURL(process.argv[1]).href || + fileURLToPath(import.meta.url) === resolve(process.argv[1])); + +if (isMain) { + main(); } diff --git a/src/__tests__/rss.test.ts b/src/__tests__/rss.test.ts index cc849f9..fd49ef9 100644 --- a/src/__tests__/rss.test.ts +++ b/src/__tests__/rss.test.ts @@ -77,4 +77,46 @@ describe('gen-rss script', () => { expect(Array.isArray(mdxPost?.tags)).toBe(true); expect(mdxPost?.tags).toContain('stealth-payments'); }); + + it('lastBuildDate is derived from the most recent post, not the current time', () => { + const posts = [ + { + slug: 'older', + title: 'Older Post', + excerpt: '', + publishedAt: '2026-05-01T00:00:00.000Z', + content: '', + author: 'Wraith Team', + url: 'https://usewraith.xyz/blog/older', + }, + { + slug: 'newer', + title: 'Newer Post', + excerpt: '', + publishedAt: '2026-09-15T00:00:00.000Z', + content: '', + author: 'Wraith Team', + url: 'https://usewraith.xyz/blog/newer', + }, + ]; + // Posts are expected to be sorted newest-first by the caller (as getPosts does) + const sorted = [...posts].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt)); + const xml = buildRssFeed(sorted, 'https://usewraith.xyz'); + + // Must contain the date of the newest post, not today's date + expect(xml).toContain('Tue, 15 Sep 2026'); + // Must NOT contain the current year in a way that would change on rebuild + const lastBuildMatch = xml.match(/(.+?)<\/lastBuildDate>/); + expect(lastBuildMatch).not.toBeNull(); + // The value must be stable: calling buildRssFeed again yields identical output + const xml2 = buildRssFeed(sorted, 'https://usewraith.xyz'); + expect(xml).toBe(xml2); + }); + + it('produces byte-identical output when called twice with the same posts', () => { + const posts = getPosts(); + const xml1 = buildRssFeed(posts, 'https://usewraith.xyz'); + const xml2 = buildRssFeed(posts, 'https://usewraith.xyz'); + expect(xml1).toBe(xml2); + }); }); diff --git a/src/__tests__/sitemap.test.ts b/src/__tests__/sitemap.test.ts new file mode 100644 index 0000000..3f4003f --- /dev/null +++ b/src/__tests__/sitemap.test.ts @@ -0,0 +1,127 @@ +/** + * Determinism tests for the sitemap generator. + * + * These tests verify the invariants required by issue #189: + * • lastmod values are derived from content dates / git history, never + * stamped with the current wall-clock date. + * • Calling the builder twice with identical input yields byte-identical + * output. + * • Blog posts, case-study entries, and tag/author aggregate pages each get + * the correct treatment. + */ + +import { describe, expect, it } from 'vitest'; +import { + buildSitemap, + gitLastModified, + toDateString, + type RouteEntry, +} from '../../scripts/sitemap'; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('sitemap builder', () => { + it('produces byte-identical output when called twice with the same routes', () => { + const routes: RouteEntry[] = [ + { route: '/', lastmod: '2026-10-18', changefreq: 'daily', priority: '1.0' }, + { route: '/faq', lastmod: '2026-09-01', changefreq: 'weekly', priority: '0.8' }, + { route: '/blog/tag/privacy', lastmod: undefined, changefreq: 'weekly', priority: '0.8' }, + ]; + expect(buildSitemap(routes)).toBe(buildSitemap(routes)); + }); + + it('omits when the value is undefined', () => { + const routes: RouteEntry[] = [ + { route: '/blog/tag/privacy', lastmod: undefined, changefreq: 'weekly', priority: '0.8' }, + ]; + const xml = buildSitemap(routes); + expect(xml).not.toContain(''); + expect(xml).toContain('https://usewraith.xyz/blog/tag/privacy'); + }); + + it('includes when a date is provided', () => { + const routes: RouteEntry[] = [ + { route: '/faq', lastmod: '2026-09-15', changefreq: 'weekly', priority: '0.8' }, + ]; + const xml = buildSitemap(routes); + expect(xml).toContain('2026-09-15'); + }); + + it('renders the root URL without a trailing slash or path segment', () => { + const routes: RouteEntry[] = [ + { route: '/', lastmod: '2026-10-01', changefreq: 'daily', priority: '1.0' }, + ]; + const xml = buildSitemap(routes); + expect(xml).toContain('https://usewraith.xyz'); + expect(xml).not.toContain('https://usewraith.xyz/'); + }); + + it('does not contain the current date — dates are always fixed values', () => { + const routes: RouteEntry[] = [ + { route: '/', lastmod: '2026-10-18', changefreq: 'daily', priority: '1.0' }, + { route: '/faq', lastmod: '2026-09-01', changefreq: 'weekly', priority: '0.8' }, + ]; + const xml = buildSitemap(routes); + const today = new Date().toISOString().split('T')[0]; + // The only way today's date appears is if a caller passes it explicitly. + // With our deterministic date derivation this must not happen for existing routes. + expect(xml).not.toContain(`${today}`); + }); +}); + +describe('toDateString helper', () => { + it('parses a full ISO timestamp to YYYY-MM-DD', () => { + expect(toDateString('2026-08-12T00:00:00.000Z')).toBe('2026-08-12'); + }); + + it('passes through a bare date string', () => { + expect(toDateString('2026-07-27')).toBe('2026-07-27'); + }); + + it('returns undefined for invalid input', () => { + expect(toDateString('not-a-date')).toBeUndefined(); + expect(toDateString('')).toBeUndefined(); + expect(toDateString(undefined)).toBeUndefined(); + }); +}); + +describe('gitLastModified helper', () => { + it('returns a short date string for tracked repository files', () => { + const date = gitLastModified('package.json'); + expect(date).toBeDefined(); + expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('returns undefined for non-existent or untracked files', () => { + expect(gitLastModified('src/content/blog/non-existent-blog-post-xyz.md')).toBeUndefined(); + }); + + it('returns undefined when called without arguments', () => { + expect(gitLastModified()).toBeUndefined(); + }); + + it('safely handles filenames with quotes and shell metacharacters without throwing', () => { + const maliciousPaths = [ + 'src/content/blog/"; touch /tmp/pwned; #.md', + "src/content/blog/test'; touch /tmp/pwned; '.md", + 'src/content/blog/$(touch /tmp/pwned).md', + 'src/content/blog/`touch /tmp/pwned`.md', + 'src/content/blog/test.md; touch /tmp/pwned', + 'src/content/blog/test.md && touch /tmp/pwned', + 'src/content/blog/test.md | touch /tmp/pwned', + 'src/content/blog/test.md > /tmp/pwned', + ]; + + for (const p of maliciousPaths) { + expect(gitLastModified(p)).toBeUndefined(); + } + }); + + it('handles multiple paths where one contains shell metacharacters', () => { + const maliciousPath = 'src/content/blog/"; touch /tmp/pwned; #.md'; + const result = gitLastModified('package.json', maliciousPath); + if (result) { + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); +}); diff --git a/tests/sitemap.test.ts b/tests/sitemap.test.ts new file mode 100644 index 0000000..347b276 --- /dev/null +++ b/tests/sitemap.test.ts @@ -0,0 +1,74 @@ +/** + * Security regression tests for scripts/sitemap.ts + * + * Verifies that repository-controlled paths (such as filenames from + * src/content/blog passed to gitLastModified via getBlogRoutes) cannot + * execute arbitrary shell commands when containing quotes or shell metacharacters. + */ + +import { existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { gitLastModified } from '../scripts/sitemap'; + +describe('sitemap command injection prevention (regression test)', () => { + it('does not execute shell commands when repository-controlled paths contain quotes and metacharacters', () => { + const canary = join( + tmpdir(), + `sitemap-canary-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`, + ); + + if (existsSync(canary)) { + unlinkSync(canary); + } + + try { + // Craft paths similar to filenames that could be submitted via PR into src/content/blog + const injectionPayloads = [ + `src/content/blog/"; touch "${canary}"; #.md`, + `src/content/blog/test'; touch "${canary}"; '.md`, + `src/content/blog/$(touch "${canary}").md`, + `src/content/blog/\`touch "${canary}"\`.md`, + `src/content/blog/post.md; touch "${canary}"`, + `src/content/blog/post.md && touch "${canary}"`, + `src/content/blog/post.md | touch "${canary}"`, + `src/content/blog/post.md > "${canary}"`, + ]; + + for (const payload of injectionPayloads) { + const result = gitLastModified(payload); + expect(result).toBeUndefined(); + expect(existsSync(canary)).toBe(false); + } + } finally { + if (existsSync(canary)) { + unlinkSync(canary); + } + } + }); + + it('handles multiple paths where one contains shell metacharacters without command execution', () => { + const canary = join( + tmpdir(), + `sitemap-multi-canary-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`, + ); + + if (existsSync(canary)) { + unlinkSync(canary); + } + + try { + const maliciousPath = `src/content/blog/"; touch "${canary}"; #.md`; + const result = gitLastModified('package.json', maliciousPath); + expect(existsSync(canary)).toBe(false); + if (result) { + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + } finally { + if (existsSync(canary)) { + unlinkSync(canary); + } + } + }); +}); diff --git a/tests/smoke/serverless.smoke.test.ts b/tests/smoke/serverless.smoke.test.ts index 0b80acb..8b90868 100644 --- a/tests/smoke/serverless.smoke.test.ts +++ b/tests/smoke/serverless.smoke.test.ts @@ -218,27 +218,46 @@ describe('POST /api/subscribe', () => { ' '.repeat(2048), JSON.stringify({ email: 'padded@example.com' }), ]; - const body = new ReadableStream({ - start(controller) { - for (const part of parts) controller.enqueue(encoder.encode(part)); - controller.close(); - }, - }); - const response = await fetch(subscribeUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-vercel-forwarded-for': '198.51.100.250', - }, - body, - duplex: 'half', - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - } as RequestInit & { duplex: 'half' }); + let response: Response | undefined; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const body = new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(encoder.encode(part)); + controller.close(); + }, + }); + + response = await fetch(subscribeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-vercel-forwarded-for': '198.51.100.250', + }, + body, + duplex: 'half', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + } as RequestInit & { duplex: 'half' }); + break; + } catch (err: unknown) { + const isPipeError = + err instanceof Error && + (err.message.includes('EPIPE') || + (typeof (err as { cause?: { code?: string } }).cause?.code === 'string' && + ['EPIPE', 'ECONNRESET'].includes((err as { cause: { code: string } }).cause.code))); + if (isPipeError && attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 100)); + continue; + } + throw err; + } + } - expect(response.status).toBe(413); - expect(await response.json()).toMatchObject({ code: 'payload_too_large' }); - expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); + expect(response).toBeDefined(); + expect(response!.status).toBe(413); + expect(await response!.json()).toMatchObject({ code: 'payload_too_large' }); + expectCommonSecurityHeaders(response!, SUBSCRIBE_API_SECURITY_HEADERS); expect((await recordedUpstreamCalls()).length).toBe(before); });