diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f6d6a7a..ba1b056 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,7 +79,13 @@ jobs: - name: Validate Internal Links run: npm run validate:links - + + - name: Install xmllint + run: sudo apt-get update && sudo apt-get install -y libxml2-utils + + - name: Validate RSS Feed + run: npm run validate:feed + - name: Upload Pages Artifact id: upload uses: actions/upload-pages-artifact@v3 @@ -93,4 +99,4 @@ jobs: echo "artifact-uploaded=true" >> $GITHUB_OUTPUT else echo "artifact-uploaded=false" >> $GITHUB_OUTPUT - fi \ No newline at end of file + fi diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index c4da95b..8dce8cc 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -46,6 +46,7 @@ jobs: '✓ Type checking & Build', '✓ Spell check (HTML)', '✓ Internal link validation', + '✓ RSS feed validation', '✓ Artifact upload' ]; diff --git a/CLAUDE.md b/CLAUDE.md index bd4ff16..11a75e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,8 @@ Some key commands are: - just spellcheck-html: checks spelling in built HTML output - just lint: runs ESLint on all files - just lint-fix: auto-fixes ESLint issues where possible -- just validate: runs all validation checks (lint + spellcheck + build + links) +- just validate-feed: builds the site then validates the RSS feed XML (well-formedness + namespaces, via xmllint) +- just validate: runs all validation checks (lint + spellcheck + build + links + feed) ## Key Technical Decisions diff --git a/justfile b/justfile index 2e0c8fd..72bcba0 100644 --- a/justfile +++ b/justfile @@ -83,6 +83,7 @@ install: setup: npm install npx playwright install + @command -v xmllint >/dev/null 2>&1 || echo "⚠️ xmllint not found — needed for 'just validate-feed'. macOS ships it; on Debian/Ubuntu run 'sudo apt-get install -y libxml2-utils'." # Spellcheck: checks spelling in source files spellcheck: @@ -108,10 +109,15 @@ lint-fix: lint-markdown: npm run lint:markdown -# Validate: runs all validation checks (lint + spellcheck + build + links) +# Validate: runs all validation checks (lint + spellcheck + build + links + feed) validate: npm run validate:all +# Validate-feed: builds the site then validates the RSS feed XML (well-formedness + namespaces, via xmllint) +validate-feed: + npm run build + npm run validate:feed + # QA: runs all Playwright QA tests qa: npm run qa diff --git a/package.json b/package.json index dee19d2..b57d513 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,11 @@ "spellcheck:html": "cspell \"dist/**/*.html\" --no-progress", "spellcheck:all": "npm run spellcheck && npm run build && npm run spellcheck:html", "validate:links": "node scripts/validate-links.js", - "validate:all": "npm run lint && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links", + "validate:feed": "node scripts/validate-feed.js", + "validate:all": "npm run lint && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links && npm run validate:feed", "test:unit": "vitest run", - "test:ci": "npm run lint && npm run test:unit && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links", - "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run test:unit && echo '✓ Unit tests passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && echo '✅ All CI checks passed!'", + "test:ci": "npm run lint && npm run test:unit && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links && npm run validate:feed", + "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run test:unit && echo '✓ Unit tests passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && npm run validate:feed && echo '✓ Feed validation passed' && echo '✅ All CI checks passed!'", "qa": "playwright test --ignore-snapshots", "qa:headed": "playwright test --headed --ignore-snapshots", "qa:ui": "playwright test --ui", diff --git a/scripts/validate-feed.js b/scripts/validate-feed.js new file mode 100644 index 0000000..0854bc5 --- /dev/null +++ b/scripts/validate-feed.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +/** + * Validates the generated RSS feed (dist/rss.xml) for XML well-formedness and + * namespace correctness using xmllint. + * + * Why wrap xmllint instead of calling it directly? + * xmllint treats an undeclared namespace prefix — e.g. with no + * matching xmlns:itunes on the root — as a *recoverable* error: it prints + * a "namespace error" to stderr but still exits 0. A bare `xmllint --noout` + * would therefore happily pass the exact bug this check exists to catch. So we + * treat ANY stderr output (or a non-zero exit) as a validation failure. + * + * Usage: + * node scripts/validate-feed.js [path-to-xml] # defaults to dist/rss.xml + */ + +import { existsSync } from "fs"; +import { spawnSync } from "child_process"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Allow an explicit path argument (handy for tests); default to the built feed. +const feedPath = process.argv[2] + ? path.resolve(process.argv[2]) + : path.join(__dirname, "..", "dist", "rss.xml"); + +const relFeed = path.relative(process.cwd(), feedPath); + +console.log("🔍 Validating RSS feed XML...\n"); + +// 1. The feed has to have been built first. +if (!existsSync(feedPath)) { + console.error(`❌ Feed not found: ${relFeed}`); + console.error(" Run \"npm run build\" first (or pass a path to an XML file)."); + process.exit(1); +} + +// 2. xmllint has to be available. +const probe = spawnSync("xmllint", ["--version"], { stdio: "ignore" }); +if (probe.error || probe.status !== 0) { + console.error("❌ xmllint not found on PATH."); + console.error(" macOS ships it by default; on Debian/Ubuntu install it with"); + console.error(" \"sudo apt-get install -y libxml2-utils\"."); + process.exit(1); +} + +// 3. Validate. --nonet keeps the check hermetic (never fetch over the network). +const result = spawnSync("xmllint", ["--noout", "--nonet", feedPath], { + encoding: "utf8", +}); + +const stderr = (result.stderr || "").trim(); + +// xmllint exits 0 on recoverable namespace errors, so fail on ANY stderr too. +if (result.status !== 0 || stderr) { + console.error(`❌ Feed validation failed for ${relFeed}:\n`); + if (stderr) { + console.error(stderr); + } else { + console.error(`xmllint exited with status ${result.status}.`); + } + console.error("\nThe feed is malformed or uses an undeclared XML namespace."); + process.exit(1); +} + +console.log(`✅ ${relFeed} is well-formed and all XML namespaces are declared.`); diff --git a/scripts/validate-feed.test.ts b/scripts/validate-feed.test.ts new file mode 100644 index 0000000..8f72209 --- /dev/null +++ b/scripts/validate-feed.test.ts @@ -0,0 +1,68 @@ +import { spawnSync } from "child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const validatorPath = path.join(__dirname, "validate-feed.js"); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const tempDir = mkdtempSync(path.join(tmpdir(), "validate-feed-")); + tempDirs.push(tempDir); + return tempDir; +} + +function writeExecutable(filePath: string, contents: string): void { + writeFileSync(filePath, contents); + chmodSync(filePath, 0o755); +} + +function runValidator(feedPath: string, binDir: string) { + return spawnSync(process.execPath, [validatorPath, feedPath], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }); +} + +describe("validate-feed", () => { + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + it("fails when xmllint exits 0 but reports a namespace error on stderr", () => { + const tempDir = makeTempDir(); + const binDir = path.join(tempDir, "bin"); + const feedPath = path.join(tempDir, "rss.xml"); + const xmllintPath = path.join(binDir, "xmllint"); + + mkdirSync(binDir); + writeFileSync(feedPath, ""); + writeExecutable( + xmllintPath, + `#!/usr/bin/env node +if (process.argv.includes("--version")) { + process.exit(0); +} + +console.error("namespace error : Namespace prefix itunes on image is not defined"); +process.exit(0); +`, + ); + + const result = runValidator(feedPath, binDir); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("namespace error"); + expect(result.stderr).toContain("undeclared XML namespace"); + }); +}); diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index 157a9fe..42f1bfc 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -14,6 +14,11 @@ export async function GET(context: Context) { const items = byDateDesc([...blog, ...projects]); return rss({ + // Declare the itunes namespace so the tag in customData below + // is valid XML. Without this, the feed parses with a namespace error. + xmlns: { + itunes: "http://www.itunes.com/dtds/podcast-1.0.dtd", + }, title: HOME.TITLE, description: HOME.DESCRIPTION, site: context.site, diff --git a/vitest.config.ts b/vitest.config.ts index 0292c80..65c1631 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from "vitest/config"; -// Unit tests live next to the code they cover as `*.test.ts` under `src/`. +// Unit tests live next to the code they cover as `*.test.ts` under `src/` +// or `scripts/`. // The `tests/` directory is reserved for Playwright (`*.spec.ts`) QA suites, // so we scope Vitest to `src/` to avoid the two runners fighting over files. export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "scripts/**/*.test.ts"], }, });