From fbf1961056302e0f40d0228e2653c5d3f2e71680 Mon Sep 17 00:00:00 2001 From: plmn95 Date: Wed, 23 Sep 2026 14:15:44 +0300 Subject: [PATCH] Add account-free documentation suggestions with reviewed bot delivery --- .github/workflows/ci.yml | 4 + .github/workflows/deploy.yml | 4 + .gitignore | 4 + CONTRIBUTING.md | 12 +- README.md | 12 +- astro.config.mjs | 5 + package-lock.json | 1007 +++++++++++++++++++++- package.json | 15 +- services/suggestions/README.md | 157 ++++ services/suggestions/github.mjs | 107 +++ services/suggestions/migrations/0001.sql | 16 + services/suggestions/test.mjs | 247 ++++++ services/suggestions/worker.mjs | 149 ++++ services/suggestions/wrangler.jsonc | 22 + src/components/GitHubEditLink.astro | 5 + src/components/ManualFooter.astro | 8 +- src/components/SuggestChange.astro | 74 ++ src/lib/suggestions/client.ts | 121 +++ src/lib/suggestions/config.mjs | 8 + src/lib/suggestions/remark.mjs | 34 + src/lib/suggestions/source.mjs | 78 ++ src/pages/suggestion.astro | 32 + 22 files changed, 2094 insertions(+), 27 deletions(-) create mode 100644 services/suggestions/README.md create mode 100644 services/suggestions/github.mjs create mode 100644 services/suggestions/migrations/0001.sql create mode 100644 services/suggestions/test.mjs create mode 100644 services/suggestions/worker.mjs create mode 100644 services/suggestions/wrangler.jsonc create mode 100644 src/components/GitHubEditLink.astro create mode 100644 src/components/SuggestChange.astro create mode 100644 src/lib/suggestions/client.ts create mode 100644 src/lib/suggestions/config.mjs create mode 100644 src/lib/suggestions/remark.mjs create mode 100644 src/lib/suggestions/source.mjs create mode 100644 src/pages/suggestion.astro diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89e8f2f..beceaec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,7 @@ jobs: run: npm ci - name: Validate and build run: npm run check + - name: Verify suggestions service + run: npm run test:suggestions && npm run suggestions:check + - name: Verify frozen manuals + run: npm run test:archives diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4d46a9c..52c26a6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,7 +29,11 @@ jobs: - name: Validate documentation run: npm run check:docs - run: npm run test:archives + - run: npm run test:suggestions - run: npm run build + env: + VIZARD_SUGGESTIONS_API: ${{ vars.VIZARD_SUGGESTIONS_API }} + VIZARD_TURNSTILE_SITE_KEY: ${{ vars.VIZARD_TURNSTILE_SITE_KEY }} - name: Assemble frozen release manuals run: npm run assemble:archives env: diff --git a/.gitignore b/.gitignore index fc02137..caef77e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ pnpm-debug.log* # environment variables .env .env.production +.dev.vars* +.wrangler/ +*.pem +services/suggestions/wrangler.local.jsonc # macOS-specific files .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7b9558..cef21c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,11 +4,21 @@ Corrections, clearer explanations, and missing documentation are welcome. ## Suggest a correction -If you do not want to edit Markdown, open a +When **Suggest a change** is available on a published page, choose a passage, +edit its wording, review it, and send. You do not need a GitHub account or +Markdown knowledge. Use **Describe a problem** for missing information or a +larger change. Suggestions and explanations are public and reviewed before +publication. Save the receipt link if you want to check progress. + +For contributors who already use GitHub, you can also open a [documentation correction](https://github.com/plmn95/vizard-docs/issues/new?template=documentation-correction.yml). Include the page URL, what is unclear or incorrect, and the wording you suggest when you have it. +If the on-page contribution controls are unavailable, the account-free service +has not been enabled or is temporarily disabled. The GitHub route below is an +alternative for people who already have an account. + ## Edit a page on GitHub Use **Edit page** at the bottom of a published article. GitHub will guide you diff --git a/README.md b/README.md index b175ee2..09dad92 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,15 @@ npm run check ## Contributing -Small corrections can be made with the **Edit page** link on the published -site. For larger changes, read [CONTRIBUTING.md](CONTRIBUTING.md) and open a -pull request. +When enabled, **Suggest a change** lets readers edit a passage and send it for +review without a GitHub account. **Describe a problem** accepts broader reports. +GitHub editing remains available for experienced contributors. See +[CONTRIBUTING.md](CONTRIBUTING.md). + +The account-free service requires separate Cloudflare and GitHub App setup; +see [setup and operations](services/suggestions/README.md). It stays disabled +until configured. Run `npm run test:suggestions` for delivery and source-mapping +tests, and `npm run suggestions:check` to verify the Worker bundle. ## Manuals shipped with Vizard diff --git a/astro.config.mjs b/astro.config.mjs index 7f80bb7..3faa415 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,11 +1,15 @@ // @ts-check import { defineConfig } from 'astro/config'; +import { unified } from '@astrojs/markdown-remark'; import starlight from '@astrojs/starlight'; +import suggestionPassages from './src/lib/suggestions/remark.mjs'; +import { revision, enabled } from './src/lib/suggestions/config.mjs'; const isVercel = process.env.VERCEL === '1'; // https://astro.build/config export default defineConfig({ + markdown: { processor: unified({ remarkPlugins: [[suggestionPassages, { revision, enabled }]] }) }, site: isVercel ? 'https://vizard-docs.vercel.app' : 'https://plmn95.github.io', base: process.env.VIZARD_DOCS_BASE || (isVercel ? '/' : '/vizard-docs'), integrations: [ @@ -19,6 +23,7 @@ export default defineConfig({ components: { SiteTitle: './src/components/SiteTitle.astro', Footer: './src/components/ManualFooter.astro', + EditLink: './src/components/GitHubEditLink.astro', }, editLink: { baseUrl: 'https://github.com/plmn95/vizard-docs/edit/main/', diff --git a/package-lock.json b/package-lock.json index 1fd4e4d..c11c1c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,18 @@ "name": "vizard-docs", "version": "0.0.1", "dependencies": { + "@astrojs/markdown-remark": "7.2.4", "@astrojs/starlight": "^0.41.10", "@fontsource-variable/jetbrains-mono": "^5.3.0", "astro": "^7.1.6", - "sharp": "^0.35.3" + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "sharp": "^0.35.3", + "unified": "^11.0.5" + }, + "devDependencies": { + "wrangler": "^4.136.3" }, "engines": { "node": ">=22.12.0" @@ -815,6 +823,130 @@ "node": ">= 20.12.0" } }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260921.1.tgz", + "integrity": "sha512-3iB2WnYOlZ29T+1zhCwbHFExCBp6E9bgmDUMryATYwrIGEQ1YbvR78m4ydm56XKN/d/yF3803ivMGfZMYDtiMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260921.1.tgz", + "integrity": "sha512-FpqVR7IQXVBmGtajyonEmhmb5UAsmV7dTaIkpemmHZXHEw7uYpkhkzKPjc4BOPhNQy8iwt2p+RZBPMY3Y7/bvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260921.1.tgz", + "integrity": "sha512-riAJIohaVp5A8Sqy4yKlzHOaLPOICMf5oey+jC2rm45RVT+wK8+7UU0d31Dy/02Nc8YUkobAFwNVjX06P8WQ5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260921.1.tgz", + "integrity": "sha512-tnJu08tT7s0XWDqp3O0H/vCp0voy9OqVAzspb89biMo1dh8IiEpnyXnoPmdJ7H4qBnXCmXgy0kuEphuvpDPj9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260921.1.tgz", + "integrity": "sha512-VgNcRPstoZMb1G94JTrx+jU24GtkkazNfox0gnF/2fkuXpcfW/M0e0xvdMovYfwt8ZxG5AB2ZNvanD6ufBwiuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", @@ -1875,12 +2007,33 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -2051,6 +2204,35 @@ "win32" ] }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", @@ -2415,6 +2597,26 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -2772,6 +2974,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3224,6 +3433,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -3454,6 +3673,19 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3510,6 +3742,14 @@ "node": ">=20" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4101,6 +4341,16 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/klona": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", @@ -4508,6 +4758,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-gfm": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", @@ -4849,6 +5117,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-gfm": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", @@ -5497,6 +5781,34 @@ ], "license": "MIT" }, + "node_modules/miniflare": { + "version": "5.20260921.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260921.0-alpha.tgz", + "integrity": "sha512-vHH/unOYvV2jA1Q9SdkmzrQhhMoksdwg5jegu6ZeKaaRzgxZhVbt1NdTpQjHF2VTgiBjgP8SiUlUMfruB3N3SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260921.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -5754,6 +6066,20 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -6097,6 +6423,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-frontmatter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", + "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-frontmatter": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -6510,6 +6852,19 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/svgo": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", @@ -6635,6 +6990,16 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -7063,31 +7428,633 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/xxhash-wasm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", - "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", - "license": "MIT" + "node_modules/workerd": { + "version": "1.20260921.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260921.1.tgz", + "integrity": "sha512-4HyG7G1W4ksa6tUZ8bV2jxDRWuL5PXnHm9+Z1sjFPb9OZNoYtXz4y7QQRh4ibi0BF/lOmlAVjbhkUqsAVZuUKA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260921.1", + "@cloudflare/workerd-darwin-arm64": "1.20260921.1", + "@cloudflare/workerd-linux-64": "1.20260921.1", + "@cloudflare/workerd-linux-arm64": "1.20260921.1", + "@cloudflare/workerd-windows-64": "1.20260921.1" + } + }, + "node_modules/wrangler": { + "version": "4.136.3", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.136.3.tgz", + "integrity": "sha512-L1bS8BI9xoEk3RRr5XoZn5q5k1jCRo8+37ZEEab/jsUFzE1Ea2sIBAbmP1CdwSeD6mZYmnUtR6xpi9esHIl6GA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260921.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260921.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260921.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" + "node": ">=18" } }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/youch/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/zod": { diff --git a/package.json b/package.json index afc6fe6..297cf86 100644 --- a/package.json +++ b/package.json @@ -14,18 +14,29 @@ "astro": "astro", "build:release": "node scripts/build-release.mjs", "assemble:archives": "python3 scripts/assemble-archives.py", - "test:archives": "python3 scripts/test-archives.py" + "test:archives": "python3 scripts/test-archives.py", + "test:suggestions": "node --experimental-sqlite --test services/suggestions/test.mjs", + "suggestions:dev": "wrangler dev --config services/suggestions/wrangler.jsonc", + "suggestions:check": "wrangler deploy --dry-run --config services/suggestions/wrangler.jsonc" }, "dependencies": { + "@astrojs/markdown-remark": "7.2.4", "@astrojs/starlight": "^0.41.10", "@fontsource-variable/jetbrains-mono": "^5.3.0", "astro": "^7.1.6", - "sharp": "^0.35.3" + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "sharp": "^0.35.3", + "unified": "^11.0.5" }, "allowScripts": { "esbuild": true }, "engines": { "node": ">=22.12.0" + }, + "devDependencies": { + "wrangler": "^4.136.3" } } diff --git a/services/suggestions/README.md b/services/suggestions/README.md new file mode 100644 index 0000000..338cee8 --- /dev/null +++ b/services/suggestions/README.md @@ -0,0 +1,157 @@ +# Account-free documentation suggestions + +Readers edit a passage on the documentation site, review their wording, and send +it without a login. A separate Cloudflare Worker saves each submission in D1. +A GitHub App opens a pull request for safe text edits, or an issue for stale +pages, changes across formatting boundaries, and problem reports. Maintainers +review in GitHub. Merging a PR invokes the existing docs deployment. + +The feature is off unless both site build variables are configured. The service +also has its own `ENABLED` switch. Nothing in a static page grants repository +access; GitHub credentials live only in Worker secrets. + +## Local validation + +Use Node 24 or newer for the service tests/tooling (the docs retain their existing +Node requirement). + +```sh +npm ci +npm run test:suggestions +npm run check +npm run test:archives +npm run suggestions:check +``` + +To preview the UI, use a local Worker endpoint and Cloudflare's public test key: + +```sh +VIZARD_SUGGESTIONS_API=http://127.0.0.1:8787 \ +VIZARD_TURNSTILE_SITE_KEY=1x00000000000000000000AA \ +npm run dev -- --host 127.0.0.1 +``` + +The test key is for local testing only. There is no production CAPTCHA bypass. +For a local Worker, copy `wrangler.jsonc` to the ignored `wrangler.local.jsonc`, +add `http://127.0.0.1:4321` to `ALLOWED_ORIGINS`, and use a **test repository** +for `GITHUB_REPO`. Initialize local D1 with the command below using `--local`, +then run `wrangler dev --config services/suggestions/wrangler.local.jsonc`. +Put local secrets in `services/suggestions/.dev.vars` (ignored by Git). Use +Cloudflare's matching test secret for local Turnstile. Local mode still requires +GitHub App credentials if you want it to create real review items. + +## Production setup + +1. Log into the existing account with `npx wrangler login`. If several accounts + are available, set the intended `account_id` in the Worker configuration. +2. Create a D1 database: + + ```sh + npx wrangler d1 create vizard-doc-suggestions + ``` + + Copy its database ID into `services/suggestions/wrangler.jsonc`, then run: + + ```sh + npx wrangler d1 migrations apply vizard-doc-suggestions --remote --config services/suggestions/wrangler.jsonc + ``` + +3. Create a Turnstile widget for `plmn95.github.io` (and only additional domains + actually serving current docs). Use managed mode. Keep its secret in the + Worker and its public site key in the docs build variable described below. +4. Register a GitHub App owned by the maintainer. Set the homepage to the docs + site and its webhook URL to `https:///webhook`. Grant repository + permissions **Contents: read/write**, **Pull requests: read/write**, and + **Issues: read/write**. Subscribe to **Pull request** and **Issues** events. + No user OAuth authorization or contributor login is needed. +5. Install the App on **only `plmn95/vizard-docs`**. Generate a private key and + convert it to unencrypted PKCS#8 locally if GitHub downloads PKCS#1: + + ```sh + openssl pkcs8 -topk8 -nocrypt -in downloaded-private-key.pem -out private-key-pkcs8.pem + ``` + + Never commit private keys. Store these values with + `npx wrangler secret put NAME --config services/suggestions/wrangler.jsonc`: + + | Secret | Value | + | --- | --- | + | `GITHUB_APP_ID` | Registered App ID | + | `GITHUB_INSTALLATION_ID` | Installation ID for the docs repository | + | `GITHUB_PRIVATE_KEY` | Complete PKCS#8 PEM private key | + | `GITHUB_WEBHOOK_SECRET` | Random secret matching the App webhook settings | + | `TURNSTILE_SECRET` | Production widget secret | + | `RATE_SALT` | Independently generated random secret | + + For the PEM, pipe the file to `wrangler secret put GITHUB_PRIVATE_KEY` rather + than pasting its content into a command line or chat. +6. Protect `main`: require a pull request and passing docs checks, and do not + grant this App a bypass. GitHub's Contents permission is repository-wide; + the service's path validation restricts its writes to existing docs Markdown + files. No workflow-writing permission is requested. Keep PR validation + unprivileged (`pull_request`, never a privileged checkout of submitted code). +7. Deploy initially with `ENABLED: "false"`: + + ```sh + npx wrangler deploy --config services/suggestions/wrangler.jsonc + ``` + + Confirm the Worker URL and finish webhook configuration. Set `ENABLED` to + `"true"` and redeploy once secrets and repository rules are in place. +8. Set these **GitHub Actions repository variables**, then build/deploy the docs: + + - `VIZARD_SUGGESTIONS_API`: Worker origin, with no trailing slash. + - `VIZARD_TURNSTILE_SITE_KEY`: Production Turnstile site key. + + The Pages workflow passes them to Astro. For other hosts, configure the same + build environment variables there and update `ALLOWED_ORIGINS` explicitly. + `ALLOWED_ORIGINS` contains origins only; `DOCS_URL` includes `/vizard-docs`. + +## Launch verification + +- First rehearse against a test repository and a staging Worker/database. +- Submit a plain wording correction and inspect the exact PR diff. Verify that + no unrelated Markdown, frontmatter, links, or workflow files change. +- Submit a change spanning inline code/formatting and a stale revision. Verify + that they become issues with both versions, not guessed patches. +- Retry after a simulated network error; verify one review item and one receipt. +- Merge the test PR; check the receipt changes to Accepted and the normal + deployment succeeds. Accepted does not claim the site is already published. +- Close and reopen review items to check webhook synchronization. The five-minute + scheduled handler repairs missed updates for pending reviews. +- Verify keyboard selection, Escape/Close, small screens, and blocked spam-check + loading. User wording stays in the open page after failure or cancellation. +- Ask a nontechnical reader to make a correction unaided. + +## Delivery and operational behavior + +The API only reports receipt after D1 storage. It tries delivery immediately; +the five-minute scheduled handler retries failures with backoff up to one day. +An atomic ten-minute lease prevents concurrent delivery of the same submission. +Deterministic branch names and receipt markers reconcile lost GitHub responses. +If reconciliation cannot be completed, delivery remains delayed rather than +creating another review item. Logs identify the receipt and failure category, +never the proposed text, client IP, or credentials. + +Daily quotas allow 10 new submissions per IP and 200 globally. IP keys are +salted and rotated daily; expired counters are deleted. Receipt endpoints reveal +only status, not submission text or identifiers on GitHub. The receipt token is +a random UUID stored in the URL fragment. No email, user account, or raw IP is +stored. Submission text and receipts remain in D1 until an operator removes +them; review items are public in GitHub. Do not treat this as a private inbox. + +Watch for `suggestion_delivery ... retry_required` logs and old `received` or +`delayed` rows. Fix the underlying credentials/API problem and leave retries +enabled. To retry a particular saved submission immediately, set its +`next_attempt` and `lease_until` to zero after ensuring no delivery is running. +Suggestions closed because the claimed source cannot be verified are not retried. + +Setting `ENABLED` to `"false"` stops new submissions and scheduled delivery; +in-flight delivery may finish. To remove the UI, clear the docs build variables +and redeploy current docs. Existing receipts continue to work while the service +is running. Rotate compromised secrets through Wrangler and the corresponding +provider settings. + +Release builds never embed passage editors or CAPTCHA requests. Future manuals +link to the matching current online page with their version attached; archived +manual files already published are never rewritten by this feature. diff --git a/services/suggestions/github.mjs b/services/suggestions/github.mjs new file mode 100644 index 0000000..77f4185 --- /dev/null +++ b/services/suggestions/github.mjs @@ -0,0 +1,107 @@ +import { passages, prepareEdit } from '../../src/lib/suggestions/source.mjs'; + +const encoder = new TextEncoder(); +const base64 = (bytes) => { + let text = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) text += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + return btoa(text); +}; +const b64url = (value) => base64(encoder.encode(JSON.stringify(value))).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); +export class InvalidSuggestion extends Error {} + +export async function githubClient(env, fetcher = fetch) { + const now = Math.floor(Date.now() / 1000); + const pem = env.GITHUB_PRIVATE_KEY.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''); + const key = await crypto.subtle.importKey('pkcs8', Uint8Array.from(atob(pem), c => c.charCodeAt(0)), + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']); + const unsigned = `${b64url({ alg: 'RS256', typ: 'JWT' })}.${b64url({ iat: now - 60, exp: now + 540, iss: env.GITHUB_APP_ID })}`; + const signature = base64(new Uint8Array(await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, encoder.encode(unsigned)))) + .replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + async function request(path, token, method = 'GET', body) { + const response = await fetcher(`https://api.github.com${path}`, { method, + headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'vizard-doc-suggestions', 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15000) }); + if (!response.ok) { + const error = new Error(`GitHub request failed (${response.status}).`); + error.status = response.status; + throw error; + } + return response.status === 204 ? null : response.json(); + } + const installation = await request(`/app/installations/${env.GITHUB_INSTALLATION_ID}/access_tokens`, `${unsigned}.${signature}`, 'POST', { + repositories: [env.GITHUB_REPO.split('/')[1]], permissions: { contents: 'write', pull_requests: 'write', issues: 'write' }, + }); + return (path, method, body) => request(`/repos/${env.GITHUB_REPO}${path}`, installation.token, method, body); +} + +const encodePath = (path) => path.split('/').map(encodeURIComponent).join('/'); +const textBlock = (text) => text.split('\n').map(line => ` ${line}`).join('\n'); +const decodeContent = (data) => new TextDecoder().decode(Uint8Array.from(atob(data.content.replace(/\s/g, '')), c => c.charCodeAt(0))); + +export async function deliver(row, env, api) { + const item = JSON.parse(row.payload); + const branch = `suggestion/${item.id}`; + const marker = ``; + const owner = env.GITHUB_REPO.split('/')[0]; + // Recover a previous successful request when its HTTP response or DB update was lost. + const prs = await api(`/pulls?state=all&head=${encodeURIComponent(`${owner}:${branch}`)}`); + if (prs.length) return { number: prs[0].number, kind: 'pull' }; + // Issues do not offer an idempotency key. Reconcile all items created since this + // submission before another create; stop and retry rather than guess at a bound. + for (let page = 1; ; page++) { + if (page > 10) throw new Error('Issue reconciliation needs operator attention.'); + const issues = await api(`/issues?state=all&sort=created&direction=asc&since=${encodeURIComponent(new Date(row.created_at - 60000).toISOString())}&per_page=100&page=${page}`); + const existing = issues.find(issue => issue.body?.startsWith(marker)); + if (existing) return { number: existing.number, kind: existing.pull_request ? 'pull' : 'issue' }; + if (issues.length < 100) break; + } + let ancestor; + try { ancestor = await api(`/compare/${item.revision}...main`); } + catch (error) { if (error.status === 404 || error.status === 422) throw new InvalidSuggestion('The original document revision could not be verified.'); throw error; } + if (!['ahead', 'identical'].includes(ancestor.status)) throw new InvalidSuggestion('The source is not a published main-branch revision.'); + let base; + try { base = await api(`/contents/${encodePath(item.path)}?ref=${item.revision}`); } + catch (error) { if (error.status === 404) throw new InvalidSuggestion('The original page could not be found.'); throw error; } + if (base.type !== 'file' || base.encoding !== 'base64' || base.size > 150000) throw new InvalidSuggestion('Unsupported source file.'); + const source = decodeContent(base); + let patch = { reason: 'Reader reported a documentation problem.' }; + if (item.kind === 'edit') { + if (!passages(source).some(p => p.id === item.passage && p.text === item.original)) throw new InvalidSuggestion('The original passage could not be verified.'); + patch = prepareEdit(source, item); + } + const main = await api('/git/ref/heads/main'); + let current; + try { current = await api(`/contents/${encodePath(item.path)}?ref=${main.object.sha}`); } + catch (error) { if (error.status !== 404) throw error; } + if (!current || current.sha !== base.sha) patch = { reason: 'The page has changed since the reader opened it. Review against current documentation.' }; + const route = item.path.replace('src/content/docs/', '').replace(/(?:\/index)?\.md$/, '').replace(/^index$/, ''); + const pageURL = `${env.DOCS_URL.replace(/\/$/, '')}/${route}/`; + const body = [marker, 'An account-free contribution from the documentation site. Treat this text as an untrusted reader submission.', + `Page: ${pageURL}`, `Source revision: ${item.revision}`, item.version ? `Reported manual version:\n${textBlock(item.version)}` : '', + item.original ? `### Original\n${textBlock(item.original)}` : '', item.replacement ? `### Suggested wording\n${textBlock(item.replacement)}` : '', + item.explanation ? `### Explanation\n${textBlock(item.explanation)}` : '', patch.reason ? `### Manual review needed\n${patch.reason}` : '', + ].filter(Boolean).join('\n\n'); + const title = `Docs: ${item.kind === 'edit' ? 'wording correction' : 'reader report'} for ${route || 'home'}`; + if (!patch.content) { + const issue = await api('/issues', 'POST', { title, body }); + return { number: issue.number, kind: 'issue' }; + } + let existingBranch; + try { existingBranch = await api(`/git/ref/heads/${branch}`); } catch (error) { if (error.status !== 404) throw error; } + if (!existingBranch) await api('/git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: main.object.sha }); + const branchFile = await api(`/contents/${encodePath(item.path)}?ref=${encodeURIComponent(branch)}`); + if (decodeContent(branchFile) !== patch.content) { + if (branchFile.sha !== base.sha) throw new Error('Suggestion branch changed; refusing to overwrite it.'); + await api(`/contents/${encodePath(item.path)}`, 'PUT', { branch, sha: branchFile.sha, + message: title, content: base64(encoder.encode(patch.content)) }); + } + const pr = await api('/pulls', 'POST', { title, body, head: branch, base: 'main', maintainer_can_modify: true }); + return { number: pr.number, kind: 'pull' }; +} + +export function reviewStatus(item, kind) { + if (kind === 'pull' && (item.merged || item.merged_at)) return 'accepted'; + if (item.state !== 'closed') return 'review'; + return kind === 'issue' && item.state_reason === 'completed' ? 'accepted' : 'closed'; +} diff --git a/services/suggestions/migrations/0001.sql b/services/suggestions/migrations/0001.sql new file mode 100644 index 0000000..d870ba3 --- /dev/null +++ b/services/suggestions/migrations/0001.sql @@ -0,0 +1,16 @@ +CREATE TABLE suggestions ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'received', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt INTEGER NOT NULL DEFAULT 0, + lease_until INTEGER NOT NULL DEFAULT 0, + github_number INTEGER, + github_kind TEXT, + message TEXT +); +CREATE INDEX suggestions_delivery ON suggestions(status, next_attempt, lease_until); +CREATE INDEX suggestions_github ON suggestions(github_number); +CREATE TABLE rate_limits (key TEXT PRIMARY KEY, hits INTEGER NOT NULL, expires_at INTEGER NOT NULL); diff --git a/services/suggestions/test.mjs b/services/suggestions/test.mjs new file mode 100644 index 0000000..06cc155 --- /dev/null +++ b/services/suggestions/test.mjs @@ -0,0 +1,247 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { readFileSync, readdirSync } from 'node:fs'; +import { passages, prepareEdit, validateSubmission, validPath } from '../../src/lib/suggestions/source.mjs'; +import { deliver, reviewStatus, InvalidSuggestion, githubClient } from './github.mjs'; +import suggestionPassages from '../../src/lib/suggestions/remark.mjs'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import worker, { handle, drain, limitedBody } from './worker.mjs'; + +const source = '---\ntitle: Example\n---\n\nA useful **bold word** with a [link](../target/) and `CODE`.\n\nSame text.\n\nSame text.\n'; +function submission(overrides = {}) { + const block = passages(source)[0]; + return { id: crypto.randomUUID(), path: 'src/content/docs/concepts/example.md', revision: 'a'.repeat(40), kind: 'edit', + passage: block.id, original: block.text, replacement: block.text.replace('useful', 'clearer'), explanation: '', version: '', ...overrides }; +} +function database() { + const db = new DatabaseSync(':memory:'); + db.exec(readFileSync(new URL('./migrations/0001.sql', import.meta.url), 'utf8')); + return { prepare(sql) { + let values = []; + return { bind(...args) { values = args; return this; }, + async first() { return db.prepare(sql).get(...values) ?? null; }, + async all() { return { results: db.prepare(sql).all(...values) }; }, + async run() { return db.prepare(sql).run(...values); } }; + }, raw: db }; +} +function environment() { return { DB: database(), ENABLED: 'true', RATE_SALT: 'test-only', TURNSTILE_SECRET: 'test-only', + GITHUB_REPO: 'owner/docs', DOCS_URL: 'https://docs.example.test', ALLOWED_ORIGINS: 'https://docs.example.test' }; } +const encodedFile = (content = source, sha = 'file-sha') => ({ type: 'file', encoding: 'base64', size: content.length, sha, content: Buffer.from(content).toString('base64') }); +function github(overrides = {}) { + const writes = [], pulls = [], issues = []; + let branch = false, file = encodedFile(); + const api = async (path, method = 'GET', body) => { + if (method !== 'GET') writes.push({ path, method, body }); + if (overrides.request) { const result = await overrides.request(path, method, body); if (result !== undefined) return result; } + if (path.startsWith('/pulls?')) return pulls; + if (path.startsWith('/issues?')) return issues; + if (path.startsWith('/compare/')) return { status: overrides.ancestor ?? 'ahead' }; + if (path === '/git/ref/heads/main') return { object: { sha: 'current-main' } }; + if (path.startsWith('/git/ref/heads/suggestion/')) { if (branch) return { object: { sha: 'branch' } }; const error = new Error('missing'); error.status = 404; throw error; } + if (path === '/git/refs') { branch = true; return {}; } + if (path.startsWith('/contents/') && method === 'GET') return path.includes('ref=suggestion') ? file : path.includes('ref=current-main') ? encodedFile(overrides.current ?? source, overrides.current ? 'new-sha' : 'file-sha') : encodedFile(); + if (path.startsWith('/contents/') && method === 'PUT') { file = encodedFile(Buffer.from(body.content, 'base64').toString(), 'edited-sha'); return {}; } + if (path === '/pulls') { const pr = { number: 42, body: body.body }; pulls.push(pr); if (overrides.loseResponse) throw new Error('connection lost'); return pr; } + if (path === '/issues') { const issue = { number: 43, body: body.body }; issues.push(issue); if (overrides.loseResponse) throw new Error('connection lost'); return issue; } + throw new Error(`Unexpected mock request: ${method} ${path}`); + }; + return { api, writes }; +} +const row = item => ({ id: item.id, payload: JSON.stringify(item), created_at: Date.now() - 1000 }); + +test('patch preserves surrounding Markdown, frontmatter, and duplicate passages', () => { + const result = prepareEdit(source, submission()); + assert.equal(result.content, source.replace('useful', 'clearer')); + const repeated = passages(source)[2]; + assert.equal(prepareEdit(source, { passage: repeated.id, original: repeated.text, replacement: 'Last text.' }).content, + source.slice(0, repeated.start) + 'Last text.\n'); +}); +test('formatting-sensitive changes are held for manual review', () => { + for (const replacement of ['Rewrite the whole paragraph.', submission().original.replace('CODE', 'NEW'), 'New\nparagraph.']) + assert.ok(prepareEdit(source, submission({ replacement })).reason); + assert.throws(() => prepareEdit(source, submission({ original: 'forged' }))); +}); +test('inserted markup is literal text and cannot execute or change links', () => { + const replacement = submission().original.replace('useful', ' [x](javascript:alert(1))'); + const result = prepareEdit(source, submission({ replacement })); + assert.ok(result.content); + assert.ok(!result.content.includes(' + diff --git a/src/lib/suggestions/client.ts b/src/lib/suggestions/client.ts new file mode 100644 index 0000000..a84ea6c --- /dev/null +++ b/src/lib/suggestions/client.ts @@ -0,0 +1,121 @@ +type Selection = { path: string; revision: string; passage: string; original: string }; +type Turnstile = { render: (element: HTMLElement, options: Record) => string; + reset: (id: string) => void; remove: (id: string) => void }; +declare global { interface Window { turnstile?: Turnstile } } + +const root = document.querySelector('[data-suggestions]'); +if (root) { + const query = (selector: string) => root.querySelector(selector)!; + const dialog = query('dialog'), form = query('[data-form]'); + const wording = query('[data-wording]'), explanation = query('[data-explanation]'); + const message = query('[data-message]'), send = query('[data-send]'); + const baseSelection = { path: root.dataset.path!, revision: root.dataset.revision!, passage: '', original: '' }; + const drafts = new Map(); + let selection: Selection = baseSelection, kind = 'edit', id = '', token = '', widget: string | undefined, sending = false; + let opener: HTMLElement | null = null; + const draftKey = () => `${kind}:${selection.passage}`; + const save = () => drafts.set(draftKey(), { wording: wording.value, explanation: explanation.value, id }); + function stopPicking() { + document.querySelectorAll('.suggestion-pick').forEach(button => button.remove()); + query('[data-picker]').hidden = true; + } + function editMode() { + query('[data-edit-fields]').hidden = false; + query('[data-preview]').hidden = true; + query('[data-review]').hidden = false; + send.hidden = true; query('[data-back]').hidden = true; + wording.readOnly = false; explanation.readOnly = false; + } + async function challenge() { + token = ''; + if (widget && window.turnstile) { window.turnstile.reset(widget); return; } + if (!window.turnstile) { + await new Promise((resolve, reject) => { + document.querySelector('script[data-vizard-turnstile]')?.remove(); + const script = document.createElement('script'); + script.dataset.vizardTurnstile = ''; script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + script.async = true; script.onload = () => resolve(); script.onerror = () => reject(new Error('The spam check could not load. Please retry when connected.')); + document.head.append(script); + }); + } + widget = window.turnstile!.render(query('[data-challenge]'), { sitekey: root!.dataset.key, action: 'suggestion', theme: 'dark', + callback: (value: string) => { token = value; }, 'expired-callback': () => { token = ''; }, + 'error-callback': () => { token = ''; message.textContent = 'The spam check could not complete. Your draft is kept; please retry.'; } }); + } + function open(nextKind: string, nextSelection: Selection, trigger: HTMLElement) { + kind = nextKind; selection = nextSelection; opener = trigger; + const draft = drafts.get(draftKey()); + id = draft?.id ?? crypto.randomUUID(); + wording.value = draft?.wording ?? selection.original; + explanation.value = draft?.explanation ?? ''; + query('[data-original]').textContent = selection.original; + query('[data-editor]').hidden = kind !== 'edit'; + query('[data-explanation-label]').textContent = kind === 'edit' ? 'Why this change? (optional)' : 'What is incorrect, unclear, or missing?'; + query('#suggestion-title').textContent = kind === 'edit' ? 'Suggest a change' : 'Describe a problem'; + query('[data-success]').hidden = true; message.textContent = ''; editMode(); + dialog.showModal(); (kind === 'edit' ? wording : explanation).focus(); + challenge().catch(error => { message.textContent = error.message; }); + } + query('[data-start]').addEventListener('click', () => { + stopPicking(); + const blocks = document.querySelectorAll('.sl-markdown-content [data-suggestion]'); + if (!blocks.length) { open('problem', baseSelection, query('[data-start]')); return; } + query('[data-picker]').hidden = false; + blocks.forEach((block, index) => { + const button = document.createElement('button'); + button.type = 'button'; button.className = 'suggestion-pick'; button.textContent = 'Edit this passage'; + button.setAttribute('aria-label', `Edit passage ${index + 1}: ${JSON.parse(block.dataset.suggestion!).original.slice(0, 70)}`); + button.dataset.pagefindIgnore = ''; + button.addEventListener('click', () => open('edit', JSON.parse(block.dataset.suggestion!), button)); + block.after(button); + }); + document.querySelector('.suggestion-pick')?.focus(); + }); + query('[data-problem]').addEventListener('click', () => open('problem', baseSelection, query('[data-problem]'))); + query('[data-stop]').addEventListener('click', () => { stopPicking(); query('[data-start]').focus(); }); + query('[data-close]').addEventListener('click', () => { if (!sending) dialog.close(); }); + dialog.addEventListener('cancel', event => { if (sending) event.preventDefault(); }); + dialog.addEventListener('close', () => { + if (query('[data-success]').hidden) save(); + if (widget && window.turnstile) window.turnstile.remove(widget); + widget = undefined; token = ''; + (opener?.isConnected ? opener : query('[data-start]')).focus(); + }); + [wording, explanation].forEach(field => field.addEventListener('input', () => { id = crypto.randomUUID(); save(); })); + query('[data-back]').addEventListener('click', () => { editMode(); (kind === 'edit' ? wording : explanation).focus(); }); + query('[data-review]').addEventListener('click', () => { + message.textContent = ''; + if (kind === 'edit' && (!wording.value.trim() || wording.value === selection.original)) { message.textContent = 'Change the wording before reviewing your suggestion.'; wording.focus(); return; } + if (kind === 'problem' && !explanation.value.trim()) { message.textContent = 'Describe the problem before reviewing.'; explanation.focus(); return; } + query('[data-before]').textContent = kind === 'edit' ? `Original: ${selection.original}` : ''; + query('[data-after]').textContent = kind === 'edit' ? `Your wording: ${wording.value}` : explanation.value; + query('[data-review-reason]').textContent = kind === 'edit' && explanation.value ? `Explanation: ${explanation.value}` : ''; + query('[data-edit-fields]').hidden = true; + query('[data-preview]').hidden = false; query('[data-review]').hidden = true; + send.hidden = false; query('[data-back]').hidden = false; + wording.readOnly = true; explanation.readOnly = true; send.focus(); + }); + form.addEventListener('submit', async event => { + event.preventDefault(); + if (sending || send.hidden) return; + if (!token) { message.textContent = 'Please complete the spam check, then send again.'; await challenge().catch(error => { message.textContent = error.message; }); return; } + sending = true; send.disabled = true; query('[data-back]').disabled = true; + message.textContent = 'Sending your suggestion…'; save(); + try { + const version = new URLSearchParams(location.search).get('manual')?.slice(0, 80) ?? ''; + const response = await fetch(`${root!.dataset.api}/suggestions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, kind, ...selection, replacement: kind === 'edit' ? wording.value : '', explanation: explanation.value, version, token }), + signal: AbortSignal.timeout(20000) }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Could not send your suggestion. Please try again.'); + if (result.id !== id) throw new Error('Could not confirm receipt. Please retry.'); + drafts.delete(draftKey()); message.textContent = ''; query('[data-success]').hidden = false; + query('[data-preview]').hidden = true; send.hidden = true; query('[data-back]').hidden = true; + const link = query('[data-status-link]'); + link.href = `${root!.dataset.receipt}#${result.id}`; link.focus(); stopPicking(); + } catch (error) { + message.textContent = error instanceof Error ? `${error.message} Your draft has been kept.` : 'Could not send. Your draft has been kept.'; + await challenge().catch(() => {}); + } finally { sending = false; send.disabled = false; query('[data-back]').disabled = false; } + }); +} diff --git a/src/lib/suggestions/config.mjs b/src/lib/suggestions/config.mjs new file mode 100644 index 0000000..dbc5ba7 --- /dev/null +++ b/src/lib/suggestions/config.mjs @@ -0,0 +1,8 @@ +import { execFileSync } from 'node:child_process'; + +export const revision = process.env.VIZARD_DOCS_COMMIT || execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +export const api = (process.env.VIZARD_SUGGESTIONS_API || '').replace(/\/$/, ''); +export const siteKey = process.env.VIZARD_TURNSTILE_SITE_KEY || ''; +export const enabled = Boolean(api && siteKey && !process.env.VIZARD_DOCS_VERSION); +if (api && !/^https:\/\/[^\s]+$/.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(api)) + throw new Error('VIZARD_SUGGESTIONS_API must be an HTTPS URL (or localhost for development).'); diff --git a/src/lib/suggestions/remark.mjs b/src/lib/suggestions/remark.mjs new file mode 100644 index 0000000..1bc5bec --- /dev/null +++ b/src/lib/suggestions/remark.mjs @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { passages, validPath } from './source.mjs'; + +export default function suggestionPassages({ revision, enabled }) { + return (tree, file) => { + if (!enabled || !file.path) return; + const root = fileURLToPath(new URL('../../../', import.meta.url)); + const path = relative(root, file.path).replaceAll('\\', '/'); + if (!validPath(path)) return; + const source = readFileSync(file.path, 'utf8'); + const blocks = passages(source); + // Astro strips frontmatter before rendering. Positions survive typography + // plugins, whereas comparing rendered quotation marks with source does not. + const offset = source.indexOf(String(file.value)); + if (offset < 0) return; + function visit(node, parent, grandparent) { + if (node.type === 'paragraph') { + const block = blocks.find(block => block.start === node.position?.start.offset + offset); + if (block) { + node.data ??= {}; + // Tight-list rendering unwraps

