', start)
- if (end < 0) {
- result += html.slice(cursor)
- break
- }
- result += html.slice(cursor, start)
- result += `${html.slice(start, end + 8)}
`
- cursor = end + 8
- }
- return result
-}
-
-const toc = (source) => {
- const headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading')
- if (headings.length === 0) return ''
- const root = { depth: 0, children: [] }
- const stack = [root]
- const headingId = createSlugger()
- for (const heading of headings) {
- while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop()
- const node = { depth: heading.depth, heading, children: [] }
- stack.at(-1).children.push(node)
- stack.push(node)
- }
- const renderItems = (items) =>
- `${items
- .map(({ heading, children }) => {
- const id = headingId(heading.tokens)
- const nested = children.length > 0 ? renderItems(children) : ''
- return `- ${escapeHtml(headingLabel(heading.tokens))}${nested}
`
- })
- .join('')}
`
- return renderItems(root.children)
-}
-
-const excerpt = (source, max = 280) => {
- const paragraph = marked
- .lexer(withoutTitle(stripHeadingSidenotes(source)))
- .find((token) => token.type === 'paragraph')
- if (!paragraph) return ''
- const text = stripSidenotes(headingLabel(paragraph.tokens ?? [{ text: paragraph.text ?? '' }]))
- .replace(/\s+/g, ' ')
- .trim()
- if (text.length <= max) return text
- const slice = text.slice(0, max)
- const bound = slice.lastIndexOf(' ')
- return `${(bound > 80 ? slice.slice(0, bound) : slice).trimEnd()}…`
-}
-
-const toRssDate = (iso) => {
- if (!iso) return ''
- const date = new Date(iso)
- return Number.isNaN(date.valueOf()) ? '' : date.toUTCString()
-}
-
-const toSitemapDate = (iso) => (iso ? iso.slice(0, 10) : '')
-
-const renderFeed = (items) => {
- const lastBuild = toRssDate(items.find((item) => item.date)?.date) || new Date().toUTCString()
- const itemXml = items
- .map((item) => {
- const loc = `${siteUrl}/${item.dir}/${item.name}/`
- const description = excerpt(item.source)
- const pubDate = toRssDate(item.date)
- return `-
- ${escapeHtml(item.title)}
- ${escapeHtml(loc)}
- ${escapeHtml(loc)}
- ${pubDate ? `${escapeHtml(pubDate)}` : ''}
- ${escapeHtml(item.dir)}
- ${description ? `${escapeHtml(description)}` : ''}
-
`
- })
- .join('\n ')
-
- return `
-
-
- ${escapeHtml(siteName)}
- ${escapeHtml(`${siteUrl}/`)}
- ${escapeHtml(`Notes and posts from ${siteName}`)}
- en-US
-
- ${escapeHtml(lastBuild)}
- ${itemXml}
-
-
-`
-}
-
-const renderSitemap = (urls) => `
-
-${urls
- .map(({ loc, lastmod }) => {
- const modified = lastmod ? `\n ${escapeHtml(lastmod)}` : ''
- return `
- ${escapeHtml(loc)}${modified}
- `
- })
- .join('\n')}
-
-`
-
const writePage = async (path, html) => {
const directory = join(output, path)
await mkdir(directory, { recursive: true })
@@ -407,7 +94,7 @@ const renderNotesTable = (pages, directory) => {
`,
)
.join('')
- return `${escapeHtml(directory)} pages${rows}
`
+ return `${escapeHtml(directory)} pages| Page | Last updated |
${rows}
`
}
const renderPostsList = (pages, directory) => {
@@ -425,7 +112,7 @@ const renderPostsList = (pages, directory) => {
const renderEmptySection = () => `
-
**crickets**
+
crickets
Nothing to list...
Go Home
`
@@ -463,19 +150,21 @@ const renderArticle = (directory, page, navigationPath = `/${directory}/${page.n
const draftNotice = page.draft
? `Draft — rename .${escapeHtml(page.name)}.md to ${escapeHtml(page.name)}.md to publish.
`
: ''
- const tableOfContents = toc(page.source)
+ const tableOfContents = page.markdown.toc
const contents = tableOfContents
? `Contents
${tableOfContents}
`
: ''
- const article = `${renderMarkdown(page.source)}`
+ const article = ``
const body = `${heading}${draftNotice}${updated}${contents}${article}`
return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) })
}
-await rm(output, { recursive: true, force: true })
-await mkdir(output, { recursive: true })
-const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)])))
+try {
+const sections = Object.fromEntries(await Promise.all(contentDirectories.map(async (dir) => [dir, await readPages(dir, { includeDrafts })])))
+for (const pages of Object.values(sections)) {
+ for (const page of pages) page.markdown = renderMarkdownPage(page.source)
+}
const external = JSON.parse(await readFile('external.json', 'utf8'))
@@ -483,7 +172,7 @@ const externalLinks = Object.entries(external)
.map(([text, href]) => `${escapeHtml(text)}`)
.join('')
-const sectionLinks = markdownDirs
+const sectionLinks = contentDirectories
.map((dir) => `${dir[0].toUpperCase() + dir.slice(1)}`)
.join('')
@@ -492,7 +181,7 @@ const standaloneLinks = standalonePages
.map((page) => `${page.title}`)
.join('')
-const recentPages = markdownDirs
+const recentPages = contentDirectories
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
.sort((a, b) => b.date.localeCompare(a.date))
.slice(0, 5)
@@ -506,7 +195,7 @@ const recentLinks = recentPages
await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks))
await writeFile(join(output, '404.html'), renderNotFound())
-for (const dir of markdownDirs) {
+for (const dir of contentDirectories) {
const pages = sections[dir]
await writePage(
dir,
@@ -528,16 +217,19 @@ for (const page of standalonePages) {
name: page.path,
title: page.title,
source,
+ markdown: renderMarkdownPage(source),
date: await dateFor(page.source),
}
generatedStandalone.push(generatedPage)
await writePage(page.path, renderArticle(page.path, generatedPage, '/'))
}
-const feedItems = markdownDirs
+const feedItems = contentDirectories
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
.sort((a, b) => b.date.localeCompare(a.date))
+for (const item of feedItems) item.description = item.markdown.excerpt
+
await writeFile(join(output, 'feed.xml'), renderFeed(feedItems))
const sitemapUrls = [
@@ -545,7 +237,7 @@ const sitemapUrls = [
loc: `${siteUrl}/`,
lastmod: toSitemapDate(feedItems[0]?.date),
},
- ...markdownDirs.map((dir) => ({
+ ...contentDirectories.map((dir) => ({
loc: `${siteUrl}/${dir}/`,
lastmod: toSitemapDate(
[...sections[dir]].filter((page) => !page.draft).sort((a, b) => b.date.localeCompare(a.date))[0]?.date,
@@ -563,3 +255,8 @@ const sitemapUrls = [
]
await writeFile(join(output, 'sitemap.xml'), renderSitemap(sitemapUrls))
+await publishOutput(output, generatedRoot)
+} catch (error) {
+ await discardOutput(output)
+ throw error
+}
diff --git a/html-checks.js b/html-checks.js
new file mode 100644
index 0000000..c679212
--- /dev/null
+++ b/html-checks.js
@@ -0,0 +1,31 @@
+import { parse } from 'parse5'
+
+const attribute = (node, name) => node.attrs?.find((item) => item.name === name)?.value
+
+export const htmlFacts = (html) => {
+ const facts = {
+ ids: [], hrefs: [], languages: [],
+ hasViewport: false, hasHeadingElement: false, isDraft: false,
+ }
+ const visit = (node) => {
+ if (node.tagName) {
+ const id = attribute(node, 'id')
+ if (id !== undefined) facts.ids.push(id)
+ const href = attribute(node, 'href')
+ if (href !== undefined) facts.hrefs.push(href)
+ for (const className of (attribute(node, 'class') ?? '').split(/\s+/)) {
+ if (className.startsWith('language-')) facts.languages.push(className.slice('language-'.length))
+ }
+ if (node.tagName === 'heading') facts.hasHeadingElement = true
+ if (node.tagName === 'meta') {
+ const name = attribute(node, 'name')?.toLowerCase()
+ if (name === 'viewport') facts.hasViewport = true
+ if (name === 'robots' && /(?:^|[,\s])noindex(?:$|[,\s])/i.test(attribute(node, 'content') ?? '')) facts.isDraft = true
+ }
+ }
+ for (const child of node.childNodes ?? []) visit(child)
+ if (node.content) visit(node.content)
+ }
+ visit(parse(html))
+ return facts
+}
diff --git a/markdown.js b/markdown.js
new file mode 100644
index 0000000..19d1efc
--- /dev/null
+++ b/markdown.js
@@ -0,0 +1,125 @@
+import { Marked } from 'marked'
+import { stripSidenotes } from './content.js'
+import { escapeHtml } from './html.js'
+
+const languageLabels = { ini: 'INI', js: 'JavaScript', javascript: 'JavaScript', lua: 'Lua', sh: 'Shell', bash: 'Shell' }
+
+const headingLabel = (tokens) => tokens.map((token) => {
+ if (token.type === 'image') return token.text ?? ''
+ if (token.tokens) return headingLabel(token.tokens)
+ return token.text ?? token.raw ?? ''
+}).join('').trim()
+
+const slug = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
+
+const createSlugger = () => {
+ const counts = new Map()
+ return (tokens) => {
+ const base = slug(headingLabel(tokens)) || 'section'
+ const count = (counts.get(base) ?? 0) + 1
+ counts.set(base, count)
+ return count === 1 ? base : `${base}-${count}`
+ }
+}
+
+const withoutTitle = (source) => {
+ const lines = source.split('\n')
+ const index = lines.findIndex((line) => line.startsWith('# '))
+ if (index >= 0) lines.splice(index, 1)
+ return lines.join('\n')
+}
+
+const stripHeadingSidenotes = (source) => source.split('\n').map((line) => {
+ const content = line.trimStart()
+ return /^#{1,6} /.test(content)
+ ? line.slice(0, line.length - content.length) + stripSidenotes(content)
+ : line
+}).join('\n')
+
+const renderToc = (headings) => {
+ if (headings.length === 0) return ''
+ const root = { depth: 0, children: [] }
+ const stack = [root]
+ for (const heading of headings) {
+ while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop()
+ const node = { ...heading, children: [] }
+ stack.at(-1).children.push(node)
+ stack.push(node)
+ }
+ const renderItems = (items) => `${items.map(({ id, label, children }) =>
+ `- ${escapeHtml(label)}${children.length ? renderItems(children) : ''}
`).join('')}
`
+ return renderItems(root.children)
+}
+
+const excerptFrom = (tokens, max = 280) => {
+ const paragraph = tokens.find((token) => token.type === 'paragraph')
+ if (!paragraph) return ''
+ const plainText = (items) => items.map((token) => token.type === 'sidenote'
+ ? ''
+ : token.tokens ? plainText(token.tokens) : token.text ?? '').join('')
+ const text = stripSidenotes(plainText(paragraph.tokens ?? [{ text: paragraph.text ?? '' }]))
+ .replace(/\s+/g, ' ').trim()
+ if (text.length <= max) return text
+ const slice = text.slice(0, max)
+ const bound = slice.lastIndexOf(' ')
+ return `${(bound > 80 ? slice.slice(0, bound) : slice).trimEnd()}…`
+}
+
+const wrapTables = (html) => html.replace(//g, (table) => `${table}
`)
+
+export const renderMarkdownPage = (source) => {
+ let sidenoteNumber = 0
+ let headingIndex = 0
+ let headings = []
+ const parser = new Marked({
+ extensions: [{
+ name: 'sidenote',
+ level: 'inline',
+ start: (text) => text.indexOf('^['),
+ tokenizer(text) {
+ if (!text.startsWith('^[')) return undefined
+ let depth = 1
+ for (let index = 2; index < text.length; index += 1) {
+ if (text[index] === '\\') index += 1
+ else if (text[index] === '[') depth += 1
+ else if (text[index] === ']') {
+ depth -= 1
+ if (depth === 0) return {
+ type: 'sidenote', raw: text.slice(0, index + 1),
+ tokens: this.lexer.inlineTokens(text.slice(2, index)),
+ }
+ }
+ }
+ return undefined
+ },
+ renderer({ tokens }) {
+ sidenoteNumber += 1
+ const id = `sidenote-${sidenoteNumber}`
+ const content = this.parser.parseInline(tokens)
+ return `${content}`
+ },
+ }],
+ renderer: {
+ code({ text, lang, escaped }) {
+ const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain'
+ const label = languageLabels[language] ?? language.charAt(0).toUpperCase() + language.slice(1)
+ const code = escaped ? text : escapeHtml(text.replace(/\n$/, ''))
+ return `\n`
+ },
+ heading({ tokens, depth }) {
+ const id = headings[headingIndex++]?.id
+ return `${this.parser.parseInline(tokens)}\n`
+ },
+ },
+ })
+ const tokens = parser.lexer(withoutTitle(stripHeadingSidenotes(source)))
+ const nextId = createSlugger()
+ headings = tokens.filter((token) => token.type === 'heading').map((token) => ({
+ depth: token.depth, label: headingLabel(token.tokens), id: nextId(token.tokens),
+ }))
+ return {
+ html: wrapTables(parser.parser(tokens)),
+ toc: renderToc(headings),
+ excerpt: excerptFrom(tokens),
+ }
+}
diff --git a/output.js b/output.js
new file mode 100644
index 0000000..a3ea837
--- /dev/null
+++ b/output.js
@@ -0,0 +1,29 @@
+import { mkdtemp, rename, rm, stat } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+
+const exists = async (path) => {
+ try {
+ await stat(path)
+ return true
+ } catch (error) {
+ if (error.code === 'ENOENT') return false
+ throw error
+ }
+}
+
+export const createOutput = (destination) => mkdtemp(join(dirname(destination), '.generated-next-'))
+
+export const publishOutput = async (staging, destination) => {
+ const previous = `${staging}-previous`
+ const hadPrevious = await exists(destination)
+ if (hadPrevious) await rename(destination, previous)
+ try {
+ await rename(staging, destination)
+ } catch (error) {
+ if (hadPrevious) await rename(previous, destination)
+ throw error
+ }
+ if (hadPrevious) await rm(previous, { recursive: true })
+}
+
+export const discardOutput = (staging) => rm(staging, { recursive: true, force: true })
diff --git a/package.json b/package.json
index fe9a629..8fac27c 100644
--- a/package.json
+++ b/package.json
@@ -4,10 +4,11 @@
"type": "module",
"scripts": {
"generate": "node generator.js",
- "check": "node generator.js --clean && node check.js",
+ "check": "node generator.js && node check.js",
+ "test": "node generator.js && bun test",
"clean": "node clean.js",
"dev": "node generator.js --drafts && vite",
- "build": "node generator.js --clean && node check.js && vite build",
+ "build": "node generator.js && node check.js && vite build",
"preview": "vite preview"
},
"dependencies": {
@@ -18,6 +19,7 @@
"prismjs": "^1.30.0"
},
"devDependencies": {
+ "parse5": "^8.0.1",
"vite": "^7.0.0"
},
"packageManager": "bun@1.3.8"
diff --git a/site.config.js b/site.config.js
new file mode 100644
index 0000000..a9d0a14
--- /dev/null
+++ b/site.config.js
@@ -0,0 +1,29 @@
+export const siteName = 'PaulW.XYZ'
+export const siteUrl = 'https://paulw.xyz'
+export const generatedRoot = '.generated'
+
+export const contentDirectories = ['notes', 'posts']
+export const standalonePages = [
+ { source: 'README.md', path: 'about', title: 'About' },
+ { source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' },
+]
+
+export const navLabels = {
+ notes: 'Notes',
+ posts: 'Posts',
+ about: 'About',
+ licenses: 'Licenses',
+}
+
+export const contentSources = [
+ ...contentDirectories,
+ ...standalonePages.map(({ source }) => source),
+ 'external.json',
+ 'generator.js',
+ 'content.js',
+ 'feed.js',
+ 'html.js',
+ 'markdown.js',
+ 'output.js',
+ 'site.config.js',
+]
diff --git a/src/fonts.css b/src/fonts.css
index 022dabe..2537088 100644
--- a/src/fonts.css
+++ b/src/fonts.css
@@ -69,27 +69,3 @@
font-weight: 400;
src: url('@fontsource/iosevka/files/iosevka-latin-400-normal.woff2') format('woff2');
}
-
-@font-face {
- font-family: 'Iosevka';
- font-style: italic;
- font-display: swap;
- font-weight: 400;
- src: url('@fontsource/iosevka/files/iosevka-latin-400-italic.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'Iosevka';
- font-style: normal;
- font-display: swap;
- font-weight: 700;
- src: url('@fontsource/iosevka/files/iosevka-latin-700-normal.woff2') format('woff2');
-}
-
-@font-face {
- font-family: 'Iosevka';
- font-style: italic;
- font-display: swap;
- font-weight: 700;
- src: url('@fontsource/iosevka/files/iosevka-latin-700-italic.woff2') format('woff2');
-}
diff --git a/test/dev-reload.test.js b/test/dev-reload.test.js
new file mode 100644
index 0000000..b5eabbc
--- /dev/null
+++ b/test/dev-reload.test.js
@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { createRegenerationQueue } from '../dev-reload.js'
+
+const waitFor = async (predicate) => {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ if (predicate()) return
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ }
+ assert.fail('timed out waiting for generation')
+}
+
+test('rapid edits coalesce and only the latest generation reloads', async () => {
+ const pending = []
+ const events = []
+ const queue = createRegenerationQueue({
+ generate: () => new Promise((resolve) => pending.push(resolve)),
+ onSuccess: () => events.push('reload'),
+ onError: () => events.push('error'),
+ delay: 1,
+ })
+ try {
+ queue.request()
+ queue.request()
+ await waitFor(() => pending.length === 1)
+ queue.request()
+ pending[0]()
+ await waitFor(() => pending.length === 2)
+ assert.deepEqual(events, [])
+ pending[1]()
+ await waitFor(() => events.length === 1)
+ assert.deepEqual(events, ['reload'])
+ } finally {
+ queue.close()
+ }
+})
+
+test('failed latest generation reports an error without reloading', async () => {
+ const events = []
+ const queue = createRegenerationQueue({
+ generate: () => Promise.reject(new Error('broken content')),
+ onSuccess: () => events.push('reload'),
+ onError: (error) => events.push(error.message),
+ delay: 1,
+ })
+ try {
+ queue.request()
+ await waitFor(() => events.length === 1)
+ assert.deepEqual(events, ['broken content'])
+ } finally {
+ queue.close()
+ }
+})
diff --git a/test/html-checks.test.js b/test/html-checks.test.js
new file mode 100644
index 0000000..b34569a
--- /dev/null
+++ b/test/html-checks.test.js
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { htmlFacts } from '../html-checks.js'
+
+test('extracts decoded attributes regardless of quote style or order', () => {
+ const facts = htmlFacts(`
+ GoTitle
+ `)
+ assert.equal(facts.hasViewport, true)
+ assert.deepEqual(facts.ids, ['a&b'])
+ assert.deepEqual(facts.hrefs, ['#a&b'])
+ assert.deepEqual(facts.languages, ['js'])
+})
+
+test('ignores markup-like text in scripts and comments', () => {
+ const facts = htmlFacts(`
+
+ `)
+ assert.deepEqual(facts.ids, [])
+ assert.deepEqual(facts.hrefs, [])
+ assert.equal(facts.hasHeadingElement, false)
+ assert.equal(facts.isDraft, true)
+})
diff --git a/test/markdown.test.js b/test/markdown.test.js
new file mode 100644
index 0000000..9fc48bb
--- /dev/null
+++ b/test/markdown.test.js
@@ -0,0 +1,20 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { renderMarkdownPage } from '../markdown.js'
+
+test('a page uses matching IDs in document and TOC order, including nested duplicates', () => {
+ const page = renderMarkdownPage('# Title\n\n### Repeat\n\n## Repeat\n\n### Repeat\n\nA note^[with **emphasis**].\n\n```js\nconst x = 1\n```')
+ const ids = [...page.html.matchAll(/ match[1])
+ const links = [...page.toc.matchAll(/href="#([^"]+)"/g)].map((match) => match[1])
+ assert.deepEqual(ids, ['repeat', 'repeat-2', 'repeat-3'])
+ assert.deepEqual(links, ids)
+ assert.match(page.html, /id="sidenote-1"/)
+ assert.match(page.html, /emphasis<\/strong>/)
+ assert.match(page.html, /class="language-js"/)
+ assert.equal(page.excerpt, 'A note.')
+})
+
+test('sidenote numbering resets for each page', () => {
+ assert.match(renderMarkdownPage('# One\n\nA^[first]').html, /id="sidenote-1"/)
+ assert.match(renderMarkdownPage('# Two\n\nB^[second]').html, /id="sidenote-1"/)
+})
diff --git a/test/site.test.js b/test/site.test.js
new file mode 100644
index 0000000..ca3cbd3
--- /dev/null
+++ b/test/site.test.js
@@ -0,0 +1,78 @@
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+import { createOutput, discardOutput, publishOutput } from '../output.js'
+import { readPages } from '../content.js'
+import { generatedRoot, siteUrl } from '../site.config.js'
+
+test('headings have unique IDs and matching TOC links', async () => {
+ const html = await readFile(join(generatedRoot, 'notes', 'steam', 'index.html'), 'utf8')
+ const ids = [...html.matchAll(/ match[1])
+ const links = [...html.matchAll(//g)].map((match) => match[1])
+ assert.ok(ids.length > 1)
+ assert.equal(new Set(ids).size, ids.length)
+ assert.deepEqual(links, ids)
+})
+
+test('sidenotes render Markdown with paired, unique controls', async () => {
+ const html = await readFile(join(generatedRoot, 'notes', 'lua', 'index.html'), 'utf8')
+ const controls = [...html.matchAll(/ match[1])
+ assert.deepEqual(controls, ['sidenote-1', 'sidenote-2'])
+ for (const id of controls) assert.ok(html.includes(`'))
+})
+
+test('production excludes hidden draft pages and feed links resolve', async () => {
+ const pages = await readdir(join(generatedRoot, 'posts'))
+ assert.ok(!pages.includes('rendering-lab'))
+ const feed = await readFile(join(generatedRoot, 'feed.xml'), 'utf8')
+ assert.ok(!feed.includes('rendering-lab'))
+ for (const [, url] of feed.matchAll(/([^<]+)<\/guid>/g)) {
+ assert.ok(url.startsWith(`${siteUrl}/`))
+ const pathname = new URL(url).pathname
+ await readFile(join(generatedRoot, pathname, 'index.html'))
+ }
+})
+
+test('content discovery excludes drafts and prefers a published page with the same slug', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'www-pages-test-'))
+ try {
+ await writeFile(join(directory, '.draft.md'), '# Draft\n')
+ await writeFile(join(directory, '.same.md'), '# Old draft\n')
+ await writeFile(join(directory, 'same.md'), '# Published\n')
+ assert.deepEqual((await readPages(directory)).map(({ name }) => name), ['same'])
+ const pages = await readPages(directory, { includeDrafts: true })
+ assert.deepEqual(pages.map(({ name }) => name), ['draft', 'same'])
+ assert.equal(pages.find(({ name }) => name === 'same').title, 'Published')
+ } finally {
+ await rm(directory, { recursive: true, force: true })
+ }
+})
+
+test('publishing replaces output, while a discarded generation preserves it', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'www-output-test-'))
+ const destination = join(parent, '.generated')
+ try {
+ const first = await createOutput(destination)
+ await writeFile(join(first, 'index.html'), 'first')
+ await publishOutput(first, destination)
+
+ const failed = await createOutput(destination)
+ await writeFile(join(failed, 'index.html'), 'incomplete')
+ await discardOutput(failed)
+ assert.equal(await readFile(join(destination, 'index.html'), 'utf8'), 'first')
+
+ const missingStage = join(parent, 'missing-stage')
+ await assert.rejects(publishOutput(missingStage, destination))
+ assert.equal(await readFile(join(destination, 'index.html'), 'utf8'), 'first')
+
+ const second = await createOutput(destination)
+ await writeFile(join(second, 'index.html'), 'second')
+ await publishOutput(second, destination)
+ assert.equal(await readFile(join(destination, 'index.html'), 'utf8'), 'second')
+ } finally {
+ await rm(parent, { recursive: true, force: true })
+ }
+})
diff --git a/vite.config.js b/vite.config.js
index ae4e701..9b70c5c 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -2,6 +2,8 @@ import fs from 'node:fs'
import { spawn } from 'node:child_process'
import path from 'node:path'
import { defineConfig } from 'vite'
+import { createRegenerationQueue } from './dev-reload.js'
+import { contentDirectories, contentSources, generatedRoot } from './site.config.js'
function generatedPages(directory) {
const inputs = {}
@@ -9,10 +11,10 @@ function generatedPages(directory) {
const filePath = path.join(directory, entry.name)
if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath))
else if (entry.name === 'index.html') {
- const relativePath = path.relative('.generated', filePath)
+ const relativePath = path.relative(generatedRoot, filePath)
const key = relativePath.endsWith('/index.html') ? relativePath.slice(0, -'/index.html'.length) : relativePath
inputs[key] = path.resolve(filePath)
- } else if (entry.name === '404.html' && directory === '.generated') {
+ } else if (entry.name === '404.html' && directory === generatedRoot) {
inputs['404'] = path.resolve(filePath)
}
}
@@ -27,7 +29,7 @@ function copyGeneratedFiles() {
const dist = path.resolve('dist')
if (!fs.existsSync(dist)) return
for (const file of files) {
- const source = path.resolve('.generated', file)
+ const source = path.resolve(generatedRoot, file)
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(dist, file))
}
},
@@ -35,57 +37,34 @@ function copyGeneratedFiles() {
}
function contentGenerator() {
- let timer
- let generator
- let rerun = false
-
- const generate = (server) => {
- if (generator) {
- rerun = true
- return
- }
-
- generator = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' })
- generator.on('error', (error) => {
- console.error(`Content generation failed: ${error.message}`)
- })
- generator.on('close', (code) => {
- generator = undefined
- if (code === 0) server.ws.send({ type: 'full-reload' })
- if (rerun) {
- rerun = false
- generate(server)
- }
- })
- }
-
return {
name: 'content-generator',
configureServer(server) {
- const sources = [
- path.resolve('notes'),
- path.resolve('posts'),
- path.resolve('README.md'),
- path.resolve('THIRD_PARTY_LICENSES.md'),
- path.resolve('external.json'),
- path.resolve('generator.js'),
- path.resolve('html.js'),
- ]
- server.watcher.add(sources)
+ const queue = createRegenerationQueue({
+ generate: () => new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' })
+ child.once('error', reject)
+ child.once('close', (code) => code === 0 ? resolve() : reject(new Error(`exit ${code ?? 'unknown'}`)))
+ }),
+ onSuccess: () => server.ws.send({ type: 'full-reload' }),
+ onError: (error) => {
+ console.error(`Content generation failed: ${error.message}`)
+ server.ws.send({
+ type: 'error',
+ err: { message: `Content generation failed (${error.message}). Check the terminal for details.`, stack: '' },
+ })
+ },
+ })
+ const sources = new Set(contentSources.map((source) => path.resolve(source)))
+ server.watcher.add([...sources])
+ server.httpServer?.once('close', () => queue.close())
server.watcher.on('all', (event, file) => {
if (!['add', 'change', 'unlink'].includes(event)) return
const isMarkdown =
file.endsWith('.md') &&
- (file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`))
- const isExternal = file === path.resolve('external.json')
- const isStandaloneMarkdown =
- file === path.resolve('README.md') || file === path.resolve('THIRD_PARTY_LICENSES.md')
- const isGenerator = file === path.resolve('generator.js') || file === path.resolve('html.js')
- if (!isMarkdown && !isStandaloneMarkdown && !isExternal && !isGenerator) return
- clearTimeout(timer)
- timer = setTimeout(() => {
- generate(server)
- }, 100)
+ contentDirectories.some((dir) => file.startsWith(`${path.resolve(dir)}/`))
+ if (!isMarkdown && !sources.has(file)) return
+ queue.request()
})
},
}
@@ -93,7 +72,7 @@ function contentGenerator() {
export default defineConfig({
plugins: [contentGenerator(), copyGeneratedFiles()],
- root: '.generated',
+ root: generatedRoot,
publicDir: '../public',
resolve: {
alias: {
@@ -102,14 +81,14 @@ export default defineConfig({
},
server: {
watch: {
- ignored: ['**/.generated/**'],
+ ignored: [`**/${generatedRoot}/**`],
},
},
build: {
outDir: path.resolve('dist'),
emptyOutDir: true,
rollupOptions: {
- input: generatedPages('.generated'),
+ input: generatedPages(generatedRoot),
},
},
})