diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index e89973545..df2040933 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index 65417d65f..f90a8820d 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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" diff --git a/gitnexus/src/core/wiki/mermaid-sanitizer.ts b/gitnexus/src/core/wiki/mermaid-sanitizer.ts index c8d443c78..74198ea6f 100644 --- a/gitnexus/src/core/wiki/mermaid-sanitizer.ts +++ b/gitnexus/src/core/wiki/mermaid-sanitizer.ts @@ -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(); + const labeledIds = new Set(); + 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(); 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, + 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 = { '[': ']', '(': ')', '{': '}' }; + +/** + * 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 + // 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, '
')); } @@ -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 { + // 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' ); } diff --git a/gitnexus/test/unit/wiki-mermaid-sanitizer-parse.test.ts b/gitnexus/test/unit/wiki-mermaid-sanitizer-parse.test.ts new file mode 100644 index 000000000..f84c5642c --- /dev/null +++ b/gitnexus/test/unit/wiki-mermaid-sanitizer-parse.test.ts @@ -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 { + const parsed = await mermaid.mermaidAPI.getDiagramFromText(diagram); + const db = parsed.db as { + getVertices(): Map; + 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 } + ).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|', '-- returns Map -->'])( + '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 } + ).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); + }, + ); +}); diff --git a/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts index e8c74c8cb..d967755a4 100644 --- a/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts +++ b/gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts @@ -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.',