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
|
||||
.env
|
||||
.generated/
|
||||
.generated-next-*/
|
||||
.bun-tmp/
|
||||
dist/
|
||||
node_modules/
|
||||
mirror/
|
||||
*.pdb
|
||||
*.exe
|
||||
@@ -21,5 +21,29 @@ bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Run `bun run check` to validate generated pages and `bun run build` to create
|
||||
the production site in `dist/`.
|
||||
Run `bun run test` for focused content and output tests, `bun run check` to
|
||||
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 { readdir, readFile, stat } from 'node:fs/promises'
|
||||
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 files = []
|
||||
@@ -45,21 +46,22 @@ const prismLanguageAliases = { sh: 'bash', shell: 'bash' }
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
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)
|
||||
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 (html.includes('<heading')) errors.push(`${file}: contains a nonstandard <heading> element`)
|
||||
if (html.includes('name="robots"') && html.includes('noindex')) {
|
||||
if (!hasViewport) errors.push(`${file}: missing viewport metadata`)
|
||||
if (hasHeadingElement) errors.push(`${file}: contains a nonstandard <heading> element`)
|
||||
if (isDraft) {
|
||||
noindexFiles.add(file)
|
||||
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}`)
|
||||
}
|
||||
|
||||
for (const [, href] of html.matchAll(/href="(\/[^"]*)"/g)) {
|
||||
for (const href of hrefs.filter((value) => value.startsWith('/'))) {
|
||||
if (href === '/') continue
|
||||
const pathname = href.split(/[?#]/, 1)[0]
|
||||
const relative = pathname.replace(/^\//, '')
|
||||
@@ -70,7 +72,7 @@ for (const file of htmlFiles) {
|
||||
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
|
||||
const component = prismLanguageAliases[language] ?? language
|
||||
if (!clientSource.includes(`prism-${component}`))
|
||||
@@ -78,7 +80,6 @@ for (const file of htmlFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
const siteUrl = 'https://paulw.xyz'
|
||||
const generatedFile = (pathname) => {
|
||||
const relative = pathname.replace(/^\//, '')
|
||||
return pathname.endsWith('/') ? join(generatedRoot, relative, 'index.html') : join(generatedRoot, relative)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
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 navLabels = {
|
||||
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 output = await createOutput(generatedRoot)
|
||||
|
||||
const pageShell = (title, body, path = '/', navTitle = '', { draft = false } = {}) =>
|
||||
`<!DOCTYPE html>${render(
|
||||
@@ -106,272 +59,6 @@ const nav = (path, currentTitle = '') => {
|
||||
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 directory = join(output, path)
|
||||
await mkdir(directory, { recursive: true })
|
||||
@@ -407,7 +94,7 @@ const renderNotesTable = (pages, directory) => {
|
||||
</tr>`,
|
||||
)
|
||||
.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) => {
|
||||
@@ -425,7 +112,7 @@ const renderPostsList = (pages, directory) => {
|
||||
|
||||
const renderEmptySection = () => `
|
||||
<div class="text center">
|
||||
<p>**crickets**</p>
|
||||
<p><strong>crickets</strong></p>
|
||||
<p>Nothing to list...</p>
|
||||
<a class="link button back" href="/">Go Home</a>
|
||||
</div>`
|
||||
@@ -463,19 +150,21 @@ const renderArticle = (directory, page, navigationPath = `/${directory}/${page.n
|
||||
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>`
|
||||
: ''
|
||||
const tableOfContents = toc(page.source)
|
||||
const tableOfContents = page.markdown.toc
|
||||
const contents = tableOfContents
|
||||
? `<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>`
|
||||
|
||||
return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) })
|
||||
}
|
||||
|
||||
await rm(output, { recursive: true, force: true })
|
||||
await mkdir(output, { recursive: true })
|
||||
const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)])))
|
||||
try {
|
||||
const sections = Object.fromEntries(await Promise.all(contentDirectories.map(async (dir) => [dir, await readPages(dir, { includeDrafts })])))
|
||||
for (const pages of Object.values(sections)) {
|
||||
for (const page of pages) page.markdown = renderMarkdownPage(page.source)
|
||||
}
|
||||
|
||||
const external = JSON.parse(await readFile('external.json', 'utf8'))
|
||||
|
||||
@@ -483,7 +172,7 @@ const externalLinks = Object.entries(external)
|
||||
.map(([text, href]) => `<a class="button blue link extern" href="${escapeHtml(href)}">${escapeHtml(text)}</a>`)
|
||||
.join('')
|
||||
|
||||
const sectionLinks = markdownDirs
|
||||
const sectionLinks = contentDirectories
|
||||
.map((dir) => `<a class="button link" href="/${dir}/">${dir[0].toUpperCase() + dir.slice(1)}</a>`)
|
||||
.join('')
|
||||
|
||||
@@ -492,7 +181,7 @@ const standaloneLinks = standalonePages
|
||||
.map((page) => `<a class="button link" href="/${page.path}/">${page.title}</a>`)
|
||||
.join('')
|
||||
|
||||
const recentPages = markdownDirs
|
||||
const recentPages = contentDirectories
|
||||
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.slice(0, 5)
|
||||
@@ -506,7 +195,7 @@ const recentLinks = recentPages
|
||||
await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks))
|
||||
await writeFile(join(output, '404.html'), renderNotFound())
|
||||
|
||||
for (const dir of markdownDirs) {
|
||||
for (const dir of contentDirectories) {
|
||||
const pages = sections[dir]
|
||||
await writePage(
|
||||
dir,
|
||||
@@ -528,16 +217,19 @@ for (const page of standalonePages) {
|
||||
name: page.path,
|
||||
title: page.title,
|
||||
source,
|
||||
markdown: renderMarkdownPage(source),
|
||||
date: await dateFor(page.source),
|
||||
}
|
||||
generatedStandalone.push(generatedPage)
|
||||
await writePage(page.path, renderArticle(page.path, generatedPage, '/'))
|
||||
}
|
||||
|
||||
const feedItems = markdownDirs
|
||||
const feedItems = contentDirectories
|
||||
.flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir })))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
|
||||
for (const item of feedItems) item.description = item.markdown.excerpt
|
||||
|
||||
await writeFile(join(output, 'feed.xml'), renderFeed(feedItems))
|
||||
|
||||
const sitemapUrls = [
|
||||
@@ -545,7 +237,7 @@ const sitemapUrls = [
|
||||
loc: `${siteUrl}/`,
|
||||
lastmod: toSitemapDate(feedItems[0]?.date),
|
||||
},
|
||||
...markdownDirs.map((dir) => ({
|
||||
...contentDirectories.map((dir) => ({
|
||||
loc: `${siteUrl}/${dir}/`,
|
||||
lastmod: toSitemapDate(
|
||||
[...sections[dir]].filter((page) => !page.draft).sort((a, b) => b.date.localeCompare(a.date))[0]?.date,
|
||||
@@ -563,3 +255,8 @@ const sitemapUrls = [
|
||||
]
|
||||
|
||||
await writeFile(join(output, 'sitemap.xml'), renderSitemap(sitemapUrls))
|
||||
await publishOutput(output, generatedRoot)
|
||||
} catch (error) {
|
||||
await discardOutput(output)
|
||||
throw error
|
||||
}
|
||||
@@ -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",
|
||||
"scripts": {
|
||||
"generate": "node generator.js",
|
||||
"check": "node generator.js --clean && node check.js",
|
||||
"check": "node generator.js && node check.js",
|
||||
"test": "node generator.js && bun test",
|
||||
"clean": "node clean.js",
|
||||
"dev": "node generator.js --drafts && vite",
|
||||
"build": "node generator.js --clean && node check.js && vite build",
|
||||
"build": "node generator.js && node check.js && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -18,6 +19,7 @@
|
||||
"prismjs": "^1.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"parse5": "^8.0.1",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"packageManager": "bun@1.3.8"
|
||||
|
||||
@@ -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;
|
||||
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 path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { createRegenerationQueue } from './dev-reload.js'
|
||||
import { contentDirectories, contentSources, generatedRoot } from './site.config.js'
|
||||
|
||||
function generatedPages(directory) {
|
||||
const inputs = {}
|
||||
@@ -9,10 +11,10 @@ function generatedPages(directory) {
|
||||
const filePath = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath))
|
||||
else if (entry.name === 'index.html') {
|
||||
const relativePath = path.relative('.generated', filePath)
|
||||
const relativePath = path.relative(generatedRoot, filePath)
|
||||
const key = relativePath.endsWith('/index.html') ? relativePath.slice(0, -'/index.html'.length) : relativePath
|
||||
inputs[key] = path.resolve(filePath)
|
||||
} else if (entry.name === '404.html' && directory === '.generated') {
|
||||
} else if (entry.name === '404.html' && directory === generatedRoot) {
|
||||
inputs['404'] = path.resolve(filePath)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +29,7 @@ function copyGeneratedFiles() {
|
||||
const dist = path.resolve('dist')
|
||||
if (!fs.existsSync(dist)) return
|
||||
for (const file of files) {
|
||||
const source = path.resolve('.generated', file)
|
||||
const source = path.resolve(generatedRoot, file)
|
||||
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(dist, file))
|
||||
}
|
||||
},
|
||||
@@ -35,57 +37,34 @@ function copyGeneratedFiles() {
|
||||
}
|
||||
|
||||
function contentGenerator() {
|
||||
let timer
|
||||
let generator
|
||||
let rerun = false
|
||||
|
||||
const generate = (server) => {
|
||||
if (generator) {
|
||||
rerun = true
|
||||
return
|
||||
}
|
||||
|
||||
generator = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' })
|
||||
generator.on('error', (error) => {
|
||||
console.error(`Content generation failed: ${error.message}`)
|
||||
})
|
||||
generator.on('close', (code) => {
|
||||
generator = undefined
|
||||
if (code === 0) server.ws.send({ type: 'full-reload' })
|
||||
if (rerun) {
|
||||
rerun = false
|
||||
generate(server)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'content-generator',
|
||||
configureServer(server) {
|
||||
const sources = [
|
||||
path.resolve('notes'),
|
||||
path.resolve('posts'),
|
||||
path.resolve('README.md'),
|
||||
path.resolve('THIRD_PARTY_LICENSES.md'),
|
||||
path.resolve('external.json'),
|
||||
path.resolve('generator.js'),
|
||||
path.resolve('html.js'),
|
||||
]
|
||||
server.watcher.add(sources)
|
||||
const queue = createRegenerationQueue({
|
||||
generate: () => new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' })
|
||||
child.once('error', reject)
|
||||
child.once('close', (code) => code === 0 ? resolve() : reject(new Error(`exit ${code ?? 'unknown'}`)))
|
||||
}),
|
||||
onSuccess: () => server.ws.send({ type: 'full-reload' }),
|
||||
onError: (error) => {
|
||||
console.error(`Content generation failed: ${error.message}`)
|
||||
server.ws.send({
|
||||
type: 'error',
|
||||
err: { message: `Content generation failed (${error.message}). Check the terminal for details.`, stack: '' },
|
||||
})
|
||||
},
|
||||
})
|
||||
const sources = new Set(contentSources.map((source) => path.resolve(source)))
|
||||
server.watcher.add([...sources])
|
||||
server.httpServer?.once('close', () => queue.close())
|
||||
server.watcher.on('all', (event, file) => {
|
||||
if (!['add', 'change', 'unlink'].includes(event)) return
|
||||
const isMarkdown =
|
||||
file.endsWith('.md') &&
|
||||
(file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`))
|
||||
const isExternal = file === path.resolve('external.json')
|
||||
const isStandaloneMarkdown =
|
||||
file === path.resolve('README.md') || file === path.resolve('THIRD_PARTY_LICENSES.md')
|
||||
const isGenerator = file === path.resolve('generator.js') || file === path.resolve('html.js')
|
||||
if (!isMarkdown && !isStandaloneMarkdown && !isExternal && !isGenerator) return
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
generate(server)
|
||||
}, 100)
|
||||
contentDirectories.some((dir) => file.startsWith(`${path.resolve(dir)}/`))
|
||||
if (!isMarkdown && !sources.has(file)) return
|
||||
queue.request()
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -93,7 +72,7 @@ function contentGenerator() {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [contentGenerator(), copyGeneratedFiles()],
|
||||
root: '.generated',
|
||||
root: generatedRoot,
|
||||
publicDir: '../public',
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -102,14 +81,14 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/.generated/**'],
|
||||
ignored: [`**/${generatedRoot}/**`],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve('dist'),
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: generatedPages('.generated'),
|
||||
input: generatedPages(generatedRoot),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user