mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
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:
parent
68c0cdf198
commit
be32540b6b
5 changed files with 1142 additions and 72 deletions
543
gitnexus/package-lock.json
generated
543
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
348
gitnexus/test/unit/wiki-mermaid-sanitizer-parse.test.ts
Normal file
348
gitnexus/test/unit/wiki-mermaid-sanitizer-parse.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue