Refactor site generation into a modular content pipeline
Deploy website / deploy (push) Successful in 24s
Deploy website / deploy (push) Successful in 24s
This commit is contained in:
1 parent
0f69028d4a
commit
146d516f24
20 files changed
+826
-427
No files matched your search
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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&b'>Go</a><h2 id='a&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)
|
||||||
|
})
|
||||||
@@ -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"/)
|
||||||
|
})
|
||||||
@@ -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
@@ -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),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user