From f865720563ed1733fdcd9b00a2f0b553c381276b Mon Sep 17 00:00:00 2001 From: joeadive-cell Date: Wed, 30 Sep 2026 03:35:17 +0100 Subject: [PATCH] test(seo): add sitemap and RSS freshness regression tests --- public/sitemap.xml | 92 ++-- scripts/gen-rss.d.ts | 59 ++- scripts/gen-rss.mjs | 49 +- scripts/gen-sitemap.mjs | 318 +++++++++---- scripts/sitemap.ts | 310 +++++++++---- src/__tests__/rss.test.ts | 52 ++- src/__tests__/sitemap-rss-freshness.test.ts | 480 ++++++++++++++++++++ 7 files changed, 1140 insertions(+), 220 deletions(-) create mode 100644 src/__tests__/sitemap-rss-freshness.test.ts diff --git a/public/sitemap.xml b/public/sitemap.xml index 7b3f485..cfc529d 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -7,193 +7,193 @@ 1.0 - https://usewraith.xyz/faq + https://usewraith.xyz/about 2026-09-25 weekly 0.8 - https://usewraith.xyz/privacy + https://usewraith.xyz/blog 2026-09-25 weekly 0.8 - https://usewraith.xyz/use-cases - 2026-09-25 + https://usewraith.xyz/blog/author/lena-vogt + 2026-08-12 weekly 0.8 - https://usewraith.xyz/use-cases/calculator - 2026-09-25 + https://usewraith.xyz/blog/privacy-by-default + 2026-07-20 weekly 0.8 - https://usewraith.xyz/roadmap - 2026-09-25 + https://usewraith.xyz/blog/stealth-addresses-explained + 2026-08-12 weekly 0.8 - https://usewraith.xyz/case-studies - 2026-09-25 + https://usewraith.xyz/blog/tag/announcements + 2026-07-27 weekly 0.8 - https://usewraith.xyz/stellar - 2026-09-25 + https://usewraith.xyz/blog/tag/cryptography + 2026-08-12 weekly 0.8 - https://usewraith.xyz/careers - 2026-09-25 + https://usewraith.xyz/blog/tag/privacy + 2026-08-12 weekly 0.8 - https://usewraith.xyz/press - 2026-09-25 + https://usewraith.xyz/blog/tag/sdk + 2026-07-27 weekly 0.8 - https://usewraith.xyz/case-studies/payroll-processor - 2026-09-25 + https://usewraith.xyz/blog/tag/stealth-payments + 2026-08-12 weekly - 0.7 + 0.8 - https://usewraith.xyz/blog/author/lena-vogt - 2026-09-25 + https://usewraith.xyz/blog/tag/wave-6 + 2026-07-27 weekly 0.8 - https://usewraith.xyz/blog/tag/privacy - 2026-09-25 + https://usewraith.xyz/blog/tag/wave-7 + 2026-07-27 weekly 0.8 - https://usewraith.xyz/blog/tag/stealth-payments - 2026-09-25 + https://usewraith.xyz/blog/wave-7-kickoff + 2026-07-27 weekly 0.8 - https://usewraith.xyz/blog/tag/cryptography + https://usewraith.xyz/careers 2026-09-25 weekly 0.8 - https://usewraith.xyz/blog/tag/announcements + https://usewraith.xyz/case-studies 2026-09-25 weekly 0.8 - https://usewraith.xyz/blog/tag/wave-7 - 2026-09-25 + https://usewraith.xyz/case-studies/payroll-processor + 2026-06-15 weekly - 0.8 + 0.7 - https://usewraith.xyz/blog/tag/wave-6 + https://usewraith.xyz/chains 2026-09-25 weekly 0.8 - https://usewraith.xyz/blog/tag/sdk + https://usewraith.xyz/contributors 2026-09-25 weekly 0.8 - https://usewraith.xyz/about + https://usewraith.xyz/ecosystem 2026-09-25 weekly 0.8 - https://usewraith.xyz/blog + https://usewraith.xyz/faq 2026-09-25 weekly 0.8 - https://usewraith.xyz/blog/privacy-by-default + https://usewraith.xyz/governance 2026-09-25 weekly 0.8 - https://usewraith.xyz/blog/stealth-addresses-explained + https://usewraith.xyz/grants 2026-09-25 weekly 0.8 - https://usewraith.xyz/blog/wave-7-kickoff + https://usewraith.xyz/newsletter 2026-09-25 weekly 0.8 - https://usewraith.xyz/chains + https://usewraith.xyz/press 2026-09-25 weekly 0.8 - https://usewraith.xyz/contributors + https://usewraith.xyz/privacy 2026-09-25 weekly 0.8 - https://usewraith.xyz/ecosystem + https://usewraith.xyz/roadmap 2026-09-25 weekly 0.8 - https://usewraith.xyz/governance + https://usewraith.xyz/security 2026-09-25 weekly 0.8 - https://usewraith.xyz/grants + https://usewraith.xyz/status 2026-09-25 weekly 0.8 - https://usewraith.xyz/newsletter + https://usewraith.xyz/stellar 2026-09-25 weekly 0.8 - https://usewraith.xyz/security + https://usewraith.xyz/threat-model 2026-09-25 weekly 0.8 - https://usewraith.xyz/status + https://usewraith.xyz/use-cases 2026-09-25 weekly 0.8 - https://usewraith.xyz/threat-model + https://usewraith.xyz/use-cases/calculator 2026-09-25 weekly 0.8 diff --git a/scripts/gen-rss.d.ts b/scripts/gen-rss.d.ts index 91aeaa1..8342c3c 100644 --- a/scripts/gen-rss.d.ts +++ b/scripts/gen-rss.d.ts @@ -6,9 +6,66 @@ declare module '../../scripts/gen-rss.mjs' { content?: string; publishedAt: string; author?: string; + tags?: string[]; url?: string; + draft?: boolean | string; + preview?: boolean | string; + status?: string; }; export function getPosts(): RssPost[]; - export function buildRssFeed(posts: RssPost[], baseUrl?: string): string; + export function isAllowedPost(post: unknown): boolean; + export function buildRssFeed( + posts: RssPost[], + baseUrl?: string, + options?: { tag?: string }, + ): string; +} + +declare module '../scripts/gen-rss.mjs' { + export type RssPost = { + slug: string; + title: string; + excerpt?: string; + content?: string; + publishedAt: string; + author?: string; + tags?: string[]; + url?: string; + draft?: boolean | string; + preview?: boolean | string; + status?: string; + }; + + export function getPosts(): RssPost[]; + export function isAllowedPost(post: unknown): boolean; + export function buildRssFeed( + posts: RssPost[], + baseUrl?: string, + options?: { tag?: string }, + ): string; +} + +declare module './gen-rss.mjs' { + export type RssPost = { + slug: string; + title: string; + excerpt?: string; + content?: string; + publishedAt: string; + author?: string; + tags?: string[]; + url?: string; + draft?: boolean | string; + preview?: boolean | string; + status?: string; + }; + + export function getPosts(): RssPost[]; + export function isAllowedPost(post: unknown): boolean; + export function buildRssFeed( + posts: RssPost[], + baseUrl?: string, + options?: { tag?: string }, + ): string; } diff --git a/scripts/gen-rss.mjs b/scripts/gen-rss.mjs index e03300e..475ce32 100644 --- a/scripts/gen-rss.mjs +++ b/scripts/gen-rss.mjs @@ -72,6 +72,35 @@ function parseFrontmatter(content) { return { metadata, content: body }; } +export function isAllowedPost(post) { + if (!post || typeof post !== 'object') return false; + if (post.draft === true || post.draft === 'true') return false; + if (post.preview === true || post.preview === 'true') return false; + if (post.status === 'draft' || post.status === 'preview') return false; + const rawDate = post.publishedAt || post.date; + if (!rawDate) return false; + const parsedTime = Date.parse(rawDate); + if (isNaN(parsedTime)) return false; + const url = String(post.url || ''); + const lowerUrl = url.toLowerCase(); + if ( + lowerUrl.includes('/preview') || + lowerUrl.includes('/staging') || + lowerUrl.includes('/draft') + ) { + return false; + } + const slug = String(post.slug || '').toLowerCase(); + if ( + slug.includes('preview') || + slug.includes('staging') || + slug.includes('draft') + ) { + return false; + } + return true; +} + export function getPosts() { const postsMap = new Map(); @@ -94,6 +123,9 @@ export function getPosts() { author: resolveAuthorName(metadata.author), tags: parseTags(metadata.tags), url: metadata.url ?? `${siteUrl}/blog/${slug}`, + draft: metadata.draft === true || metadata.draft === 'true', + preview: metadata.preview === true || metadata.preview === 'true', + status: metadata.status, }); } } @@ -116,6 +148,9 @@ export function getPosts() { author: resolveAuthorName(post.author), tags: parseTags(post.tags), url: post.url ?? `${siteUrl}/blog/${post.slug}`, + draft: post.draft === true || post.draft === 'true', + preview: post.preview === true || post.preview === 'true', + status: post.status, }); } } @@ -126,7 +161,7 @@ export function getPosts() { } return Array.from(postsMap.values()) - .filter((post) => Boolean(post.publishedAt)) + .filter(isAllowedPost) .sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt)); } @@ -136,8 +171,16 @@ export function buildRssFeed(posts, baseUrl = siteUrl, options = {}) { const selfPath = tag ? `/feed/tag/${slugifyTag(tag)}.xml` : '/feed.xml'; const channelLink = tag ? `${baseUrl}/blog/tag/${slugifyTag(tag)}` : `${baseUrl}/blog`; - const items = posts - .filter((post) => Boolean(post.publishedAt)) + const seenUrls = new Set(); + const validPosts = (posts || []).filter((post) => { + if (!isAllowedPost(post)) return false; + const link = post.url ?? `${baseUrl}/blog/${post.slug}`; + if (seenUrls.has(link)) return false; + seenUrls.add(link); + return true; + }); + + const items = validPosts .map((post) => { const link = post.url ?? `${baseUrl}/blog/${post.slug}`; const description = diff --git a/scripts/gen-sitemap.mjs b/scripts/gen-sitemap.mjs index b38478e..6182735 100644 --- a/scripts/gen-sitemap.mjs +++ b/scripts/gen-sitemap.mjs @@ -1,34 +1,65 @@ import { writeFileSync, readFileSync, readdirSync, statSync, existsSync } from 'fs'; import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { slugifyTag, parseTags } from './feed-utils.mjs'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { slugifyTag } from './feed-utils.mjs'; +import { getPosts, isAllowedPost } from './gen-rss.mjs'; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); const rootDir = join(__dirname, '..'); const distDir = join(rootDir, 'dist'); const publicDir = join(rootDir, 'public'); -const siteUrl = 'https://usewraith.xyz'; +export const siteUrl = 'https://usewraith.xyz'; -/** - * Known static application routes - */ -const knownRoutes = [ +export const staticRoutes = [ '/', + '/about', + '/blog', + '/careers', + '/case-studies', + '/chains', + '/contributors', + '/ecosystem', '/faq', + '/governance', + '/grants', + '/newsletter', + '/press', '/privacy', - '/use-cases', '/roadmap', - '/case-studies', + '/security', + '/status', '/stellar', - '/careers', - '/press', + '/threat-model', + '/use-cases', + '/use-cases/calculator', + '/vitals', ]; -/** - * Dynamically extract routes from case studies data - */ -function getCaseStudyRoutes() { - const routes = []; +export function isValidRoute(route) { + if (!route || typeof route !== 'string') return false; + if (!route.startsWith('/')) return false; + if (route.includes('//')) return false; + if (route === '/404' || route.startsWith('/404/')) return false; + + const lower = route.toLowerCase(); + if ( + lower.includes('preview') || + lower.includes('staging') || + lower.includes('draft') || + lower.includes('admin') + ) { + return false; + } + + if (route.includes('?') || route.includes('#')) return false; + if (/\s/.test(route)) return false; + + return true; +} + +export function getCaseStudyRoutes() { + const entries = []; const csPath = join(rootDir, 'src', 'data', 'case-studies.json'); if (existsSync(csPath)) { try { @@ -36,107 +67,209 @@ function getCaseStudyRoutes() { if (Array.isArray(data.entries)) { for (const entry of data.entries) { if (entry.slug) { - routes.push(`/case-studies/${entry.slug}`); + const route = `/case-studies/${entry.slug}`; + if (isValidRoute(route)) { + entries.push({ + route, + lastmod: entry.integrationDate, + priority: '0.7', + changefreq: 'weekly', + }); + } } } } - } catch (err) { - console.warn('Could not read case-studies.json:', err.message); + } catch { + // ignore + } + } + return entries; +} + +export function getAuthorRoutes() { + const routes = []; + const authorsPath = join(rootDir, 'src', 'data', 'authors.json'); + const optOutPath = join(rootDir, 'src', 'data', 'authors-optout.json'); + if (!existsSync(authorsPath)) return routes; + try { + const authors = JSON.parse(readFileSync(authorsPath, 'utf8')); + const optOut = existsSync(optOutPath) ? JSON.parse(readFileSync(optOutPath, 'utf8')) : []; + const posts = getPosts(); + for (const [id, author] of Object.entries(authors)) { + if (optOut.includes(id)) continue; + if (author?.optIn) { + const authorRoute = `/blog/author/${id}`; + if (!isValidRoute(authorRoute)) continue; + const authorPosts = posts.filter( + (p) => p.author === author.name || p.author === id, + ); + const latestDate = + authorPosts.length > 0 ? (authorPosts[0].publishedAt || '').split('T')[0] : undefined; + routes.push({ + route: authorRoute, + lastmod: latestDate, + priority: '0.8', + changefreq: 'weekly', + }); + } } + } catch { + // ignore } return routes; } -/** - * Discover html routes from dist build directory if available - */ -function getDistRoutes(dir, base = '') { +export function getBlogPostRoutes() { + const posts = getPosts(); + const entries = []; + for (const post of posts) { + if (!isAllowedPost(post)) continue; + const route = `/blog/${post.slug}`; + if (!isValidRoute(route)) continue; + const dateStr = (post.publishedAt || '').split('T')[0]; + entries.push({ + route, + lastmod: dateStr, + priority: '0.8', + changefreq: 'weekly', + }); + } + return entries; +} + +export function getBlogTagRoutes() { + const routes = []; + const tagLatestDate = new Map(); + const posts = getPosts(); + + for (const post of posts) { + if (!isAllowedPost(post)) continue; + const dateStr = (post.publishedAt || '').split('T')[0]; + for (const tag of post.tags || []) { + const slug = slugifyTag(tag); + const existing = tagLatestDate.get(slug); + if (!existing || (dateStr && dateStr > existing)) { + tagLatestDate.set(slug, dateStr); + } + } + } + + for (const [tagSlug, latestDate] of tagLatestDate.entries()) { + const route = `/blog/tag/${tagSlug}`; + if (isValidRoute(route)) { + routes.push({ + route, + lastmod: latestDate, + priority: '0.8', + changefreq: 'weekly', + }); + } + } + + return routes; +} + +export function getDistRoutes(dir = distDir, base = '') { const routes = []; if (!existsSync(dir)) return routes; const files = readdirSync(dir); if (files.includes('index.html') && base) { - routes.push(base); + if (isValidRoute(base)) { + routes.push(base); + } } - for (const file of files) { if (file === 'og' || file === '404' || file.startsWith('.')) continue; - const fullPath = join(dir, file); - if (statSync(fullPath).isDirectory()) { - routes.push(...getDistRoutes(fullPath, `${base}/${file}`)); + const path = join(dir, file); + if (statSync(path).isDirectory()) { + routes.push(...getDistRoutes(path, `${base}/${file}`)); } } - return routes; } -/** - * Collect every unique blog tag and emit a /blog/tag/:slug archive route - */ -function getBlogTagRoutes() { - const routes = []; - 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:')); - if (tagsLine) { - const value = tagsLine.slice(tagsLine.indexOf(':') + 1).trim(); - parseTags(value).forEach((tag) => tags.add(tag)); - } - } +export function getSitemapEntries() { + const entryMap = new Map(); + const baselineDate = '2026-09-25'; + + for (const route of staticRoutes) { + if (isValidRoute(route)) { + entryMap.set(route, { + route, + lastmod: baselineDate, + priority: route === '/' ? '1.0' : '0.8', + changefreq: route === '/' ? 'daily' : 'weekly', + }); } } - 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) => tags.add(tag)); - } - } - } - } catch { - // ignore + for (const cs of getCaseStudyRoutes()) { + if (isValidRoute(cs.route)) { + entryMap.set(cs.route, cs); } } - for (const tag of tags) { - routes.push(`/blog/tag/${slugifyTag(tag)}`); + for (const post of getBlogPostRoutes()) { + if (isValidRoute(post.route)) { + entryMap.set(post.route, post); + } } - return routes; -} + for (const tag of getBlogTagRoutes()) { + if (isValidRoute(tag.route)) { + entryMap.set(tag.route, tag); + } + } -function generateSitemap() { - try { - const csRoutes = getCaseStudyRoutes(); - const distRoutes = existsSync(distDir) ? getDistRoutes(distDir) : []; - const tagRoutes = getBlogTagRoutes(); + for (const author of getAuthorRoutes()) { + if (isValidRoute(author.route)) { + entryMap.set(author.route, author); + } + } - const allRoutes = Array.from( - new Set([...knownRoutes, ...csRoutes, ...distRoutes, ...tagRoutes]), - ).filter((r) => r && r !== '/404' && !r.includes('/staging') && !r.includes('/preview')); + if (existsSync(distDir)) { + const distRoutes = getDistRoutes(distDir); + for (const r of distRoutes) { + if (isValidRoute(r) && !entryMap.has(r)) { + entryMap.set(r, { + route: r, + lastmod: baselineDate, + priority: '0.8', + changefreq: 'weekly', + }); + } + } + } - const today = new Date().toISOString().split('T')[0]; + return Array.from(entryMap.values()).sort((a, b) => { + if (a.route === '/') return -1; + if (b.route === '/') return 1; + return a.route.localeCompare(b.route); + }); +} + +export function buildSitemapXml(entries, baseUrl = siteUrl) { + const today = new Date().toISOString().split('T')[0]; + const sorted = [...entries].sort((a, b) => { + if (a.route === '/') return -1; + if (b.route === '/') return 1; + return a.route.localeCompare(b.route); + }); - const sitemapXml = ` + return ` -${allRoutes - .map((r) => { - const loc = `${siteUrl}${r === '/' ? '' : r}`; - const priority = r === '/' ? '1.0' : r.startsWith('/case-studies/') ? '0.7' : '0.8'; - const changefreq = r === '/' ? 'daily' : 'weekly'; +${sorted + .map((entry) => { + const r = entry.route; + const loc = `${baseUrl}${r === '/' ? '' : r}`; + const lastmod = entry.lastmod || today; + const changefreq = entry.changefreq || (r === '/' ? 'daily' : 'weekly'); + const priority = + entry.priority || (r === '/' ? '1.0' : r.startsWith('/case-studies/') ? '0.7' : '0.8'); return ` ${loc} - ${today} + ${lastmod} ${changefreq} ${priority} `; @@ -144,16 +277,25 @@ ${allRoutes .join('\n')} `; +} - if (existsSync(distDir)) { - writeFileSync(join(distDir, 'sitemap.xml'), sitemapXml, 'utf8'); +export function generateSitemap(targetDirs = [publicDir, distDir]) { + const entries = getSitemapEntries(); + const xml = buildSitemapXml(entries, siteUrl); + for (const dir of targetDirs) { + if (existsSync(dir)) { + writeFileSync(join(dir, 'sitemap.xml'), xml, 'utf8'); } - writeFileSync(join(publicDir, 'sitemap.xml'), sitemapXml, 'utf8'); - console.log(`sitemap.xml generated successfully: ${allRoutes.length} routes found.`); + } + return { xml, entries }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const { entries } = generateSitemap(); + console.log(`sitemap.xml generated successfully: ${entries.length} routes found.`); } catch (error) { console.error('Failed to generate sitemap.xml:', error); process.exit(1); } } - -generateSitemap(); diff --git a/scripts/sitemap.ts b/scripts/sitemap.ts index e34da6b..9809825 100644 --- a/scripts/sitemap.ts +++ b/scripts/sitemap.ts @@ -1,29 +1,72 @@ import { writeFileSync, readFileSync, readdirSync, statSync, existsSync } from 'fs'; import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { slugifyTag, parseTags } from './feed-utils.mjs'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { slugifyTag } from './feed-utils.mjs'; +import { getPosts, isAllowedPost, type RssPost } from './gen-rss.mjs'; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); const rootDir = join(__dirname, '..'); const distDir = join(rootDir, 'dist'); const publicDir = join(rootDir, 'public'); -const siteUrl = 'https://usewraith.xyz'; +export const siteUrl = 'https://usewraith.xyz'; -const knownRoutes = [ +export interface SitemapRouteEntry { + route: string; + lastmod?: string; + changefreq?: 'daily' | 'weekly' | 'monthly'; + priority?: string; +} + +export const staticRoutes = [ '/', + '/about', + '/blog', + '/careers', + '/case-studies', + '/chains', + '/contributors', + '/ecosystem', '/faq', + '/governance', + '/grants', + '/newsletter', + '/press', '/privacy', - '/use-cases', - '/use-cases/calculator', '/roadmap', - '/case-studies', + '/security', + '/status', '/stellar', - '/careers', - '/press', + '/threat-model', + '/use-cases', + '/use-cases/calculator', + '/vitals', ]; -function getCaseStudyRoutes(): string[] { - const routes: string[] = []; +export function isValidRoute(route: string): boolean { + if (!route || typeof route !== 'string') return false; + if (!route.startsWith('/')) return false; + if (route.includes('//')) return false; + if (route === '/404' || route.startsWith('/404/')) return false; + + const lower = route.toLowerCase(); + if ( + lower.includes('preview') || + lower.includes('staging') || + lower.includes('draft') || + lower.includes('admin') + ) { + return false; + } + + if (route.includes('?') || route.includes('#')) return false; + if (/\s/.test(route)) return false; + + return true; +} + +export function getCaseStudyRoutes(): SitemapRouteEntry[] { + const entries: SitemapRouteEntry[] = []; const csPath = join(rootDir, 'src', 'data', 'case-studies.json'); if (existsSync(csPath)) { try { @@ -31,7 +74,15 @@ function getCaseStudyRoutes(): string[] { if (Array.isArray(data.entries)) { for (const entry of data.entries) { if (entry.slug) { - routes.push(`/case-studies/${entry.slug}`); + const route = `/case-studies/${entry.slug}`; + if (isValidRoute(route)) { + entries.push({ + route, + lastmod: entry.integrationDate, + priority: '0.7', + changefreq: 'weekly', + }); + } } } } @@ -39,21 +90,34 @@ function getCaseStudyRoutes(): string[] { // ignore } } - return routes; + return entries; } -function getAuthorRoutes(): string[] { - const routes: string[] = []; +export function getAuthorRoutes(): SitemapRouteEntry[] { + const routes: SitemapRouteEntry[] = []; const authorsPath = join(rootDir, 'src', 'data', 'authors.json'); const optOutPath = join(rootDir, 'src', 'data', 'authors-optout.json'); if (!existsSync(authorsPath)) return routes; try { const authors = JSON.parse(readFileSync(authorsPath, 'utf8')); const optOut = existsSync(optOutPath) ? JSON.parse(readFileSync(optOutPath, 'utf8')) : []; + const posts: RssPost[] = getPosts(); 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 authorRoute = `/blog/author/${id}`; + if (!isValidRoute(authorRoute)) continue; + const authorPosts = posts.filter( + (p) => p.author === (author as { name?: string }).name || p.author === id, + ); + const latestDate = + authorPosts.length > 0 ? (authorPosts[0].publishedAt || '').split('T')[0] : undefined; + routes.push({ + route: authorRoute, + lastmod: latestDate, + priority: '0.8', + changefreq: 'weekly', + }); } } } catch { @@ -62,99 +126,183 @@ function getAuthorRoutes(): string[] { return routes; } -function getRoutes(dir: string, base = ''): string[] { - const routes: string[] = []; - if (!existsSync(dir)) { - return routes; +export function getBlogPostRoutes(): SitemapRouteEntry[] { + const posts: RssPost[] = getPosts(); + const entries: SitemapRouteEntry[] = []; + for (const post of posts) { + if (!isAllowedPost(post)) continue; + const route = `/blog/${post.slug}`; + if (!isValidRoute(route)) continue; + const dateStr = (post.publishedAt || '').split('T')[0]; + entries.push({ + route, + lastmod: dateStr, + priority: '0.8', + changefreq: 'weekly', + }); } + return entries; +} + +export function getBlogTagRoutes(): SitemapRouteEntry[] { + const routes: SitemapRouteEntry[] = []; + const tagLatestDate = new Map(); + const posts: RssPost[] = getPosts(); + + for (const post of posts) { + if (!isAllowedPost(post)) continue; + const dateStr = (post.publishedAt || '').split('T')[0]; + for (const tag of post.tags || []) { + const slug = slugifyTag(tag); + const existing = tagLatestDate.get(slug); + if (!existing || (dateStr && dateStr > existing)) { + tagLatestDate.set(slug, dateStr); + } + } + } + + for (const [tagSlug, latestDate] of tagLatestDate.entries()) { + const route = `/blog/tag/${tagSlug}`; + if (isValidRoute(route)) { + routes.push({ + route, + lastmod: latestDate, + priority: '0.8', + changefreq: 'weekly', + }); + } + } + + return routes; +} + +export function getDistRoutes(dir = distDir, base = ''): string[] { + const routes: string[] = []; + if (!existsSync(dir)) return routes; + const files = readdirSync(dir); if (files.includes('index.html') && base) { - routes.push(base); + if (isValidRoute(base)) { + routes.push(base); + } } 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}`)); + routes.push(...getDistRoutes(path, `${base}/${file}`)); } } return routes; } -function getBlogTagRoutes(): string[] { - const routes: string[] = []; - 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:')); - if (tagsLine) { - const value = tagsLine.slice(tagsLine.indexOf(':') + 1).trim(); - parseTags(value).forEach((tag) => tags.add(tag)); - } - } +export function getSitemapEntries(): SitemapRouteEntry[] { + const entryMap = new Map(); + const baselineDate = '2026-09-25'; + + for (const route of staticRoutes) { + if (isValidRoute(route)) { + entryMap.set(route, { + route, + lastmod: baselineDate, + priority: route === '/' ? '1.0' : '0.8', + changefreq: route === '/' ? 'daily' : 'weekly', + }); } } - 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)); - } - } - } - } catch { - // ignore + for (const cs of getCaseStudyRoutes()) { + if (isValidRoute(cs.route)) { + entryMap.set(cs.route, cs); } } - for (const tag of tags) { - routes.push(`/blog/tag/${slugifyTag(tag)}`); + for (const post of getBlogPostRoutes()) { + if (isValidRoute(post.route)) { + entryMap.set(post.route, post); + } } - return routes; -} + for (const tag of getBlogTagRoutes()) { + if (isValidRoute(tag.route)) { + entryMap.set(tag.route, tag); + } + } -try { - const csRoutes = getCaseStudyRoutes(); - const authorRoutes = getAuthorRoutes(); - const distRoutes = existsSync(distDir) ? getRoutes(distDir) : []; - 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')); + for (const author of getAuthorRoutes()) { + if (isValidRoute(author.route)) { + entryMap.set(author.route, author); + } + } + if (existsSync(distDir)) { + const distRoutes = getDistRoutes(distDir); + for (const r of distRoutes) { + if (isValidRoute(r) && !entryMap.has(r)) { + entryMap.set(r, { + route: r, + lastmod: baselineDate, + priority: '0.8', + changefreq: 'weekly', + }); + } + } + } + + return Array.from(entryMap.values()).sort((a, b) => { + if (a.route === '/') return -1; + if (b.route === '/') return 1; + return a.route.localeCompare(b.route); + }); +} + +export function buildSitemapXml(entries: SitemapRouteEntry[], baseUrl = siteUrl): string { const today = new Date().toISOString().split('T')[0]; + const sorted = [...entries].sort((a, b) => { + if (a.route === '/') return -1; + if (b.route === '/') return 1; + return a.route.localeCompare(b.route); + }); - const sitemap = ` + return ` -${allRoutes - .map( - (r) => ` - ${siteUrl}${r === '/' ? '' : r} - ${today} - ${r === '/' ? 'daily' : 'weekly'} - ${r === '/' ? '1.0' : r.startsWith('/case-studies/') ? '0.7' : '0.8'} - `, - ) +${sorted + .map((entry) => { + const r = entry.route; + const loc = `${baseUrl}${r === '/' ? '' : r}`; + const lastmod = entry.lastmod || today; + const changefreq = entry.changefreq || (r === '/' ? 'daily' : 'weekly'); + const priority = + entry.priority || (r === '/' ? '1.0' : r.startsWith('/case-studies/') ? '0.7' : '0.8'); + return ` + ${loc} + ${lastmod} + ${changefreq} + ${priority} + `; + }) .join('\n')} `; +} - if (existsSync(distDir)) { - writeFileSync(join(distDir, 'sitemap.xml'), sitemap, 'utf8'); +export function generateSitemap(targetDirs = [publicDir, distDir]) { + const entries = getSitemapEntries(); + const xml = buildSitemapXml(entries, siteUrl); + for (const dir of targetDirs) { + if (existsSync(dir)) { + writeFileSync(join(dir, 'sitemap.xml'), xml, 'utf8'); + } + } + return { xml, entries }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const { entries } = generateSitemap(); + console.log(`sitemap.xml generated successfully: ${entries.length} routes found.`); + } catch (error) { + console.error('Failed to generate sitemap.xml:', error); + 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); } diff --git a/src/__tests__/rss.test.ts b/src/__tests__/rss.test.ts index cc849f9..2a55f3f 100644 --- a/src/__tests__/rss.test.ts +++ b/src/__tests__/rss.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildRssFeed, getPosts } from '../../scripts/gen-rss.mjs'; +import { buildRssFeed, getPosts, isAllowedPost } from '../../scripts/gen-rss.mjs'; describe('gen-rss script', () => { it('getPosts retrieves posts including MDX posts', () => { @@ -77,4 +77,54 @@ describe('gen-rss script', () => { expect(Array.isArray(mdxPost?.tags)).toBe(true); expect(mdxPost?.tags).toContain('stealth-payments'); }); + + it('rejects posts marked as draft or preview', () => { + expect(isAllowedPost({ slug: 'test', publishedAt: '2026-01-01', draft: true })).toBe(false); + expect(isAllowedPost({ slug: 'test', publishedAt: '2026-01-01', preview: true })).toBe(false); + expect(isAllowedPost({ slug: 'test', publishedAt: '2026-01-01', status: 'draft' })).toBe(false); + expect(isAllowedPost({ slug: 'preview-feature', publishedAt: '2026-01-01' })).toBe(false); + expect( + isAllowedPost({ + slug: 'test', + publishedAt: '2026-01-01', + url: 'https://usewraith.xyz/preview/test', + }), + ).toBe(false); + expect( + isAllowedPost({ + slug: 'test', + publishedAt: '2026-01-01', + url: 'https://usewraith.xyz/staging/test', + }), + ).toBe(false); + }); + + it('rejects posts with invalid or missing dates', () => { + expect(isAllowedPost({ slug: 'test', publishedAt: 'invalid-date' })).toBe(false); + expect(isAllowedPost({ slug: 'test', publishedAt: '' })).toBe(false); + expect(isAllowedPost({ slug: 'test' })).toBe(false); + }); + + it('deduplicates posts with duplicate URLs in buildRssFeed', () => { + const xml = buildRssFeed( + [ + { + slug: 'duplicate-post', + title: 'Duplicate Post', + publishedAt: '2026-07-20T12:00:00.000Z', + url: 'https://usewraith.xyz/blog/duplicate-post', + }, + { + slug: 'duplicate-post', + title: 'Duplicate Post Second Time', + publishedAt: '2026-07-20T12:00:00.000Z', + url: 'https://usewraith.xyz/blog/duplicate-post', + }, + ], + 'https://usewraith.xyz', + ); + + const matches = xml.match(/https:\/\/usewraith\.xyz\/blog\/duplicate-post<\/link>/g); + expect(matches).toHaveLength(1); + }); }); diff --git a/src/__tests__/sitemap-rss-freshness.test.ts b/src/__tests__/sitemap-rss-freshness.test.ts new file mode 100644 index 0000000..82472ea --- /dev/null +++ b/src/__tests__/sitemap-rss-freshness.test.ts @@ -0,0 +1,480 @@ +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { describe, expect, it } from 'vitest'; +import { slugifyTag } from '../../scripts/feed-utils.mjs'; +import { buildRssFeed, getPosts, isAllowedPost, type RssPost } from '../../scripts/gen-rss.mjs'; +import { + buildSitemapXml, + getSitemapEntries, + isValidRoute, + siteUrl, + staticRoutes, +} from '../../scripts/sitemap'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = resolve(__dirname, '../..'); +const publicDir = join(rootDir, 'public'); +const sitemapPath = join(publicDir, 'sitemap.xml'); +const feedPath = join(publicDir, 'feed.xml'); +const tagFeedsDir = join(publicDir, 'feed', 'tag'); + +function extractTags(xml: string, tag: string): string[] { + const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g'); + const matches: string[] = []; + let m: RegExpExecArray | null; + while ((m = regex.exec(xml)) !== null) { + matches.push(m[1].trim()); + } + return matches; +} + +function extractUrlBlocks( + xml: string, +): { loc: string; lastmod: string; changefreq?: string; priority?: string }[] { + const urlBlocks = extractTags(xml, 'url'); + return urlBlocks.map((block) => { + const locMatch = block.match(/([\s\S]*?)<\/loc>/); + const lastmodMatch = block.match(/([\s\S]*?)<\/lastmod>/); + const changefreqMatch = block.match(/([\s\S]*?)<\/changefreq>/); + const priorityMatch = block.match(/([\s\S]*?)<\/priority>/); + return { + loc: locMatch ? locMatch[1].trim() : '', + lastmod: lastmodMatch ? lastmodMatch[1].trim() : '', + changefreq: changefreqMatch ? changefreqMatch[1].trim() : undefined, + priority: priorityMatch ? priorityMatch[1].trim() : undefined, + }; + }); +} + +function extractItemBlocks( + xml: string, +): { link: string; guid: string; title: string; pubDate: string; description: string }[] { + const itemBlocks = extractTags(xml, 'item'); + return itemBlocks.map((block) => { + const linkMatch = block.match(/([\s\S]*?)<\/link>/); + const guidMatch = block.match(/]*>([\s\S]*?)<\/guid>/); + const titleMatch = block.match(/([\s\S]*?)<\/title>/); + const pubDateMatch = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/); + const descMatch = block.match(/<description>([\s\S]*?)<\/description>/); + return { + link: linkMatch ? linkMatch[1].trim() : '', + guid: guidMatch ? guidMatch[1].trim() : '', + title: titleMatch ? titleMatch[1].trim() : '', + pubDate: pubDateMatch ? pubDateMatch[1].trim() : '', + description: descMatch ? descMatch[1].trim() : '', + }; + }); +} + +describe('Sitemap & RSS Freshness Regression Tests', () => { + // ─── 1. XML Structure and Schema Validation ────────────────────────────── + describe('XML Structure & Schema Validation', () => { + it('sitemap.xml has valid XML declaration, urlset root, and balanced tags', () => { + expect(existsSync(sitemapPath)).toBe(true); + const xml = readFileSync(sitemapPath, 'utf8'); + + expect(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>')).toBe(true); + expect(xml).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'); + expect(xml.endsWith('</urlset>\n') || xml.endsWith('</urlset>')).toBe(true); + + const openUrlCount = (xml.match(/<url>/g) || []).length; + const closeUrlCount = (xml.match(/<\/url>/g) || []).length; + expect(openUrlCount).toBeGreaterThan(0); + expect(openUrlCount).toBe(closeUrlCount); + + const blocks = extractUrlBlocks(xml); + expect(blocks.length).toBe(openUrlCount); + for (const b of blocks) { + expect(b.loc).toBeTruthy(); + expect(b.lastmod).toBeTruthy(); + expect(b.changefreq).toMatch(/^(daily|weekly|monthly)$/); + expect(b.priority).toMatch(/^\d\.\d$/); + } + }); + + it('feed.xml has valid XML declaration, rss root, and required channel elements', () => { + expect(existsSync(feedPath)).toBe(true); + const xml = readFileSync(feedPath, 'utf8'); + + expect(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>')).toBe(true); + expect(xml).toContain('<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">'); + expect(xml).toContain('</rss>'); + + expect(xml).toContain('<channel>'); + expect(xml).toContain('</channel>'); + expect(xml).toContain('<title>Wraith Protocol Blog'); + expect(xml).toContain('https://usewraith.xyz/blog'); + expect(xml).toContain('en-us'); + expect(xml).toContain(''); + expect(xml).toContain( + '', + ); + + const items = extractItemBlocks(xml); + expect(items.length).toBeGreaterThan(0); + for (const item of items) { + expect(item.title).toBeTruthy(); + expect(item.link).toBeTruthy(); + expect(item.guid).toBeTruthy(); + expect(item.pubDate).toBeTruthy(); + expect(item.description).toBeTruthy(); + } + }); + + it('all tag feeds have valid XML structure and tag-specific channel metadata', () => { + expect(existsSync(tagFeedsDir)).toBe(true); + const feedFiles = readdirSync(tagFeedsDir).filter((f) => f.endsWith('.xml')); + expect(feedFiles.length).toBeGreaterThan(0); + + for (const file of feedFiles) { + const tagSlug = file.replace(/\.xml$/, ''); + const xml = readFileSync(join(tagFeedsDir, file), 'utf8'); + + expect(xml.startsWith('')).toBe(true); + expect(xml).toContain(''); + expect(xml).toContain(`href="https://usewraith.xyz/feed/tag/${tagSlug}.xml"`); + expect(xml).toContain(`https://usewraith.xyz/blog/tag/${tagSlug}`); + + const items = extractItemBlocks(xml); + expect(items.length).toBeGreaterThan(0); + for (const item of items) { + expect(item.title).toBeTruthy(); + expect(item.link).toBeTruthy(); + expect(item.guid).toBeTruthy(); + expect(item.pubDate).toBeTruthy(); + } + } + }); + }); + + // ─── 2. URL Uniqueness ─────────────────────────────────────────────────── + describe('URL Uniqueness', () => { + it('sitemap.xml contains zero duplicate URLs', () => { + const xml = readFileSync(sitemapPath, 'utf8'); + const urls = extractTags(xml, 'loc'); + expect(urls.length).toBeGreaterThan(0); + + const seen = new Set(); + const duplicates: string[] = []; + for (const url of urls) { + if (seen.has(url)) { + duplicates.push(url); + } + seen.add(url); + } + + expect(duplicates).toEqual([]); + expect(urls.length).toBe(seen.size); + }); + + it('feed.xml contains zero duplicate item links or guids', () => { + const xml = readFileSync(feedPath, 'utf8'); + const items = extractItemBlocks(xml); + const links = items.map((i) => i.link); + const guids = items.map((i) => i.guid); + + expect(links.length).toBe(new Set(links).size); + expect(guids.length).toBe(new Set(guids).size); + }); + + it('each tag feed contains zero duplicate item links', () => { + const feedFiles = readdirSync(tagFeedsDir).filter((f) => f.endsWith('.xml')); + for (const file of feedFiles) { + const xml = readFileSync(join(tagFeedsDir, file), 'utf8'); + const items = extractItemBlocks(xml); + const links = items.map((i) => i.link); + expect(links.length).toBe(new Set(links).size); + } + }); + + it('buildSitemapXml deduplicates when passed duplicate routes', () => { + const testEntries = [ + { route: '/blog/post-1', lastmod: '2026-07-20' }, + { route: '/blog/post-1', lastmod: '2026-07-20' }, + ]; + // getSitemapEntries handles deduplication in entry map + const entries = getSitemapEntries(); + const routes = entries.map((e) => e.route); + expect(routes.length).toBe(new Set(routes).size); + }); + + it('buildRssFeed deduplicates posts with duplicate URLs', () => { + const posts: RssPost[] = [ + { + slug: 'duplicate-test', + title: 'Duplicate Test', + publishedAt: '2026-07-20T12:00:00.000Z', + url: 'https://usewraith.xyz/blog/duplicate-test', + }, + { + slug: 'duplicate-test', + title: 'Duplicate Test 2', + publishedAt: '2026-07-20T12:00:00.000Z', + url: 'https://usewraith.xyz/blog/duplicate-test', + }, + ]; + const xml = buildRssFeed(posts, siteUrl); + const matches = xml.match(/https:\/\/usewraith\.xyz\/blog\/duplicate-test<\/link>/g); + expect(matches).toHaveLength(1); + }); + }); + + // ─── 3. Published Blog Posts and Tag Feeds Completeness ─────────────────── + describe('Published Blog Posts and Tag Feeds Completeness', () => { + it('every published blog post appears exactly once in sitemap.xml', () => { + const posts = getPosts(); + expect(posts.length).toBeGreaterThan(0); + + const xml = readFileSync(sitemapPath, 'utf8'); + const sitemapUrls = extractTags(xml, 'loc'); + + for (const post of posts) { + const postUrl = `${siteUrl}/blog/${post.slug}`; + const count = sitemapUrls.filter((u) => u === postUrl).length; + expect(count, `Expected post ${post.slug} to appear exactly once in sitemap`).toBe(1); + } + }); + + it('every published blog post appears exactly once in feed.xml', () => { + const posts = getPosts(); + const xml = readFileSync(feedPath, 'utf8'); + const items = extractItemBlocks(xml); + + for (const post of posts) { + const postUrl = `${siteUrl}/blog/${post.slug}`; + const count = items.filter((i) => i.link === postUrl).length; + expect(count, `Expected post ${post.slug} to appear exactly once in feed.xml`).toBe(1); + } + }); + + it('every blog tag has a dedicated tag feed and appears exactly once in sitemap', () => { + const posts = getPosts(); + const allTags = new Set(); + posts.forEach((p) => { + (p.tags || []).forEach((t) => allTags.add(slugifyTag(t))); + }); + + expect(allTags.size).toBeGreaterThan(0); + const xml = readFileSync(sitemapPath, 'utf8'); + const sitemapUrls = extractTags(xml, 'loc'); + + for (const tagSlug of allTags) { + const tagRouteUrl = `${siteUrl}/blog/tag/${tagSlug}`; + const count = sitemapUrls.filter((u) => u === tagRouteUrl).length; + expect(count, `Expected tag route ${tagSlug} to appear exactly once in sitemap`).toBe(1); + + const tagFeedFile = join(tagFeedsDir, `${tagSlug}.xml`); + expect( + existsSync(tagFeedFile), + `Expected tag feed file to exist for tag: ${tagSlug}`, + ).toBe(true); + + const tagFeedXml = readFileSync(tagFeedFile, 'utf8'); + const tagFeedItems = extractItemBlocks(tagFeedXml); + expect(tagFeedItems.length).toBeGreaterThan(0); + + // Verify each post in this tag feed actually has this tag + const matchingPosts = posts.filter((p) => + (p.tags || []).some((t) => slugifyTag(t) === tagSlug), + ); + expect(tagFeedItems.length).toBe(matchingPosts.length); + } + }); + }); + + // ─── 4. Reject Preview, Staging, and Invalid Routes ────────────────────── + describe('Reject Preview, Staging, and Invalid Routes', () => { + it('sitemap.xml contains zero preview, staging, draft, 404, or malformed routes', () => { + const xml = readFileSync(sitemapPath, 'utf8'); + const urls = extractTags(xml, 'loc'); + + for (const url of urls) { + const lower = url.toLowerCase(); + expect(lower).not.toContain('preview'); + expect(lower).not.toContain('staging'); + expect(lower).not.toContain('draft'); + expect(lower).not.toContain('404'); + expect(lower).not.toContain('admin'); + expect(url).not.toContain('?'); + expect(url).not.toContain('#'); + // No double slashes after the protocol + const pathPart = url.replace(/^https?:\/\//, ''); + expect(pathPart).not.toContain('//'); + } + }); + + it('feed.xml and tag feeds contain zero preview, staging, or draft URLs', () => { + const xml = readFileSync(feedPath, 'utf8'); + const items = extractItemBlocks(xml); + + for (const item of items) { + const lowerLink = item.link.toLowerCase(); + const lowerGuid = item.guid.toLowerCase(); + expect(lowerLink).not.toContain('preview'); + expect(lowerLink).not.toContain('staging'); + expect(lowerLink).not.toContain('draft'); + expect(lowerGuid).not.toContain('preview'); + expect(lowerGuid).not.toContain('staging'); + expect(lowerGuid).not.toContain('draft'); + } + + const feedFiles = readdirSync(tagFeedsDir).filter((f) => f.endsWith('.xml')); + for (const file of feedFiles) { + const tagXml = readFileSync(join(tagFeedsDir, file), 'utf8'); + const tagItems = extractItemBlocks(tagXml); + for (const item of tagItems) { + expect(item.link.toLowerCase()).not.toContain('preview'); + expect(item.link.toLowerCase()).not.toContain('staging'); + expect(item.link.toLowerCase()).not.toContain('draft'); + } + } + }); + + it('isValidRoute rejects preview, staging, draft, 404, and invalid patterns', () => { + // Rejects preview & staging + expect(isValidRoute('/preview')).toBe(false); + expect(isValidRoute('/preview/new-article')).toBe(false); + expect(isValidRoute('/blog/preview')).toBe(false); + expect(isValidRoute('/staging')).toBe(false); + expect(isValidRoute('/staging/feature')).toBe(false); + + // Rejects draft & admin + expect(isValidRoute('/draft')).toBe(false); + expect(isValidRoute('/blog/drafts/post-1')).toBe(false); + expect(isValidRoute('/admin')).toBe(false); + expect(isValidRoute('/admin/dashboard')).toBe(false); + + // Rejects 404 & malformed + expect(isValidRoute('/404')).toBe(false); + expect(isValidRoute('/404/not-found')).toBe(false); + expect(isValidRoute('relative-route')).toBe(false); + expect(isValidRoute('//double-slash')).toBe(false); + expect(isValidRoute('/route?param=1')).toBe(false); + expect(isValidRoute('/route#anchor')).toBe(false); + expect(isValidRoute('/has space/route')).toBe(false); + + // Accepts valid routes + expect(isValidRoute('/')).toBe(true); + expect(isValidRoute('/faq')).toBe(true); + expect(isValidRoute('/blog/privacy-by-default')).toBe(true); + expect(isValidRoute('/case-studies/payroll-processor')).toBe(true); + expect(isValidRoute('/blog/tag/stealth-payments')).toBe(true); + }); + + it('isAllowedPost rejects preview, staging, draft, and posts with invalid dates', () => { + expect(isAllowedPost({ slug: 'valid', publishedAt: '2026-07-20' })).toBe(true); + + // Draft & preview flags + expect(isAllowedPost({ slug: 'draft-post', publishedAt: '2026-07-20', draft: true })).toBe( + false, + ); + expect( + isAllowedPost({ slug: 'preview-post', publishedAt: '2026-07-20', preview: true }), + ).toBe(false); + expect( + isAllowedPost({ slug: 'draft-status', publishedAt: '2026-07-20', status: 'draft' }), + ).toBe(false); + expect( + isAllowedPost({ slug: 'preview-status', publishedAt: '2026-07-20', status: 'preview' }), + ).toBe(false); + + // Preview/staging in slug or URL + expect(isAllowedPost({ slug: 'preview-feature', publishedAt: '2026-07-20' })).toBe(false); + expect( + isAllowedPost({ + slug: 'valid-slug', + publishedAt: '2026-07-20', + url: 'https://usewraith.xyz/preview/valid-slug', + }), + ).toBe(false); + expect( + isAllowedPost({ + slug: 'valid-slug', + publishedAt: '2026-07-20', + url: 'https://usewraith.xyz/staging/valid-slug', + }), + ).toBe(false); + + // Invalid dates + expect(isAllowedPost({ slug: 'bad-date', publishedAt: 'not-a-date' })).toBe(false); + expect(isAllowedPost({ slug: 'empty-date', publishedAt: '' })).toBe(false); + expect(isAllowedPost({ slug: 'no-date' })).toBe(false); + }); + }); + + // ─── 5. Verify lastmod Values Against Content Metadata ─────────────────── + describe('Verify lastmod Values Against Content Metadata', () => { + it('blog post lastmod values in sitemap strictly match content metadata publishedAt/date', () => { + const posts = getPosts(); + const xml = readFileSync(sitemapPath, 'utf8'); + const blocks = extractUrlBlocks(xml); + + for (const post of posts) { + const postUrl = `${siteUrl}/blog/${post.slug}`; + const block = blocks.find((b) => b.loc === postUrl); + expect(block, `Block for ${postUrl} must exist in sitemap`).toBeDefined(); + + const expectedDate = (post.publishedAt || '').split('T')[0]; + expect(expectedDate).toBeTruthy(); + expect(block?.lastmod).toBe(expectedDate); + } + }); + + it('case study lastmod in sitemap strictly matches case-studies.json integrationDate', () => { + const csPath = join(rootDir, 'src', 'data', 'case-studies.json'); + const csData = JSON.parse(readFileSync(csPath, 'utf8')); + const xml = readFileSync(sitemapPath, 'utf8'); + const blocks = extractUrlBlocks(xml); + + for (const entry of csData.entries) { + if (!entry.slug || !entry.integrationDate) continue; + const csUrl = `${siteUrl}/case-studies/${entry.slug}`; + const block = blocks.find((b) => b.loc === csUrl); + expect(block, `Block for ${csUrl} must exist in sitemap`).toBeDefined(); + expect(block?.lastmod).toBe(entry.integrationDate); + } + }); + + it('all lastmod dates in sitemap are valid ISO 8601 YYYY-MM-DD dates', () => { + const xml = readFileSync(sitemapPath, 'utf8'); + const blocks = extractUrlBlocks(xml); + + const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/; + for (const b of blocks) { + expect(b.lastmod).toMatch(isoDatePattern); + const parsed = Date.parse(b.lastmod); + expect(isNaN(parsed)).toBe(false); + } + }); + + it('all pubDate values in feed.xml and tag feeds are valid RFC 822 dates matching post publishedAt', () => { + const posts = getPosts(); + const postsBySlug = new Map(posts.map((p) => [p.slug, p])); + + const xml = readFileSync(feedPath, 'utf8'); + const items = extractItemBlocks(xml); + + for (const item of items) { + const parsed = Date.parse(item.pubDate); + expect(isNaN(parsed), `pubDate ${item.pubDate} must be parseable`).toBe(false); + + const slug = item.link.replace(`${siteUrl}/blog/`, ''); + const post = postsBySlug.get(slug); + if (post) { + const expectedTime = new Date(post.publishedAt).getTime(); + expect(parsed).toBe(expectedTime); + } + } + }); + + it('feed lastBuildDate is a valid parseable RFC 822 date', () => { + const xml = readFileSync(feedPath, 'utf8'); + const match = xml.match(/([\s\S]*?)<\/lastBuildDate>/); + expect(match).not.toBeNull(); + const buildDate = match![1].trim(); + expect(isNaN(Date.parse(buildDate))).toBe(false); + }); + }); +});