diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index beceaec..87cf9d2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,3 +26,7 @@ jobs:
run: npm run test:suggestions && npm run suggestions:check
- name: Verify frozen manuals
run: npm run test:archives
+ - name: Install browser for editor checks
+ run: npx playwright install --with-deps chromium
+ - name: Verify full-page editor and complete Markdown corpus
+ run: npm run test:editor
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cef21c2..39778eb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,10 +4,10 @@ Corrections, clearer explanations, and missing documentation are welcome.
## Suggest a correction
-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
+Choose **Edit this page** to edit the full document, including paragraphs,
+headings, lists, links, and tables. Review the highlighted changes and send. You do not need a GitHub account or
+Markdown knowledge. Use **Describe a problem** if you would rather explain a change
+than edit the page. 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
@@ -21,7 +21,7 @@ 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
+Use **Edit on GitHub** at the bottom of a published article. GitHub will guide you
through proposing the change. Contributors without write access can submit the
change from a fork as a pull request.
diff --git a/README.md b/README.md
index 09dad92..6716e8d 100644
--- a/README.md
+++ b/README.md
@@ -25,15 +25,15 @@ npm run check
## Contributing
-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.
+When enabled, **Edit this page** opens a full-page Milkdown editor. Readers can
+send changes for review without a GitHub account. **Describe a problem** accepts 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.
+tests, `npm run test:editor` for corpus and browser checks, 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 3faa415..7e0c1d3 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,18 +1,16 @@
// @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';
+import sitemap from '@astrojs/sitemap';
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: [
+ sitemap({ filter: (page) => !/\/(edit|suggestion)(\/|$)/.test(new URL(page).pathname) }),
starlight({
title: 'Vizard Documentation',
description: 'Documentation for the Vizard video synthesizer.',
@@ -21,6 +19,7 @@ export default defineConfig({
'./src/styles/vizard.css',
],
components: {
+ PageTitle: './src/components/PageTitle.astro',
SiteTitle: './src/components/SiteTitle.astro',
Footer: './src/components/ManualFooter.astro',
EditLink: './src/components/GitHubEditLink.astro',
diff --git a/package-lock.json b/package-lock.json
index c11c1c1..2f55a79 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,14 +11,18 @@
"@astrojs/markdown-remark": "7.2.4",
"@astrojs/starlight": "^0.41.10",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
+ "@milkdown/crepe": "7.22.1",
+ "@milkdown/kit": "7.22.1",
"astro": "^7.1.6",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"sharp": "^0.35.3",
- "unified": "^11.0.5"
+ "unified": "^11.0.5",
+ "@astrojs/sitemap": "3.7.3"
},
"devDependencies": {
+ "@playwright/test": "1.63.0",
"wrangler": "^4.136.3"
},
"engines": {
@@ -934,6 +938,419 @@
"node": ">=16"
}
},
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.11.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
+ "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.7.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-angular": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz",
+ "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.1.2",
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.3"
+ }
+ },
+ "node_modules/@codemirror/lang-cpp": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
+ "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/cpp": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-css": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
+ "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/css": "^1.1.7"
+ }
+ },
+ "node_modules/@codemirror/lang-go": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
+ "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/go": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-html": {
+ "version": "6.4.12",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
+ "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/css": "^1.1.0",
+ "@lezer/html": "^1.3.12"
+ }
+ },
+ "node_modules/@codemirror/lang-java": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
+ "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/java": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
+ "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-jinja": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.1.tgz",
+ "integrity": "sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.2.0",
+ "@lezer/lr": "^1.4.0"
+ }
+ },
+ "node_modules/@codemirror/lang-json": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
+ "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/json": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-less": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz",
+ "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-css": "^6.2.0",
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-liquid": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz",
+ "integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.1"
+ }
+ },
+ "node_modules/@codemirror/lang-markdown": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
+ "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.7.1",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.3.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/markdown": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-php": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
+ "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/php": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-python": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
+ "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.3.2",
+ "@codemirror/language": "^6.8.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.1",
+ "@lezer/python": "^1.1.4"
+ }
+ },
+ "node_modules/@codemirror/lang-rust": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
+ "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/rust": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-sass": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz",
+ "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-css": "^6.2.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.0.2",
+ "@lezer/sass": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-sql": {
+ "version": "6.10.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
+ "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-vue": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz",
+ "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.1.2",
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.1"
+ }
+ },
+ "node_modules/@codemirror/lang-wast": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz",
+ "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-xml": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
+ "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.4.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/xml": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/lang-yaml": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
+ "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.2.0",
+ "@lezer/lr": "^1.0.0",
+ "@lezer/yaml": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/language-data": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz",
+ "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/lang-angular": "^0.1.0",
+ "@codemirror/lang-cpp": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-go": "^6.0.0",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/lang-java": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@codemirror/lang-jinja": "^6.0.0",
+ "@codemirror/lang-json": "^6.0.0",
+ "@codemirror/lang-less": "^6.0.0",
+ "@codemirror/lang-liquid": "^6.0.0",
+ "@codemirror/lang-markdown": "^6.0.0",
+ "@codemirror/lang-php": "^6.0.0",
+ "@codemirror/lang-python": "^6.0.0",
+ "@codemirror/lang-rust": "^6.0.0",
+ "@codemirror/lang-sass": "^6.0.0",
+ "@codemirror/lang-sql": "^6.0.0",
+ "@codemirror/lang-vue": "^0.1.1",
+ "@codemirror/lang-wast": "^6.0.0",
+ "@codemirror/lang-xml": "^6.0.0",
+ "@codemirror/lang-yaml": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/legacy-modes": "^6.4.0"
+ }
+ },
+ "node_modules/@codemirror/legacy-modes": {
+ "version": "6.5.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
+ "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.42.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
+ "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.7.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
+ "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.13",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
+ "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.7.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
"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",
@@ -1450,6 +1867,31 @@
"@expressive-code/core": "^0.44.1"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+ "license": "MIT"
+ },
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
@@ -2034,6 +2476,189 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "node_modules/@lezer/common": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/cpp": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.6.tgz",
+ "integrity": "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/css": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.8.tgz",
+ "integrity": "sha512-EJn1zcL9qoDptief6ipWKZKLiOpXkxSe0+t8CH9oiMVcZlq7NBWrjCqnc/41EIjeo/ITj1gFFiATdTkaJDL+Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/go": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
+ "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/html": {
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
+ "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/java": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.4.tgz",
+ "integrity": "sha512-gQylJ2xHV2xXsVqtnAcJrcROvLZJaQiC0tR//+q0k961BxqvyYttusPkJ8d9T1fPDJKHJW2etUH8XbIn9jhVUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.5.tgz",
+ "integrity": "sha512-sWg4yX1J6XW67AaAynVt0iwF0M5c+np36TEu+P2ifAJ8haRYvHnWDV28r1jdwnJehWCwXECutAUy56K4RBZIyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/json": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
+ "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/markdown": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz",
+ "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/php": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.6.tgz",
+ "integrity": "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.1.0"
+ }
+ },
+ "node_modules/@lezer/python": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
+ "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/rust": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
+ "integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/sass": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz",
+ "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/xml": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
+ "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0"
+ }
+ },
+ "node_modules/@lezer/yaml": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
+ "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.4.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
+ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
+ "license": "MIT"
+ },
"node_modules/@mdx-js/mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz",
@@ -2071,6 +2696,392 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/@milkdown/components": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/components/-/components-7.22.1.tgz",
+ "integrity": "sha512-6IA8fcFcBTm/x1X1typz73yUoT1JuN4srmifGAIeWEtCnayEwRjFxpQOoQrfvMgBYx4DSHcPVLhJUlR/xbBtxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.5.1",
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/plugin-diff": "7.22.1",
+ "@milkdown/plugin-tooltip": "7.22.1",
+ "@milkdown/preset-commonmark": "7.22.1",
+ "@milkdown/preset-gfm": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/transformer": "7.22.1",
+ "@milkdown/utils": "7.22.1",
+ "@types/lodash-es": "^4.17.12",
+ "clsx": "^2.0.0",
+ "dompurify": "^3.2.5",
+ "lodash-es": "^4.17.21",
+ "nanoid": "^6.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vue": "^3.5.20"
+ },
+ "peerDependencies": {
+ "@codemirror/language": "^6",
+ "@codemirror/state": "^6",
+ "@codemirror/view": "^6"
+ }
+ },
+ "node_modules/@milkdown/components/node_modules/nanoid": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.1.tgz",
+ "integrity": "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^22 || ^24 || >=26"
+ }
+ },
+ "node_modules/@milkdown/core": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/core/-/core-7.22.1.tgz",
+ "integrity": "sha512-X4O3al2aYugpFP/Do7VoPgvVEftjJVi+6UhZmz9fSdqMtpU70AitJ8R/erUA18hay2hWvNO9m3pxIJoR6no6KQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/transformer": "7.22.1",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.3"
+ }
+ },
+ "node_modules/@milkdown/crepe": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/crepe/-/crepe-7.22.1.tgz",
+ "integrity": "sha512-0BZTGBRV8lnlKsA3vmPK5Qyomjqd952493AIzfnbhiYwzHpluX03iMtOgHC6E2UcpMXP86P5CXhPUWjzDu+JZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/commands": "^6.2.4",
+ "@codemirror/language": "^6.10.1",
+ "@codemirror/language-data": "^6.3.1",
+ "@codemirror/state": "^6.4.1",
+ "@codemirror/theme-one-dark": "^6.1.2",
+ "@codemirror/view": "^6.16.0",
+ "@milkdown/kit": "7.22.1",
+ "@types/lodash-es": "^4.17.12",
+ "clsx": "^2.0.0",
+ "codemirror": "^6.0.1",
+ "dompurify": "^3.2.5",
+ "katex": "^0.18.0",
+ "lodash-es": "^4.17.21",
+ "prosemirror-virtual-cursor": "^0.4.2",
+ "remark-math": "^6.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vue": "^3.5.20"
+ }
+ },
+ "node_modules/@milkdown/ctx": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/ctx/-/ctx-7.22.1.tgz",
+ "integrity": "sha512-6+LJdo3DcrkO1lzGcje0yWqaG6ZKgTR8kDxyDrrm7iocBjq3M+bkw04f99ZG/BLp1Rbc4yRyin2mUy6m4SEVaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/exception": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/exception": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/exception/-/exception-7.22.1.tgz",
+ "integrity": "sha512-wXUfn+fpWy0jeJ6AVEjKPieouLfJIDLQ4IRqyEL3d5yZDiZTtKu1Va0+RprrvqANpGJ1YBRQ1uCgDE6sJgTEjQ==",
+ "license": "MIT"
+ },
+ "node_modules/@milkdown/kit": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/kit/-/kit-7.22.1.tgz",
+ "integrity": "sha512-gXLMhjqe0j8XRSUe97LBXbpZ0sDi5EM6nnt1zXewUaGDS3mleMjNntSCWrp9ln40kz8tGs0ol50kSAr1n/JBnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/components": "7.22.1",
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/plugin-block": "7.22.1",
+ "@milkdown/plugin-clipboard": "7.22.1",
+ "@milkdown/plugin-cursor": "7.22.1",
+ "@milkdown/plugin-diff": "7.22.1",
+ "@milkdown/plugin-history": "7.22.1",
+ "@milkdown/plugin-indent": "7.22.1",
+ "@milkdown/plugin-listener": "7.22.1",
+ "@milkdown/plugin-slash": "7.22.1",
+ "@milkdown/plugin-streaming": "7.22.1",
+ "@milkdown/plugin-tooltip": "7.22.1",
+ "@milkdown/plugin-trailing": "7.22.1",
+ "@milkdown/plugin-upload": "7.22.1",
+ "@milkdown/preset-commonmark": "7.22.1",
+ "@milkdown/preset-gfm": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/transformer": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/plugin-block": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-block/-/plugin-block-7.22.1.tgz",
+ "integrity": "sha512-R0gOuKRqR1DyFULD75Oic0+57DXTthagUZbbB9lsHM59aQmxKJ1nRmujptPeMfDlDfEod2ao5rsow7LO8p+ZWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.5.1",
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1",
+ "@types/lodash-es": "^4.17.12",
+ "lodash-es": "^4.17.21"
+ }
+ },
+ "node_modules/@milkdown/plugin-clipboard": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-clipboard/-/plugin-clipboard-7.22.1.tgz",
+ "integrity": "sha512-H1meyLEj2fOnFWSopeQ2H7hl6VXM+OQZoU9H7GrlHFM5GKtBFBErFFV4Cl5tUSVFY+xYy/4rEPOA23Ba1u6OfA==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/plugin-cursor": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-cursor/-/plugin-cursor-7.22.1.tgz",
+ "integrity": "sha512-3tJzpQl5cyn+8vzsHkPBYsW6QacEN7yP2O5GkyUjrNLvMIQ5vVi7GUuztLeoouJKlOc1vjIq3h7ARXCgHAKe8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1",
+ "prosemirror-drop-indicator": "^0.1.0"
+ }
+ },
+ "node_modules/@milkdown/plugin-diff": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-diff/-/plugin-diff-7.22.1.tgz",
+ "integrity": "sha512-qjuCMx11HwhMlTJuybqbFZT8PQkasSi0qICxq2HdYqH0WQuLO99I2mb17UAJz1aF+JM9rp1StHWr4bxC0ofamA==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/transformer": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/plugin-history": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-history/-/plugin-history-7.22.1.tgz",
+ "integrity": "sha512-SRD6emVhfVmA6mrcCsCiWrONN/2Kp+VT5LLojIQxNc/spzjWw15PtkA3r4nUe9cZmNN5rE7XvrPaA0WnjJIyeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/plugin-indent": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-indent/-/plugin-indent-7.22.1.tgz",
+ "integrity": "sha512-s/wmqxaJpIKT01gEXsC9gCkzA3Clm+MNioZLzNCYD8xI/4Hf3AhmIEoEqf3LIQ9w10Zs2yhIxIrknNWfCOgsEw==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/plugin-listener": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-listener/-/plugin-listener-7.22.1.tgz",
+ "integrity": "sha512-6k8JMDrdAL0E0WygpDX1YOR0Q9Wc4pia8pIMN5nN5liEeAyNaM6LwAxXJY+awQJ0OstDKHTfjkh7WzJo4QiXOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@types/lodash-es": "^4.17.12",
+ "lodash-es": "^4.17.21"
+ }
+ },
+ "node_modules/@milkdown/plugin-slash": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-slash/-/plugin-slash-7.22.1.tgz",
+ "integrity": "sha512-eFKCjfgXfAQusTSOyzhz4nASquOApdiBy9hnGkPB/SJ6oltTC8SonB3nIxF/X0wPZHfn09vuz/JBqh9cyDtXJA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.5.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1",
+ "@types/lodash-es": "^4.17.12",
+ "lodash-es": "^4.17.21"
+ }
+ },
+ "node_modules/@milkdown/plugin-streaming": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-streaming/-/plugin-streaming-7.22.1.tgz",
+ "integrity": "sha512-CR0lujyc7ae0sbjNfscvS+b+cUP7b6XJ90ioTor605UV+F8EKh5t4PMJM79IVzSUCeob51a+rtXA2LAA1b/GyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/plugin-diff": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/plugin-tooltip": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-tooltip/-/plugin-tooltip-7.22.1.tgz",
+ "integrity": "sha512-drdWA/7WlrDr2B+ABYf4tY9xTiwg/CJMUCydfD05dR2d8wOkaF6oOHZwJbWnkWLMtAJWdieOYgfXGPhQRwXxCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.5.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1",
+ "@types/lodash-es": "^4.17.12",
+ "lodash-es": "^4.17.21"
+ }
+ },
+ "node_modules/@milkdown/plugin-trailing": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-trailing/-/plugin-trailing-7.22.1.tgz",
+ "integrity": "sha512-Buy8IX3I7wwq0QPfJRep77QnvlkBeg6BvDHkbh5eTMrgnwSiWhN+tV9RVCYpdWXM7QhQZpSjU2SejluLuTyVCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/plugin-upload": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/plugin-upload/-/plugin-upload-7.22.1.tgz",
+ "integrity": "sha512-vV0bZzdh/PhM+Jk9Nk5cKHDXHY5jt+ePcaitTfvESDYfg83YZ9AglOhw/YclPWklMvswrnCGZZFTZD4daqc4hg==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/utils": "7.22.1"
+ }
+ },
+ "node_modules/@milkdown/preset-commonmark": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/preset-commonmark/-/preset-commonmark-7.22.1.tgz",
+ "integrity": "sha512-it+G0YUG5MDXt0qLB6W083ss5i6tnWAXWCW8SgnmsehGxntkN5//gNM/8vh3rqwl8RvnBvjLWpJ6TgHSlBPhlw==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/transformer": "7.22.1",
+ "@milkdown/utils": "7.22.1",
+ "remark-inline-links": "^7.0.0",
+ "unist-util-visit": "^5.0.0",
+ "unist-util-visit-parents": "^6.0.1"
+ }
+ },
+ "node_modules/@milkdown/preset-gfm": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/preset-gfm/-/preset-gfm-7.22.1.tgz",
+ "integrity": "sha512-UPMdHRdMHlVourOOTiwsp3qHu614pDFWlWtVzxe8fyl8uKZUk6IOuewH5ubhqD/opos8OEB+zgEomUGfee1oTw==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/preset-commonmark": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/transformer": "7.22.1",
+ "@milkdown/utils": "7.22.1",
+ "prosemirror-safari-ime-span": "^1.0.1",
+ "remark-gfm": "^4.0.1"
+ }
+ },
+ "node_modules/@milkdown/prose": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/prose/-/prose-7.22.1.tgz",
+ "integrity": "sha512-fqgTHl94G7oDPY94cPYBm6ARxEoLQ10ubkTNLD1nzAlTUfJLULZkIEMSMy4CECncw2go3ExW32CZPpXLJVSxnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/exception": "7.22.1",
+ "prosemirror-changeset": "^2.3.1",
+ "prosemirror-commands": "^1.7.1",
+ "prosemirror-dropcursor": "^1.8.2",
+ "prosemirror-gapcursor": "^1.4.0",
+ "prosemirror-history": "^1.5.0",
+ "prosemirror-inputrules": "^1.5.1",
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-schema-list": "^1.5.1",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-tables": "^1.8.1",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.3"
+ }
+ },
+ "node_modules/@milkdown/transformer": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/transformer/-/transformer-7.22.1.tgz",
+ "integrity": "sha512-rU1IBtxezNg7TSWaq+RP59/t6tfD3jIPGrwAtMKPIGuYEax+8d1fyGisp98TeI3l0VQEIgMpF+KlNDV+Y1HJVA==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "remark": "^15.0.1",
+ "unified": "^11.0.3"
+ }
+ },
+ "node_modules/@milkdown/utils": {
+ "version": "7.22.1",
+ "resolved": "https://registry.npmjs.org/@milkdown/utils/-/utils-7.22.1.tgz",
+ "integrity": "sha512-ye0LGy/Ez8fjtcYyYj93wp4XfH00JYpCiy1IDHYB+9HVIpnbr3ed7PvY4s2000xmDWe0Vlx07TOyv96yvFrnjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@milkdown/core": "7.22.1",
+ "@milkdown/ctx": "7.22.1",
+ "@milkdown/exception": "7.22.1",
+ "@milkdown/prose": "7.22.1",
+ "@milkdown/transformer": "7.22.1",
+ "nanoid": "^6.0.0"
+ }
+ },
+ "node_modules/@milkdown/utils/node_modules/nanoid": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.1.tgz",
+ "integrity": "sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^22 || ^24 || >=26"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz",
@@ -2092,6 +3103,15 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
+ "node_modules/@ocavue/utils": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@ocavue/utils/-/utils-1.8.0.tgz",
+ "integrity": "sha512-BD1ro+6Kllkmr/at+o32kvnLrVooUbk9gw3JAPaoAZXx+A5GhZ6Laa+UA38ifhG3vaamx+jcgEwX22BmTSMUVA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ocavue"
+ }
+ },
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -2204,6 +3224,22 @@
"win32"
]
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -2666,6 +3702,27 @@
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"license": "MIT"
},
+ "node_modules/@types/katex": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz",
+ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -2687,44 +3744,173 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
- "node_modules/@types/nlcst": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz",
- "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==",
+ "node_modules/@types/nlcst": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz",
+ "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/sax": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
+ "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
+ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
+ "license": "ISC"
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
+ "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/shared": "3.5.43",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-core/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/@vue/compiler-core/node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
+ "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
+ "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/compiler-core": "3.5.43",
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/compiler-ssr": "3.5.43",
+ "@vue/shared": "3.5.43",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.28",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-sfc/node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
+ "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
"license": "MIT",
"dependencies": {
- "@types/unist": "*"
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/shared": "3.5.43"
}
},
- "node_modules/@types/node": {
- "version": "24.13.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
- "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz",
+ "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
"license": "MIT",
"dependencies": {
- "undici-types": "~7.18.0"
+ "@vue/shared": "3.5.43"
}
},
- "node_modules/@types/sax": {
- "version": "1.2.7",
- "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
- "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz",
+ "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==",
"license": "MIT",
"dependencies": {
- "@types/node": "*"
+ "@vue/reactivity": "3.5.43",
+ "@vue/shared": "3.5.43"
}
},
- "node_modules/@types/unist": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
- "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
- "license": "MIT"
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz",
+ "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.43",
+ "@vue/runtime-core": "3.5.43",
+ "@vue/shared": "3.5.43",
+ "csstype": "^3.2.3"
+ }
},
- "node_modules/@ungap/structured-clone": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
- "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
- "license": "ISC"
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz",
+ "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.43",
+ "@vue/runtime-dom": "3.5.43",
+ "@vue/shared": "3.5.43"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz",
+ "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
+ "license": "MIT"
},
"node_modules/acorn": {
"version": "8.18.0",
@@ -3076,6 +4262,21 @@
"node": ">=6"
}
},
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
"node_modules/collapse-white-space": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
@@ -3133,6 +4334,12 @@
"integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==",
"license": "MIT"
},
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
"node_modules/crossws": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz",
@@ -3244,6 +4451,12 @@
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"license": "CC0-1.0"
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3398,6 +4611,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.15",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
+ "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -4341,6 +5563,31 @@
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
+ "node_modules/katex": {
+ "version": "0.18.7",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.7.tgz",
+ "integrity": "sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^15.0.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
+ "node_modules/katex/node_modules/commander": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -4621,6 +5868,12 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -4877,6 +6130,25 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdast-util-math": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz",
+ "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.1.0",
+ "unist-util-remove-position": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/mdast-util-mdx": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz",
@@ -5254,6 +6526,50 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/micromark-extension-math": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz",
+ "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/katex": "^0.16.0",
+ "devlop": "^1.0.0",
+ "katex": "^0.16.0",
+ "micromark-factory-space": "^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-math/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/micromark-extension-math/node_modules/katex": {
+ "version": "0.16.47",
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
+ "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
+ "funding": [
+ "https://opencollective.com/katex",
+ "https://github.com/sponsors/katex"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "commander": "^8.3.0"
+ },
+ "bin": {
+ "katex": "cli.js"
+ }
+ },
"node_modules/micromark-extension-mdx-expression": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz",
@@ -5944,6 +7260,12 @@
"regex-recursion": "^6.0.2"
}
},
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
"node_modules/p-limit": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz",
@@ -6104,10 +7426,39 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/postcss": {
- "version": "8.5.26",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
- "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"funding": [
{
"type": "opencollective",
@@ -6124,7 +7475,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.17",
+ "nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -6198,6 +7549,198 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/prosemirror-changeset": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.3.tgz",
+ "integrity": "sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
+ "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-drop-indicator": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-drop-indicator/-/prosemirror-drop-indicator-0.1.4.tgz",
+ "integrity": "sha512-YaRB1pZmU5GCorPVWbc9dbhbwqr4iMBO/AjPu4BTKHCUzxEDUXje2dUyoxOHib/z4uyPUZTJz64h7mHDJZeSzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@ocavue/utils": "^1.6.0",
+ "prosemirror-model": "^1.25.7",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-view": "^1.41.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ocavue"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
+ "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+ "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+ "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+ "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.12",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
+ "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-safari-ime-span": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-safari-ime-span/-/prosemirror-safari-ime-span-1.0.2.tgz",
+ "integrity": "sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-view": "^1.33.8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ocavue"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz",
+ "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.42.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.5.tgz",
+ "integrity": "sha512-7qcRaCWM48NC4utNQnx5Obvvs48/jMHXXvOXHH6WlBojmiK15eyxoHYlv5aJARHJHiFccSuWZ7ULW2SPrFAS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.12",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-virtual-cursor": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-virtual-cursor/-/prosemirror-virtual-cursor-0.4.2.tgz",
+ "integrity": "sha512-pUMKnIuOhhnMcgIJUjhIQTVJruBEGxfMBVQSrK0g2qhGPDm1i12KdsVaFw15dYk+29tZcxjMeR7P5VDKwmbwJg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ocavue"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "prosemirror-model": {
+ "optional": true
+ },
+ "prosemirror-state": {
+ "optional": true
+ },
+ "prosemirror-view": {
+ "optional": true
+ }
+ }
+ },
"node_modules/radix3": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz",
@@ -6407,6 +7950,22 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark": {
+ "version": "15.0.1",
+ "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz",
+ "integrity": "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-directive": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz",
@@ -6457,6 +8016,37 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/remark-inline-links": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/remark-inline-links/-/remark-inline-links-7.0.0.tgz",
+ "integrity": "sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-definitions": "^6.0.0",
+ "unist-util-visit": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-math": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz",
+ "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-math": "^3.0.0",
+ "micromark-extension-math": "^3.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/remark-mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz",
@@ -6637,6 +8227,12 @@
"@rolldown/binding-win32-x64-msvc": "1.2.6"
}
},
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
"node_modules/satteri": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz",
@@ -6834,6 +8430,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/style-mod": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
+ "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
+ "license": "MIT"
+ },
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -7418,6 +9020,33 @@
}
}
},
+ "node_modules/vue": {
+ "version": "3.5.43",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz",
+ "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.43",
+ "@vue/compiler-sfc": "3.5.43",
+ "@vue/runtime-dom": "3.5.43",
+ "@vue/server-renderer": "3.5.43",
+ "@vue/shared": "3.5.43"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
diff --git a/package.json b/package.json
index 297cf86..2238748 100644
--- a/package.json
+++ b/package.json
@@ -17,18 +17,22 @@
"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"
+ "suggestions:check": "wrangler deploy --dry-run --config services/suggestions/wrangler.jsonc",
+ "test:editor": "playwright test"
},
"dependencies": {
"@astrojs/markdown-remark": "7.2.4",
"@astrojs/starlight": "^0.41.10",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
+ "@milkdown/crepe": "7.22.1",
+ "@milkdown/kit": "7.22.1",
"astro": "^7.1.6",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"sharp": "^0.35.3",
- "unified": "^11.0.5"
+ "unified": "^11.0.5",
+ "@astrojs/sitemap": "3.7.3"
},
"allowScripts": {
"esbuild": true
@@ -37,6 +41,7 @@
"node": ">=22.12.0"
},
"devDependencies": {
+ "@playwright/test": "1.63.0",
"wrangler": "^4.136.3"
}
}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..97ff03d
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from '@playwright/test';
+export default defineConfig({
+ testDir: './tests', workers: 1, timeout: 120000,
+ outputDir: '/tmp/vizard-editor-test-results',
+ use: { baseURL: 'http://127.0.0.1:4322/vizard-docs/', browserName: 'chromium',
+ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined } },
+ webServer: { command: 'npm run dev -- --host 127.0.0.1 --port 4322', url: 'http://127.0.0.1:4322/vizard-docs/', reuseExistingServer: !process.env.CI,
+ env: { ASTRO_DEV_BACKGROUND: '1', VIZARD_SUGGESTIONS_API: 'http://127.0.0.1:8787', VIZARD_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' } },
+});
diff --git a/services/suggestions/README.md b/services/suggestions/README.md
index 338cee8..2a79fa4 100644
--- a/services/suggestions/README.md
+++ b/services/suggestions/README.md
@@ -1,9 +1,9 @@
# Account-free documentation suggestions
-Readers edit a passage on the documentation site, review their wording, and send
+Readers edit the whole page in a Milkdown visual editor, review changed sections, 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
+A GitHub App opens a pull request for page edits, or an issue for problem reports.
+Older passage submissions remain supported, including their manual-review fallbacks. 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
@@ -21,8 +21,46 @@ npm run test:suggestions
npm run check
npm run test:archives
npm run suggestions:check
+npx playwright install chromium
+npm run test:editor
```
+The browser suite opens all authored pages in the actual Milkdown editor, verifies
+semantic import/export equivalence, byte-identical no-op saves, and an edit to each
+page. It also covers desktop/mobile editing, table cells, keyboard toolbar controls,
+local draft recovery, review, failed submission/retry, and problem reports. GitHub
+and Turnstile are mocked in browser tests. On a Mac with installed Chrome, use
+`PLAYWRIGHT_CHROMIUM_EXECUTABLE='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' npm run test:editor`.
+
+## Full-page editing contract
+
+- Editor code loads only on `/edit/.../`, never on the reading page. These routes
+ are absent from disabled/release builds and excluded from indexing.
+- The page body is editable; the existing page title and other frontmatter remain
+ protected. Tables, headings, lists, links, code, and paragraphs are supported.
+ Raw HTML, uploads, images, scripts, and unsupported Markdown nodes are rejected.
+- The original source is captured with its commit. Matching unchanged top-level
+ blocks (including duplicate blocks) and intervening whitespace are reused verbatim.
+ Changed blocks may be normalized by Milkdown. The reconstructed body must parse
+ to the same semantic document as the proposal. No-op submissions are rejected.
+- Empty table cells in Milkdown 7.22.1 export as `
`. Our adapter removes only
+ a sole HTML break inside an otherwise empty table cell. It never strips arbitrary
+ HTML. Initial import/export checks fail closed if any other content changes.
+- Crepe's top bar lacks button names and keyboard activation in this version. The
+ integration supplies accessible names and bridges Enter/Space to its pointer
+ command handler. These are narrow compatibility adaptations, not a dependency fork.
+- Drafts stay in localStorage on the reader's device, keyed by page and mode. They
+ retain their original source/revision across site updates. Discard clears a draft;
+ successful submission clears it. If storage is blocked, the UI says it is tab-only.
+- Full-page requests allow at most 100,000 UTF-8 bytes of body and 250,000 bytes of
+ JSON transport. The server validates content independently and retains metadata.
+- If the page changed since the draft began, the bot branches from the original
+ verified main-branch revision. GitHub then exposes conflicts against current main;
+ it never commits the old full page directly onto the newer main revision. Review
+ the warning on such PRs before merging. Existing review and build rules still apply.
+- Deploy the backward-compatible Worker update before merging the UI PR. Old
+ passage clients and saved submissions continue to work during the transition.
+
To preview the UI, use a local Worker endpoint and Cloudflare's public test key:
```sh
@@ -112,8 +150,9 @@ GitHub App credentials if you want it to create real review items.
- 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.
+- Submit a multi-section edit with inline code and tables. Verify unrelated blocks
+ remain unchanged. Submit a stale revision and verify its PR branches from the
+ original commit, with a warning and any conflicts visible against current main.
- 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.
@@ -152,6 +191,6 @@ 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
+Release builds never embed visual 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
index 77f4185..a5fe40d 100644
--- a/services/suggestions/github.mjs
+++ b/services/suggestions/github.mjs
@@ -1,4 +1,5 @@
import { passages, prepareEdit } from '../../src/lib/suggestions/source.mjs';
+import { preparePage } from '../../src/lib/suggestions/document.mjs';
const encoder = new TextEncoder();
const base64 = (bytes) => {
@@ -66,6 +67,10 @@ export async function deliver(row, env, api) {
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 === 'page') {
+ try { patch = preparePage(source, item.replacement); }
+ catch (error) { throw new InvalidSuggestion(error.message); }
+ }
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);
@@ -74,22 +79,25 @@ export async function deliver(row, env, api) {
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 stale = !current || current.sha !== base.sha;
+ if (stale && item.kind !== 'page') 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.original ? `### Original\n${textBlock(item.original)}` : '',
+ item.replacement ? (item.kind === 'page' ? 'Full-page suggestion. Review the proposed document in the Files changed tab.' : `### Suggested wording\n${textBlock(item.replacement)}`) : '',
+ stale && item.kind === 'page' ? 'The published page changed after this draft began. This branch starts from the reader’s original revision so GitHub can surface conflicts; review against current main before merging.' : '',
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'}`;
+ const title = `Docs: ${item.kind === 'page' ? 'page suggestion' : 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 });
+ if (!existingBranch) await api('/git/refs', 'POST', { ref: `refs/heads/${branch}`, sha: stale && item.kind === 'page' ? item.revision : 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.');
diff --git a/services/suggestions/test.mjs b/services/suggestions/test.mjs
index 06cc155..5c2836f 100644
--- a/services/suggestions/test.mjs
+++ b/services/suggestions/test.mjs
@@ -8,6 +8,51 @@ 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';
+import { splitDocument, preserveSource, equivalent, preparePage, validateBody, cleanEditorMarkdown } from '../../src/lib/suggestions/document.mjs';
+
+test('Milkdown empty-cell adaptation never strips authored HTML or real line breaks', () => {
+ const md = '| A | B |\n|---|---|\n|
| x
y |\n\n
\n';
+ assert.equal(cleanEditorMarkdown(md), md.replace('|
|', '| |'));
+ assert.throws(() => validateBody(cleanEditorMarkdown(md)));
+});
+
+test('full-page no-op saves preserve every byte, including metadata and source wrapping', () => {
+ const {body} = splitDocument(source);
+ assert.equal(preserveSource(body, body.replace(/\n\n/g, '\n\n\n')), body);
+ assert.throws(() => preparePage(source, body), /Make a change/);
+});
+test('full-page edits preserve unrelated sections, tables, relative links, and duplicate paragraphs', () => {
+ const input = '\nFirst wrapped\nparagraph.\n\n| Key | Value |\n|---|---|\n| A | `B` |\n\nSame text.\n\nSame text.\n\n[Next](../next/)\n';
+ const edited = input.replace('First wrapped\nparagraph.', 'First revised paragraph.').replace('Same text.\n\n[Next]', 'Different text.\n\n[Next]');
+ assert.equal(preserveSource(input, edited), edited);
+ const inserted = edited + '\n## Added section\n\nUseful content.\n';
+ assert.ok(equivalent(preserveSource(input, inserted), inserted));
+ assert.ok(preserveSource(input, inserted).includes('|---|---|'));
+ const deletion = edited.replace('Same text.\n\n', '');
+ assert.ok(equivalent(preserveSource(input, deletion), deletion));
+});
+test('full-page validation rejects executable HTML, dangerous URLs, images, metadata and oversize documents', () => {
+ for (const body of ['', '[x](javascript:alert)', '[x](data:text/html,hello)', '', '---\ntitle: changed\n---', '# New title', 'x'.repeat(100001)])
+ assert.throws(() => validateBody(body), body.slice(0, 50));
+ assert.doesNotThrow(() => validateBody('Useful `code` and [link](../next/).'));
+});
+test('full-page delivery creates one PR, preserves metadata, and recovers a lost GitHub response', async () => {
+ const body = splitDocument(source).body.replace('useful', 'clearer');
+ const item = validateSubmission(submission({kind: 'page', passage: '', original: '', replacement: body}));
+ const mock = github({loseResponse: true});
+ await assert.rejects(deliver(row(item), environment(), mock.api));
+ assert.equal((await deliver(row(item), environment(), mock.api)).kind, 'pull');
+ const write = mock.writes.find(w => w.method === 'PUT');
+ assert.equal(Buffer.from(write.body.content, 'base64').toString(), source.replace('useful', 'clearer'));
+ assert.equal(mock.writes.filter(w => w.path === '/pulls').length, 1);
+});
+test('a stale full-page draft branches from its verified original revision instead of replacing current main', async () => {
+ const item = submission({kind: 'page', passage: '', original: '', replacement: splitDocument(source).body.replace('useful', 'clearer')});
+ const mock = github({current: source.replace('useful', 'published')});
+ assert.equal((await deliver(row(item), environment(), mock.api)).kind, 'pull');
+ assert.equal(mock.writes.find(w => w.path === '/git/refs').body.sha, item.revision);
+ assert.match(mock.writes.find(w => w.path === '/pulls').body.body, /published page changed/);
+});
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 = {}) {
diff --git a/services/suggestions/worker.mjs b/services/suggestions/worker.mjs
index 06890e2..4c9909d 100644
--- a/services/suggestions/worker.mjs
+++ b/services/suggestions/worker.mjs
@@ -94,7 +94,7 @@ export async function handle(request, env, ctx, dependencies = {}) {
if (env.ENABLED !== 'true') return json({ error: 'Suggestions are temporarily unavailable. Your draft has been kept.' }, 503);
if (!request.headers.get('content-type')?.startsWith('application/json')) return json({ error: 'Expected JSON.' }, 415);
let value, item;
- try { value = JSON.parse(await limitedBody(request, 50000)); item = validateSubmission(value); }
+ try { value = JSON.parse(await limitedBody(request, 250000)); item = validateSubmission(value); }
catch (error) { return json({ error: error.message }, 400); }
const saved = await env.DB.prepare('SELECT payload FROM suggestions WHERE id = ?').bind(item.id).first();
if (saved) return saved.payload === JSON.stringify(item) ? json({ id: item.id }, 202) : json({ error: 'This receipt belongs to a different submission.' }, 409);
diff --git a/src/components/EditPageLink.astro b/src/components/EditPageLink.astro
new file mode 100644
index 0000000..9983d6b
--- /dev/null
+++ b/src/components/EditPageLink.astro
@@ -0,0 +1,14 @@
+---
+import { enabled } from '../lib/suggestions/config.mjs';
+const path = String(Astro.locals.starlightRoute.editUrl ?? '').split('/edit/main/src/content/docs/')[1];
+const base = import.meta.env.BASE_URL.replace(/\/$/, '');
+const href = path ? `${base}/edit/${path.replace(/\.md$/, '')}/` : '';
+---
+{enabled && href && Edit this page ↗}
+
+
diff --git a/src/components/PageTitle.astro b/src/components/PageTitle.astro
new file mode 100644
index 0000000..53c6e3c
--- /dev/null
+++ b/src/components/PageTitle.astro
@@ -0,0 +1,6 @@
+---
+import PageTitle from '@astrojs/starlight/components/PageTitle.astro';
+import EditPageLink from './EditPageLink.astro';
+---
+
+
diff --git a/src/components/SuggestChange.astro b/src/components/SuggestChange.astro
index 1f8f1a2..19b655f 100644
--- a/src/components/SuggestChange.astro
+++ b/src/components/SuggestChange.astro
@@ -1,74 +1,12 @@
---
-import { api, siteKey, enabled, revision } from '../lib/suggestions/config.mjs';
-const { editUrl } = Astro.locals.starlightRoute;
-const path = String(editUrl ?? '').split('/edit/main/')[1] ?? '';
+import EditPageLink from './EditPageLink.astro';
+import { enabled } from '../lib/suggestions/config.mjs';
+const path = String(Astro.locals.starlightRoute.editUrl ?? '').split('/edit/main/src/content/docs/')[1];
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
---
-{enabled && path &&
-
-
-
-
- No account needed. Changes are reviewed before publication.
-
-
-
Choose a passage on this page to edit its wording.
-
-
-
+{enabled && path && }
-
-
+
diff --git a/src/content/docs/concepts/modulation-matrix.md b/src/content/docs/concepts/modulation-matrix.md
index 472d300..3416e4e 100644
--- a/src/content/docs/concepts/modulation-matrix.md
+++ b/src/content/docs/concepts/modulation-matrix.md
@@ -4,7 +4,7 @@ title: "Modulation Matrix"
Any parameter that accepts modulation can be assigned one from the same
place: right-clicking it and choosing `Add Modulation Source` opens a tabbed
-popover, headed "Modulate: ," with one tab per source family:
+popover, headed "Modulate: ``," with one tab per source family:
`LFO`, `ENV`, `TIME`, `MACRO`, `Audio`, `CV/Gate`, `MIDI`, `MIDI Note`, and
`OpenSoundControl`.
diff --git a/src/lib/suggestions/client.ts b/src/lib/suggestions/client.ts
deleted file mode 100644
index a84ea6c..0000000
--- a/src/lib/suggestions/client.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-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/document.mjs b/src/lib/suggestions/document.mjs
new file mode 100644
index 0000000..4de2f16
--- /dev/null
+++ b/src/lib/suggestions/document.mjs
@@ -0,0 +1,112 @@
+import { unified } from 'unified';
+import remarkParse from 'remark-parse';
+import remarkGfm from 'remark-gfm';
+
+const parser = unified().use(remarkParse).use(remarkGfm);
+export const MAX_DOCUMENT = 100000;
+export function splitDocument(source) {
+ const match = source.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/);
+ if (!match) throw new Error('The page metadata could not be read.');
+ return { metadata: match[0], body: source.slice(match[0].length) };
+}
+export function parseBody(body) { return parser.parse(body); }
+// Milkdown serializes a lone empty table-cell paragraph as
. This is
+// editor scaffolding, not authored HTML. Remove ONLY that entire empty-cell
+// placeholder; never strip HTML generally or touch real inline line breaks.
+export function cleanEditorMarkdown(body) {
+ const ranges = [];
+ function visit(node) {
+ if (node.type === 'tableCell' && node.children.length === 1 && node.children[0].type === 'html' &&
+ /^
$/i.test(node.children[0].value)) ranges.push(node.children[0].position);
+ else node.children?.forEach(visit);
+ }
+ visit(parseBody(body));
+ for (const range of ranges.reverse()) body = body.slice(0, range.start.offset) + body.slice(range.end.offset);
+ return body;
+}
+// Soft source line-wraps and list spacing are presentation, not reader edits.
+export function semantic(node) {
+ if (Array.isArray(node)) return node.map(semantic);
+ if (!node || typeof node !== 'object') return node;
+ return Object.fromEntries(Object.entries(node).filter(([key]) => !['position', 'spread'].includes(key))
+ .map(([key, value]) => [key, key === 'value' && node.type === 'text' ? value.replace(/\r?\n/g, ' ') : semantic(value)]));
+}
+const signature = node => JSON.stringify(semantic(node));
+export function equivalent(a, b) { return signature(parseBody(a)) === signature(parseBody(b)); }
+
+export function validateBody(body) {
+ if (typeof body !== 'string' || !body.trim() || new TextEncoder().encode(body).length > MAX_DOCUMENT)
+ throw new Error('Please keep the page between 1 and 100,000 bytes.');
+ if (/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/.test(body)) throw new Error('Page metadata cannot be edited here.');
+ const supported = new Set(['root', 'paragraph', 'text', 'heading', 'strong', 'emphasis', 'delete', 'inlineCode',
+ 'code', 'blockquote', 'list', 'listItem', 'table', 'tableRow', 'tableCell', 'link', 'break', 'thematicBreak']);
+ let count = 0;
+ function visit(node, depth = 0) {
+ if (++count > 12000 || depth > 40) throw new Error('This document is too complex to submit.');
+ if (!supported.has(node.type)) throw new Error('This page contains unsupported formatting. Keep your draft and describe the change instead.');
+ if (node.type === 'heading' && node.depth === 1) throw new Error('Use section headings below the existing page title.');
+ if (node.type === 'link') {
+ const url = node.url.replace(/[\u0000-\u0020\u007f]/g, '');
+ if (/^[a-z][a-z\d+.-]*:/i.test(url) && !/^(https?:|mailto:)/i.test(url)) throw new Error('Use a web, email, or documentation link.');
+ if (url.startsWith('//') || url.includes('\\')) throw new Error('Use a complete HTTPS link or a relative documentation link.');
+ }
+ node.children?.forEach(child => visit(child, depth + 1));
+ }
+ const tree = parseBody(body); visit(tree);
+ if (tree.children.length > 500) throw new Error('Please keep the page below 500 sections and paragraphs.');
+ return tree;
+}
+
+// Match entire semantic blocks in order. Unchanged blocks and their original
+// whitespace are retained verbatim; only changed blocks use editor Markdown.
+// A final semantic check ensures reuse can never silently change the proposal.
+export function preserveSource(original, edited) {
+ const a = parseBody(original).children, b = validateBody(edited).children;
+ if (a.length > 500) throw new Error('This page is too large for visual editing.');
+ const ak = a.map(signature), bk = b.map(signature);
+ if (signature(a) === signature(b)) return original;
+ const dp = Array.from({ length: a.length + 1 }, () => new Uint16Array(b.length + 1));
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--)
+ dp[i][j] = ak[i] === bk[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
+ const matches = new Map();
+ for (let i = 0, j = 0; i < a.length && j < b.length;) {
+ if (ak[i] === bk[j]) { matches.set(j++, i++); }
+ else if (dp[i + 1][j] >= dp[i][j + 1]) i++; else j++;
+ }
+ let output = original.slice(0, a[0]?.position.start.offset ?? 0);
+ for (let j = 0; j < b.length; j++) {
+ const old = matches.get(j), node = old === undefined ? b[j] : a[old];
+ const source = old === undefined ? edited : original;
+ output += source.slice(node.position.start.offset, node.position.end.offset);
+ if (j < b.length - 1) {
+ const next = matches.get(j + 1);
+ output += old !== undefined && next === old + 1
+ ? original.slice(node.position.end.offset, a[next].position.start.offset) : '\n\n';
+ }
+ }
+ output += original.slice(a.at(-1)?.position.end.offset ?? original.length) || '\n';
+ if (!equivalent(output, edited)) throw new Error('The document could not be preserved safely. Your draft has been kept.');
+ return output;
+}
+
+export function preparePage(source, replacement) {
+ const { metadata, body } = splitDocument(source);
+ const content = preserveSource(body, replacement);
+ if (content === body) throw new Error('Make a change before sending your suggestion.');
+ return { content: metadata + content };
+}
+
+export function changedBlocks(original, edited) {
+ const a = parseBody(original).children, b = parseBody(edited).children;
+ const ak = a.map(signature), bk = b.map(signature);
+ const dp = Array.from({length: a.length + 1}, () => new Uint16Array(b.length + 1));
+ for (let i = a.length - 1; i >= 0; i--) for (let j = b.length - 1; j >= 0; j--)
+ dp[i][j] = ak[i] === bk[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
+ const result = [];
+ for (let i = 0, j = 0; i < a.length || j < b.length;) {
+ if (i < a.length && j < b.length && ak[i] === bk[j]) { i++; j++; }
+ else if (i < a.length && (j === b.length || dp[i + 1][j] >= dp[i][j + 1])) result.push({kind: 'removed', node: a[i++]});
+ else result.push({kind: 'added', node: b[j++]});
+ }
+ return result;
+}
diff --git a/src/lib/suggestions/editor.ts b/src/lib/suggestions/editor.ts
new file mode 100644
index 0000000..d81119f
--- /dev/null
+++ b/src/lib/suggestions/editor.ts
@@ -0,0 +1,67 @@
+import { CrepeBuilder } from '@milkdown/crepe/builder';
+import { table } from '@milkdown/crepe/feature/table';
+import { listItem } from '@milkdown/crepe/feature/list-item';
+import { linkTooltip } from '@milkdown/crepe/feature/link-tooltip';
+import { cursor } from '@milkdown/crepe/feature/cursor';
+import { topBar } from '@milkdown/crepe/feature/top-bar';
+import { editorViewCtx } from '@milkdown/kit/core';
+import { undoCommand, redoCommand } from '@milkdown/kit/plugin/history';
+import { callCommand } from '@milkdown/kit/utils';
+import { cleanEditorMarkdown, equivalent, validateBody } from './document.mjs';
+
+export async function createDocumentEditor(root: HTMLElement, markdown: string, onChange = (_text: string) => {}) {
+ validateBody(markdown);
+ const editor = new CrepeBuilder({ root, defaultValue: markdown })
+ .addFeature(table).addFeature(listItem).addFeature(linkTooltip).addFeature(cursor)
+ .addFeature(topBar, {
+ headingOptions: [{ label: 'Paragraph', level: null }, ...[2, 3, 4, 5, 6].map(level => ({label: `Heading ${level - 1}`, level}))],
+ buildTopBar(builder) {
+ builder.getGroup('formatting').group.items = builder.getGroup('formatting').group.items.filter(item => item.key !== 'strikethrough');
+ builder.getGroup('list').group.items = builder.getGroup('list').group.items.filter(item => item.key !== 'task-list');
+ builder.getGroup('block').clear();
+ builder.getGroup('more').clear();
+ builder.addGroup('history', 'History')
+ .addItem('undo', { icon: '↶', active: () => false, onRun: ctx => callCommand(undoCommand.key)(ctx) })
+ .addItem('redo', { icon: '↷', active: () => false, onRun: ctx => callCommand(redoCommand.key)(ctx) });
+ const labels: Record = { bold: 'Bold', italic: 'Italic', code: 'Inline code', 'bullet-list': 'Bullet list', 'ordered-list': 'Numbered list', link: 'Insert link', table: 'Insert table', undo: 'Undo', redo: 'Redo' };
+ for (const group of builder.build()) for (const item of group.items) {
+ if (labels[item.key]) item.icon = `${item.icon}${labels[item.key]}`;
+ }
+ },
+ });
+ await editor.create();
+ const readMarkdown = editor.getMarkdown;
+ editor.getMarkdown = () => cleanEditorMarkdown(readMarkdown());
+ if (!equivalent(markdown, editor.getMarkdown())) {
+ await editor.destroy();
+ throw new Error('This page contains formatting the visual editor cannot preserve. You can still describe a change below.');
+ }
+ editor.editor.action(ctx => {
+ const view = ctx.get(editorViewCtx);
+ view.dom.setAttribute('role', 'textbox');
+ view.dom.setAttribute('aria-label', 'Page content');
+ view.dom.setAttribute('aria-multiline', 'true');
+ view.dom.setAttribute('spellcheck', 'true');
+ // File uploads are outside this text-only contribution flow.
+ view.setProps({ handleDOMEvents: { drop: (_view, event) => {
+ if (event.dataTransfer?.files.length) { event.preventDefault(); return true; } return false;
+ }, paste: (_view, event) => {
+ if (event.clipboardData?.files.length) { event.preventDefault(); return true; } return false;
+ } } });
+ });
+ // Crepe's top bar currently runs commands only on pointerdown. Bridge native
+ // keyboard button activation as well, without moving the editor selection.
+ root.querySelector('.milkdown-top-bar')?.addEventListener('keydown', event => {
+ const key = (event as KeyboardEvent).key;
+ const target = event.target as HTMLElement;
+ if ((key === 'Enter' || key === ' ') && target.tagName === 'BUTTON') {
+ event.preventDefault(); target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, cancelable: true}));
+ }
+ });
+ editor.on(listener => listener.markdownUpdated((_ctx, markdown, previous) => {
+ if (markdown !== previous) onChange(cleanEditorMarkdown(markdown));
+ }));
+ return editor;
+}
+
+export { editorViewCtx };
diff --git a/src/lib/suggestions/page-client.ts b/src/lib/suggestions/page-client.ts
new file mode 100644
index 0000000..a4138a9
--- /dev/null
+++ b/src/lib/suggestions/page-client.ts
@@ -0,0 +1,163 @@
+import { changedBlocks, equivalent, preserveSource, validateBody } from './document.mjs';
+
+const data = JSON.parse(document.querySelector('#page-source')!.textContent!);
+const q = (selector: string) => document.querySelector(selector)!;
+const message = q('[data-message]'), status = q('[data-draft-status]');
+const review = q('[data-review]'), send = q('[data-send]');
+const explanation = q('#explanation'), problem = q('#problem');
+const kind = new URLSearchParams(location.search).get('mode') === 'problem' ? 'problem' : 'page';
+const key = `vizard-page-draft:${data.path}:${kind}`;
+const version = new URLSearchParams(location.search).get('manual')?.slice(0, 80) ?? '';
+let original = data.body, markdown = original, revision = data.revision, id = crypto.randomUUID();
+let editor: Awaited> | undefined;
+let token = '', widget: string | undefined, sending = false, completed = false, timer: ReturnType;
+let storageAvailable = true, restored = false;
+type Turnstile = { render: (root: HTMLElement, options: Record) => string; reset: (id: string) => void };
+declare global { interface Window { turnstile?: Turnstile } }
+
+try {
+ const saved = JSON.parse(localStorage.getItem(key) || 'null');
+ if (saved && typeof saved.markdown === 'string' && typeof saved.original === 'string' &&
+ typeof saved.explanation === 'string' && /^[a-f0-9]{40}$/.test(saved.revision) && /^[a-f0-9-]{36}$/.test(saved.id)) {
+ markdown = saved.markdown; original = saved.original; revision = saved.revision; id = saved.id;
+ explanation.value = saved.explanation; problem.value = saved.problem || ''; restored = true;
+ }
+} catch { storageAvailable = false; }
+
+function dirty() { return kind === 'problem' ? Boolean(problem.value.trim()) : !equivalent(original, markdown); }
+function save() {
+ if (completed) return;
+ try {
+ localStorage.setItem(key, JSON.stringify({ markdown, original, revision, id, explanation: explanation.value, problem: problem.value }));
+ status.textContent = 'Draft saved on this device'; storageAvailable = true;
+ } catch { storageAvailable = false; status.textContent = 'Draft kept in this tab only. Keep this tab open until you send.'; }
+ q('[data-discard]').hidden = false;
+}
+function changed() { id = crypto.randomUUID(); clearTimeout(timer); timer = setTimeout(save, 200); }
+function sync() { if (editor) markdown = editor.getMarkdown(); clearTimeout(timer); save(); }
+window.addEventListener('pagehide', sync);
+window.addEventListener('beforeunload', event => {
+ if (sending || (!storageAvailable && dirty() && !completed)) { event.preventDefault(); event.returnValue = ''; }
+});
+q('[data-discard]').addEventListener('click', () => {
+ if (!confirm('Discard this saved draft and start again from the published page?')) return;
+ try { localStorage.removeItem(key); } catch {} completed = true; location.reload();
+});
+q('[data-cancel]').href = data.returnUrl + (version ? `?manual=${encodeURIComponent(version)}` : '');
+q('[data-problem-link]').href = `?mode=problem${version ? `&manual=${encodeURIComponent(version)}` : ''}`;
+explanation.addEventListener('input', changed); problem.addEventListener('input', changed);
+
+// Render the Markdown syntax tree using DOM APIs, never reader-supplied HTML.
+function markedText(text: string, previous?: string): Node {
+ if (text === previous) return document.createTextNode(text);
+ let left = 0, right = 0;
+ if (previous !== undefined) {
+ while (left < text.length && left < previous.length && text[left] === previous[left]) left++;
+ while (right < text.length - left && right < previous.length - left && text[text.length - right - 1] === previous[previous.length - right - 1]) right++;
+ }
+ const fragment = document.createDocumentFragment(), mark = document.createElement('mark');
+ mark.textContent = text.slice(left, text.length - right);
+ fragment.append(document.createTextNode(text.slice(0, left)), mark, document.createTextNode(text.slice(text.length - right)));
+ return fragment;
+}
+function renderNode(node: any, previous?: any): Node {
+ if (node.type === 'text') return markedText(node.value.replace(/\r?\n/g, ' '), previous?.value?.replace(/\r?\n/g, ' '));
+ const tags: Record = { paragraph: 'p', strong: 'strong', emphasis: 'em', delete: 's', inlineCode: 'code',
+ code: 'pre', blockquote: 'blockquote', listItem: 'li', table: 'table', tableRow: 'tr', tableCell: 'td', link: 'a', break: 'br', thematicBreak: 'hr' };
+ const tag = node.type === 'heading' ? `h${node.depth}` : node.type === 'list' ? (node.ordered ? 'ol' : 'ul') : tags[node.type] || 'div';
+ const el = document.createElement(tag);
+ if (node.type === 'link') { (el as HTMLAnchorElement).href = new URL(node.url, new URL(data.returnUrl, location.origin)).href; el.setAttribute('rel', 'noopener noreferrer'); el.setAttribute('target', '_blank'); }
+ if (node.type === 'list' && node.ordered && node.start) el.setAttribute('start', String(node.start));
+ if (node.value) el.append(markedText(node.value, previous?.value));
+ node.children?.forEach((child: any, index: number) => el.append(renderNode(child, previous?.children?.[index])));
+ if (node.type === 'link' && node.url !== previous?.url) {
+ const target = document.createElement('small'); target.textContent = ` (${node.url})`; el.append(target);
+ }
+ return el;
+}
+let challengeLoading: Promise | undefined;
+async function challenge() {
+ token = '';
+ if (widget && window.turnstile) { window.turnstile.reset(widget); return; }
+ if (!window.turnstile) {
+ challengeLoading ??= new Promise((resolve, reject) => {
+ const script = document.createElement('script'); script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; script.async = true;
+ const timeout = setTimeout(() => { script.remove(); challengeLoading = undefined; reject(new Error('The spam check did not load. Your draft is safe; try again.')); }, 15000);
+ script.onload = () => { clearTimeout(timeout); resolve(); };
+ script.onerror = () => { clearTimeout(timeout); script.remove(); challengeLoading = undefined; reject(new Error('The spam check could not load. Your draft is safe; try again.')); };
+ document.head.append(script);
+ });
+ await challengeLoading;
+ }
+ widget = window.turnstile!.render(q('[data-challenge]'), {sitekey: data.siteKey, action: 'suggestion', theme: 'dark',
+ callback: (value: string) => { token = value; }, 'expired-callback': () => { token = ''; },
+ 'error-callback': () => { token = ''; message.textContent = 'The spam check could not complete. Please try again; your draft is kept.'; } });
+}
+review.addEventListener('click', () => {
+ message.textContent = '';
+ try {
+ sync();
+ if (!dirty()) throw new Error(kind === 'page' ? 'Make a change before reviewing your suggestion.' : 'Describe the problem before reviewing.');
+ const diff = q('[data-diff]'); diff.replaceChildren();
+ if (kind === 'page') {
+ const proposed = preserveSource(original, markdown);
+ const changes = changedBlocks(original, proposed);
+ const removed = changes.filter(change => change.kind === 'removed'), added = changes.filter(change => change.kind === 'added');
+ let removedIndex = 0, addedIndex = 0;
+ for (const change of changes) {
+ const counterpart = change.kind === 'removed' ? added[removedIndex++] : removed[addedIndex++];
+ const section = document.createElement('section'); section.className = `change ${change.kind}`;
+ const label = document.createElement('p'); label.className = 'change-label'; label.textContent = change.kind === 'removed' ? 'Before' : 'After';
+ section.append(label, renderNode(change.node, counterpart?.node.type === change.node.type ? counterpart.node : undefined)); diff.append(section);
+ }
+ } else {
+ diff.textContent = problem.value; q('[data-review-hint]').textContent = 'Your description will be sent for review.';
+ explanation.hidden = true; q('label[for="explanation"]').hidden = true;
+ }
+ q('[data-edit]').hidden = true; q('[data-problem]').hidden = true; q('[data-preview]').hidden = false;
+ review.hidden = true; send.hidden = false; q('[data-back]').hidden = false; q('[data-preview]').focus(); window.scrollTo(0, 0);
+ challenge().catch(error => { message.textContent = error.message; });
+ } catch (error) { message.textContent = (error as Error).message; }
+});
+q('[data-back]').addEventListener('click', () => {
+ if (sending) return;
+ q('[data-preview]').hidden = true; q('[data-edit]').hidden = kind !== 'page'; q('[data-problem]').hidden = kind !== 'problem';
+ review.hidden = false; send.hidden = true; q('[data-back]').hidden = true;
+ (kind === 'page' ? q('[role="textbox"]') : problem).focus(); window.scrollTo(0, 0);
+});
+send.addEventListener('click', async () => {
+ 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; q('[data-back]').disabled = true; explanation.disabled = true;
+ q('[data-cancel]').setAttribute('aria-disabled', 'true'); save(); message.textContent = 'Sending your suggestion…';
+ try {
+ const payload = { id, kind, path: data.path, revision, replacement: kind === 'page' ? preserveSource(original, markdown) : '',
+ explanation: kind === 'problem' ? problem.value : explanation.value, version, token };
+ const response = await fetch(`${data.api}/suggestions`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload), signal: AbortSignal.timeout(20000)});
+ const result = await response.json();
+ if (!response.ok || result.id !== id) throw new Error(result.error || 'Could not confirm receipt. Please retry.');
+ completed = true; clearTimeout(timer); try { localStorage.removeItem(key); } catch {}
+ q('[data-preview]').hidden = true; send.hidden = true; q('[data-back]').hidden = true; q('[data-editor-footer]').hidden = true;
+ q('[data-success]').hidden = false; message.textContent = ''; status.textContent = 'Sent for review';
+ const receipt = q('[data-receipt]'); receipt.href = `${data.receipt}#${id}`; receipt.focus();
+ } catch (error) { message.textContent = `${(error as Error).message} Your draft has been kept.`; await challenge().catch(() => {}); }
+ finally { sending = false; send.disabled = false; explanation.disabled = false; q('[data-back]').disabled = false; q('[data-cancel]').removeAttribute('aria-disabled'); }
+});
+q('[data-cancel]').addEventListener('click', event => { if (sending) event.preventDefault(); else sync(); });
+
+async function start() {
+ if (kind === 'problem') {
+ q('[data-edit]').hidden = true; q('[data-problem]').hidden = false; q('[data-intro]').textContent = 'Tell us what needs to change. No account needed.';
+ q('[data-problem-link]').hidden = true; review.disabled = false;
+ } else {
+ try {
+ validateBody(original);
+ const { createDocumentEditor } = await import('./editor');
+ editor = await createDocumentEditor(q('[data-editor]'), markdown, value => { markdown = value; changed(); });
+ review.disabled = false;
+ } catch (error) { message.textContent = (error as Error).message; status.textContent = 'Editor unavailable'; return; }
+ }
+ status.textContent = restored ? (revision !== data.revision ? 'Saved draft restored from an earlier page version. Newer edits will be preserved during review.' : 'Saved draft restored') : 'Changes are saved on this device';
+ q('[data-discard]').hidden = !restored;
+}
+start();
diff --git a/src/lib/suggestions/source.mjs b/src/lib/suggestions/source.mjs
index 5741d7e..6804f0b 100644
--- a/src/lib/suggestions/source.mjs
+++ b/src/lib/suggestions/source.mjs
@@ -2,6 +2,7 @@ import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
+import { validateBody } from './document.mjs';
const parser = unified().use(remarkParse).use(remarkFrontmatter, ['yaml']).use(remarkGfm);
export const normalize = (text) => text.replace(/\r?\n/g, ' ');
@@ -65,14 +66,15 @@ 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.');
+ !['edit', 'problem', 'page'].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 ||
+ if (original.length > 6000 || (kind !== 'page' && 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.');
+ if (kind === 'page') { if (original || passage) throw new Error('Invalid page suggestion.'); validateBody(replacement); }
return { id, path, revision, kind, passage, original, replacement, explanation, version };
}
diff --git a/src/pages/edit/[...page].astro b/src/pages/edit/[...page].astro
new file mode 100644
index 0000000..a05a386
--- /dev/null
+++ b/src/pages/edit/[...page].astro
@@ -0,0 +1,40 @@
+---
+import { readFileSync, readdirSync } from 'node:fs';
+import { api, siteKey, enabled, revision } from '../../lib/suggestions/config.mjs';
+import { splitDocument } from '../../lib/suggestions/document.mjs';
+import '@fontsource-variable/jetbrains-mono';
+import '../../styles/editor.css';
+export function getStaticPaths() {
+ if (!enabled) return [];
+ return readdirSync('src/content/docs', { recursive: true }).filter(file => String(file).endsWith('.md')).map(file => {
+ const source = readFileSync(`src/content/docs/${file}`, 'utf8');
+ const { body } = splitDocument(source);
+ const titleLine = source.match(/^title:\s*(.+)$/m)?.[1] ?? String(file);
+ const title = titleLine.startsWith('"') ? JSON.parse(titleLine) : titleLine.replace(/^'|'$/g, '');
+ return { params: { page: String(file).replace(/\.md$/, '') }, props: { body, title, path: `src/content/docs/${file}`, file: String(file) } };
+ });
+}
+const { body, title, path, file } = Astro.props;
+const base = import.meta.env.BASE_URL.replace(/\/$/, '');
+const route = file.replace(/(?:\/index)?\.md$/, '').replace(/^index$/, '');
+const returnUrl = `${base}/${route ? route + '/' : ''}`;
+const data = JSON.stringify({ body, title, path, revision, api, siteKey, returnUrl, receipt: `${base}/suggestion/` }).replace(/
+Edit {title} | Vizard Documentation
+
+
+
+ {title}
Edit the page. Your changes will be reviewed before publication.
+ Loading editor…
+
+
+ Review your changes
Only changed sections are shown.
Your suggestion will be public. Please leave out personal information.
+
+
+
+
+
+
+
+
diff --git a/src/styles/editor.css b/src/styles/editor.css
new file mode 100644
index 0000000..ce6cb6e
--- /dev/null
+++ b/src/styles/editor.css
@@ -0,0 +1,23 @@
+@import '@milkdown/crepe/theme/common/style.css';
+@import '@milkdown/crepe/theme/frame-dark.css';
+:root {color-scheme:dark;--bg:#0f0f10;--surface:#17171a;--border:#303034;--text:#c7c7cc;--muted:#909098;--green:#86c96b;font-family:'JetBrains Mono Variable',monospace;background:var(--bg);color:var(--text);font-size:14px;}
+*{box-sizing:border-box;}body{margin:0;}[hidden]{display:none!important;}a{color:var(--green);text-underline-offset:4px;}button,textarea{font:inherit;}button{cursor:pointer;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:#e8e8ea;padding:10px 15px;}button:hover{border-color:#777;}button:disabled{opacity:.45;cursor:wait;}button.primary{background:var(--green);color:#14200f;border-color:var(--green);font-weight:600;}button:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--green);outline-offset:4px;}
+.edit-header{position:sticky;top:0;z-index:30;min-height:72px;padding:14px 32px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--bg);}.brand{text-decoration:none;color:#e8e8ea;letter-spacing:2px;font-size:13px;font-weight:600;}.brand span{letter-spacing:0;color:var(--muted);font-weight:400;margin-left:12px;}.actions{display:flex;gap:20px;align-items:center;}.actions a{color:var(--text);text-decoration:none;}[aria-disabled=true]{opacity:.5;cursor:wait;}
+main{max-width:960px;margin:0 auto;padding:48px 32px 64px;}h1{font-size:30px;color:#e8e8ea;line-height:1.25;margin:0 0 16px;}h2{font-size:22px;}[data-intro]{color:var(--muted);font-size:13px;line-height:1.7;margin:0;}.draft-status{font-size:11px;color:var(--muted);margin:28px 0 12px;min-height:18px;text-align:right;}
+.milkdown{--crepe-color-background:var(--bg);--crepe-color-surface:var(--surface);--crepe-color-surface-low:#17171a;--crepe-color-on-background:var(--text);--crepe-color-on-surface:#e8e8ea;--crepe-color-primary:var(--green);--crepe-color-outline:var(--border);--crepe-color-inline-code:#c7c7cc;--crepe-color-inline-area:#1c1c1f;--crepe-font-default:'JetBrains Mono Variable',monospace;--crepe-font-title:'JetBrains Mono Variable',monospace;--crepe-font-code:'JetBrains Mono Variable',monospace;--crepe-shadow-1:0 4px 20px #0006;--crepe-shadow-2:0 4px 24px #0008;}
+.milkdown .ProseMirror{padding:24px 0 60px;min-height:340px;line-height:1.85;font-size:15px;}.milkdown .ProseMirror p{margin-block:0 1.4em;}.milkdown .ProseMirror h2{font-size:23px;}.milkdown .ProseMirror h3{font-size:19px;}.milkdown .milkdown-top-bar{position:sticky;top:72px;z-index:20;border:1px solid var(--border);border-radius:4px;background:var(--surface);}.milkdown .milkdown-table-block{max-width:100%;}.milkdown .ProseMirror table{font-size:13px;}.milkdown .ProseMirror code{font-size:.9em;}.milkdown .top-bar-item{font-family:inherit;}
+label{display:block;margin:24px 0 10px;font-size:13px;}textarea{display:block;width:100%;padding:14px;resize:vertical;background:#17171a;border:1px solid var(--border);color:var(--text);border-radius:4px;line-height:1.7;}.muted{color:var(--muted);font-size:12px;line-height:1.7;}.message{line-height:1.8;color:#ecc58d;font-size:13px;}.message:empty{display:none;}footer{display:flex;gap:16px;flex-wrap:wrap;align-items:center;border-top:1px solid var(--border);padding-top:24px;margin-top:24px;color:var(--muted);font-size:11px;}footer a{margin-left:auto;color:var(--muted);}footer button{font-size:11px;padding:5px 8px;background:transparent;}
+.change{border-left:2px solid;padding:2px 20px;margin:20px 0;overflow-x:auto;overflow-wrap:anywhere;line-height:1.8;font-size:14px;}.change.removed{border-color:#b87972;background:#ae514b0b;}.change.added{border-color:var(--green);background:#86c96b09;}.change-label{text-transform:uppercase;letter-spacing:1px;font-size:10px;color:var(--muted);}.change p{white-space:pre-wrap;}.change table{border-collapse:collapse;font-size:12px;}.change td{padding:8px;border:1px solid var(--border);min-width:90px;}.change code{background:#242426;padding:1px 4px;border-radius:3px;}.change pre{white-space:pre-wrap;}.change blockquote{border-left:2px solid #555;padding-left:16px;}[data-diff]{white-space:pre-wrap;}[data-diff] .change{white-space:normal;}[data-success]{padding:36px 0;line-height:1.8;}
+@media(max-width:640px){.edit-header{padding:12px 16px;gap:12px;min-height:64px;}.brand{font-size:11px;}.brand span{display:none;}.actions{gap:12px;font-size:11px;}button{padding:10px 12px;}main{padding:28px 18px 40px;}h1{font-size:25px;}.draft-status{text-align:left;margin-top:20px;}.milkdown .ProseMirror{font-size:14px;padding-top:20px;}.milkdown .milkdown-top-bar{top:64px;}.change{padding-inline:12px;}footer a{margin-left:0;}}
+/* Keep controls legible and tables scrollable rather than squeezing cells. */
+.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
+.milkdown{--crepe-color-outline:#92929a;}
+.milkdown .milkdown-top-bar .top-bar-item{margin:4px;width:32px;height:32px;}
+.milkdown .milkdown-top-bar .top-bar-divider{margin:6px;height:20px;background:var(--border);}
+.milkdown .milkdown-top-bar .top-bar-divider:has(+.top-bar-divider){display:none;}
+.milkdown .milkdown-table-block{overflow-x:auto;padding:20px 1px;}
+.milkdown .ProseMirror table{min-width:680px;table-layout:auto;width:100%;}
+.milkdown .ProseMirror td,.milkdown .ProseMirror th{min-width:130px;border-color:var(--border);vertical-align:top;padding:10px 12px;}
+.change table{min-width:680px;}.change td{min-width:130px;vertical-align:top;}
+.change mark{color:inherit;border-radius:2px;}.change.added mark{background:#86c96b36;}.change.removed mark{background:#c06f6536;text-decoration:line-through;}.change small{font-size:.85em;}
+@media(max-width:640px){.milkdown .milkdown-top-bar{padding-inline:5px;}.milkdown .milkdown-top-bar .top-bar-item{margin:3px;width:28px;height:28px;}.milkdown .milkdown-top-bar .top-bar-divider{margin:4px;}}
diff --git a/tests/editor.spec.ts b/tests/editor.spec.ts
new file mode 100644
index 0000000..7d17be5
--- /dev/null
+++ b/tests/editor.spec.ts
@@ -0,0 +1,115 @@
+import { test, expect } from '@playwright/test';
+import { readFileSync, readdirSync } from 'node:fs';
+import { splitDocument, equivalent, preserveSource } from '../src/lib/suggestions/document.mjs';
+
+test('every documentation page survives Milkdown import/export and an edit without losing content', async ({ page }) => {
+ await page.goto('edit/concepts/signal-chain/');
+ await expect(page.getByRole('textbox', {name: 'Page content'})).toBeVisible();
+ const files = readdirSync('src/content/docs', {recursive: true}).filter(file => String(file).endsWith('.md'));
+ const failures: string[] = [];
+ for (const file of files) {
+ const body = splitDocument(readFileSync(`src/content/docs/${file}`, 'utf8')).body;
+ const result = await page.evaluate(async (body) => {
+ const { createDocumentEditor, editorViewCtx } = await import('/src/lib/suggestions/editor.ts');
+ const root = document.createElement('div'); document.body.append(root);
+ let editor;
+ try {
+ editor = await createDocumentEditor(root, body);
+ const before = editor.getMarkdown();
+ editor.editor.action(ctx => { const view = ctx.get(editorViewCtx); view.dispatch(view.state.tr.insertText('Corpus check: ', 1)); });
+ return { before, after: editor.getMarkdown() };
+ } catch(error) { return { error: String(error) }; }
+ finally { if(editor) await editor.destroy(); root.remove(); }
+ }, body);
+ if (result.error) { failures.push(`${file}: ${result.error}`); continue; }
+ if (!equivalent(body, result.before)) { failures.push(`${file}: semantic mismatch`); continue; }
+ try {
+ expect(preserveSource(body, result.before), String(file)).toBe(body);
+ const patched = preserveSource(body, result.after);
+ expect(equivalent(patched, result.after), String(file)).toBe(true);
+ expect(patched).toContain('Corpus check: ');
+ } catch(error) { failures.push(`${file}: ${error}`); }
+ }
+ expect(failures).toEqual([]);
+ console.log(`Milkdown corpus: ${files.length} pages, exact no-op preservation and edited semantic preservation.`);
+});
+
+async function mockChallenge(page: any) {
+ await page.route('https://challenges.cloudflare.com/**', route => route.fulfill({contentType:'application/javascript', body:
+ 'window.turnstile={render:(el,o)=>{window.testChallenge=o;setTimeout(()=>o.callback("test-token"),0);el.textContent="Spam check passed (test)";return "test"},reset:()=>window.testChallenge.callback("test-token")};'}));
+}
+
+test('reader edits multiple sections, restores draft, reviews, retries once and receives one receipt', async ({page}) => {
+ const errors: string[] = []; page.on('pageerror', error => errors.push(error.message));
+ await page.setViewportSize({width:1440,height:1000}); await mockChallenge(page);
+ const payloads: any[] = [];
+ await page.route('http://127.0.0.1:8787/suggestions', route => {
+ payloads.push(route.request().postDataJSON());
+ return route.fulfill({status: payloads.length === 1 ? 503 : 202, contentType:'application/json',
+ headers:{'Access-Control-Allow-Origin':'*'}, body: JSON.stringify(payloads.length === 1 ? {error:'Temporary service outage.'} : {id:payloads[0].id})});
+ });
+ await page.goto('concepts/signal-chain/?manual=v0.14.0');
+ await expect(page).toHaveTitle('Signal Chain | Vizard Documentation');
+ await expect(page.locator('.suggestion-pick')).toHaveCount(0);
+ await page.getByRole('link',{name:'Edit this page'}).first().click();
+ const content = page.getByRole('textbox',{name:'Page content'});
+ await expect(content).toBeVisible();
+ await page.screenshot({path:'/tmp/vizard-full-page-editor-desktop.png'});
+ await page.getByRole('button',{name:'Review changes',exact:true}).click();
+ await expect(page.locator('[data-message]')).toContainText('Make a change');
+ await content.press('ControlOrMeta+Home'); await page.keyboard.insertText('Updated: ');
+ await content.press('ControlOrMeta+End'); await page.keyboard.press('Enter'); await page.keyboard.insertText('A useful additional explanation.');
+ await expect(page.locator('[data-draft-status]')).toContainText('Draft saved');
+ await page.reload(); await expect(content).toContainText('Updated:'); await expect(content).toContainText('A useful additional explanation.');
+ await page.getByRole('button',{name:'Review changes',exact:true}).click();
+ await expect(page.locator('[data-diff]')).toContainText('Updated:');
+ await expect(page.locator('[data-diff]')).not.toContainText('Every module carries');
+ await page.locator('#explanation').fill('Clarify two parts of the page.');
+ await page.getByRole('button',{name:'Send suggestion',exact:true}).click();
+ await expect(page.locator('[data-message]')).toContainText('Your draft has been kept');
+ await page.getByRole('button',{name:'Send suggestion',exact:true}).click();
+ await expect(page.getByRole('link',{name:'Check suggestion status'})).toBeVisible();
+ expect(payloads).toHaveLength(2); expect(payloads[0].id).toBe(payloads[1].id);
+ expect(payloads[0].kind).toBe('page'); expect(payloads[0].version).toBe('v0.14.0'); expect(payloads[0].replacement).toContain('`Chain`');
+ expect(payloads[0].replacement).toContain('A useful additional explanation.');
+ expect(await page.evaluate(() => Object.keys(localStorage).filter(k=>k.startsWith('vizard-page-draft:')))).toHaveLength(0);
+ expect(errors).toEqual([]);
+});
+
+test('mobile tables stay usable and cell edits preserve the rest of the document', async ({page}) => {
+ await page.setViewportSize({width:390,height:844}); await mockChallenge(page);
+ await page.goto('edit/reference/insert-fx-region/');
+ const content = page.getByRole('textbox',{name:'Page content'}); await expect(content).toBeVisible();
+ const cell = content.locator('td').first();
+ await expect(cell).toContainText('Enable Region');
+ await cell.click(); await page.keyboard.press('Home'); await page.keyboard.insertText('Updated ');
+ await expect(cell).toContainText('Updated');
+ await page.evaluate(()=>window.scrollTo(0,0));
+ await page.screenshot({path:'/tmp/vizard-full-page-editor-mobile.png'});
+ expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ await page.getByRole('button',{name:'Review changes',exact:true}).click();
+ await expect(page.locator('[data-diff]')).toContainText('Updated');
+ await expect(page.locator('[data-diff]')).not.toContainText('An optional soft-edged');
+ await page.screenshot({path:'/tmp/vizard-full-page-editor-review-mobile.png'});
+});
+
+test('problem reports work independently of the visual editor', async ({page}) => {
+ await mockChallenge(page); let payload: any;
+ await page.route('http://127.0.0.1:8787/suggestions', route => {payload=route.request().postDataJSON();return route.fulfill({status:202,contentType:'application/json',headers:{'Access-Control-Allow-Origin':'*'},body:JSON.stringify({id:payload.id})});});
+ await page.goto('edit/concepts/signal-chain/?mode=problem');
+ await page.locator('#problem').fill('Please explain the difference between BYP and MUTE.');
+ await page.getByRole('button',{name:'Review changes',exact:true}).click();
+ await page.getByRole('button',{name:'Send suggestion',exact:true}).click();
+ await expect(page.getByRole('link',{name:'Check suggestion status'})).toBeVisible();
+ expect(payload.kind).toBe('problem'); expect(payload.replacement).toBe('');
+});
+
+ test('toolbar controls have names and respond to the keyboard', async ({page}) => {
+ await page.goto('edit/concepts/signal-chain/');
+ await expect(page.getByRole('textbox',{name:'Page content'})).toBeVisible();
+ const bold=page.getByRole('button',{name:'Bold',exact:true});
+ await bold.focus(); await bold.press('Enter');
+ await expect(bold).toHaveClass(/active/);
+ await page.getByRole('button',{name:'Undo',exact:true}).focus();
+ await expect(page.getByRole('button',{name:'Insert table',exact:true})).toBeVisible();
+ });