Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install xmllint before validating the feed

In the ubuntu-latest job used by both deploy and PR validation, this new step invokes scripts/validate-feed.js without first installing xmllint; the validator exits 1 when xmllint is missing, and the current GitHub-hosted Ubuntu 24.04 runner software list does not include libxml2-utils/xmllint (also reproduced in this clean Ubuntu environment). This makes every build fail at RSS validation unless the workflow installs libxml2-utils before running this command.

Useful? React with 👍 / 👎.


- name: Upload Pages Artifact
id: upload
uses: actions/upload-pages-artifact@v3
Expand All @@ -93,4 +99,4 @@ jobs:
echo "artifact-uploaded=true" >> $GITHUB_OUTPUT
else
echo "artifact-uploaded=false" >> $GITHUB_OUTPUT
fi
fi
1 change: 1 addition & 0 deletions .github/workflows/pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
'✓ Type checking & Build',
'✓ Spell check (HTML)',
'✓ Internal link validation',
'✓ RSS feed validation',
'✓ Artifact upload'
];

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions scripts/validate-feed.js
Original file line number Diff line number Diff line change
@@ -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. <itunes:image> with no
* matching xmlns:itunes on the <rss> 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.`);
68 changes: 68 additions & 0 deletions scripts/validate-feed.test.ts
Original file line number Diff line number Diff line change
@@ -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, "<rss><channel><itunes:image /></channel></rss>");
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");
});
});
5 changes: 5 additions & 0 deletions src/pages/rss.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export async function GET(context: Context) {
const items = byDateDesc([...blog, ...projects]);

return rss({
// Declare the itunes namespace so the <itunes:image> 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,
Expand Down
5 changes: 3 additions & 2 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
});
Loading