import assert from 'node:assert/strict'; import { mkdtempSync, cpSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { checkSite } from './check.mjs'; import { siteBaseURL } from './config.mjs'; import { checkWriting, checkDescriptions } from './test-writing.mjs'; const temp = mkdtempSync(join(tmpdir(), 'extroonie-test-')); try { for (const section of ['', 'writing', 'writing/posts', 'writing/essays']) { mkdirSync(join(temp, 'content', section), { recursive: true }); cpSync(join('content', section, '_index.md'), join(temp, 'content', section, '_index.md')); } for (const fixture of ['math', 'post', 'tagged-post']) cpSync(`tests/fixtures/${fixture}.md`, join(temp, `content/writing/${fixture}-test.md`)); for (const [name, headings] of Object.entries({ 'one-section': '# An in-body title\n\n## One section\n\n#### A minor heading', 'two-sections': '## First section\n\n### Second section', 'abstract-only': '', 'abstract-and-section': '## One section', })) { writeFileSync( join(temp, `content/writing/${name}.md`), `---\ntitle: ${name}\ndate: 2026-01-01\nformat: post\n${name.startsWith('abstract-') ? 'abstract: A short abstract.\n' : ''}---\n${headings}\n`, ); } const hugo = process.env.HUGO_BIN || 'hugo'; const build = (...args) => { rmSync(join(temp, 'out'), { recursive: true, force: true }); return spawnSync( hugo, [ '--contentDir', join(temp, 'content'), '--destination', join(temp, 'out'), '--baseURL', siteBaseURL().href, ...args, ], { encoding: 'utf8' }, ); }; const result = build(); assert.equal(result.status, 0, result.stdout + result.stderr); checkSite(join(temp, 'out')); checkWriting(join(temp, 'out')); const page = readFileSync(join(temp, 'out/writing/math-test/index.html'), 'utf8'); assert(page.includes('katex.min.css'), 'Math stylesheet missing'); assert(page.includes('katex-mathml'), 'Accessible build-time math missing'); assert(page.includes('eq-entropy'), 'Equation anchor missing'); assert(page.includes('href="#eq-entropy"'), 'Equation reference missing'); assert(!page.includes('>Published'), 'Publication label should not appear'); assert(!page.includes('>Updated'), 'Update label should not appear'); assert(page.includes('January 1, 2026'), 'Published date missing'); assert(page.includes('“quoted”'), 'Smart quotes missing'); assert( page.includes('writer’s') || page.includes('writer’s') || page.includes('writer's'), 'Smart apostrophe missing', ); assert( page.includes('"code"') || page.includes('"code"') || page.includes('"code"'), 'Code quotes changed: ' + page.match(/[\s\S]*?<\/code>/g), ); assert(!page.includes('katex.min.js'), 'Math should require no client JavaScript'); const home = readFileSync(join(temp, 'out/index.html'), 'utf8'); assert(home.includes('class="biography"'), 'Biography missing'); assert(!home.includes('some of my writing'), 'Homepage writing invitation missing'); assert(!home.includes('class="writing-list"'), 'Article lists must live in the archive'); assert(home.includes('Ishmaam Khan'), 'Homepage name must reset the URL'); new HTMLRewriter() .on('nav nav', { element() { assert.fail('Contents must not nest navigation landmarks'); }, }) .transform(home); const essays = readFileSync(join(temp, 'out/writing/essays/rss.xml'), 'utf8'); assert(essays.includes('/writing/math-test/'), 'Essay filtering failed'); assert(!essays.includes('/writing/tagged-post-test/'), 'Post leaked into essay feed'); assert(page.includes('/js/contents.'), 'Sectioned essay should load navigation'); assert(!home.includes('/js/contents.'), 'Homepage should not load article navigation'); assert(page.includes('aria-controls="article-navigation"'), 'Contents control missing'); assert(page.includes('aria-label="Close contents"'), 'Accessible sidebar close button missing'); assert(page.includes('class="contents-reopen"'), 'Sidebar reopening control missing'); assert(!home.includes('contents-reopen'), 'Homepage should not have sidebar controls'); assert(page.includes('class="subtitle"'), 'Essay subtitle missing'); assert(page.includes('href="#abstract">Abstract'), 'Abstract missing from Contents'); for (const [name, expected] of [ ['one-section', false], ['two-sections', true], ['abstract-only', false], ['abstract-and-section', true], ]) { const article = readFileSync(join(temp, `out/writing/${name}/index.html`), 'utf8'); for (const marker of ['has-contents', 'class="article-contents"', 'class="contents-reopen"', '/js/contents.']) assert.equal( article.includes(marker), expected, `${name}: inconsistent Contents eligibility for ${marker}`, ); assert(!/]*\bopen\b/.test(article), 'Inline Contents must start collapsed'); if (name === 'abstract-and-section') assert(article.includes('href="#abstract">Abstract')); } const postFixture = readFileSync(join(temp, 'out/writing/post-test/index.html'), 'utf8'); assert(!postFixture.includes('/js/contents.'), 'Unsectioned article loads unused navigation'); assert(postFixture.includes('An italic subtitle for a post'), 'Post subtitle missing'); assert(postFixture.includes('katex-mathml'), 'Post math missing'); for (const article of [page, postFixture]) { assert(article.includes('class="footnote-ref"'), 'Footnote reference missing'); assert(article.includes('class="footnote-backref"'), 'Footnote return link missing'); assert(article.includes('/js/footnotes.'), 'Footnote previews missing'); } assert(postFixture.includes('A second paragraph'), 'Multi-paragraph footnote missing'); assert(postFixture.match(/class="footnote-ref"/g).length === 2, 'Repeated footnote references missing'); assert(!home.includes('/js/footnotes.'), 'Homepage loads unused footnote script'); const post = readFileSync(join(temp, 'out/writing/tagged-post-test/index.html'), 'utf8'); assert(!post.includes('katex.min.css'), 'Non-math post loads math assets'); const rss = readFileSync(join(temp, 'out/writing/rss.xml'), 'utf8'); assert(rss.includes('A “quoted” title'), 'RSS typography inconsistent'); assert(rss.includes('Code & tools'), 'RSS titles must escape XML characters'); assert(rss.includes('Tools & code for a small project.')); assert(page.includes('
'), 'Fenced code should use plain semantic markup');
	assert(!page.includes('class="chroma"'), 'Unused syntax-token markup should not be generated');
	for (const [shipped, upstream] of [
		['static/licenses/cc0.txt', 'LICENSE'],
		['static/licenses/katex.txt', 'node_modules/katex/LICENSE'],
		['static/licenses/newsreader.txt', 'node_modules/@fontsource-variable/newsreader/LICENSE'],
	])
		assert.equal(readFileSync(shipped, 'utf8'), readFileSync(upstream, 'utf8'), `Outdated license: ${shipped}`);
	const checks = join(temp, 'checks');
	mkdirSync(checks);
	const checkFile = join(checks, 'index.html');
	writeFileSync(checkFile, '

Heading

Link'); checkSite(checks); for (const [html, expected] of [ ['Broken fragment', /missing fragment/], ['', /broken asset or link/], ['

', /duplicate id/], ['', /missing accessible control target/], ]) { writeFileSync(checkFile, html); assert.throws(() => checkSite(checks), expected); } const originalURL = process.env.SITE_BASE_URL; try { process.env.SITE_BASE_URL = 'https://alternate.example/'; assert.equal(siteBaseURL().href, process.env.SITE_BASE_URL); writeFileSync(checkFile, 'Broken absolute link'); assert.throws(() => checkSite(checks), /broken asset or link/); for (const url of [ 'https://example.test/subpath/', 'file:///tmp/', 'https://example.test/?q=1', 'https://example.test/#hash', 'https://user:pass@example.test/', ]) { process.env.SITE_BASE_URL = url; assert.throws(siteBaseURL, /must be an HTTP\(S\) origin/); } } finally { if (originalURL === undefined) delete process.env.SITE_BASE_URL; else process.env.SITE_BASE_URL = originalURL; } const writingIndex = join(temp, 'content/writing/_index.md'); const originalWriting = readFileSync(writingIndex, 'utf8'); const changedDescription = 'Notes & ideas about writing.'; writeFileSync(writingIndex, originalWriting.replace(/^description:.*$/m, `description: "${changedDescription}"`)); assert.equal(build().status, 0); checkDescriptions(join(temp, 'out'), changedDescription); assert.equal( readFileSync(join(temp, 'out/index.html'), 'utf8'), home, 'Writing changes must not alter the homepage', ); writeFileSync(writingIndex, originalWriting); const invalidFile = join(temp, 'content/writing/invalid.md'); for (const format of ['', 'format: typo\n']) { writeFileSync(invalidFile, `---\ntitle: Invalid\ndate: 2026-01-01\n${format}---\nText.\n`); const invalid = build(); assert.notEqual(invalid.status, 0, 'Invalid writing format must fail the build'); assert((invalid.stdout + invalid.stderr).includes('format must be post or essay')); } rmSync(invalidFile); writeFileSync( join(temp, 'content/writing/draft.md'), '---\ntitle: Draft\ndate: 2026-01-01\nformat: post\ntags: [programming, draft-only]\ndraft: true\n---\nDraft text.\n', ); assert.equal(build().status, 0); assert(!existsSync(join(temp, 'out/writing/draft/index.html')), 'Draft leaked into the production build'); assert(!existsSync(join(temp, 'out/tags/draft-only/index.html')), 'Draft-only tag leaked into production'); assert(!readFileSync(join(temp, 'out/tags/index.html'), 'utf8').includes('#draft-only')); for (const path of [ 'writing/index.html', 'writing/posts/index.html', 'tags/programming/index.html', 'rss.xml', 'writing/rss.xml', 'writing/posts/rss.xml', ]) assert(!readFileSync(join(temp, 'out', path), 'utf8').includes('/writing/draft/'), `Draft leaked into ${path}`); assert.equal(build('--buildDrafts').status, 0); assert(existsSync(join(temp, 'out/writing/draft/index.html')), 'Draft preview omitted draft content'); assert( readFileSync(join(temp, 'out/tags/draft-only/index.html'), 'utf8').includes('/writing/draft/'), 'Draft tag preview missing', ); rmSync(join(temp, 'content/writing/math-test.md')); assert.equal(build().status, 0); const emptyEssays = readFileSync(join(temp, 'out/writing/essays/index.html'), 'utf8'); assert(emptyEssays.includes('No essays yet.'), 'Empty essay archive missing'); assert(emptyEssays.includes('/writing/essays/rss.xml'), 'Empty essay archive lost its feed'); assert(!readFileSync(join(temp, 'out/writing/essays/rss.xml'), 'utf8').includes('')); for (const name of readdirSync(join(temp, 'content/writing'))) if (name.endsWith('.md') && name !== '_index.md') rmSync(join(temp, 'content/writing', name)); assert.equal(build().status, 0); checkSite(join(temp, 'out')); assert(readFileSync(join(temp, 'out/writing/index.html'), 'utf8').includes('No writing yet.')); assert(readFileSync(join(temp, 'out/tags/index.html'), 'utf8').includes('No tags yet.')); console.log( 'Passed: writing archives, tags, drafts, empty states, math, equations, typography, dates, subtitles, biography, RSS, and conditional assets.', ); } finally { rmSync(temp, { recursive: true, force: true }); }