, dropping its attributes. A span + // preserves the passage identity without changing list structure. + if (parent?.type === 'listItem' && grandparent?.type === 'list' && + !grandparent.spread && grandparent.children.every(item => !item.spread)) node.data.hName = 'span'; + node.data.hProperties = { ...node.data.hProperties, + 'data-suggestion': JSON.stringify({ path, revision, passage: block.id, original: block.text }) }; + } + } else node.children?.forEach(child => visit(child, node, parent)); + } + visit(tree); + }; +} diff --git a/src/lib/suggestions/source.mjs b/src/lib/suggestions/source.mjs new file mode 100644 index 0000000..5741d7e --- /dev/null +++ b/src/lib/suggestions/source.mjs @@ -0,0 +1,78 @@ +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkFrontmatter from 'remark-frontmatter'; +import remarkGfm from 'remark-gfm'; + +const parser = unified().use(remarkParse).use(remarkFrontmatter, ['yaml']).use(remarkGfm); +export const normalize = (text) => text.replace(/\r?\n/g, ' '); +export const validPath = (path) => typeof path === 'string' && + /^src\/content\/docs\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+\.md$/.test(path); + +export function passages(source) { + const result = []; + function visit(node) { + if (node.type === 'paragraph') { + const runs = []; + let text = '', supported = true; + function flatten(child) { + if (['text', 'inlineCode'].includes(child.type)) { + const value = normalize(child.value); + runs.push({ type: child.type, from: text.length, to: text.length + value.length, + start: child.position.start.offset, end: child.position.end.offset, + literal: source.slice(child.position.start.offset, child.position.end.offset) === child.value }); + text += value; + } else if (['paragraph', 'emphasis', 'strong', 'delete', 'link', 'linkReference'].includes(child.type)) { + child.children.forEach(flatten); + } else supported = false; + } + flatten(node); + if (supported && text.trim() && text.length <= 6000) result.push({ + id: `${node.position.start.offset}-${node.position.end.offset}`, + text, runs, start: node.position.start.offset, end: node.position.end.offset, + }); + } else node.children?.forEach(visit); + } + visit(parser.parse(source)); + return result; +} + +// Patch source text, never serialize a rendered page or accept visitor-supplied Markdown. +export function prepareEdit(source, { passage, original, replacement }) { + const block = passages(source).find((item) => item.id === passage); + if (!block || block.text !== original) throw new Error('The original passage does not match the source.'); + if (replacement === original || !replacement.trim()) throw new Error('Please make a change before sending.'); + if (/[\r\n]/.test(replacement)) return { reason: 'The suggestion changes paragraph structure.' }; + let left = 0, right = 0; + while (left < original.length && left < replacement.length && original[left] === replacement[left]) left++; + while (right < original.length - left && right < replacement.length - left && + original[original.length - right - 1] === replacement[replacement.length - right - 1]) right++; + const run = block.runs.find((item) => item.type === 'text' && item.literal && + item.from <= left && item.to >= original.length - right); + if (!run) return { reason: 'The suggestion crosses formatting boundaries or changes formatted code.' }; + // Newlines in a text run occupy one display character; CRLF requires manual review. + if (source.slice(run.start, run.end).includes('\r')) return { reason: 'This source uses different line endings.' }; + const inserted = replacement.slice(left, replacement.length - right); + const escaped = inserted.replace(/[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/g, '\\$&'); + const start = run.start + left - run.from; + const end = run.start + original.length - right - run.from; + const content = source.slice(0, start) + escaped + source.slice(end); + const next = passages(content).find((item) => item.start === block.start); + if (!next || next.text !== replacement) return { reason: 'This change needs a manual formatting check.' }; + return { content }; +} + +export function validateSubmission(value) { + if (!value || typeof value !== 'object') throw new Error('Invalid submission.'); + const { id, path, revision, kind, passage = '', original = '', replacement = '', explanation = '', version = '' } = value; + if (!/^[a-f0-9-]{36}$/.test(id ?? '') || !validPath(path) || !/^[a-f0-9]{40}$/.test(revision ?? '') || + !['edit', 'problem'].includes(kind)) throw new Error('Invalid page or submission identity.'); + for (const text of [passage, original, replacement, explanation, version]) { + if (typeof text !== 'string' || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(text)) throw new Error('Invalid text.'); + } + if (original.length > 6000 || replacement.length > 6000 || explanation.length > 3000 || version.length > 80 || + passage.length > 40) throw new Error('Please shorten your suggestion.'); + if (kind === 'edit' && (!/^\d+-\d+$/.test(passage) || !original.trim() || !replacement.trim() || original === replacement)) + throw new Error('Please change the wording before sending.'); + if (kind === 'problem' && !explanation.trim()) throw new Error('Please describe the problem.'); + return { id, path, revision, kind, passage, original, replacement, explanation, version }; +} diff --git a/src/pages/suggestion.astro b/src/pages/suggestion.astro new file mode 100644 index 0000000..aca4e09 --- /dev/null +++ b/src/pages/suggestion.astro @@ -0,0 +1,32 @@ +--- +import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; +import { api } from '../lib/suggestions/config.mjs'; +--- + +

+

Loading your suggestion status…

+ +

You don’t need an account. Save this page’s link to check again later.

+
+ + +