diff --git a/public/sitemap.xml b/public/sitemap.xml
index ab6be3e..3642274 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -7,194 +7,194 @@
1.0
- https://usewraith.xyz/faq
- 2026-09-28
+ https://usewraith.xyz/about
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/privacy
- 2026-09-28
+ https://usewraith.xyz/blog
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/use-cases
- 2026-09-28
+ https://usewraith.xyz/blog/author/lena-vogt
+ 2026-08-12
weekly
0.8
- https://usewraith.xyz/use-cases/calculator
- 2026-09-28
+ https://usewraith.xyz/blog/privacy-by-default
+ 2026-07-20
weekly
0.8
- https://usewraith.xyz/roadmap
- 2026-09-28
+ https://usewraith.xyz/blog/stealth-addresses-explained
+ 2026-08-12
weekly
0.8
- https://usewraith.xyz/case-studies
- 2026-09-28
+ https://usewraith.xyz/blog/tag/announcements
+ 2026-07-27
weekly
0.8
- https://usewraith.xyz/stellar
- 2026-09-28
+ https://usewraith.xyz/blog/tag/cryptography
+ 2026-08-12
weekly
0.8
- https://usewraith.xyz/careers
- 2026-09-28
+ https://usewraith.xyz/blog/tag/privacy
+ 2026-08-12
weekly
0.8
- https://usewraith.xyz/press
- 2026-09-28
+ https://usewraith.xyz/blog/tag/sdk
+ 2026-07-27
weekly
0.8
- https://usewraith.xyz/case-studies/payroll-processor
- 2026-09-28
+ https://usewraith.xyz/blog/tag/stealth-payments
+ 2026-08-12
weekly
- 0.7
+ 0.8
- https://usewraith.xyz/blog/author/lena-vogt
- 2026-09-28
+ https://usewraith.xyz/blog/tag/wave-6
+ 2026-07-27
weekly
0.8
- https://usewraith.xyz/blog/tag/privacy
- 2026-09-28
+ https://usewraith.xyz/blog/tag/wave-7
+ 2026-07-27
weekly
0.8
- https://usewraith.xyz/blog/tag/stealth-payments
- 2026-09-28
+ https://usewraith.xyz/blog/wave-7-kickoff
+ 2026-07-27
weekly
0.8
- https://usewraith.xyz/blog/tag/cryptography
- 2026-09-28
+ https://usewraith.xyz/careers
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/blog/tag/announcements
- 2026-09-28
+ https://usewraith.xyz/case-studies
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/blog/tag/wave-7
- 2026-09-28
+ https://usewraith.xyz/case-studies/payroll-processor
+ 2026-06-15
weekly
- 0.8
+ 0.7
- https://usewraith.xyz/blog/tag/wave-6
- 2026-09-28
+ https://usewraith.xyz/chains
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/blog/tag/sdk
- 2026-09-28
+ https://usewraith.xyz/contributors
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/about
- 2026-09-28
+ https://usewraith.xyz/ecosystem
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/blog
- 2026-09-28
+ https://usewraith.xyz/faq
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/blog/privacy-by-default
- 2026-09-28
+ https://usewraith.xyz/governance
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/blog/stealth-addresses-explained
- 2026-09-28
+ https://usewraith.xyz/grants
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/blog/wave-7-kickoff
- 2026-09-28
+ https://usewraith.xyz/newsletter
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/chains
- 2026-09-28
+ https://usewraith.xyz/press
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/contributors
- 2026-09-28
+ https://usewraith.xyz/privacy
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/ecosystem
- 2026-09-28
+ https://usewraith.xyz/roadmap
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/governance
- 2026-09-28
+ https://usewraith.xyz/security
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/grants
- 2026-09-28
+ https://usewraith.xyz/status
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/newsletter
- 2026-09-28
+ https://usewraith.xyz/stellar
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/security
- 2026-09-28
+ https://usewraith.xyz/threat-model
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/status
- 2026-09-28
+ https://usewraith.xyz/use-cases
+ 2026-09-25
weekly
0.8
- https://usewraith.xyz/threat-model
- 2026-09-28
+ 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(/([\s\S]*?)<\/pubDate>/);
+ const descMatch = block.match(/([\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('')).toBe(true);
+ expect(xml).toContain('');
+ expect(xml.endsWith('\n') || xml.endsWith('')).toBe(true);
+
+ const openUrlCount = (xml.match(//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('')).toBe(true);
+ expect(xml).toContain('');
+ expect(xml).toContain('');
+
+ expect(xml).toContain('');
+ expect(xml).toContain('');
+ expect(xml).toContain('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);
+ });
+ });
+});