From 146d516f24b1e73487813417a01cd0cb4a5e55f9 Mon Sep 17 00:00:00 2001 From: "Paul W." Date: Tue, 22 Sep 2026 20:51:13 -0400 Subject: [PATCH] Refactor site generation into a modular content pipeline --- .gitignore | 2 + README.md | 28 ++- bun.lock | 163 +++++++++++++++++ check.js | 19 +- clean.js | 3 +- content.js | 71 ++++++++ dev-reload.js | 39 ++++ feed.js | 56 ++++++ generator.js | 375 ++++----------------------------------- html-checks.js | 31 ++++ markdown.js | 125 +++++++++++++ output.js | 29 +++ package.json | 6 +- site.config.js | 29 +++ src/fonts.css | 24 --- test/dev-reload.test.js | 53 ++++++ test/html-checks.test.js | 23 +++ test/markdown.test.js | 20 +++ test/site.test.js | 78 ++++++++ vite.config.js | 79 +++------ 20 files changed, 826 insertions(+), 427 deletions(-) create mode 100644 bun.lock create mode 100644 content.js create mode 100644 dev-reload.js create mode 100644 feed.js create mode 100644 html-checks.js create mode 100644 markdown.js create mode 100644 output.js create mode 100644 site.config.js create mode 100644 test/dev-reload.test.js create mode 100644 test/html-checks.test.js create mode 100644 test/markdown.test.js create mode 100644 test/site.test.js diff --git a/.gitignore b/.gitignore index 1e65dd0..732a580 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ **/.*.md .env .generated/ +.generated-next-*/ .bun-tmp/ dist/ node_modules/ +mirror/ *.pdb *.exe diff --git a/README.md b/README.md index 364cf0d..f642c7f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2cefd47 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/check.js b/check.js index fc3e1f7..8cb57bc 100644 --- a/check.js +++ b/check.js @@ -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(' 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 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) diff --git a/clean.js b/clean.js index b433cfb..6890d06 100644 --- a/clean.js +++ b/clean.js @@ -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 })]) diff --git a/content.js b/content.js new file mode 100644 index 0000000..fb4c9a9 --- /dev/null +++ b/content.js @@ -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)) +} diff --git a/dev-reload.js b/dev-reload.js new file mode 100644 index 0000000..7d88877 --- /dev/null +++ b/dev-reload.js @@ -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) + }, + } +} diff --git a/feed.js b/feed.js new file mode 100644 index 0000000..dd3d193 --- /dev/null +++ b/feed.js @@ -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 ` + ${escapeHtml(item.title)} + ${escapeHtml(loc)} + ${escapeHtml(loc)} + ${pubDate ? `${escapeHtml(pubDate)}` : ''} + ${escapeHtml(item.dir)} + ${description ? `${escapeHtml(description)}` : ''} + ` + }) + .join('\n ') + + return ` + + + ${escapeHtml(siteName)} + ${escapeHtml(`${siteUrl}/`)} + ${escapeHtml(`Notes and posts from ${siteName}`)} + en-US + + ${escapeHtml(lastBuild)} + ${itemXml} + + +` +} + +export const renderSitemap = (urls) => ` + +${urls + .map(({ loc, lastmod }) => { + const modified = lastmod ? `\n ${escapeHtml(lastmod)}` : '' + return ` + ${escapeHtml(loc)}${modified} + ` + }) + .join('\n')} + +` diff --git a/generator.js b/generator.js index 2f5a43f..d4d149e 100644 --- a/generator.js +++ b/generator.js @@ -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 } = {}) => `${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 `${content}` - }, - }, - ], - renderer: { - code({ text, lang, escaped }) { - const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain' - const label = languageLabels[language] ?? language.charAt(0).toUpperCase() + language.slice(1) - const source = escaped ? text : escapeHtml(text.replace(/\n$/, '')) - return `
${escapeHtml(label)}
${source}\n
\n` - }, - heading({ tokens, depth }) { - const text = this.parser.parseInline(tokens) - const id = nextHeadingId(tokens) - return `${text}\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('', start) - if (end < 0) { - result += html.slice(cursor) - break - } - result += html.slice(cursor, start) - result += `
${html.slice(start, end + 8)}
` - cursor = end + 8 - } - return result -} - -const toc = (source) => { - const headings = marked.lexer(withoutTitle(stripHeadingSidenotes(source))).filter((token) => token.type === 'heading') - if (headings.length === 0) return '' - const root = { depth: 0, children: [] } - const stack = [root] - const headingId = createSlugger() - for (const heading of headings) { - while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop() - const node = { depth: heading.depth, heading, children: [] } - stack.at(-1).children.push(node) - stack.push(node) - } - const renderItems = (items) => - `
    ${items - .map(({ heading, children }) => { - const id = headingId(heading.tokens) - const nested = children.length > 0 ? renderItems(children) : '' - return `
  1. ${escapeHtml(headingLabel(heading.tokens))}${nested}
  2. ` - }) - .join('')}
` - return renderItems(root.children) -} - -const excerpt = (source, max = 280) => { - const paragraph = marked - .lexer(withoutTitle(stripHeadingSidenotes(source))) - .find((token) => token.type === 'paragraph') - if (!paragraph) return '' - const text = stripSidenotes(headingLabel(paragraph.tokens ?? [{ text: paragraph.text ?? '' }])) - .replace(/\s+/g, ' ') - .trim() - if (text.length <= max) return text - const slice = text.slice(0, max) - const bound = slice.lastIndexOf(' ') - return `${(bound > 80 ? slice.slice(0, bound) : slice).trimEnd()}…` -} - -const toRssDate = (iso) => { - if (!iso) return '' - const date = new Date(iso) - return Number.isNaN(date.valueOf()) ? '' : date.toUTCString() -} - -const toSitemapDate = (iso) => (iso ? iso.slice(0, 10) : '') - -const renderFeed = (items) => { - const lastBuild = toRssDate(items.find((item) => item.date)?.date) || new Date().toUTCString() - const itemXml = items - .map((item) => { - const loc = `${siteUrl}/${item.dir}/${item.name}/` - const description = excerpt(item.source) - const pubDate = toRssDate(item.date) - return ` - ${escapeHtml(item.title)} - ${escapeHtml(loc)} - ${escapeHtml(loc)} - ${pubDate ? `${escapeHtml(pubDate)}` : ''} - ${escapeHtml(item.dir)} - ${description ? `${escapeHtml(description)}` : ''} - ` - }) - .join('\n ') - - return ` - - - ${escapeHtml(siteName)} - ${escapeHtml(`${siteUrl}/`)} - ${escapeHtml(`Notes and posts from ${siteName}`)} - en-US - - ${escapeHtml(lastBuild)} - ${itemXml} - - -` -} - -const renderSitemap = (urls) => ` - -${urls - .map(({ loc, lastmod }) => { - const modified = lastmod ? `\n ${escapeHtml(lastmod)}` : '' - return ` - ${escapeHtml(loc)}${modified} - ` - }) - .join('\n')} - -` - const writePage = async (path, html) => { const directory = join(output, path) await mkdir(directory, { recursive: true }) @@ -407,7 +94,7 @@ const renderNotesTable = (pages, directory) => { `, ) .join('') - return `${rows}
${escapeHtml(directory)} pages
` + return `${rows}
${escapeHtml(directory)} pages
PageLast updated
` } const renderPostsList = (pages, directory) => { @@ -425,7 +112,7 @@ const renderPostsList = (pages, directory) => { const renderEmptySection = () => `
-

**crickets**

+

crickets

Nothing to list...

Go Home
` @@ -463,19 +150,21 @@ const renderArticle = (directory, page, navigationPath = `/${directory}/${page.n const draftNotice = page.draft ? `

Draft — rename .${escapeHtml(page.name)}.md to ${escapeHtml(page.name)}.md to publish.

` : '' - const tableOfContents = toc(page.source) + const tableOfContents = page.markdown.toc const contents = tableOfContents ? `
Contents${tableOfContents}

` : '' - const article = `
${renderMarkdown(page.source)}
` + const article = `
${page.markdown.html}
` const body = `${heading}
${draftNotice}${updated}${contents}${article}
` return pageShell(page.title, body, navigationPath, page.title, { draft: Boolean(page.draft) }) } -await rm(output, { recursive: true, force: true }) -await mkdir(output, { recursive: true }) -const sections = Object.fromEntries(await Promise.all(markdownDirs.map(async (dir) => [dir, await readPages(dir)]))) +try { +const sections = Object.fromEntries(await Promise.all(contentDirectories.map(async (dir) => [dir, await readPages(dir, { includeDrafts })]))) +for (const pages of Object.values(sections)) { + for (const page of pages) page.markdown = renderMarkdownPage(page.source) +} const external = JSON.parse(await readFile('external.json', 'utf8')) @@ -483,7 +172,7 @@ const externalLinks = Object.entries(external) .map(([text, href]) => `${escapeHtml(text)}`) .join('') -const sectionLinks = markdownDirs +const sectionLinks = contentDirectories .map((dir) => `${dir[0].toUpperCase() + dir.slice(1)}`) .join('') @@ -492,7 +181,7 @@ const standaloneLinks = standalonePages .map((page) => `${page.title}`) .join('') -const recentPages = markdownDirs +const recentPages = contentDirectories .flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir }))) .sort((a, b) => b.date.localeCompare(a.date)) .slice(0, 5) @@ -506,7 +195,7 @@ const recentLinks = recentPages await writeFile(join(output, 'index.html'), renderHome(externalLinks, sectionLinks + standaloneLinks, recentLinks)) await writeFile(join(output, '404.html'), renderNotFound()) -for (const dir of markdownDirs) { +for (const dir of contentDirectories) { const pages = sections[dir] await writePage( dir, @@ -528,16 +217,19 @@ for (const page of standalonePages) { name: page.path, title: page.title, source, + markdown: renderMarkdownPage(source), date: await dateFor(page.source), } generatedStandalone.push(generatedPage) await writePage(page.path, renderArticle(page.path, generatedPage, '/')) } -const feedItems = markdownDirs +const feedItems = contentDirectories .flatMap((dir) => sections[dir].filter((page) => !page.draft).map((page) => ({ ...page, dir }))) .sort((a, b) => b.date.localeCompare(a.date)) +for (const item of feedItems) item.description = item.markdown.excerpt + await writeFile(join(output, 'feed.xml'), renderFeed(feedItems)) const sitemapUrls = [ @@ -545,7 +237,7 @@ const sitemapUrls = [ loc: `${siteUrl}/`, lastmod: toSitemapDate(feedItems[0]?.date), }, - ...markdownDirs.map((dir) => ({ + ...contentDirectories.map((dir) => ({ loc: `${siteUrl}/${dir}/`, lastmod: toSitemapDate( [...sections[dir]].filter((page) => !page.draft).sort((a, b) => b.date.localeCompare(a.date))[0]?.date, @@ -563,3 +255,8 @@ const sitemapUrls = [ ] await writeFile(join(output, 'sitemap.xml'), renderSitemap(sitemapUrls)) +await publishOutput(output, generatedRoot) +} catch (error) { + await discardOutput(output) + throw error +} diff --git a/html-checks.js b/html-checks.js new file mode 100644 index 0000000..c679212 --- /dev/null +++ b/html-checks.js @@ -0,0 +1,31 @@ +import { parse } from 'parse5' + +const attribute = (node, name) => node.attrs?.find((item) => item.name === name)?.value + +export const htmlFacts = (html) => { + const facts = { + ids: [], hrefs: [], languages: [], + hasViewport: false, hasHeadingElement: false, isDraft: false, + } + const visit = (node) => { + if (node.tagName) { + const id = attribute(node, 'id') + if (id !== undefined) facts.ids.push(id) + const href = attribute(node, 'href') + if (href !== undefined) facts.hrefs.push(href) + for (const className of (attribute(node, 'class') ?? '').split(/\s+/)) { + if (className.startsWith('language-')) facts.languages.push(className.slice('language-'.length)) + } + if (node.tagName === 'heading') facts.hasHeadingElement = true + if (node.tagName === 'meta') { + const name = attribute(node, 'name')?.toLowerCase() + if (name === 'viewport') facts.hasViewport = true + if (name === 'robots' && /(?:^|[,\s])noindex(?:$|[,\s])/i.test(attribute(node, 'content') ?? '')) facts.isDraft = true + } + } + for (const child of node.childNodes ?? []) visit(child) + if (node.content) visit(node.content) + } + visit(parse(html)) + return facts +} diff --git a/markdown.js b/markdown.js new file mode 100644 index 0000000..19d1efc --- /dev/null +++ b/markdown.js @@ -0,0 +1,125 @@ +import { Marked } from 'marked' +import { stripSidenotes } from './content.js' +import { escapeHtml } from './html.js' + +const languageLabels = { ini: 'INI', js: 'JavaScript', javascript: 'JavaScript', lua: 'Lua', sh: 'Shell', bash: 'Shell' } + +const headingLabel = (tokens) => tokens.map((token) => { + if (token.type === 'image') return token.text ?? '' + if (token.tokens) return headingLabel(token.tokens) + return token.text ?? token.raw ?? '' +}).join('').trim() + +const slug = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + +const createSlugger = () => { + const counts = new Map() + return (tokens) => { + const base = slug(headingLabel(tokens)) || 'section' + const count = (counts.get(base) ?? 0) + 1 + counts.set(base, count) + return count === 1 ? base : `${base}-${count}` + } +} + +const withoutTitle = (source) => { + const lines = source.split('\n') + const index = lines.findIndex((line) => line.startsWith('# ')) + if (index >= 0) lines.splice(index, 1) + return lines.join('\n') +} + +const stripHeadingSidenotes = (source) => source.split('\n').map((line) => { + const content = line.trimStart() + return /^#{1,6} /.test(content) + ? line.slice(0, line.length - content.length) + stripSidenotes(content) + : line +}).join('\n') + +const renderToc = (headings) => { + if (headings.length === 0) return '' + const root = { depth: 0, children: [] } + const stack = [root] + for (const heading of headings) { + while (stack.length > 1 && stack.at(-1).depth >= heading.depth) stack.pop() + const node = { ...heading, children: [] } + stack.at(-1).children.push(node) + stack.push(node) + } + const renderItems = (items) => `
    ${items.map(({ id, label, children }) => + `
  1. ${escapeHtml(label)}${children.length ? renderItems(children) : ''}
  2. `).join('')}
` + return renderItems(root.children) +} + +const excerptFrom = (tokens, max = 280) => { + const paragraph = tokens.find((token) => token.type === 'paragraph') + if (!paragraph) return '' + const plainText = (items) => items.map((token) => token.type === 'sidenote' + ? '' + : token.tokens ? plainText(token.tokens) : token.text ?? '').join('') + const text = stripSidenotes(plainText(paragraph.tokens ?? [{ text: paragraph.text ?? '' }])) + .replace(/\s+/g, ' ').trim() + if (text.length <= max) return text + const slice = text.slice(0, max) + const bound = slice.lastIndexOf(' ') + return `${(bound > 80 ? slice.slice(0, bound) : slice).trimEnd()}…` +} + +const wrapTables = (html) => html.replace(//g, (table) => `
${table}
`) + +export const renderMarkdownPage = (source) => { + let sidenoteNumber = 0 + let headingIndex = 0 + let headings = [] + const parser = new Marked({ + extensions: [{ + name: 'sidenote', + level: 'inline', + start: (text) => text.indexOf('^['), + tokenizer(text) { + if (!text.startsWith('^[')) return undefined + let depth = 1 + for (let index = 2; index < text.length; index += 1) { + if (text[index] === '\\') index += 1 + else if (text[index] === '[') depth += 1 + else if (text[index] === ']') { + depth -= 1 + if (depth === 0) return { + type: 'sidenote', raw: text.slice(0, index + 1), + tokens: this.lexer.inlineTokens(text.slice(2, index)), + } + } + } + return undefined + }, + renderer({ tokens }) { + sidenoteNumber += 1 + const id = `sidenote-${sidenoteNumber}` + const content = this.parser.parseInline(tokens) + return `${content}` + }, + }], + renderer: { + code({ text, lang, escaped }) { + const language = lang?.trim().split(/\s+/, 1)[0].toLowerCase() || 'plain' + const label = languageLabels[language] ?? language.charAt(0).toUpperCase() + language.slice(1) + const code = escaped ? text : escapeHtml(text.replace(/\n$/, '')) + return `
${escapeHtml(label)}
${code}\n
\n` + }, + heading({ tokens, depth }) { + const id = headings[headingIndex++]?.id + return `${this.parser.parseInline(tokens)}\n` + }, + }, + }) + const tokens = parser.lexer(withoutTitle(stripHeadingSidenotes(source))) + const nextId = createSlugger() + headings = tokens.filter((token) => token.type === 'heading').map((token) => ({ + depth: token.depth, label: headingLabel(token.tokens), id: nextId(token.tokens), + })) + return { + html: wrapTables(parser.parser(tokens)), + toc: renderToc(headings), + excerpt: excerptFrom(tokens), + } +} diff --git a/output.js b/output.js new file mode 100644 index 0000000..a3ea837 --- /dev/null +++ b/output.js @@ -0,0 +1,29 @@ +import { mkdtemp, rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +const exists = async (path) => { + try { + await stat(path) + return true + } catch (error) { + if (error.code === 'ENOENT') return false + throw error + } +} + +export const createOutput = (destination) => mkdtemp(join(dirname(destination), '.generated-next-')) + +export const publishOutput = async (staging, destination) => { + const previous = `${staging}-previous` + const hadPrevious = await exists(destination) + if (hadPrevious) await rename(destination, previous) + try { + await rename(staging, destination) + } catch (error) { + if (hadPrevious) await rename(previous, destination) + throw error + } + if (hadPrevious) await rm(previous, { recursive: true }) +} + +export const discardOutput = (staging) => rm(staging, { recursive: true, force: true }) diff --git a/package.json b/package.json index fe9a629..8fac27c 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,11 @@ "type": "module", "scripts": { "generate": "node generator.js", - "check": "node generator.js --clean && node check.js", + "check": "node generator.js && node check.js", + "test": "node generator.js && bun test", "clean": "node clean.js", "dev": "node generator.js --drafts && vite", - "build": "node generator.js --clean && node check.js && vite build", + "build": "node generator.js && node check.js && vite build", "preview": "vite preview" }, "dependencies": { @@ -18,6 +19,7 @@ "prismjs": "^1.30.0" }, "devDependencies": { + "parse5": "^8.0.1", "vite": "^7.0.0" }, "packageManager": "bun@1.3.8" diff --git a/site.config.js b/site.config.js new file mode 100644 index 0000000..a9d0a14 --- /dev/null +++ b/site.config.js @@ -0,0 +1,29 @@ +export const siteName = 'PaulW.XYZ' +export const siteUrl = 'https://paulw.xyz' +export const generatedRoot = '.generated' + +export const contentDirectories = ['notes', 'posts'] +export const standalonePages = [ + { source: 'README.md', path: 'about', title: 'About' }, + { source: 'THIRD_PARTY_LICENSES.md', path: 'licenses', title: 'Licenses' }, +] + +export const navLabels = { + notes: 'Notes', + posts: 'Posts', + about: 'About', + licenses: 'Licenses', +} + +export const contentSources = [ + ...contentDirectories, + ...standalonePages.map(({ source }) => source), + 'external.json', + 'generator.js', + 'content.js', + 'feed.js', + 'html.js', + 'markdown.js', + 'output.js', + 'site.config.js', +] diff --git a/src/fonts.css b/src/fonts.css index 022dabe..2537088 100644 --- a/src/fonts.css +++ b/src/fonts.css @@ -69,27 +69,3 @@ font-weight: 400; src: url('@fontsource/iosevka/files/iosevka-latin-400-normal.woff2') format('woff2'); } - -@font-face { - font-family: 'Iosevka'; - font-style: italic; - font-display: swap; - font-weight: 400; - src: url('@fontsource/iosevka/files/iosevka-latin-400-italic.woff2') format('woff2'); -} - -@font-face { - font-family: 'Iosevka'; - font-style: normal; - font-display: swap; - font-weight: 700; - src: url('@fontsource/iosevka/files/iosevka-latin-700-normal.woff2') format('woff2'); -} - -@font-face { - font-family: 'Iosevka'; - font-style: italic; - font-display: swap; - font-weight: 700; - src: url('@fontsource/iosevka/files/iosevka-latin-700-italic.woff2') format('woff2'); -} diff --git a/test/dev-reload.test.js b/test/dev-reload.test.js new file mode 100644 index 0000000..b5eabbc --- /dev/null +++ b/test/dev-reload.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createRegenerationQueue } from '../dev-reload.js' + +const waitFor = async (predicate) => { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) return + await new Promise((resolve) => setTimeout(resolve, 5)) + } + assert.fail('timed out waiting for generation') +} + +test('rapid edits coalesce and only the latest generation reloads', async () => { + const pending = [] + const events = [] + const queue = createRegenerationQueue({ + generate: () => new Promise((resolve) => pending.push(resolve)), + onSuccess: () => events.push('reload'), + onError: () => events.push('error'), + delay: 1, + }) + try { + queue.request() + queue.request() + await waitFor(() => pending.length === 1) + queue.request() + pending[0]() + await waitFor(() => pending.length === 2) + assert.deepEqual(events, []) + pending[1]() + await waitFor(() => events.length === 1) + assert.deepEqual(events, ['reload']) + } finally { + queue.close() + } +}) + +test('failed latest generation reports an error without reloading', async () => { + const events = [] + const queue = createRegenerationQueue({ + generate: () => Promise.reject(new Error('broken content')), + onSuccess: () => events.push('reload'), + onError: (error) => events.push(error.message), + delay: 1, + }) + try { + queue.request() + await waitFor(() => events.length === 1) + assert.deepEqual(events, ['broken content']) + } finally { + queue.close() + } +}) diff --git a/test/html-checks.test.js b/test/html-checks.test.js new file mode 100644 index 0000000..b34569a --- /dev/null +++ b/test/html-checks.test.js @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { htmlFacts } from '../html-checks.js' + +test('extracts decoded attributes regardless of quote style or order', () => { + const facts = htmlFacts(` + Go

Title

+ 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(` + + `) + assert.deepEqual(facts.ids, []) + assert.deepEqual(facts.hrefs, []) + assert.equal(facts.hasHeadingElement, false) + assert.equal(facts.isDraft, true) +}) diff --git a/test/markdown.test.js b/test/markdown.test.js new file mode 100644 index 0000000..9fc48bb --- /dev/null +++ b/test/markdown.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { renderMarkdownPage } from '../markdown.js' + +test('a page uses matching IDs in document and TOC order, including nested duplicates', () => { + const page = renderMarkdownPage('# Title\n\n### Repeat\n\n## Repeat\n\n### Repeat\n\nA note^[with **emphasis**].\n\n```js\nconst x = 1\n```') + const ids = [...page.html.matchAll(/ match[1]) + const links = [...page.toc.matchAll(/href="#([^"]+)"/g)].map((match) => match[1]) + assert.deepEqual(ids, ['repeat', 'repeat-2', 'repeat-3']) + assert.deepEqual(links, ids) + assert.match(page.html, /id="sidenote-1"/) + assert.match(page.html, /emphasis<\/strong>/) + assert.match(page.html, /class="language-js"/) + assert.equal(page.excerpt, 'A note.') +}) + +test('sidenote numbering resets for each page', () => { + assert.match(renderMarkdownPage('# One\n\nA^[first]').html, /id="sidenote-1"/) + assert.match(renderMarkdownPage('# Two\n\nB^[second]').html, /id="sidenote-1"/) +}) diff --git a/test/site.test.js b/test/site.test.js new file mode 100644 index 0000000..ca3cbd3 --- /dev/null +++ b/test/site.test.js @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createOutput, discardOutput, publishOutput } from '../output.js' +import { readPages } from '../content.js' +import { generatedRoot, siteUrl } from '../site.config.js' + +test('headings have unique IDs and matching TOC links', async () => { + const html = await readFile(join(generatedRoot, 'notes', 'steam', 'index.html'), 'utf8') + const ids = [...html.matchAll(/ match[1]) + const links = [...html.matchAll(//g)].map((match) => match[1]) + assert.ok(ids.length > 1) + assert.equal(new Set(ids).size, ids.length) + assert.deepEqual(links, ids) +}) + +test('sidenotes render Markdown with paired, unique controls', async () => { + const html = await readFile(join(generatedRoot, 'notes', 'lua', 'index.html'), 'utf8') + const controls = [...html.matchAll(/ match[1]) + assert.deepEqual(controls, ['sidenote-1', 'sidenote-2']) + for (const id of controls) assert.ok(html.includes(`')) +}) + +test('production excludes hidden draft pages and feed links resolve', async () => { + const pages = await readdir(join(generatedRoot, 'posts')) + assert.ok(!pages.includes('rendering-lab')) + const feed = await readFile(join(generatedRoot, 'feed.xml'), 'utf8') + assert.ok(!feed.includes('rendering-lab')) + for (const [, url] of feed.matchAll(/([^<]+)<\/guid>/g)) { + assert.ok(url.startsWith(`${siteUrl}/`)) + const pathname = new URL(url).pathname + await readFile(join(generatedRoot, pathname, 'index.html')) + } +}) + +test('content discovery excludes drafts and prefers a published page with the same slug', async () => { + const directory = await mkdtemp(join(tmpdir(), 'www-pages-test-')) + try { + await writeFile(join(directory, '.draft.md'), '# Draft\n') + await writeFile(join(directory, '.same.md'), '# Old draft\n') + await writeFile(join(directory, 'same.md'), '# Published\n') + assert.deepEqual((await readPages(directory)).map(({ name }) => name), ['same']) + const pages = await readPages(directory, { includeDrafts: true }) + assert.deepEqual(pages.map(({ name }) => name), ['draft', 'same']) + assert.equal(pages.find(({ name }) => name === 'same').title, 'Published') + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('publishing replaces output, while a discarded generation preserves it', async () => { + const parent = await mkdtemp(join(tmpdir(), 'www-output-test-')) + const destination = join(parent, '.generated') + try { + const first = await createOutput(destination) + await writeFile(join(first, 'index.html'), 'first') + await publishOutput(first, destination) + + const failed = await createOutput(destination) + await writeFile(join(failed, 'index.html'), 'incomplete') + await discardOutput(failed) + assert.equal(await readFile(join(destination, 'index.html'), 'utf8'), 'first') + + const missingStage = join(parent, 'missing-stage') + await assert.rejects(publishOutput(missingStage, destination)) + assert.equal(await readFile(join(destination, 'index.html'), 'utf8'), 'first') + + const second = await createOutput(destination) + await writeFile(join(second, 'index.html'), 'second') + await publishOutput(second, destination) + assert.equal(await readFile(join(destination, 'index.html'), 'utf8'), 'second') + } finally { + await rm(parent, { recursive: true, force: true }) + } +}) diff --git a/vite.config.js b/vite.config.js index ae4e701..9b70c5c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,6 +2,8 @@ import fs from 'node:fs' import { spawn } from 'node:child_process' import path from 'node:path' import { defineConfig } from 'vite' +import { createRegenerationQueue } from './dev-reload.js' +import { contentDirectories, contentSources, generatedRoot } from './site.config.js' function generatedPages(directory) { const inputs = {} @@ -9,10 +11,10 @@ function generatedPages(directory) { const filePath = path.join(directory, entry.name) if (entry.isDirectory()) Object.assign(inputs, generatedPages(filePath)) else if (entry.name === 'index.html') { - const relativePath = path.relative('.generated', filePath) + const relativePath = path.relative(generatedRoot, filePath) const key = relativePath.endsWith('/index.html') ? relativePath.slice(0, -'/index.html'.length) : relativePath inputs[key] = path.resolve(filePath) - } else if (entry.name === '404.html' && directory === '.generated') { + } else if (entry.name === '404.html' && directory === generatedRoot) { inputs['404'] = path.resolve(filePath) } } @@ -27,7 +29,7 @@ function copyGeneratedFiles() { const dist = path.resolve('dist') if (!fs.existsSync(dist)) return for (const file of files) { - const source = path.resolve('.generated', file) + const source = path.resolve(generatedRoot, file) if (fs.existsSync(source)) fs.copyFileSync(source, path.join(dist, file)) } }, @@ -35,57 +37,34 @@ function copyGeneratedFiles() { } function contentGenerator() { - let timer - let generator - let rerun = false - - const generate = (server) => { - if (generator) { - rerun = true - return - } - - generator = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' }) - generator.on('error', (error) => { - console.error(`Content generation failed: ${error.message}`) - }) - generator.on('close', (code) => { - generator = undefined - if (code === 0) server.ws.send({ type: 'full-reload' }) - if (rerun) { - rerun = false - generate(server) - } - }) - } - return { name: 'content-generator', configureServer(server) { - const sources = [ - path.resolve('notes'), - path.resolve('posts'), - path.resolve('README.md'), - path.resolve('THIRD_PARTY_LICENSES.md'), - path.resolve('external.json'), - path.resolve('generator.js'), - path.resolve('html.js'), - ] - server.watcher.add(sources) + const queue = createRegenerationQueue({ + generate: () => new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['generator.js', '--drafts'], { stdio: 'inherit' }) + child.once('error', reject) + child.once('close', (code) => code === 0 ? resolve() : reject(new Error(`exit ${code ?? 'unknown'}`))) + }), + onSuccess: () => server.ws.send({ type: 'full-reload' }), + onError: (error) => { + console.error(`Content generation failed: ${error.message}`) + server.ws.send({ + type: 'error', + err: { message: `Content generation failed (${error.message}). Check the terminal for details.`, stack: '' }, + }) + }, + }) + const sources = new Set(contentSources.map((source) => path.resolve(source))) + server.watcher.add([...sources]) + server.httpServer?.once('close', () => queue.close()) server.watcher.on('all', (event, file) => { if (!['add', 'change', 'unlink'].includes(event)) return const isMarkdown = file.endsWith('.md') && - (file.startsWith(`${path.resolve('notes')}/`) || file.startsWith(`${path.resolve('posts')}/`)) - const isExternal = file === path.resolve('external.json') - const isStandaloneMarkdown = - file === path.resolve('README.md') || file === path.resolve('THIRD_PARTY_LICENSES.md') - const isGenerator = file === path.resolve('generator.js') || file === path.resolve('html.js') - if (!isMarkdown && !isStandaloneMarkdown && !isExternal && !isGenerator) return - clearTimeout(timer) - timer = setTimeout(() => { - generate(server) - }, 100) + contentDirectories.some((dir) => file.startsWith(`${path.resolve(dir)}/`)) + if (!isMarkdown && !sources.has(file)) return + queue.request() }) }, } @@ -93,7 +72,7 @@ function contentGenerator() { export default defineConfig({ plugins: [contentGenerator(), copyGeneratedFiles()], - root: '.generated', + root: generatedRoot, publicDir: '../public', resolve: { alias: { @@ -102,14 +81,14 @@ export default defineConfig({ }, server: { watch: { - ignored: ['**/.generated/**'], + ignored: [`**/${generatedRoot}/**`], }, }, build: { outDir: path.resolve('dist'), emptyOutDir: true, rollupOptions: { - input: generatedPages('.generated'), + input: generatedPages(generatedRoot), }, }, })