Refactor site generation into a modular content pipeline
Deploy website / deploy (push) Successful in 24s

This commit is contained in:
paul committed 2026-09-22 20:51:13 -04:00
1 parent 0f69028d4a
commit 146d516f24
20 files changed
+826 -427

No files matched your search

+2
View File
@@ -2,8 +2,10 @@
**/.*.md **/.*.md
.env .env
.generated/ .generated/
.generated-next-*/
.bun-tmp/ .bun-tmp/
dist/ dist/
node_modules/ node_modules/
mirror/
*.pdb *.pdb
*.exe *.exe
+26 -2
View File
@@ -21,5 +21,29 @@ bun install
bun run dev bun run dev
``` ```
Run `bun run check` to validate generated pages and `bun run build` to create Run `bun run test` for focused content and output tests, `bun run check` to
the production site in `dist/`. validate generated pages, and `bun run build` to create the production site in
`dist/`.
### Project layout
- `site.config.js` defines the site URL, content directories, standalone pages,
and files watched by the development server.
- `content.js` discovers Markdown pages and handles local drafts. A file named
`.example.md` in `notes/` or `posts/` is a local draft; `bun run dev` includes
drafts, while builds exclude them.
- `generator.js` renders static pages. `feed.js` renders the RSS feed and sitemap.
`output.js` stages the generated site before replacing `.generated/`, so a
failed generation leaves the previous output available.
- `src/` contains browser behavior, styles, and bundled fonts.
- `vite.config.js` builds the generated pages and regenerates them after content
changes during development.
- `check.js` validates the generated output before each production build.
Edit content in `notes/` and `posts/`. Keep generated files in `.generated/`
and `dist/` out of version control.
`mirror/` contains local copies of API references (`docs.gl`, Microsoft SDK
API, and Win32). It is about 906 MB, is not used by the generator or build,
and is ignored by Git. Keep it locally for reference or move it outside the
project when no longer needed.
+163
View File
@@ -0,0 +1,163 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "paulw-xyz",
"dependencies": {
"@fontsource/cardo": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/iosevka": "^5.3.0",
"marked": "^16.0.0",
"prismjs": "^1.30.0",
},
"devDependencies": {
"parse5": "^8.0.1",
"vite": "^7.0.0",
},
},
},
"packages": {
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"@fontsource/cardo": ["@fontsource/cardo@5.3.0", "", {}, "sha512-4G7oLxQRcYyOIoJH0CpZ1Tkc5iLHQPaJoThmp/f8Vz7NFFfn5fspCWkrqhlO6mVAXFruMoOwivyXrvaISJm7uA=="],
"@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="],
"@fontsource/iosevka": ["@fontsource/iosevka@5.3.0", "", {}, "sha512-RcG0v/65e4PTuThR/d7n9jj6PLlex7YUGaLa4ZpL+NnqArWQmD8Z+6gYBTxPMVWxYMRcEw6OF4rdt8XisnC5zA=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.4", "", { "os": "android", "cpu": "arm" }, "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.4", "", { "os": "android", "cpu": "arm64" }, "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.4", "", { "os": "linux", "cpu": "arm" }, "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.4", "", { "os": "linux", "cpu": "arm" }, "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.4", "", { "os": "linux", "cpu": "none" }, "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.4", "", { "os": "linux", "cpu": "x64" }, "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.4", "", { "os": "linux", "cpu": "x64" }, "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.4", "", { "os": "none", "cpu": "arm64" }, "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.4", "", { "os": "win32", "cpu": "x64" }, "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"rollup": ["rollup@4.63.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.4", "@rollup/rollup-android-arm64": "4.63.4", "@rollup/rollup-darwin-arm64": "4.63.4", "@rollup/rollup-darwin-x64": "4.63.4", "@rollup/rollup-freebsd-arm64": "4.63.4", "@rollup/rollup-freebsd-x64": "4.63.4", "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", "@rollup/rollup-linux-arm-musleabihf": "4.63.4", "@rollup/rollup-linux-arm64-gnu": "4.63.4", "@rollup/rollup-linux-arm64-musl": "4.63.4", "@rollup/rollup-linux-loong64-gnu": "4.63.4", "@rollup/rollup-linux-loong64-musl": "4.63.4", "@rollup/rollup-linux-ppc64-gnu": "4.63.4", "@rollup/rollup-linux-ppc64-musl": "4.63.4", "@rollup/rollup-linux-riscv64-gnu": "4.63.4", "@rollup/rollup-linux-riscv64-musl": "4.63.4", "@rollup/rollup-linux-s390x-gnu": "4.63.4", "@rollup/rollup-linux-x64-gnu": "4.63.4", "@rollup/rollup-linux-x64-musl": "4.63.4", "@rollup/rollup-openbsd-x64": "4.63.4", "@rollup/rollup-openharmony-arm64": "4.63.4", "@rollup/rollup-win32-arm64-msvc": "4.63.4", "@rollup/rollup-win32-ia32-msvc": "4.63.4", "@rollup/rollup-win32-x64-gnu": "4.63.4", "@rollup/rollup-win32-x64-msvc": "4.63.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
}
}
+10 -9
View File
@@ -1,8 +1,9 @@
import { execFileSync } from 'node:child_process' import { execFileSync } from 'node:child_process'
import { readdir, readFile, stat } from 'node:fs/promises' import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import { generatedRoot, siteUrl } from './site.config.js'
import { htmlFacts } from './html-checks.js'
const generatedRoot = '.generated'
const walk = async (directory) => { const walk = async (directory) => {
const files = [] const files = []
@@ -45,21 +46,22 @@ const prismLanguageAliases = { sh: 'bash', shell: 'bash' }
for (const file of htmlFiles) { for (const file of htmlFiles) {
const html = await readFile(file, 'utf8') const html = await readFile(file, 'utf8')
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]) const { ids, hrefs, languages, hasViewport, hasHeadingElement, isDraft } = htmlFacts(html)
const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index) const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index)
if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`) if (duplicateIds.length > 0) errors.push(`${file}: duplicate IDs: ${[...new Set(duplicateIds)].join(', ')}`)
if (!html.includes('<meta name="viewport"')) errors.push(`${file}: missing viewport metadata`) if (!hasViewport) errors.push(`${file}: missing viewport metadata`)
if (html.includes('<heading')) errors.push(`${file}: contains a nonstandard <heading> element`) if (hasHeadingElement) errors.push(`${file}: contains a nonstandard <heading> element`)
if (html.includes('name="robots"') && html.includes('noindex')) { if (isDraft) {
noindexFiles.add(file) noindexFiles.add(file)
errors.push(`${file}: draft page must not be in production output`) errors.push(`${file}: draft page must not be in production output`)
} }
for (const [, fragment] of html.matchAll(/href="#([^"]+)"/g)) { for (const href of hrefs.filter((value) => value.startsWith('#') && value.length > 1)) {
const fragment = decodeURIComponent(href.slice(1))
if (!ids.includes(fragment)) errors.push(`${file}: missing fragment target #${fragment}`) if (!ids.includes(fragment)) errors.push(`${file}: missing fragment target #${fragment}`)
} }
for (const [, href] of html.matchAll(/href="(\/[^"]*)"/g)) { for (const href of hrefs.filter((value) => value.startsWith('/'))) {
if (href === '/') continue if (href === '/') continue
const pathname = href.split(/[?#]/, 1)[0] const pathname = href.split(/[?#]/, 1)[0]
const relative = pathname.replace(/^\//, '') const relative = pathname.replace(/^\//, '')
@@ -70,7 +72,7 @@ for (const file of htmlFiles) {
if (!(await exists(target))) errors.push(`${file}: broken internal link ${href}`) if (!(await exists(target))) errors.push(`${file}: broken internal link ${href}`)
} }
for (const [, language] of html.matchAll(/class="language-([\w-]+)"/g)) { for (const language of languages) {
if (builtInPrismLanguages.has(language)) continue if (builtInPrismLanguages.has(language)) continue
const component = prismLanguageAliases[language] ?? language const component = prismLanguageAliases[language] ?? language
if (!clientSource.includes(`prism-${component}`)) if (!clientSource.includes(`prism-${component}`))
@@ -78,7 +80,6 @@ for (const file of htmlFiles) {
} }
} }
const siteUrl = 'https://paulw.xyz'
const generatedFile = (pathname) => { const generatedFile = (pathname) => {
const relative = pathname.replace(/^\//, '') const relative = pathname.replace(/^\//, '')
return pathname.endsWith('/') ? join(generatedRoot, relative, 'index.html') : join(generatedRoot, relative) return pathname.endsWith('/') ? join(generatedRoot, relative, 'index.html') : join(generatedRoot, relative)
+2 -1
View File
@@ -1,3 +1,4 @@
import { rm } from 'node:fs/promises' import { rm } from 'node:fs/promises'
import { generatedRoot } from './site.config.js'
await Promise.all([rm('.generated', { recursive: true, force: true }), rm('dist', { recursive: true, force: true })]) await Promise.all([rm(generatedRoot, { recursive: true, force: true }), rm('dist', { recursive: true, force: true })])
+71
View File
@@ -0,0 +1,71 @@
import { execFileSync } from 'node:child_process'
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
export const stripSidenotes = (text) => {
let result = ''
for (let index = 0; index < text.length;) {
if (text[index] === '^' && text[index + 1] === '[') {
const end = text.indexOf(']', index + 2)
if (end >= 0) {
index = end + 1
continue
}
}
result += text[index++]
}
return result
}
export const dateFor = async (file) => {
try {
const gitDate = execFileSync(
'git',
['log', '-1', '--date=format-local:%Y-%m-%dT%H:%M:%SZ', '--format=%ad', '--', file],
{ env: { ...process.env, TZ: 'UTC' }, stdio: ['ignore', 'pipe', 'ignore'] },
).toString().trim()
if (gitDate) return gitDate
} catch {}
try {
return (await stat(file)).mtime.toISOString()
} catch {
return ''
}
}
export const readPages = async (dir, { includeDrafts = false } = {}) => {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
const markdownEntries = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== '.md'
&& (includeDrafts || !entry.name.startsWith('.')))
.sort((a, b) => a.name.localeCompare(b.name))
const pages = await Promise.all(markdownEntries.map(async (entry) => {
const file = join(dir, entry.name)
const source = await readFile(file, 'utf8')
const titleLine = source.split(/\r?\n/).find((line) => line.startsWith('# '))
if (!titleLine) return undefined
const draft = entry.name.startsWith('.')
const name = (draft ? entry.name.slice(1) : entry.name).slice(0, -3)
if (!name) return undefined
return {
name,
title: stripSidenotes(titleLine.slice(2).trim()),
file,
source,
date: await dateFor(file),
draft,
}
}))
const byName = new Map()
for (const page of pages.filter(Boolean)) {
const existing = byName.get(page.name)
if (!existing || (existing.draft && !page.draft)) byName.set(page.name, page)
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
}
+39
View File
@@ -0,0 +1,39 @@
// Keep only the newest generation's result visible to the browser.
export const createRegenerationQueue = ({ generate, onSuccess, onError, delay = 100 }) => {
let timer
let running = false
let revision = 0
let closed = false
const run = async () => {
if (closed || running) return
running = true
const started = revision
try {
await generate()
if (!closed && started === revision) onSuccess()
} catch (error) {
if (!closed && started === revision) onError(error)
} finally {
running = false
if (!closed && started !== revision) schedule()
}
}
const schedule = () => {
clearTimeout(timer)
timer = setTimeout(run, delay)
}
return {
request() {
if (closed) return
revision += 1
schedule()
},
close() {
closed = true
clearTimeout(timer)
},
}
}
+56
View File
@@ -0,0 +1,56 @@
import { escapeHtml } from './html.js'
import { siteName, siteUrl } from './site.config.js'
export const toSitemapDate = (iso) => (iso ? iso.slice(0, 10) : '')
const toRssDate = (iso) => {
if (!iso) return ''
const date = new Date(iso)
return Number.isNaN(date.valueOf()) ? '' : date.toUTCString()
}
export 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 = item.description
const pubDate = toRssDate(item.date)
return `<item>
<title>${escapeHtml(item.title)}</title>
<link>${escapeHtml(loc)}</link>
<guid isPermaLink="true">${escapeHtml(loc)}</guid>
${pubDate ? `<pubDate>${escapeHtml(pubDate)}</pubDate>` : ''}
<category>${escapeHtml(item.dir)}</category>
${description ? `<description>${escapeHtml(description)}</description>` : ''}
</item>`
})
.join('\n ')
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeHtml(siteName)}</title>
<link>${escapeHtml(`${siteUrl}/`)}</link>
<description>${escapeHtml(`Notes and posts from ${siteName}`)}</description>
<language>en-US</language>
<atom:link href="${escapeHtml(`${siteUrl}/feed.xml`)}" rel="self" type="application/rss+xml"/>
<lastBuildDate>${escapeHtml(lastBuild)}</lastBuildDate>
${itemXml}
</channel>
</rss>
`
}
export const renderSitemap = (urls) => `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(({ loc, lastmod }) => {
const modified = lastmod ? `\n <lastmod>${escapeHtml(lastmod)}</lastmod>` : ''
return ` <url>
<loc>${escapeHtml(loc)}</loc>${modified}
</url>`
})
.join('\n')}
</urlset>
`
+36 -339
View File
@@ -1,68 +1,21 @@
import { execFileSync } from 'node:child_process' import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path' import { join } from 'node:path'
import { escapeHtml, h, raw, render } from './html.js' import { escapeHtml, h, raw, render } from './html.js'
import { marked } from 'marked' import { dateFor, readPages } from './content.js'
import { renderFeed, renderSitemap, toSitemapDate } from './feed.js'
import { createOutput, discardOutput, publishOutput } from './output.js'
import { renderMarkdownPage } from './markdown.js'
import {
contentDirectories,
generatedRoot,
navLabels,
siteName,
siteUrl,
standalonePages,
} from './site.config.js'
const siteName = 'PaulW.XYZ'
const siteUrl = 'https://paulw.xyz'
const output = '.generated'
const markdownDirs = ['notes', 'posts']
const includeDrafts = process.argv.includes('--drafts') const includeDrafts = process.argv.includes('--drafts')
const navLabels = { const output = await createOutput(generatedRoot)
notes: 'Notes',
posts: 'Posts',
about: 'About',
licenses: 'Licenses',
}
const standalonePages = [
{ source: 'README.md', path: 'about', title: 'About' },
{ source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' },
]
const languageLabels = {
ini: 'INI',
js: 'JavaScript',
javascript: 'JavaScript',
lua: 'Lua',
sh: 'Shell',
bash: 'Shell',
}
const slug = (value) => {
let result = ''
let dash = false
for (const character of String(value).toLowerCase()) {
const valid = (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')
if (valid) {
if (dash && result) result += '-'
result += character
dash = false
} else if (result) dash = true
}
return result
}
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 createSlugger = () => {
const counts = new Map()
return (tokens) => {
const base = slug(headingLabel(tokens)) || 'section'
const count = counts.get(base) ?? 0
counts.set(base, count + 1)
return count === 0 ? base : `${base}-${count + 1}`
}
}
const pageShell = (title, body, path = '/', navTitle = '', { draft = false } = {}) => const pageShell = (title, body, path = '/', navTitle = '', { draft = false } = {}) =>
`<!DOCTYPE html>${render( `<!DOCTYPE html>${render(
@@ -106,272 +59,6 @@ const nav = (path, currentTitle = '') => {
return render(children) return render(children)
} }
const dateFor = async (file) => {
try {
const gitDate = execFileSync(
'git',
['log', '-1', '--date=format-local:%Y-%m-%dT%H:%M:%SZ', '--format=%ad', '--', file],
{ env: { ...process.env, TZ: 'UTC' } },
)
.toString()
.trim()
if (gitDate) return gitDate
} catch {}
try {
return (await stat(file)).mtime.toISOString()
} catch {
return ''
}
}
const readPages = async (dir) => {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch (error) {
if (error.code === 'ENOENT') return []
throw error
}
const markdownEntries = entries
.filter((entry) => {
if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === '.md') return false
return includeDrafts || !entry.name.startsWith('.')
})
.sort((a, b) => a.name.localeCompare(b.name))
const pages = await Promise.all(
markdownEntries.map(async (entry) => {
const file = join(dir, entry.name)
const source = await readFile(file, 'utf8')
const titleLine = source.split(/\r?\n/).find((line) => line.startsWith('# '))
if (!titleLine) return undefined
const draft = entry.name.startsWith('.')
const name = (draft ? entry.name.slice(1) : entry.name).slice(0, -3)
if (!name) return undefined
return {
name,
title: stripSidenotes(titleLine.slice(2).trim()),
file,
source,
date: await dateFor(file),
draft,
}
}),
)
const byName = new Map()
for (const page of pages.filter(Boolean)) {
const existing = byName.get(page.name)
if (!existing || (existing.draft && !page.draft)) byName.set(page.name, page)
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
}
let nextHeadingId = createSlugger()
let sidenoteNumber = 0
marked.use({
extensions: [
{
name: 'sidenote',
level: 'inline',
start: (source) => source.indexOf('^['),
tokenizer(source) {
if (!source.startsWith('^[')) return undefined
let depth = 1
for (let index = 2; index < source.length; index += 1) {
if (source[index] === '\\') {
index += 1
} else if (source[index] === '[') {
depth += 1
} else if (source[index] === ']') {
depth -= 1
if (depth === 0) {
const text = source.slice(2, index)
return {
type: 'sidenote',
raw: source.slice(0, index + 1),
tokens: this.lexer.inlineTokens(text),
}
}
}
}
return undefined
},
renderer({ tokens }) {
sidenoteNumber += 1
const id = `sidenote-${sidenoteNumber}`
const content = this.parser.parseInline(tokens)
return `<input type="checkbox" id="${id}" class="sidenote-toggle visually-hidden"><label for="${id}" class="margin-toggle sidenote-number"><span class="visually-hidden">Toggle sidenote ${sidenoteNumber}</span></label><span class="sidenote">${content}</span>`
},
},
],
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 source = escaped ? text : escapeHtml(text.replace(/\n$/, ''))
return `<div class="code-block"><div class="code-block-header"><span>${escapeHtml(label)}</span><button type="button" class="copy-code" aria-label="Copy ${escapeHtml(label)} code" aria-live="polite">Copy</button></div><pre><code class="language-${escapeHtml(language)}">${source}\n</code></pre></div>\n`
},
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens)
const id = nextHeadingId(tokens)
return `<h${depth} id="${id}">${text}</h${depth}>\n`
},
},
})
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 stripSidenotes = (text) => {
let result = ''
for (let index = 0; index < text.length;) {
if (text[index] === '^' && text[index + 1] === '[') {
const end = text.indexOf(']', index + 2)
if (end >= 0) {
index = end + 1
continue
}
}
result += text[index++]
}
return result
}
const stripHeadingSidenotes = (source) =>
source
.split('\n')
.map((line) => {
const content = line.trimStart()
return content.startsWith('# ') ||
content.startsWith('## ') ||
content.startsWith('### ') ||
content.startsWith('#### ') ||
content.startsWith('##### ') ||
content.startsWith('###### ')
? line.slice(0, line.length - content.length) + stripSidenotes(content)
: line
})
.join('\n')
const renderMarkdown = (source) => {
nextHeadingId = createSlugger()
sidenoteNumber = 0
const html = marked.parse(withoutTitle(stripHeadingSidenotes(source)))
let result = ''
let cursor = 0
while (cursor < html.length) {
const start = html.indexOf('<table', cursor)
if (start < 0) {
result += html.slice(cursor)
break
}
const end = html.indexOf('</table>', start)
if (end < 0) {
result += html.slice(cursor)
break
}
result += html.slice(cursor, start)
result += `<div class="table-scroll">${html.slice(start, end + 8)}</div>`
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) =>
`<ol>${items
.map(({ heading, children }) => {
const id = headingId(heading.tokens)
const nested = children.length > 0 ? renderItems(children) : ''
return `<li><a href="#${id}">${escapeHtml(headingLabel(heading.tokens))}</a>${nested}</li>`
})
.join('')}</ol>`
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 `<item>
<title>${escapeHtml(item.title)}</title>
<link>${escapeHtml(loc)}</link>
<guid isPermaLink="true">${escapeHtml(loc)}</guid>
${pubDate ? `<pubDate>${escapeHtml(pubDate)}</pubDate>` : ''}
<category>${escapeHtml(item.dir)}</category>
${description ? `<description>${escapeHtml(description)}</description>` : ''}
</item>`
})
.join('\n ')
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeHtml(siteName)}</title>
<link>${escapeHtml(`${siteUrl}/`)}</link>
<description>${escapeHtml(`Notes and posts from ${siteName}`)}</description>
<language>en-US</language>
<atom:link href="${escapeHtml(`${siteUrl}/feed.xml`)}" rel="self" type="application/rss+xml"/>
<lastBuildDate>${escapeHtml(lastBuild)}</lastBuildDate>
${itemXml}
</channel>
</rss>
`
}
const renderSitemap = (urls) => `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(({ loc, lastmod }) => {
const modified = lastmod ? `\n <lastmod>${escapeHtml(lastmod)}</lastmod>` : ''
return ` <url>
<loc>${escapeHtml(loc)}</loc>${modified}
</url>`
})
.join('\n')}
</urlset>
`
const writePage = async (path, html) => { const writePage = async (path, html) => {
const directory = join(output, path) const directory = join(output, path)
await mkdir(directory, { recursive: true }) await mkdir(directory, { recursive: true })
@@ -407,7 +94,7 @@ const renderNotesTable = (pages, directory) => {
</tr>`, </tr>`,
) )
.join('') .join('')
return `<table class="${directory}-table"><caption class="visually-hidden">${escapeHtml(directory)} pages</caption><tbody>${rows}</tbody></table>` return `<table class="${directory}-table"><caption class="visually-hidden">${escapeHtml(directory)} pages</caption><thead><tr><th scope="col">Page</th><th scope="col">Last updated</th></tr></thead><tbody>${rows}</tbody></table>`
} }
const renderPostsList = (pages, directory) => { const renderPostsList = (pages, directory) => {
@@ -425,7 +112,7 @@ const renderPostsList = (pages, directory) => {
const renderEmptySection = () => ` const renderEmptySection = () => `
<div class="text center"> <div class="text center">
<p>**crickets**</p> <p><strong>crickets</strong></p>
<p>Nothing to list...</p> <p>Nothing to list...</p>
<a class="link button back" href="/">Go Home</a> <a class="link button back" href="/">Go Home</a>
</div>` </div>`
@@ -463,19 +150,21 @@ const renderArticle = (directory, page, navigationPath = `/${directory}/${page.n
const draftNotice = page.draft const draftNotice = page.draft
? `<p class="draft-banner" role="status">Draft — rename <code>.${escapeHtml(page.name)}.md</code> to <code>${escapeHtml(page.name)}.md</code> to publish.</p>` ? `<p class="draft-banner" role="status">Draft — rename <code>.${escapeHtml(page.name)}.md</code> to <code>${escapeHtml(page.name)}.md</code> to publish.</p>`
: '' : ''
const tableOfContents = toc(page.source) const tableOfContents = page.markdown.toc
const contents = tableOfContents const contents = tableOfContents
? `<section class="block toc"><details open><summary>Contents</summary>${tableOfContents}</details></section><hr>` ? `<section class="block toc"><details open><summary>Contents</summary>${tableOfContents}</details></section><hr>`
: '' : ''
const article = `<section class="block">${renderMarkdown(page.source)}</section>` const article = `<section class="block">${page.markdown.html}</section>`
const body = `${heading}<main class="article-page ${directory}-page container">${draftNotice}${updated}${contents}${article}</main>` const body = `${heading}<main class="article-page ${directory}-page container">${draftNotice}${updated}${contents}${article}</main>`
return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) }) return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) })
} }
await rm(output, { recursive: true, force: true }) try {
await mkdir(output, { recursive: true }) const sections = Object.fromEntries(await Promise.all(contentDirectories.map(async (dir) => [dir, await readPages(dir, { includeDrafts })])))
const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)]))) 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')) const external = JSON.parse(await readFile('external.json', 'utf8'))
@@ -483,7 +172,7 @@ const externalLinks = Object.entries(external)
.map(([text, href]) => `<a class="button blue link extern" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`) .map(([text, href]) => `<a class="button blue link extern" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`)
.join('') .join('')
const sectionLinks = markdownDirs const sectionLinks = contentDirectories
.map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`) .map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`)
.join('') .join('')
@@ -492,7 +181,7 @@ const standaloneLinks = standalonePages
.map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`) .map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`)
.join('') .join('')
const recentPages = markdownDirs const recentPages = contentDirectories
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir }))) .flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
.sort((a, b) => b.date.localeCompare(a.date)) .sort((a, b) => b.date.localeCompare(a.date))
.slice(0, 5) .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, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks))
await writeFile(join(output, '404.html'), renderNotFound()) await writeFile(join(output, '404.html'), renderNotFound())
for (const dir of markdownDirs) { for (const dir of contentDirectories) {
const pages = sections[dir] const pages = sections[dir]
await writePage( await writePage(
dir, dir,
@@ -528,16 +217,19 @@ for (const page of standalonePages) {
name: page.path, name: page.path,
title: page.title, title: page.title,
source, source,
markdown: renderMarkdownPage(source),
date: await dateFor(page.source), date: await dateFor(page.source),
} }
generatedStandalone.push(generatedPage) generatedStandalone.push(generatedPage)
await writePage(page.path, renderArticle(page.path, 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 }))) .flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
.sort((a, b) => b.date.localeCompare(a.date)) .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)) await writeFile(join(output, 'feed.xml'), renderFeed(feedItems))
const sitemapUrls = [ const sitemapUrls = [
@@ -545,7 +237,7 @@ const sitemapUrls = [
loc: `${siteUrl}/`, loc: `${siteUrl}/`,
lastmod: toSitemapDate(feedItems[0]?.date), lastmod: toSitemapDate(feedItems[0]?.date),
}, },
...markdownDirs.map((dir) => ({ ...contentDirectories.map((dir) => ({
loc: `${siteUrl}/${dir}/`, loc: `${siteUrl}/${dir}/`,
lastmod: toSitemapDate( lastmod: toSitemapDate(
[...sections[dir]].filter((page) => !page.draft).sort((a, b) => b.date.localeCompare(a.date))[0]?.date, [...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 writeFile(join(output, 'sitemap.xml'), renderSitemap(sitemapUrls))
await publishOutput(output, generatedRoot)
} catch (error) {
await discardOutput(output)
throw error
}
+31
View File
@@ -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
}
+125
View File
@@ -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) => `<ol>${items.map(({ id, label, children }) =>
`<li><a href="#${id}">${escapeHtml(label)}</a>${children.length ? renderItems(children) : ''}</li>`).join('')}</ol>`
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(/<table\b[\s\S]*?<\/table>/g, (table) => `<div class="table-scroll">${table}</div>`)
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 `<input type="checkbox" id="${id}" class="sidenote-toggle visually-hidden"><label for="${id}" class="margin-toggle sidenote-number"><span class="visually-hidden">Toggle sidenote ${sidenoteNumber}</span></label><span class="sidenote">${content}</span>`
},
}],
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 `<div class="code-block"><div class="code-block-header"><span>${escapeHtml(label)}</span><button type="button" class="copy-code" aria-label="Copy ${escapeHtml(label)} code" aria-live="polite">Copy</button></div><pre><code class="language-${escapeHtml(language)}">${code}\n</code></pre></div>\n`
},
heading({ tokens, depth }) {
const id = headings[headingIndex++]?.id
return `<h${depth} id="${id}">${this.parser.parseInline(tokens)}</h${depth}>\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),
}
}
+29
View File
@@ -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 })
+4 -2
View File
@@ -4,10 +4,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate": "node generator.js", "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", "clean": "node clean.js",
"dev": "node generator.js --drafts && vite", "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" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -18,6 +19,7 @@
"prismjs": "^1.30.0" "prismjs": "^1.30.0"
}, },
"devDependencies": { "devDependencies": {
"parse5": "^8.0.1",
"vite": "^7.0.0" "vite": "^7.0.0"
}, },
"packageManager": "bun@1.3.8" "packageManager": "bun@1.3.8"
+29
View File
@@ -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',
]
-24
View File
@@ -69,27 +69,3 @@
font-weight: 400; font-weight: 400;
src: url('@fontsource/iosevka/files/iosevka-latin-400-normal.woff2') format('woff2'); 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');
}
+53
View File
@@ -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()
}
})
+23
View File
@@ -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(`<!doctype html><meta content='width=device-width' name='viewport'>
<a class='button' href='#a&amp;b'>Go</a><h2 id='a&amp;b'>Title</h2>
<code class='extra language-js another'>code</code>`)
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(`<script>const example = '<a href="/missing"><i id="fake"></i>'</script>
<!-- <heading id="also-fake"></heading> -->
<meta name='robots' content='nofollow, noindex'>`)
assert.deepEqual(facts.ids, [])
assert.deepEqual(facts.hrefs, [])
assert.equal(facts.hasHeadingElement, false)
assert.equal(facts.isDraft, true)
})
+20
View File
@@ -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(/<h[2-6] id="([^"]+)"/g)].map((match) => 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, /<strong>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"/)
})
+78
View File
@@ -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(/<h[2-6] id="([^"]+)"/g)].map((match) => match[1])
const links = [...html.matchAll(/<a href="#([^"]+)">/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(/<input type="checkbox" id="(sidenote-\d+)"/g)].map((match) => match[1])
assert.deepEqual(controls, ['sidenote-1', 'sidenote-2'])
for (const id of controls) assert.ok(html.includes(`<label for="${id}"`))
assert.ok(html.includes('<strong>unstable</strong>'))
})
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 isPermaLink="true">([^<]+)<\/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 })
}
})
+29 -50
View File
@@ -2,6 +2,8 @@ import fs from 'node:fs'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import path from 'node:path' import path from 'node:path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { createRegenerationQueue } from './dev-reload.js'
import { contentDirectories, contentSources, generatedRoot } from './site.config.js'
function generatedPages(directory) { function generatedPages(directory) {
const inputs = {} const inputs = {}
@@ -9,10 +11,10 @@ function generatedPages(directory) {
const filePath = path.join(directory, entry.name) const filePath = path.join(directory, entry.name)
if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath)) if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath))
else if (entry.name === 'index.html') { 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 const key = relativePath.endsWith('/index.html') ? relativePath.slice(0, -'/index.html'.length) : relativePath
inputs[key] = path.resolve(filePath) 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) inputs['404'] = path.resolve(filePath)
} }
} }
@@ -27,7 +29,7 @@ function copyGeneratedFiles() {
const dist = path.resolve('dist') const dist = path.resolve('dist')
if (!fs.existsSync(dist)) return if (!fs.existsSync(dist)) return
for (const file of files) { 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)) if (fs.existsSync(source)) fs.copyFileSync(source, path.join(dist, file))
} }
}, },
@@ -35,57 +37,34 @@ function copyGeneratedFiles() {
} }
function contentGenerator() { 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 { return {
name: 'content-generator', name: 'content-generator',
configureServer(server) { configureServer(server) {
const sources = [ const queue = createRegenerationQueue({
path.resolve('notes'), generate: () => new Promise((resolve, reject) => {
path.resolve('posts'), const child = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' })
path.resolve('README.md'), child.once('error', reject)
path.resolve('THIRD_PARTY_LICENSES.md'), child.once('close', (code) => code === 0 ? resolve() : reject(new Error(`exit ${code ?? 'unknown'}`)))
path.resolve('external.json'), }),
path.resolve('generator.js'), onSuccess: () => server.ws.send({ type: 'full-reload' }),
path.resolve('html.js'), onError: (error) => {
] console.error(`Content generation failed: ${error.message}`)
server.watcher.add(sources) 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) => { server.watcher.on('all', (event, file) => {
if (!['add', 'change', 'unlink'].includes(event)) return if (!['add', 'change', 'unlink'].includes(event)) return
const isMarkdown = const isMarkdown =
file.endsWith('.md') && file.endsWith('.md') &&
(file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`)) contentDirectories.some((dir) => file.startsWith(`${path.resolve(dir)}/`))
const isExternal = file === path.resolve('external.json') if (!isMarkdown && !sources.has(file)) return
const isStandaloneMarkdown = queue.request()
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)
}) })
}, },
} }
@@ -93,7 +72,7 @@ function contentGenerator() {
export default defineConfig({ export default defineConfig({
plugins: [contentGenerator(), copyGeneratedFiles()], plugins: [contentGenerator(), copyGeneratedFiles()],
root: '.generated', root: generatedRoot,
publicDir: '../public', publicDir: '../public',
resolve: { resolve: {
alias: { alias: {
@@ -102,14 +81,14 @@ export default defineConfig({
}, },
server: { server: {
watch: { watch: {
ignored: ['**/.generated/**'], ignored: [`**/${generatedRoot}/**`],
}, },
}, },
build: { build: {
outDir: path.resolve('dist'), outDir: path.resolve('dist'),
emptyOutDir: true, emptyOutDir: true,
rollupOptions: { rollupOptions: {
input: generatedPages('.generated'), input: generatedPages(generatedRoot),
}, },
}, },
}) })