fix(wiki): keep valid Mermaid labels and link text through the sanitizer (#3707)

* fix(wiki): keep valid Mermaid labels and link text through the sanitizer

The wiki's Mermaid sanitizer turned some valid diagrams into invalid
ones (#3705). `sanitizeMermaidLine` split each line on `EDGE_RE`, whose
`\.\.+` alternative matched any run of dots, so the `...` inside a
quoted label such as `A["value.when(...)"]` cut the label in two and the
remainder was aliased as a node. The text of a `-- text -->` link was
split off the same way and, when it held a dot, rewritten as a node
reference: `N -- ref.invalidate --> A` became
`N -- ref_invalidate["ref.invalidate"] --> A`. Both outputs fail
`mermaid.parse`. Since the sanitizer runs again every time the viewer is
rebuilt, a diagram fixed by hand broke again.

Two changes:

- The inside of every quoted string and every node shape (`[...]`,
  `(...)`, `{...}`) is masked with a placeholder before the line is
  split and unmasked after, so label text can never read as a link or a
  node. The delimiters stay, so the node sanitizer still sees where an
  inline label starts.
- `EDGE_RE` matches the link forms Mermaid accepts: plain, thick and
  dotted (`-.->`, `-..-`), the piped label, and the labelled forms
  `-- text -->`, `== text ==>` and `-. text .->` as one token. Dots mean
  a link only in the dotted syntax.

The three diagrams from the report now come out unchanged, dotted links
still work, and the nodes around a labelled link are still aliased.
Three unit tests cover that; the first and third fail on the previous
sanitizer. The six existing tests pass unchanged.

* fix(wiki): keep shaped nodes and chained links intact in the Mermaid sanitizer

Two regressions from the first version of this branch, both found in
review:

- Rounded and circular nodes became invalid: with the label masked, the
  node id regex still swallowed the opening parenthesis of `A(Start)`
  and the restored label landed outside the generated shape. The id now
  stops where a shape opens (`[`, `(`, `{`). A call-shaped id such as
  `doc()` — a masked, empty pair of parentheses right after the id — is
  still one id and is repaired to `doc["doc()"]` as before.

- A chained link split one node in two: the labelled-link alternative
  of EDGE_RE took `--- file.ts ---` for one link with text, so the middle
  node skipped aliasing while the next line aliased the same id. A
  labelled link now opens only with a bare `--`, `==` or `-.` followed
  by a space; a complete link never does. Thick links too.

Parser-backed tests in test/unit/wiki-mermaid-sanitizer-parse.test.ts
run the output through mermaid 11.17.2, the release the viewer loads,
under jsdom, and compare the parsed graph: shaped nodes at both ends
yield the same vertices and edges as the input; the plain and thick
chains yield exactly A, file_ts, B, C with the three links; the report
diagrams, the labelled link and the call-shaped id are checked the same
way. mermaid and jsdom join the package's devDependencies for that
file only.

* fix(wiki): read compact head links as links and keep a call-shaped id's member chain

Two cases from review:

- `A --xfile.ts --> B`: Mermaid's lexer takes the longer match, so `--`
  followed by `-`, `>`, `x` or `o` is a link with a tail whatever comes
  next. The labelled-link opener excluded `x`/`o` only before a space,
  so `--xfile.ts -->` was taken for one labelled link and `file.ts`
  skipped aliasing on that line while the next line aliased it. The
  opener now excludes those four characters outright.

- `foo().bar --> B`: the empty-parentheses rule absorbed `()` into the
  id but left `.bar` in the suffix, which was emitted after the
  generated label. The member chain that follows is part of the id, so
  `foo().bar` is one node again, `foo___bar["foo().bar"]`, as the
  previous sanitizer produced.

Parser-backed tests for the compact cross and circle forms (three edges
on one middle node) and for a qualified call-shaped id at both ends of
a link. Both fail on the previous commit.

* fix(wiki): preserve Mermaid graph identity with tiny parser coverage

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
This commit is contained in:
samuel mbabhazi 2026-10-25 09:21:55 +02:00 • committed by GitHub
parent 68c0cdf198
commit be32540b6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1142 additions and 72 deletions

View file

@ -72,6 +72,7 @@
"@babel/parser": "^8.0.0",
"@babel/traverse": "^8.0.0",
"@babel/types": "^8.0.0",
"@mermaid-js/tiny": "11.17.2",
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
@ -80,6 +81,7 @@
"@types/proxy-addr": "^2.0.3",
"@vitest/coverage-v8": "^5.0.0",
"gitnexus-shared": "file:../gitnexus-shared",
"jsdom": "26.1.0",
"tsx": "^4.0.0",
"typescript": "^7.0.2",
"vitest": "^5.0.0"
@ -95,6 +97,27 @@
"typescript": "^7.0.2"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz",
@ -231,6 +254,121 @@
"node": ">=18"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
@ -826,6 +964,13 @@
"integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
"license": "MIT"
},
"node_modules/@mermaid-js/tiny": {
"version": "11.17.2",
"resolved": "https://registry.npmjs.org/@mermaid-js/tiny/-/tiny-11.17.2.tgz",
"integrity": "sha512-PmFr1c5cSfNf5AngLLR98DrbvR4U5KeD7I+hQkGqG5w5Veu3QT4dG0qu9f99cpn/sKj2XxRBK0q7EL6hr9NY/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.32.1",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz",
@ -1011,9 +1156,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1032,9 +1174,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1053,9 +1192,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1074,9 +1210,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1095,9 +1228,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1116,9 +1246,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1832,6 +1959,16 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@ -2376,6 +2513,34 @@
"node": ">= 8"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@ -2402,6 +2567,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@ -2475,6 +2647,19 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3086,6 +3271,19 @@
"node": ">=16.9.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -3106,6 +3304,34 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@ -3170,6 +3396,13 @@
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@ -3244,6 +3477,46 @@
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@ -3444,9 +3717,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3469,9 +3739,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3494,9 +3761,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3519,9 +3783,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3587,9 +3848,9 @@
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@ -3839,6 +4100,13 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nwsapi": {
"version": "2.2.28",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz",
"integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -3934,6 +4202,19 @@
"obliterator": "^2.0.1"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -4174,6 +4455,16 @@
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
@ -4336,6 +4627,13 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
@ -4351,6 +4649,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@ -4649,6 +4960,13 @@
"node": ">=8"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/table-layout": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/table-layout/-/table-layout-4.1.1.tgz",
@ -4737,6 +5055,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -4746,6 +5084,32 @@
"node": ">=0.6"
}
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tree-sitter": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.25.1.tgz",
@ -5315,6 +5679,80 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/which": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
@ -5375,6 +5813,38 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
@ -5390,6 +5860,13 @@
"node": ">=16.0.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View file

@ -124,6 +124,7 @@
"@babel/parser": "^8.0.0",
"@babel/traverse": "^8.0.0",
"@babel/types": "^8.0.0",
"@mermaid-js/tiny": "11.17.2",
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
@ -132,6 +133,7 @@
"@types/proxy-addr": "^2.0.3",
"@vitest/coverage-v8": "^5.0.0",
"gitnexus-shared": "file:../gitnexus-shared",
"jsdom": "26.1.0",
"tsx": "^4.0.0",
"typescript": "^7.0.2",
"vitest": "^5.0.0"

View file

@ -6,9 +6,37 @@ const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/;
const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/;
const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/;
const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/;
const EDGE_RE =
/(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g;
const LINE_PREFIX_RE = /^(\s*)(.*)$/;
// One link between two node references, in the forms Mermaid accepts: a
// plain, thick or dotted link (`-->`, `===`, `-.->`), a piped label after it
// (`-->|text|`), or a label inside it (`-- text -->`, `== text ==>`,
// `-. text .->`). The labelled forms are matched whole, so their text is
// never taken for a node reference (#3705). Dots mean a link only in the
// dotted syntax: a plain run of dots is text.
//
// A labelled link opens with a bare `--`, `==` or `-.` followed by its text,
// with or without a space (`-- text -->`, `--text-->`). A complete link never
// opens one. Mermaid's lexer takes the longer match, so `--` followed by
// `-`, `>`, `x` or `o` is a link with a tail (`---`, `-->`, `--x`, `--o`),
// whatever comes after it: `A --xfile.ts --> B` is a chain of three nodes,
// and `file.ts` reaches the node sanitizer like any other reference.
const LINK_HEAD = '(?:--+|==+|-\\.+-?)';
const LABEL_OPENER = '(?:--|==|-\\.)(?![-=.>|xo])';
const EDGE_RE = new RegExp(
'(' +
[
// `-- text -->`, `--text-->`, `== text ==>`, `-. text .->`
`\\s*(?:[ox<])?${LABEL_OPENER}[^\\n]*?\\s*(?:--+|==+|\\.+-+)(?:[>ox])?`,
// `-->|text|`
`\\s*(?:[ox<])?${LINK_HEAD}(?:[>|ox])?\\|[^|\\n]*\\|(?:[>|ox])?`,
// `-->`, `---`, `-.->`, `==>`
`\\s*(?:[ox<])?${LINK_HEAD}(?:[>|ox])?`,
'\\s*<--+>?\\s*',
].join('|') +
')',
'g',
);
const EDGE_AT_RE = new RegExp(EDGE_RE.source, 'y');
export function sanitizeMermaidMarkdown(markdown: string): string {
return markdown.replace(MERMAID_FENCE_RE, (_match, diagram: string) => {
@ -17,50 +45,191 @@ export function sanitizeMermaidMarkdown(markdown: string): string {
}
export function sanitizeMermaidDiagram(diagram: string): string {
const frontmatter = diagram.match(/^\s*---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/)?.[0] ?? '';
const body = diagram.slice(frontmatter.length);
const prelude = body.match(/^(?:\s|%%\{[\s\S]*?\}%%|%%[^\n]*(?:\n|$))*/)?.[0] ?? '';
const content = body.slice(prelude.length);
if (!/^(?:graph|flowchart)\s/.test(content)) return diagram;
// Mask before splitting lines so multiline labels remain a single token.
const { masked, labels } = maskLabels(content);
const lines = masked.split('\n');
const usedIds = new Set<string>();
const labeledIds = new Set<string>();
for (const line of lines) {
sanitizeMermaidLine(
line,
(id, hasLabel) => {
usedIds.add(id);
if (hasLabel) labeledIds.add(id);
return id;
},
labeledIds,
labels,
);
}
const aliases = new Map<string, string>();
let nextAlias = 1;
const aliasFor = (id: string): string => {
if (!UNSAFE_NODE_ID_RE.test(id)) return id;
const existing = aliases.get(id);
if (existing) return existing;
const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node';
let base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node';
if (
/^(?:style|classDef|class|click|linkStyle|direction|accTitle|accDescr|end)(?:-|$)/.test(base)
) {
base = `node_${base}`;
}
let alias = base;
while ([...aliases.values()].includes(alias)) {
while (usedIds.has(alias)) {
nextAlias += 1;
alias = `${base}_${nextAlias}`;
}
aliases.set(id, alias);
usedIds.add(alias);
return alias;
};
return diagram
.split('\n')
.map((line) => sanitizeMermaidLine(line, aliasFor))
.join('\n');
return (
frontmatter +
prelude +
lines
.map((line) => unmaskLabels(sanitizeMermaidLine(line, aliasFor, labeledIds, labels), labels))
.join('\n')
);
}
function sanitizeMermaidLine(line: string, aliasFor: (id: string) => string): string {
let sanitized = replaceLiteralLineBreaksInLabels(line);
sanitized = quoteUnsafeEdgeLabels(sanitized);
const prefixMatch = sanitized.match(LINE_PREFIX_RE);
if (!prefixMatch) return sanitized;
function sanitizeMermaidLine(
line: string,
aliasFor: (id: string, hasLabel?: boolean) => string,
labeledIds: ReadonlySet<string>,
labels: string[],
): string {
const prefixMatch = line.match(LINE_PREFIX_RE);
if (!prefixMatch) return line;
const prefix = prefixMatch[1];
const body = prefixMatch[2];
if (isDirectiveLine(body)) return sanitized;
const header = body.match(/^((?:graph|flowchart)\s+\w+\s*;)(.*)$/);
if (header) {
return prefix + header[1] + sanitizeMermaidLine(header[2], aliasFor, labeledIds, labels);
}
const reference = body.match(/^((?:style|class|click)\s+)([^\s]+)(.*)$/);
if (reference) {
const ids = reference[2].split(',').map((id) => aliasFor(id));
return prefix + reference[1] + ids.join(',') + reference[3];
}
const subgraph = body.match(/^(subgraph\s+)([A-Za-z0-9_.:/-]+)(\s*(?:\[.*\])?\s*)$/);
if (subgraph) {
const [, keyword, id, suffix] = subgraph;
const alias = aliasFor(id, true);
const label = alias !== id && !suffix.trim() ? `["${escapeMermaidLabel(id)}"]` : '';
return prefix + keyword + alias + label + suffix;
}
if (isDirectiveLine(body)) return line;
// Text inside quotes and inside node shapes is a label, not syntax: it is
// hidden from the split so a `...` or a `--` in it cannot read as a link,
// and from the node sanitizer so it cannot read as a node (#3705).
const parts = body.split(EDGE_RE);
if (parts.length === 1) return sanitized;
for (let i = 0; i < parts.length; i += 2) {
parts[i] = sanitizeNodeReference(parts[i], aliasFor);
for (let i = 0; i < parts.length; i += 1) {
if (i % 2 === 1) {
parts[i] = quoteUnsafeEdgeLabels(unmaskLabels(parts[i], labels));
} else {
const nodes = parts[i].split(/([&;])/);
for (let j = 0; j < nodes.length; j += 2) {
nodes[j] = sanitizeNodeReference(nodes[j], aliasFor, labels, labeledIds);
}
parts[i] = replaceLiteralLineBreaksInLabels(unmaskLabels(nodes.join(''), labels));
}
}
return prefix + parts.join('');
}
const MASK = '\u0000';
const MASK_RE = /\u0000(\d+)\u0000/g;
const OPENERS: Record<string, string> = { '[': ']', '(': ')', '{': '}' };
/**
* Replace the inside of every quoted string and every bracketed label with a
* placeholder, keeping the delimiters, so the shape of the line survives and
* the node sanitizer still sees where an inline label starts.
*/
function maskLabels(body: string): { masked: string; labels: string[] } {
const labels: string[] = [];
// Protect quoted link text before recognising links; a quoted arrow is text.
body = body.replace(/%%[^\n]*|"[^"]*"/g, (quoted) => {
if (quoted.startsWith('%%')) return quoted;
const index = labels.push(quoted.slice(1, -1)) - 1;
return '"' + MASK + index + MASK + '"';
});
let masked = '';
let i = 0;
while (i < body.length) {
if (body.startsWith('%%', i)) {
const end = body.indexOf('\n', i);
const next = end === -1 ? body.length : end;
masked += body.slice(i, next);
i = next;
continue;
}
// Consume complete links before looking for shapes. A `>` in List<T>
// belongs to the link label, not to an asymmetric node shape.
EDGE_AT_RE.lastIndex = i;
const edge = EDGE_AT_RE.exec(body);
if (edge) {
masked += edge[0];
i += edge[0].length;
continue;
}
const char = body[i];
const closer =
OPENERS[char] ?? (char === '>' && /[A-Za-z0-9_]/.test(body[i - 1] ?? '') ? ']' : undefined);
if (closer) {
const end = findLabelEnd(body, i, closer, char);
if (end === -1) {
masked += body.slice(i);
break;
}
masked += char + MASK + labels.length + MASK + body[end];
labels.push(unmaskLabels(body.slice(i + 1, end), labels));
i = end + 1;
continue;
}
masked += char;
i += 1;
}
return { masked, labels };
}
/** Index of the delimiter that closes the one at `start`, or -1. */
function findLabelEnd(body: string, start: number, closer: string, opener: string): number {
let depth = 0;
for (let i = start + 1; i < body.length; i += 1) {
const char = body[i];
if (char === '"') {
const quoteEnd = body.indexOf('"', i + 1);
if (quoteEnd === -1) return -1;
i = quoteEnd;
continue;
}
if (char === opener && opener !== '>') depth += 1;
else if (char === closer) {
if (depth === 0) return i;
depth -= 1;
}
}
return -1;
}
function unmaskLabels(masked: string, labels: string[]): string {
return masked.replace(MASK_RE, (_match, index: string) => labels[Number(index)] ?? '');
}
function replaceLiteralLineBreaksInLabels(line: string): string {
return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '<br/>'));
}
@ -79,22 +248,57 @@ function quoteUnsafeEdgeLabels(line: string): string {
});
}
function sanitizeNodeReference(segment: string, aliasFor: (id: string) => string): string {
const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/);
// A masked, empty pair of parentheses right after an id: `doc()`, a
// call-shaped id the sanitizer has always repaired, not a round node. The
// member chain that may follow (`foo().bar`) belongs to the same id.
const EMPTY_PARENS_RE = /^\(\u0000(\d+)\u0000\)/;
const MEMBER_CHAIN_RE = /^(?:(?!:::)[A-Za-z0-9_.:/-])+/;
function sanitizeNodeReference(
segment: string,
aliasFor: (id: string, hasLabel?: boolean) => string,
labels: string[],
labeledIds: ReadonlySet<string>,
): string {
// The id stops where a node shape opens (`[`, `(`, `{`): `A(Start)` is the
// node `A` with a round label, and the label is already masked.
const match = segment.match(/^(\s*)((?:(?!:::)[A-Za-z0-9_.:/-])+)(.*?)(\s*)$/);
if (!match) return segment;
const [, leading, id, suffix, trailing] = match;
if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id)) return segment;
const hasInlineLabel =
suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{');
const leading = match[1];
const trailing = match[4];
let id = match[2];
let suffix = match[3];
for (;;) {
const emptyParens = suffix.match(EMPTY_PARENS_RE);
if (!emptyParens || labels[Number(emptyParens[1])] !== '') break;
id += '()';
suffix = suffix.slice(emptyParens[0].length);
const members = suffix.match(MEMBER_CHAIN_RE);
if (members) {
id += members[0];
suffix = suffix.slice(members[0].length);
}
}
if (!NODE_ID_RE.test(id)) return segment;
const hasInlineLabel = /^[\[({>]/.test(suffix.trim()) || suffix.trim().startsWith('@{');
const alias = aliasFor(id, hasInlineLabel);
if (!UNSAFE_NODE_ID_RE.test(id)) return segment;
if (hasInlineLabel) return `${leading}${aliasFor(id)}${suffix}${trailing}`;
const metadata = suffix.match(/^\s*@\{\u0000(\d+)\u0000\}/);
if (metadata) {
const content = labels[Number(metadata[1])];
if (!/(?:^|,)\s*label\s*:/.test(content.replace(/"[^"]*"|'[^']*'/g, ''))) {
suffix = suffix.replace(metadata[0], `@{ label: "${escapeMermaidLabel(id)}", ${content} }`);
}
}
if (hasInlineLabel || labeledIds.has(id)) return `${leading}${alias}${suffix}${trailing}`;
return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`;
return `${leading}${alias}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`;
}
function escapeMermaidLabel(label: string): string {
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return label.replace(/"/g, '#quot;');
}
function isDirectiveLine(line: string): boolean {
@ -104,16 +308,10 @@ function isDirectiveLine(line: string): boolean {
trimmed.startsWith('%%') ||
trimmed.startsWith('graph ') ||
trimmed.startsWith('flowchart ') ||
trimmed.startsWith('sequenceDiagram') ||
trimmed.startsWith('classDiagram') ||
trimmed.startsWith('stateDiagram') ||
trimmed.startsWith('erDiagram') ||
trimmed.startsWith('journey') ||
trimmed.startsWith('gantt') ||
trimmed.startsWith('pie ') ||
trimmed.startsWith('mindmap') ||
trimmed.startsWith('timeline') ||
trimmed.startsWith('subgraph ') ||
/^(?:style|classDef|class|click|linkStyle|direction|accTitle|accDescr)(?:\s|:|$)/.test(
trimmed,
) ||
trimmed === 'end'
);
}

View file

@ -0,0 +1,348 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInThisContext } from 'node:vm';
import { describe, expect, it } from 'vitest';
import { sanitizeMermaidDiagram } from '../../src/core/wiki/mermaid-sanitizer.js';
// The official tiny browser bundle includes the same flowchart parser as the
// viewer's Mermaid 11, without installing the full renderer dependency tree.
// It is a script, not an importable module; execute it as a browser script in
// this test fork's jsdom environment. Nothing here ships with the CLI.
const require = createRequire(import.meta.url);
runInThisContext(readFileSync(require.resolve('@mermaid-js/tiny'), 'utf8'));
const mermaid = (
globalThis as unknown as {
mermaid: {
initialize(config: { startOnLoad: boolean }): void;
mermaidAPI: { getDiagramFromText(text: string): Promise<{ db: unknown }> };
};
}
).mermaid;
type Graph = {
nodes: string[];
edges: string[];
labels: Array<{ text: string; type: string }>;
links: Array<{ text: string; type: string; stroke: string }>;
};
async function parseFlowchart(diagram: string): Promise<Graph> {
const parsed = await mermaid.mermaidAPI.getDiagramFromText(diagram);
const db = parsed.db as {
getVertices(): Map<string, { id: string; text: string; type: string }>;
getEdges(): Array<{ start: string; end: string; text: string; type: string; stroke: string }>;
};
return {
nodes: [...db.getVertices().keys()],
edges: db.getEdges().map((edge) => `${edge.start}->${edge.end}`),
labels: [...db.getVertices().values()].map(({ text, type }) => ({ text, type })),
links: db.getEdges().map(({ text, type, stroke }) => ({ text, type, stroke })),
};
}
describe('sanitizeMermaidDiagram, checked with the Mermaid parser', () => {
mermaid.initialize({ startOnLoad: false });
it('keeps the three diagrams from the report unchanged and valid', async () => {
const diagrams = [
'flowchart TD\n A["value.when(...)"] -->|data| B["data"]',
'flowchart TD\n G["x = compute(...).result"] --> H["done"]',
'flowchart LR\n N -- ref.invalidate --> A',
];
for (const diagram of diagrams) {
expect(sanitizeMermaidDiagram(diagram)).toBe(diagram);
await expect(parseFlowchart(diagram)).resolves.toBeTruthy();
}
});
it('leaves rounded, circular and other shaped nodes alone at both ends of a link', async () => {
const diagrams = [
'flowchart LR\n A(Start) --> B',
'flowchart LR\n X --> Y(Done)',
'flowchart LR\n A((Start)) --> B(End)',
'flowchart LR\n A[(db)] --> B{Choice} --> C>flag]',
'flowchart LR\n A([Stadium]) -->|yes| B[[Sub]]',
];
for (const diagram of diagrams) {
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toBe(diagram);
expect(await parseFlowchart(sanitized)).toEqual(await parseFlowchart(diagram));
}
});
it('aliases every reference of a node in a chained link, plain and thick', async () => {
const plain = ['flowchart LR', ' A --- file.ts --- B', ' file.ts --> C'].join('\n');
const thick = ['flowchart LR', ' A === file.ts === B', ' file.ts ==> C'].join('\n');
for (const diagram of [plain, thick]) {
const original = await parseFlowchart(diagram);
const graph = await parseFlowchart(sanitizeMermaidDiagram(diagram));
// One node, under one id, with the same links as before.
expect(graph.nodes).toEqual(['A', 'file_ts', 'B', 'C']);
expect(graph.edges).toEqual(['A->file_ts', 'file_ts->B', 'file_ts->C']);
expect(graph.edges).toHaveLength(original.edges.length);
}
});
it('keeps labelled links written without spaces, and links with a cross or circle head', async () => {
const spaced = 'flowchart LR\n A --text--> B\n B ==text==> C\n C -.text.-> D';
expect(sanitizeMermaidDiagram(spaced)).toBe(spaced);
expect((await parseFlowchart(spaced)).edges).toEqual(['A->B', 'B->C', 'C->D']);
const heads = 'flowchart LR\n A --x B --> C\n C --o D\n D x--x E';
expect(sanitizeMermaidDiagram(heads)).toBe(heads);
expect((await parseFlowchart(heads)).edges).toEqual(['A->B', 'B->C', 'C->D', 'D->E']);
});
it('reads a compact cross or circle link as a link, so the node after it is aliased too', async () => {
const cross = ['flowchart LR', ' A --xfile.ts --> B', ' file.ts --> C'].join('\n');
const circle = ['flowchart LR', ' A --ofile.ts --> B', ' file.ts --> C'].join('\n');
for (const diagram of [cross, circle]) {
const graph = await parseFlowchart(sanitizeMermaidDiagram(diagram));
expect(graph.nodes).toEqual(['A', 'file_ts', 'B', 'C']);
expect(graph.edges).toEqual(['A->file_ts', 'file_ts->B', 'file_ts->C']);
}
});
it('still repairs a call-shaped node id', async () => {
const sanitized = sanitizeMermaidDiagram('flowchart LR\n doc() --> B');
expect(sanitized).toBe('flowchart LR\n doc["doc()"] --> B');
expect((await parseFlowchart(sanitized)).edges).toEqual(['doc->B']);
});
it('repairs a qualified call-shaped id as one node, at either end of a link', async () => {
const diagram = ['flowchart LR', ' foo().bar --> B', ' A --> foo().bar'].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
const graph = await parseFlowchart(sanitized);
expect(sanitized).toContain('foo___bar["foo().bar"] --> B');
expect(sanitized).toContain('A --> foo___bar["foo().bar"]');
expect(graph.nodes).toEqual(['foo___bar', 'B', 'A']);
expect(graph.edges).toEqual(['foo___bar->B', 'A->foo___bar']);
});
it('keeps the text of a labelled link and the graph around it', async () => {
const diagram = ['flowchart LR', ' cache.ts -- ref.invalidate --> store.ts'].join('\n');
const graph = await parseFlowchart(sanitizeMermaidDiagram(diagram));
expect(graph.nodes).toEqual(['cache_ts', 'store_ts']);
expect(graph.edges).toEqual(['cache_ts->store_ts']);
});
it('does not read pipes inside a node label as an edge label', async () => {
const diagram = 'flowchart LR\nA["a|doc()|b"] --> B';
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toBe(diagram);
expect(await parseFlowchart(sanitized)).toEqual(await parseFlowchart(diagram));
});
it('repairs an edge label even when the preceding node label contains a pipe', async () => {
const sanitized = sanitizeMermaidDiagram('flowchart LR\nA["a | b"] -->|doc()| B');
expect(sanitized).toBe('flowchart LR\nA["a | b"] -->|"doc()"| B');
expect((await parseFlowchart(sanitized)).edges).toEqual(['A->B']);
});
it.each([
'A & file.ts --> B\nfile.ts --> C',
'file.ts & A --> B\nfile.ts --> C',
'A --> B & file.ts\nfile.ts --> C',
'A --> file.ts; file.ts --> C',
])('preserves shared node identities in fan-out and statements: %s', async (body) => {
const original = await parseFlowchart('flowchart LR\n' + body);
const sanitized = sanitizeMermaidDiagram('flowchart LR\n' + body);
const graph = await parseFlowchart(sanitized);
expect(graph.nodes).toEqual(original.nodes.map((id) => id.replaceAll('.', '_')));
expect(graph.edges).toEqual(original.edges.map((edge) => edge.replaceAll('.', '_')));
expect(sanitizeMermaidDiagram(sanitized)).toBe(sanitized);
});
it.each(['A --> file.ts\nfile_ts --> B', 'file_ts --> B\nA --> file.ts'])(
'does not merge an alias with an existing node: %s',
async (body) => {
const graph = await parseFlowchart(sanitizeMermaidDiagram('flowchart LR\n' + body));
expect(graph.nodes).toHaveLength(4);
expect(graph.edges).toContain('file_ts->B');
expect(graph.edges).toContain('A->file_ts_2');
},
);
it.each([
'["value.when(...) | call() | a & b; c --> d"]',
'("value.when(...) | call() | a & b; c --> d")',
'(["value.when(...) | call() | a & b; c --> d"])',
'[["value.when(...) | call() | a & b; c --> d"]]',
'[("value.when(...) | call() | a & b; c --> d")]',
'(("value.when(...) | call() | a & b; c --> d"))',
'>"value.when(...) | call() | a & b; c --> d"]',
'{"value.when(...) | call() | a & b; c --> d"}',
'{{"value.when(...) | call() | a & b; c --> d"}}',
'[/"value.when(...) | call() | a & b; c --> d"/]',
'((("value.when(...) | call() | a & b; c --> d")))',
])('preserves node shape and label contents: %s', async (shape) => {
const diagram = `flowchart LR\nA${shape} --> B${shape}`;
const original = await parseFlowchart(diagram);
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toBe(diagram);
expect(await parseFlowchart(sanitized)).toEqual(original);
});
it.each([
'-->',
'---',
'<-->',
'==>',
'===',
'-.->',
'-..-',
'--x',
'--o',
'x--x',
'o--o',
'-- text -->',
'== text ==>',
'-. text .->',
'-->|"doc()"|',
])('preserves link style and text: %s', async (link) => {
const diagram = `flowchart LR\nA ${link} B`;
expect(await parseFlowchart(sanitizeMermaidDiagram(diagram))).toEqual(
await parseFlowchart(diagram),
);
});
it('leaves sequence diagram syntax to its own parser', async () => {
const diagram = 'sequenceDiagram\nclient.ts-->>server.ts: done';
await expect(mermaid.mermaidAPI.getDiagramFromText(diagram)).resolves.toBeTruthy();
expect(sanitizeMermaidDiagram(diagram)).toBe(diagram);
});
it('quotes edge labels using Mermaid entities rather than JavaScript escapes', async () => {
const sanitized = sanitizeMermaidDiagram('flowchart LR\nA -->|doc("C:\\tmp")| B');
expect(sanitized).toBe('flowchart LR\nA -->|"doc(#quot;C:\\tmp#quot;)"| B');
expect((await parseFlowchart(sanitized)).edges).toEqual(['A->B']);
});
it('keeps an asymmetric shape and its link-like label text', async () => {
const diagram = 'flowchart LR\nfile.ts>value --> other.ts & x] --> B';
const graph = await parseFlowchart(sanitizeMermaidDiagram(diagram));
expect(graph.nodes).toEqual(['file_ts', 'B']);
expect(graph.edges).toEqual(['file_ts->B']);
expect(graph.labels).toEqual((await parseFlowchart(diagram)).labels);
});
it.each(['file.ts["File"]\nA --> file.ts', 'A --> file.ts\nfile.ts["File"]'])(
'preserves an explicit label across bare references: %s',
async (body) => {
const original = await parseFlowchart('flowchart LR\n' + body);
const graph = await parseFlowchart(sanitizeMermaidDiagram('flowchart LR\n' + body));
expect(graph.labels).toEqual(original.labels);
},
);
it('keeps style and class references attached to the aliased node', async () => {
const diagram = 'flowchart LR\nA --> file.ts\nstyle file.ts fill:red\nclass file.ts active';
const sanitized = sanitizeMermaidDiagram(diagram);
const parsed = await mermaid.mermaidAPI.getDiagramFromText(sanitized);
const vertices = (
parsed.db as { getVertices(): Map<string, { styles: string[]; classes: string[] }> }
).getVertices();
expect([...vertices.keys()]).toEqual(['A', 'file_ts']);
expect(vertices.get('file_ts')?.styles).toContain('fill:red');
expect(vertices.get('file_ts')?.classes).toContain('active');
});
it('reserves subgraph identifiers before allocating node aliases', async () => {
const diagram = 'flowchart LR\nsubgraph file_ts\nA --> file.ts\nend';
expect((await parseFlowchart(sanitizeMermaidDiagram(diagram))).edges).toEqual(['A->file_ts_2']);
});
it.each([
['style.css', 'style_css'],
['direction.ts', 'direction_ts'],
['click-handler.ts', 'node_click-handler_ts'],
['class-loader.ts', 'node_class-loader_ts'],
])('does not mistake the filename %s for a directive', async (id, alias) => {
const sanitized = sanitizeMermaidDiagram(`flowchart LR\n${id} --> B\nA --> ${id}`);
expect((await parseFlowchart(sanitized)).edges).toEqual([`${alias}->B`, `A->${alias}`]);
expect(sanitizeMermaidDiagram(sanitized)).toBe(sanitized);
});
it('preserves frontmatter while repairing the diagram below it', async () => {
const frontmatter =
'---\ntitle: "file.ts --> doc()"\nconfig:\n flowchart:\n curve: linear\n---\n';
const sanitized = sanitizeMermaidDiagram(
frontmatter + '%% flow\nflowchart LR\ndoc() -->|doc()| B',
);
expect(sanitized.startsWith(frontmatter)).toBe(true);
expect((await parseFlowchart(sanitized)).edges).toEqual(['doc->B']);
expect(sanitizeMermaidDiagram(sanitized)).toBe(sanitized);
});
it('preserves multiline initialization directives', async () => {
const prelude = '%%{init: {\n "theme": "dark"\n}}%%\n';
const sanitized = sanitizeMermaidDiagram(prelude + 'flowchart LR\ndoc() --> B');
expect(sanitized.startsWith(prelude)).toBe(true);
expect((await parseFlowchart(sanitized)).edges).toEqual(['doc->B']);
});
it('aliases nodes following a header on the same line', async () => {
const sanitized = sanitizeMermaidDiagram('flowchart LR; A --> file.ts\nfile.ts --> B');
expect((await parseFlowchart(sanitized)).edges).toEqual(['A->file_ts', 'file_ts->B']);
});
it.each(['-->|returns List<File>|', '-- returns Map<K,V> -->'])(
'keeps generic types in link text separate from the target: %s',
async (link) => {
const diagram = `flowchart LR\nA ${link} loader.ts[Loader]\nloader.ts --> C`;
const sanitized = sanitizeMermaidDiagram(diagram);
const graph = await parseFlowchart(sanitized);
expect(graph.nodes).toEqual(['A', 'loader_ts', 'C']);
expect(graph.edges).toEqual(['A->loader_ts', 'loader_ts->C']);
expect(graph.labels).toEqual((await parseFlowchart(diagram)).labels);
expect(sanitizeMermaidDiagram(sanitized)).toBe(sanitized);
},
);
it.each(['shape: rect', 'shape: rect, label: "File"', 'label: "File", shape: rect'])(
'preserves the label of a node with metadata: %s',
async (metadata) => {
const diagram = `flowchart LR\nfile.ts@{ ${metadata} }\nfile.ts --> B`;
const graph = await parseFlowchart(sanitizeMermaidDiagram(diagram));
expect(graph.nodes).toEqual(['file_ts', 'B']);
expect(graph.labels).toEqual((await parseFlowchart(diagram)).labels);
},
);
it('preserves multiline quoted labels containing diagram syntax', async () => {
const diagram = 'flowchart LR\nA["first\nfile.ts --> done & x; y"] --> file.ts\nfile.ts --> B';
const sanitized = sanitizeMermaidDiagram(diagram);
const graph = await parseFlowchart(sanitized);
expect(graph.nodes).toEqual(['A', 'file_ts', 'B']);
const original = await parseFlowchart(diagram);
expect(graph.labels.map(({ text }) => text)).toEqual(original.labels.map(({ text }) => text));
expect(graph.labels[0]).toEqual(original.labels[0]);
expect(sanitizeMermaidDiagram(sanitized)).toBe(sanitized);
});
it.each(['A:::hot --> B', 'A --> file.ts:::hot\nfile.ts --> B'])(
'preserves class shorthand: %s',
async (body) => {
const parsed = await mermaid.mermaidAPI.getDiagramFromText(
sanitizeMermaidDiagram('flowchart LR\n' + body),
);
const vertices = (
parsed.db as { getVertices(): Map<string, { classes: string[] }> }
).getVertices();
const id = body.startsWith('A:::') ? 'A' : 'file_ts';
expect(vertices.get(id)?.classes).toContain('hot');
expect(vertices.size).toBe(body.startsWith('A:::') ? 2 : 3);
},
);
});

View file

@ -34,12 +34,12 @@ describe('sanitizeMermaidMarkdown', () => {
expect(sanitized).toContain('Client -->|HTTP params| Script');
});
it('escapes backslashes and quotes in quoted edge labels', () => {
it('encodes quotes and preserves backslashes in quoted edge labels', () => {
const diagram = ['graph LR', ' Script -->|doc("C:\\\\tmp")| Target'].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toContain('Script -->|"doc(\\"C:\\\\\\\\tmp\\")"| Target');
expect(sanitized).toContain('Script -->|"doc(#quot;C:\\\\tmp#quot;)"| Target');
});
it('aliases bare node IDs that contain dots and keeps display labels', () => {
@ -74,6 +74,51 @@ describe('sanitizeMermaidMarkdown', () => {
expect(sanitized).toContain('file_name_ts[(eXist-db XML)] --> target_node["Target node"]');
});
it('leaves an ellipsis or a dotted name inside a label alone', () => {
// #3705: these three diagrams are valid; sanitizing must not change them.
const diagrams = [
'flowchart TD\n A["value.when(...)"] -->|data| B["data"]',
'flowchart TD\n G["x = compute(...).result"] --> H["done"]',
'flowchart LR\n N -- ref.invalidate --> A',
];
for (const diagram of diagrams) {
expect(sanitizeMermaidDiagram(diagram)).toBe(diagram);
}
});
it('reads dots as a link only in the dotted link syntax', () => {
const diagram = [
'flowchart LR',
' A -.-> B',
' B -. later .-> C',
' C -..- D',
' file.ts -.-> E["x..y"]',
].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toContain('A -.-> B');
expect(sanitized).toContain('B -. later .-> C');
expect(sanitized).toContain('C -..- D');
expect(sanitized).toContain('file_ts["file.ts"] -.-> E["x..y"]');
});
it('keeps the text of a labelled link while still aliasing the nodes around it', () => {
const diagram = [
'flowchart LR',
' cache.ts -- ref.invalidate --> store.ts',
' store.ts == on.save ==> db[(db)]',
' a.b -- plain --> c[c --> d]',
].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toContain('cache_ts["cache.ts"] -- ref.invalidate --> store_ts["store.ts"]');
expect(sanitized).toContain('store_ts["store.ts"] == on.save ==> db[(db)]');
expect(sanitized).toContain('a_b["a.b"] -- plain --> c[c --> d]');
});
it('only rewrites fenced Mermaid blocks in markdown', () => {
const markdown = [
'Regular text with doc() and file.name.ts.',