diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..acb0bb3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-and-package: + name: Test and package plugin + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + package-lock.json + packages/description-renderer/package-lock.json + packages/mcp-server/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install description renderer dependencies + run: npm ci --prefix packages/description-renderer + + - name: Install MCP server dependencies + run: npm ci --prefix packages/mcp-server + + - name: Run tests + run: npm test + + - name: Build VSIX artifact + run: npm run package + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: codetour-linux-x64-${{ github.sha }} + path: codetour-linux-x64-*.vsix + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index c597eed6..44be4a20 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules .vscode-test/ *.vsix .DS_Store +.agents/ diff --git a/.tours/changes.tour b/.tours/changes.tour new file mode 100644 index 00000000..588f5c61 --- /dev/null +++ b/.tours/changes.tour @@ -0,0 +1,54 @@ +{ + "$schema": "https://aka.ms/codetour-schema", + "title": "Descriptions de tour cliquables dans un webview", + "description": "Cette branche ajoute une vue agrandie pour lire la description de l'étape CodeTour active. Depuis la barre d'actions du commentaire, l'utilisateur ouvre un panneau latéral qui rend le Markdown, conserve les liens CodeTour et protège le webview contre le HTML ou les URI dangereux.\n\nElle complète aussi les consignes du serveur MCP afin que les tours générés restent lisibles et utilisent Mermaid lorsqu'un diagramme apporte une vraie vue d'ensemble.\n\nGenerated from `feature/mermaid-tour-support` (`ba7f55f65b262af892aca94fb8cb2582c0757f9b`) to `feature/clickable-tour-description-webview` (`591effd`). Les modifications locales non commitées sont exclues.", + "ref": "591effd", + "steps": [ + { + "title": "Point d'entrée dans l'interface", + "description": "La nouvelle commande `codetour.showStepDescription` est déclarée dans le manifeste et ajoutée à la barre d'actions du commentaire CodeTour. L'icône d'aperçu apparaît en première position, tandis que les commandes de navigation et de fin de tour conservent leurs rôles.\n\nLa commande est également disponible dans la palette et dans les menus associés au contexte de lecture.", + "file": "package.json", + "pattern": "codetour.showStepDescription" + }, + { + "title": "Enregistrement de la commande", + "description": "Le player relie la contribution du manifeste à `showStepDescription`. Cette petite couture garde le manifeste déclaratif et délègue toute la logique de présentation au module du webview.", + "file": "src/player/commands.ts", + "pattern": "showStepDescription" + }, + { + "title": "Ouverture de la description active", + "description": "`showStepDescription` récupère le tour et l'étape actifs, ignore proprement les cas sans description, puis passe le contenu dans le pipeline de prévisualisation existant. Les liens de fichiers, références de tours, commandes et images Mermaid bénéficient ainsi des mêmes transformations que les autres surfaces de lecture.\n\nUn seul panneau est réutilisé au fil des étapes. Son titre et son contenu sont actualisés, et les références globales sont nettoyées à sa fermeture.", + "file": "src/player/descriptionWebview.ts", + "pattern": "export async function showStepDescription" + }, + { + "title": "Liens réellement cliquables", + "description": "Le webview renvoie les clics au processus de l'extension. Les commandes sont limitées à `codetour.*` et `vscode.open`, les liens HTTP, HTTPS et mailto sont ouverts à l'extérieur, et les chemins relatifs sont résolus dans le workspace.\n\nLes objets URI encodés dans les arguments de commande sont reconstruits avant l'appel à VS Code, ce qui permet aux liens enrichis produits par CodeTour de continuer à fonctionner.", + "file": "src/player/descriptionWebview.ts", + "pattern": "async function openLink" + }, + { + "title": "Rendu Markdown centralisé et sécurisé", + "description": "Le nouveau module convertit le Markdown avec `markdown-it`, puis nettoie systématiquement le HTML avec `sanitize-html`. L'allowlist conserve le balisage de présentation, les liens, le code et les images nécessaires au renderer Mermaid, tout en supprimant scripts, attributs et protocoles non autorisés.\n\nLe document final ajoute une Content Security Policy avec nonce, reprend les couleurs du thème VS Code et intercepte les liens non locaux pour les transmettre à l'extension.\n\n**Diagram — Flux d'une description jusqu'à l'action utilisateur**\n```mermaid\nflowchart LR\n A[Description de l'étape] --> B[Pipeline CodeTour]\n B --> C[Markdown-it]\n C --> D[Sanitization HTML]\n D --> E[Webview sécurisé]\n E -->|clic| F[Validation du lien]\n F --> G[Commande VS Code ou navigateur]\n```", + "file": "src/player/markdown.ts", + "pattern": "export function renderMarkdownToHtml" + }, + { + "title": "Décision d'architecture", + "description": "L'ADR formalise la séparation entre les surfaces natives de VS Code, qui continuent de recevoir du Markdown préparé, et les surfaces HTML possédées par l'extension, qui doivent toutes passer par le renderer et le sanitizer partagés.\n\n`markdown-it` et `sanitize-html` deviennent donc des dépendances directes: leur version et leur politique de sécurité font partie du contrat de CodeTour au lieu de dépendre d'une dépendance transitive.", + "file": "docs/adr/0009-centralize-markdown-rendering-and-sanitization.md" + }, + { + "title": "Couverture du comportement et de la sécurité", + "description": "Les tests du webview vérifient le rendu d'un titre Markdown, la transmission des clics, la Content Security Policy, l'échappement du titre du panneau et la suppression des scripts ou liens `javascript:`.\n\nUn test séparé contrôle la contribution de menu pour garantir que le nouveau bouton ne remplace pas l'action `End Tour`.", + "file": "src/player/descriptionWebview.test.ts" + }, + { + "title": "Des tours MCP plus lisibles", + "description": "Le second commit enrichit les instructions exposées par `create_project_tour` et `create_changes_tour`. Les descriptions doivent utiliser des paragraphes courts séparés par des lignes vides, des listes lorsque cela clarifie une collection ou une séquence, et éviter les gros blocs de texte.\n\nLes tours d'architecture, de workflow, de cycle de vie ou multi-modules sont désormais encouragés à inclure un diagramme Mermaid quand il synthétise des relations réparties sur plusieurs étapes. Le schéma d'entrée et les tests d'intégration rendent ces attentes visibles et vérifiables.", + "file": "packages/mcp-server/src/server.ts", + "pattern": "MARKDOWN_WRITING_GUIDANCE" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 7946d697..f8280587 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,6 @@ "typescript.tsc.autoDetect": "off", "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.organizeImports": true + "source.organizeImports": "explicit" } } diff --git a/.vscodeignore b/.vscodeignore index 75620af6..5880e3bc 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,13 +1,18 @@ .tours/** .vscode/** node_modules/** +packages/** src/** +scripts/** +docs/** .gitignore .space package.lock.json tsconfig.json webpack.config.js +dist/test/** +dist/extension-web.js* **/*.vsix .git/** .github/** -**/*.svg \ No newline at end of file +**/*.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index c12c216f..e5ec9d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## Upcoming +- Added a bundled, workspace-confined MCP server for generating Project Tours + and Changes Tours from VS Code and GitHub Copilot. +- Added secure, theme-aware Mermaid diagrams to Tour descriptions and MCP + validation. + - Automatically updating a tour file as the associated code changes - Automatically set the "pattern" record mode when you create a new tour, and select `None` for the git ref - Added support for opening a `*.tour` file in the VS Code notebook editor (Insiders only) diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..6422f287 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,21 @@ +# CodeTour + +CodeTour guides a reader through selected places in a codebase using an ordered sequence of contextual explanations. + +## Language + +**Tour**: +An ordered walkthrough of a codebase composed of Tour Steps. +_Avoid_: Guide, presentation + +**Tour Step**: +One contextual stop in a Tour, anchored to a code location or content surface and carrying a description. +_Avoid_: Comment, slide + +**Mermaid Diagram**: +A diagram whose source belongs to a Tour description and whose rendered form remains part of that description during playback. +_Avoid_: Screenshot, generated attachment + +**Playback Surface**: +A user-visible place where a Tour or Tour Step description is presented while reading a Tour. +_Avoid_: Component, container diff --git a/README.md b/README.md index 46fafad7..34dc8f6c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,17 @@ # CodeTour 🗺️ +## AI-generated tours + +The desktop extension includes a local MCP server for generating a Project +Tour or a Changes Tour. VS Code and GitHub Copilot discover one server per +workspace folder automatically. The server is bundled with the extension, +runs over stdio, remains confined to its workspace, and does not access the +network. + +Tour descriptions can include captioned Mermaid diagrams. CodeTour validates +generated diagrams before saving them and renders them as static, +theme-aware images during playback without writing generated image files. + CodeTour is a Visual Studio Code extension, which allows you to record and play back guided walkthroughs of your codebases. It's like a table of contents, that can make it easier to onboard (or re-board!) to a new project/feature area, visualize bug reports, or understand the context of a code review/PR change. A "code tour" is simply a series of interactive steps, each of which are associated with a specific directory, or file/line, and include a description of the respective code. This allows developers to clone a repo, and then immediately start **learning it**, without needing to refer to a `CONTRIBUTING.md` file and/or rely on help from others. Tours can either be checked into a repo, to enable sharing with other contributors, or [exported](#exporting-tours) to a "tour file", which allows anyone to replay the same tour, without having to clone any code to do it! diff --git a/docs/adr/0006-distribute-and-discover-the-mcp-server-with-the-extension.md b/docs/adr/0006-distribute-and-discover-the-mcp-server-with-the-extension.md new file mode 100644 index 00000000..c0f36852 --- /dev/null +++ b/docs/adr/0006-distribute-and-discover-the-mcp-server-with-the-extension.md @@ -0,0 +1,11 @@ +# Distribute and discover the MCP server with the extension + +The desktop extension bundles the compiled stdio MCP server and registers one +server definition per workspace folder. This allows VS Code and GitHub Copilot +to discover the tools without a workspace-specific MCP configuration file. + +Each server receives one explicit workspace root and remains confined to it. +The web extension does not register or start the Node.js server. + +The MCP server definition API requires VS Code 1.101, so the extension's +minimum VS Code version moves to 1.101. diff --git a/docs/adr/0007-render-mermaid-diagrams-within-codetour.md b/docs/adr/0007-render-mermaid-diagrams-within-codetour.md new file mode 100644 index 00000000..fbf13f63 --- /dev/null +++ b/docs/adr/0007-render-mermaid-diagrams-within-codetour.md @@ -0,0 +1,19 @@ +# Render Mermaid diagrams within CodeTour + +CodeTour stores Mermaid source in fenced `mermaid` blocks inside a Tour's existing Markdown descriptions and renders diagrams itself, without requiring another VS Code extension or committing generated images. The Tour Generator's MCP tools validate Mermaid before writing a generated Tour, using the same exact, locked Mermaid version and diagram rules as playback. During playback, CodeTour renders with strict security, disables interactive links and HTML, and adapts the result to the active theme: Mermaid produces a theme-aware SVG internally, which CodeTour sanitizes as defense in depth and rasterizes in memory. Native comments transport the diagram as a `data:image/png;base64` image in the final Markdown, because SVG data URIs and in-memory filesystem URIs did not display as images in native comments. A rendering failure shows a compact warning and the diagram's alternative text without exposing its source, and no generated SVG or PNG file is ever written. + +A Mermaid Diagram is introduced by a visible Markdown caption of the form `**Diagram — …**`, which also provides its alternative text. The first version accepts `flowchart`, `sequenceDiagram`, `stateDiagram-v2`, `classDiagram`, and `erDiagram`. A single Markdown description may contain at most three diagrams, and each Mermaid source may contain at most 20 KB. Changing the active VS Code theme invalidates the render cache, immediately refreshes the visible Tour step and Tour tree, and leaves other diagrams to be regenerated on demand. Diagrams in the same description render independently, so one failure does not hide successful diagrams. Mermaid fences are excluded from CodeTour's existing `Insert Code` transformation. + +Packaging accounts for the rasterizer's native code by producing one VSIX per +supported platform. The build externalizes `@resvg/resvg-js`, stages only the +matching native binary, and verifies the staged runtime before publication. + +```mermaid +flowchart LR + Generator[Tour Generator] -->|proposes Mermaid source| MCP[CodeTour MCP tools] + MCP -->|validates with the locked Mermaid| Tour[Tour file] + Tour -->|reads the fenced Mermaid block| Renderer[CodeTour Mermaid renderer] + Renderer -->|strict, theme-aware SVG| Cache[In-memory cache] + Cache -->|in-memory PNG as a data URI image| Surface[Markdown description surfaces] + Renderer -->|rendering failure| Fallback[Warning and alternative text] +``` diff --git a/docs/adr/0008-drop-vscode-web-support.md b/docs/adr/0008-drop-vscode-web-support.md new file mode 100644 index 00000000..6d90d60e --- /dev/null +++ b/docs/adr/0008-drop-vscode-web-support.md @@ -0,0 +1,5 @@ +# Drop VS Code Web support + +CodeTour targets the Node-based VS Code extension host and no longer builds or publishes a browser extension. The manifest exposes only the Node `main` entry point, the build emits the Node extension and the bundled MCP server with no Web Worker bundle, and the Web-specific desktop-integration shim and the `os-browserify`/`path-browserify` fallbacks are deleted. Packaging and CI verify that no web artifact reaches the VSIX. + +Desktop VS Code remains supported locally and through remote Node extension hosts such as SSH, Dev Containers, and Codespaces; `extensionKind: ["workspace"]` is unchanged. The `isWeb` property of a tour's `when` expression context is retained and now always evaluates to `false`. diff --git a/docs/adr/0009-centralize-markdown-rendering-and-sanitization.md b/docs/adr/0009-centralize-markdown-rendering-and-sanitization.md new file mode 100644 index 00000000..7fd3fd4a --- /dev/null +++ b/docs/adr/0009-centralize-markdown-rendering-and-sanitization.md @@ -0,0 +1,19 @@ +--- +status: proposed +--- + +# Centralize Markdown rendering and sanitization + +CodeTour uses one shared description pipeline for every Playback Surface. That pipeline prepares CodeTour-specific links and references, renders Mermaid diagrams, and produces the same Markdown regardless of its destination. Native VS Code surfaces continue to receive this prepared Markdown because VS Code owns their rendering; extension-owned HTML surfaces, including the Expanded Description View for the active Tour Step, convert it through a directly declared Markdown engine and an explicit HTML sanitizer before display. + +The Markdown engine and sanitizer are direct application dependencies rather than incidental transitive dependencies of Mermaid or packaging tools. This keeps rendering behavior versioned and testable at the CodeTour boundary, prevents a dependency update elsewhere from silently changing descriptions, and gives every extension-owned HTML surface the same allowlist for markup, attributes, URI schemes, and images. + +## Considered options + +- Relying on a Markdown engine brought transitively by another package was rejected because its presence and version are not part of CodeTour's dependency contract. +- Letting each webview choose and configure its own renderer was rejected because rendering and security rules would drift between Playback Surfaces. +- Replacing native VS Code Markdown rendering with generated HTML was rejected because native comments, hovers, tooltips, and notebook cells own their rendering lifecycle and capabilities. + +## Consequences + +All extension-owned HTML rendering must go through the shared renderer and sanitizer; a Playback Surface must not instantiate its own Markdown engine. Changes to rendering or the sanitizer allowlist are cross-surface behavior changes and require tests. Native surfaces may still differ visually where VS Code intentionally controls presentation, but they consume the same prepared Markdown semantics. diff --git a/package-lock.json b/package-lock.json index 54c0d1c4..1b58d7b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,30 +9,33 @@ "version": "0.0.59", "license": "MIT", "dependencies": { + "@resvg/resvg-js": "2.6.2", "@types/jexl": "^2.2.0", "axios": "^0.21.4", "jexl": "^2.3.0", + "markdown-it": "^12.3.2", "mobx": "^5.14.2", - "os-browserify": "0.3.0", - "path-browserify": "1.0.1", + "sanitize-html": "^2.13.1", "throttle-debounce": "^3.0.1", "vsls": "^1.0.2532" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/node": "^18.14.0", + "@types/sanitize-html": "^2.11.0", "@types/throttle-debounce": "^5.0.0", - "@types/vscode": "^1.60", + "@types/vscode": "1.101.0", "@vscode/vsce": "^2.17.0", "debug": "^4.3.4", "eslint": "^8.34.0", "ts-loader": "^9.4.2", - "typescript": "^4.9.5", + "typescript": "4.9.5", "webpack": "^5.75.0", "webpack-cli": "^5.0.1", "webpack-merge": "^5.8.0" }, "engines": { - "vscode": "^1.60.0" + "vscode": "^1.101.0" } }, "node_modules/@babel/runtime": { @@ -75,12 +78,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -243,6 +240,233 @@ "node": ">= 8" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@types/eslint": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.1.tgz", @@ -280,12 +504,126 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.14.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz", "integrity": "sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A==", "dev": true }, + "node_modules/@types/sanitize-html": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.11.0.tgz", + "integrity": "sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, + "node_modules/@types/sanitize-html/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/@types/throttle-debounce": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/throttle-debounce/-/throttle-debounce-5.0.0.tgz", @@ -293,10 +631,11 @@ "dev": true }, "node_modules/@types/vscode": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.60.0.tgz", - "integrity": "sha512-wZt3VTmzYrgZ0l/3QmEbCq4KAJ71K3/hmMQ/nfpv84oH8e81KKwPEoQ5v8dNCxfHFVJ1JabHKmCvqdYOoVm1Ow==", - "dev": true + "version": "1.101.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.101.0.tgz", + "integrity": "sha512-ZWf0IWa+NGegdW3iU42AcDTFHWW7fApLdkdnBqwYEtHVIBGbTu0ZNQKP/kX3Ds/uMJXIMQNAojHR4vexCEEz5Q==", + "dev": true, + "license": "MIT" }, "node_modules/@vscode/vsce": { "version": "2.17.0", @@ -334,12 +673,6 @@ "keytar": "^7.7.0" } }, - "node_modules/@vscode/vsce/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/@vscode/vsce/node_modules/azure-devops-node-api": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz", @@ -359,40 +692,6 @@ "node": ">= 6" } }, - "node_modules/@vscode/vsce/node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", - "dev": true, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/@vscode/vsce/node_modules/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", - "dev": true, - "dependencies": { - "uc.micro": "^1.0.1" - } - }, - "node_modules/@vscode/vsce/node_modules/markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" - }, - "bin": { - "markdown-it": "bin/markdown-it.js" - } - }, "node_modules/@vscode/vsce/node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -677,6 +976,12 @@ "node": ">=4" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/await-semaphore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", @@ -1075,6 +1380,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-libc": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", @@ -1112,16 +1426,16 @@ } }, "node_modules/domelementtype": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", - "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", - "dev": true, + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "4.2.0", @@ -1346,12 +1660,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2199,6 +2507,15 @@ "node": ">= 0.8.0" } }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -2238,11 +2555,35 @@ "node": ">=10" } }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/mdurl": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=", - "dev": true + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" }, "node_modules/merge-stream": { "version": "2.0.0", @@ -2356,6 +2697,24 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -2475,11 +2834,6 @@ "node": ">= 0.8.0" } }, - "node_modules/os-browserify": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", - "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==" - }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -2537,6 +2891,12 @@ "semver": "^5.1.0" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", @@ -2552,11 +2912,6 @@ "parse5": "^6.0.1" } }, - "node_modules/path-browserify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2597,10 +2952,10 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { "version": "2.2.2", @@ -2626,6 +2981,34 @@ "node": ">=8" } }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -2673,15 +3056,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -2904,6 +3278,115 @@ } ] }, + "node_modules/sanitize-html": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.1.tgz", + "integrity": "sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", @@ -3049,6 +3532,15 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -3467,6 +3959,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3478,8 +3971,7 @@ "node_modules/uc.micro": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, "node_modules/underscore": { "version": "1.13.1", @@ -3522,6 +4014,16 @@ "punycode": "^2.1.0" } }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 619cb076..9ec107f2 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "license": "MIT", "icon": "images/icon.png", "engines": { - "vscode": "^1.60.0" + "vscode": "^1.101.0" }, "categories": [ "Other" @@ -35,8 +35,13 @@ "onNotebookEditor:codetour" ], "main": "./dist/extension-node.js", - "browser": "./dist/extension-web.js", "contributes": { + "mcpServerDefinitionProviders": [ + { + "id": "codetour.tour-generator", + "label": "CodeTour Tour Generator" + } + ], "configuration": { "type": "object", "title": "CodeTour", @@ -214,6 +219,12 @@ "category": "CodeTour", "icon": "$(eye)" }, + { + "command": "codetour.showStepDescription", + "title": "Afficher la description en grand", + "category": "CodeTour", + "icon": "$(preview)" + }, { "command": "codetour.startTour", "title": "Start Tour", @@ -344,6 +355,10 @@ "command": "codetour.showMarkers", "when": "false" }, + { + "command": "codetour.showStepDescription", + "when": "false" + }, { "command": "codetour.unmakeTourPrimary", "when": "false" @@ -354,6 +369,11 @@ } ], "comments/commentThread/title": [ + { + "command": "codetour.showStepDescription", + "group": "inline@0", + "when": "commentController == codetour" + }, { "command": "codetour.previousTourStep", "group": "inline@1", @@ -652,19 +672,22 @@ ] }, "dependencies": { + "@resvg/resvg-js": "2.6.2", "@types/jexl": "^2.2.0", "axios": "^0.21.4", "jexl": "^2.3.0", + "markdown-it": "^12.3.2", "mobx": "^5.14.2", - "os-browserify": "0.3.0", - "path-browserify": "1.0.1", + "sanitize-html": "^2.13.1", "throttle-debounce": "^3.0.1", "vsls": "^1.0.2532" }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/node": "^18.14.0", + "@types/sanitize-html": "^2.11.0", "@types/throttle-debounce": "^5.0.0", - "@types/vscode": "^1.60", + "@types/vscode": "1.101.0", "@vscode/vsce": "^2.17.0", "debug": "^4.3.4", "eslint": "^8.34.0", @@ -675,13 +698,22 @@ "webpack-merge": "^5.8.0" }, "scripts": { - "build": "webpack --mode production", + "prototype:mermaid-height": "python3 -m http.server 4173 --directory src/player", + "build:mcp": "npm --prefix packages/mcp-server run build", + "build:renderer": "npm --prefix packages/description-renderer run build", + "build": "npm run build:renderer && npm run build:mcp && webpack --mode production && npm run prepare:resvg", + "prepare:resvg": "node scripts/prepare-resvg-runtime.js", + "publish:vsix": "node scripts/publish-vsix.js", "vscode:prepublish": "npm run build", "watch": "webpack --mode development --watch", - "package": "vsce package" + "package": "node scripts/package-extension.js", + "test": "npm run test:plugin && npm run test:renderer && npm run test:mcp", + "test:plugin": "npm run build && tsc -p tsconfig.test.json && node scripts/run-compiled-tests.js dist/test", + "test:renderer": "npm --prefix packages/description-renderer test", + "test:mcp": "npm --prefix packages/mcp-server test" }, "prettier": { "arrowParens": "avoid", "trailingComma": "none" } -} \ No newline at end of file +} diff --git a/packages/description-renderer/LICENSE.txt b/packages/description-renderer/LICENSE.txt new file mode 100644 index 00000000..b2f52a2b --- /dev/null +++ b/packages/description-renderer/LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/description-renderer/README.md b/packages/description-renderer/README.md new file mode 100644 index 00000000..5fa426b5 --- /dev/null +++ b/packages/description-renderer/README.md @@ -0,0 +1,119 @@ +# codetour-description-renderer + +Offline, strict-security Mermaid rendering for [CodeTour](https://github.com/microsoft/codetour) +Markdown descriptions. The renderer is the shared description-to-comment-content +seam: it accepts a complete Markdown description plus the effective VS Code theme +and returns the final Markdown used by a native VS Code comment, with each +`mermaid` fence replaced by an in-memory PNG image or a compact warning. + +Design constraints: + +- **Offline** — no font, image, script or config fetches; rendering works with + network access blocked. +- **Strict security** — Mermaid runs with `securityLevel: "strict"` and + `htmlLabels: false`; the intermediate SVG is additionally sanitized (scripts, + event handlers, `foreignObject`, anchors, remote-resource elements and all + external references removed) as defense in depth. +- **In-memory transport** — Mermaid produces a theme-aware SVG internally, which + is rasterized to PNG with `@resvg/resvg-js` and emitted as a + `data:image/png;base64` image; SVG data URIs did not display as images in + native comments. No generated SVG or PNG file is ever written. +- **No generated assets** — the Mermaid source stays in the tour file; nothing + is committed or persisted. +- **Reuse** — rendered diagrams are reused only in memory when their exact + source, effective theme, renderer version and rendering options match. A + theme change can call `clearMermaidRenderCache()` to discard all entries; + failures are never kept in the cache. The MCP server (`packages/mcp-server`) + will validate Mermaid with the same exact, locked Mermaid version and diagram + rules through this package. + +## Diagram rules + +The rules below are implemented in `src/rules.ts` and are the single source of +truth shared by playback and (later) MCP validation. They are deliberately +syntactic and dependency-free so both sides evaluate them identically. + +- **Fence**: a Mermaid fence is a block whose opening line matches + `^\s*```mermaid[ \t]*$` and whose closing line is the next line that is only + backticks (`^\s*```[ \t]*$`). A `mermaid` line nested inside another fence is + not a Mermaid fence. +- **Caption**: the nearest non-blank line above the fence's opening line must + be a caption: `^\s*\*\*(Diagram — …)\*\*[ \t]*$`, where `…` is one or more + characters that do not contain `**`. Blank lines may sit between the caption + and the fence; any other content breaks the association. The caption stays + visible and becomes the image alternative text. +- **Allowlist**: the diagram kind is the first whitespace-delimited token of + the first significant source line (blank lines and `%%` comment/init-directive + lines are skipped). Exactly `flowchart`, `sequenceDiagram`, + `stateDiagram-v2`, `classDiagram` and `erDiagram` are supported; aliases such + as `graph` or `stateDiagram` are not. +- **Count limit**: at most 3 Mermaid fences per description, counted in + document order. During playback the first three fences are evaluated and each + fence from the fourth on fails locally with a warning, regardless of its + content. Validation (MCP) rejects the whole description when it contains more + than 3 fences. +- **Size limit**: the UTF-8 byte length of the source (the exact text between + the opening and closing fence lines) must be at most 20 KB (20480 bytes). +- **Per-fence evaluation order**: caption, then size, then kind. The first + violated rule decides the failure. Diagrams in one description are evaluated + and rendered independently, so one rejected diagram never hides its siblings. +- **Failures**: a rejected or unrenderable diagram is replaced by a single + compact warning line and never by its source. `renderMermaidDiagram` also + refuses unsupported kinds on its own, so the low-level API is safe to call + directly. + +## API + +```ts +type DescriptionTheme = "light" | "dark"; + +renderDescription(description: string, theme: DescriptionTheme): Promise; +makeRenderedImagesResponsive(markdown: string): string; +renderMermaidDiagram(source: string, theme: DescriptionTheme): Promise<{ svg: string; png: Buffer }>; +clearMermaidRenderCache(): void; +invalidateMermaidRenderCache(): void; // alias used by theme-change callers +sanitizeSvg(svg: string): string; +``` + +`renderDescription` is the shared Markdown transformation used by playback +surfaces. Its diagram work is backed by the in-memory cache; no cache entry or +generated image is written to the workspace or persisted in VS Code state. +`makeRenderedImagesResponsive` can then convert Markdown images into safe HTML +images with `width="100%"`, which lets native VS Code comments and preview +surfaces adapt them to their available width. Markdown image titles are +preserved as HTML `title` attributes. +Preview callers that create a `MarkdownString` must set `supportHtml = true` +for this HTML subset to be rendered; edit-mode comments should keep it off. +`DESCRIPTION_RENDERER_VERSION` identifies the output contract represented by +the cache. + +Shared rule surface (for playback and MCP validation): + +```ts +ALLOWED_DIAGRAM_KINDS: readonly ["flowchart", "sequenceDiagram", "stateDiagram-v2", "classDiagram", "erDiagram"]; +MAX_DIAGRAMS_PER_DESCRIPTION: 3; +MAX_DIAGRAM_SOURCE_BYTES: 20480; + +findDiagramFences(description: string): DiagramFence[]; // caption?, source, start, end +matchDiagramCaption(line: string): string | undefined; +diagramKindOf(source: string): string | undefined; +diagramSourceByteLength(source: string): number; +isAllowedDiagramKind(kind: string): boolean; +isMermaidFenceInfo(info: string): boolean; +evaluateDiagramFence(fence: { caption?: string; source: string }): DiagramFenceEvaluation; +``` + +## Development + +From the repository root: + +```bash +npm ci --prefix packages/description-renderer +npm test --prefix packages/description-renderer +``` + +Dependencies are exact-pinned (`mermaid` 11.12.2, `jsdom` 26.1.0, +`@resvg/resvg-js` 2.6.2) so every consumer renders with the same locked +versions. JSDOM has no real SVG layout: text measurement relies on bounded +heuristics (`getBBox`, `getComputedTextLength`, `getBoundingClientRect` +polyfills), validated against all five allowed diagram kinds. diff --git a/packages/description-renderer/package-lock.json b/packages/description-renderer/package-lock.json new file mode 100644 index 00000000..6807af07 --- /dev/null +++ b/packages/description-renderer/package-lock.json @@ -0,0 +1,1990 @@ +{ + "name": "codetour-description-renderer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codetour-description-renderer", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@resvg/resvg-js": "2.6.2", + "jsdom": "26.1.0", + "mermaid": "11.12.2" + }, + "devDependencies": { + "@types/jsdom": "27.0.0", + "@types/node": "24.13.3", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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==", + "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/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/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==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/chevrotain/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cytoscape": { + "version": "3.34.2", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.2.tgz", + "integrity": "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "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/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid": { + "version": "11.12.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", + "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^0.6.3", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.21", + "marked": "^16.2.1", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "license": "MIT" + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "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==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, + "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==", + "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==", + "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", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + } + } +} diff --git a/packages/description-renderer/package.json b/packages/description-renderer/package.json new file mode 100644 index 00000000..e6955d14 --- /dev/null +++ b/packages/description-renderer/package.json @@ -0,0 +1,39 @@ +{ + "name": "codetour-description-renderer", + "version": "0.1.0", + "description": "Offline, strict-security Mermaid rendering that turns CodeTour Markdown descriptions into native comment content", + "license": "MIT", + "author": { + "name": "Microsoft Corporation" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/codetour", + "directory": "packages/description-renderer" + }, + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/src", + "README.md", + "LICENSE.txt" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node ../../scripts/run-compiled-tests.js dist/test" + }, + "dependencies": { + "@resvg/resvg-js": "2.6.2", + "jsdom": "26.1.0", + "mermaid": "11.12.2" + }, + "devDependencies": { + "@types/jsdom": "27.0.0", + "@types/node": "24.13.3", + "typescript": "5.9.3" + } +} diff --git a/packages/description-renderer/src/description.ts b/packages/description-renderer/src/description.ts new file mode 100644 index 00000000..28affd63 --- /dev/null +++ b/packages/description-renderer/src/description.ts @@ -0,0 +1,146 @@ +import { findDiagramFences } from "./parse"; +import { + DescriptionTheme, + renderMermaidDiagram +} from "./render"; +import { + DiagramRuleReason, + MAX_DIAGRAMS_PER_DESCRIPTION, + evaluateDiagramFence +} from "./rules"; + +const RENDER_FAILURE_NOTICES: Record = { + caption: + "> ⚠️ The Mermaid diagram requires an immediately preceding **Diagram — …** caption.", + size: "> ⚠️ The Mermaid diagram source exceeds the 20 KB limit.", + kind: + "> ⚠️ Unsupported Mermaid diagram kind (supported: flowchart, sequenceDiagram, stateDiagram-v2, classDiagram, erDiagram).", + count: + "> ⚠️ A description accepts at most three Mermaid diagrams; this one was not rendered.", + render: "> ⚠️ The Mermaid diagram could not be rendered." +}; + +const MARKDOWN_IMAGE_PATTERN = + /!\[((?:\\.|[^\]])*)\]\(((?:\\.|[^\s)])+)(?:\s+((?:"(?:\\.|[^"])*")|'(?:\\.|[^'])*'))?\)/g; +const MARKDOWN_ALT_ESCAPE_PATTERN = /\\([[\]])/g; + +function escapeAltText(caption: string): string { + return caption.replace(/([[\]])/g, "\\$1"); +} + +function escapeHtmlAttribute(value: string): string { + return value.replace(/[&<>"']/g, character => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +/** + * VS Code's native Markdown renderer supports a safe HTML subset, including + * percentage image widths. Convert Markdown images to that subset so they + * use the available width of comments and preview surfaces. + */ +export function makeRenderedImagesResponsive(markdown: string): string { + const replaceImages = (content: string): string => + content.replace( + MARKDOWN_IMAGE_PATTERN, + ( + _, + escapedAltText: string, + escapedDestination: string, + quotedTitle: string | undefined + ) => { + const altText = escapedAltText.replace( + MARKDOWN_ALT_ESCAPE_PATTERN, + "$1" + ); + const destination = escapedDestination.replace(/\\([()\\])/g, "$1"); + const title = quotedTitle + ? quotedTitle + .slice(1, -1) + .replace(/\\([\\"'])/g, "$1") + : undefined; + return ( + '' +
+          escapeHtmlAttribute(altText) +
+          '' + ); + } + ); + + let fence: { character: string; length: number } | undefined; + return markdown + .split("\n") + .map(line => { + const marker = line.match(/^\s*([\x60]{3,}|~{3,})/); + if (marker) { + const character = marker[1][0]; + if (!fence) { + fence = { character, length: marker[1].length }; + } else if ( + fence.character === character && + marker[1].length >= fence.length + ) { + fence = undefined; + } + return line; + } + + return fence ? line : replaceImages(line); + }) + .join("\n"); +} + +export async function renderDescription( + description: string, + theme: DescriptionTheme +): Promise { + const fences = findDiagramFences(description); + if (fences.length === 0) { + return description; + } + + let content = description; + for (let index = fences.length - 1; index >= 0; index--) { + const fence = fences[index]; + let replacement: string; + + if (index >= MAX_DIAGRAMS_PER_DESCRIPTION) { + replacement = RENDER_FAILURE_NOTICES.count; + } else if (!fence.closed) { + replacement = RENDER_FAILURE_NOTICES.render; + } else { + const evaluation = evaluateDiagramFence(fence); + if (!evaluation.allowed) { + replacement = RENDER_FAILURE_NOTICES[evaluation.reason]; + } else { + try { + const { png } = await renderMermaidDiagram(fence.source, theme); + replacement = `![${escapeAltText( + fence.caption! + )}](data:image/png;base64,${png.toString("base64")})`; + } catch { + replacement = RENDER_FAILURE_NOTICES.render; + } + } + } + + content = + content.slice(0, fence.start) + replacement + content.slice(fence.end); + } + + return content; +} diff --git a/packages/description-renderer/src/dom.ts b/packages/description-renderer/src/dom.ts new file mode 100644 index 00000000..20cd866b --- /dev/null +++ b/packages/description-renderer/src/dom.ts @@ -0,0 +1,518 @@ +import { JSDOM } from "jsdom"; +import { + DEFAULT_FONT_SIZE, + LINE_HEIGHT_FACTOR, + measureTextWidth +} from "./measure"; + +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + +const DOM_GLOBAL_KEYS = [ + "window", + "self", + "document", + "navigator", + "location", + "history", + "getComputedStyle", + "DOMParser", + "XMLSerializer", + "Node", + "Element", + "HTMLElement", + "HTMLAnchorElement", + "HTMLImageElement", + "Image", + "SVGElement", + "MutationObserver", + "CSSStyleDeclaration" +]; + +const NON_RENDERED_ELEMENTS = new Set([ + "defs", + "style", + "title", + "desc", + "metadata", + "clipPath", + "mask", + "pattern", + "symbol", + "marker", + "linearGradient", + "radialGradient", + "filter", + "script" +]); + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +interface Matrix { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +const IDENTITY: Matrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +let environment: { window: DomWindow } | undefined; + +export interface DomWindow { + document: Document; + DOMParser: typeof DOMParser; + XMLSerializer: typeof XMLSerializer; + SVGElement: typeof SVGElement; + close(): void; +} + +export function ensureDomEnvironment(): DomWindow { + if (environment) { + return environment.window; + } + + const dom = new JSDOM("", { + pretendToBeVisual: false, + url: "https://codetour.invalid/" + }); + + installSvgMeasurement(dom.window); + + environment = dom; + + const globals = globalThis as unknown as Record; + const domGlobals = dom.window as unknown as Record; + for (const key of DOM_GLOBAL_KEYS) { + if (domGlobals[key] === undefined) { + continue; + } + Object.defineProperty(globals, key, { + value: domGlobals[key], + configurable: true, + writable: true, + enumerable: true + }); + } + + return dom.window; +} + +function installSvgMeasurement(window: DomWindow) { + const prototype = window.SVGElement && window.SVGElement.prototype; + if (!prototype) { + return; + } + + const svgPrototype = prototype as unknown as Record; + + if (typeof svgPrototype.getBBox !== "function") { + svgPrototype.getBBox = function(this: Element): Box { + return measureElementBox(this); + }; + } + + if (typeof svgPrototype.getComputedTextLength !== "function") { + svgPrototype.getComputedTextLength = function(this: Element): number { + return measureTextWidth(this.textContent || "", fontSizeOf(this)); + }; + } + + svgPrototype.getBoundingClientRect = function(this: Element) { + const box = measureTextBox(this); + return { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + top: box.y, + left: box.x, + right: box.x + box.width, + bottom: box.y + box.height, + toJSON() { + return box; + } + }; + }; +} + +function coordinateOf(value: string | null, fontSize: number): number | undefined { + if (value === null) { + return undefined; + } + const parsed = parseFloat(value); + if (isNaN(parsed)) { + return undefined; + } + if (value.trim().endsWith("em")) { + return parsed * fontSize; + } + return parsed; +} + +function fontSizeOf(element: Element): number { + let current: Element | null = element; + while (current) { + const attribute = coordinateOf(current.getAttribute("font-size"), DEFAULT_FONT_SIZE); + if (attribute !== undefined) { + return attribute; + } + const style = current.getAttribute("style"); + if (style) { + const match = style.match(/font-size:\s*([\d.]+)(px|em)?/); + if (match) { + const size = parseFloat(match[1]); + return match[2] === "em" ? size * DEFAULT_FONT_SIZE : size; + } + } + current = current.parentElement; + } + return DEFAULT_FONT_SIZE; +} + +function measureTextBox(element: Element): Box { + const fontSize = fontSizeOf(element); + const x = coordinateOf(element.getAttribute("x"), fontSize) || 0; + const tspans = Array.from(element.children).filter( + child => child.localName === "tspan" + ); + + let width = 0; + let top = Infinity; + let bottom = -Infinity; + + const measureLine = (text: string, y: number) => { + width = Math.max(width, measureTextWidth(text, fontSize)); + top = Math.min(top, y - fontSize * 0.85); + bottom = Math.max(bottom, y + fontSize * 0.3); + }; + + if (tspans.length > 0) { + let lineY = coordinateOf(element.getAttribute("y"), fontSize) || 0; + for (const tspan of tspans) { + const explicitY = coordinateOf(tspan.getAttribute("y"), fontSize); + const dy = coordinateOf(tspan.getAttribute("dy"), fontSize); + if (explicitY !== undefined) { + lineY = explicitY; + } else if (dy !== undefined) { + lineY += dy; + } else { + lineY += fontSize * LINE_HEIGHT_FACTOR; + } + measureLine(tspan.textContent || "", lineY); + } + } else { + measureLine( + element.textContent || "", + coordinateOf(element.getAttribute("y"), fontSize) || 0 + ); + } + + if (top === Infinity) { + top = -fontSize; + bottom = fontSize * 0.3; + } + + return { + x, + y: top, + width, + height: Math.max(bottom - top, fontSize * LINE_HEIGHT_FACTOR) + }; +} + +function union(boxes: Box[]): Box { + const minX = Math.min(...boxes.map(box => box.x)); + const minY = Math.min(...boxes.map(box => box.y)); + const maxX = Math.max(...boxes.map(box => box.x + box.width)); + const maxY = Math.max(...boxes.map(box => box.y + box.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +function multiplyMatrices(first: Matrix, second: Matrix): Matrix { + return { + a: first.a * second.a + first.c * second.b, + b: first.b * second.a + first.d * second.b, + c: first.a * second.c + first.c * second.d, + d: first.b * second.c + first.d * second.d, + e: first.a * second.e + first.c * second.f + first.e, + f: first.b * second.e + first.d * second.f + first.f + }; +} + +function parseTransform(value: string | null): Matrix { + if (!value) { + return IDENTITY; + } + + let result = IDENTITY; + const pattern = /(translate|scale|rotate|matrix)\s*\(([^)]*)\)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(value))) { + const args = match[2] + .split(/[\s,]+/) + .map(Number) + .filter(number => !isNaN(number)); + + let transform: Matrix; + if (match[1] === "translate") { + transform = { a: 1, b: 0, c: 0, d: 1, e: args[0] || 0, f: args[1] || 0 }; + } else if (match[1] === "scale") { + const scaleX = args[0] === undefined ? 1 : args[0]; + const scaleY = args[1] === undefined ? scaleX : args[1]; + transform = { a: scaleX, b: 0, c: 0, d: scaleY, e: 0, f: 0 }; + } else if (match[1] === "rotate") { + const angle = ((args[0] || 0) * Math.PI) / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const rotation = { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }; + if (args.length > 2) { + transform = multiplyMatrices( + multiplyMatrices({ a: 1, b: 0, c: 0, d: 1, e: args[1], f: args[2] }, rotation), + { a: 1, b: 0, c: 0, d: 1, e: -args[1], f: -args[2] } + ); + } else { + transform = rotation; + } + } else { + transform = { + a: args[0] || 1, + b: args[1] || 0, + c: args[2] || 0, + d: args[3] || 1, + e: args[4] || 0, + f: args[5] || 0 + }; + } + + result = multiplyMatrices(result, transform); + } + + return result; +} + +function transformBox(box: Box, matrix: Matrix): Box { + const points = [ + { x: box.x, y: box.y }, + { x: box.x + box.width, y: box.y }, + { x: box.x, y: box.y + box.height }, + { x: box.x + box.width, y: box.y + box.height } + ].map(point => ({ + x: matrix.a * point.x + matrix.c * point.y + matrix.e, + y: matrix.b * point.x + matrix.d * point.y + matrix.f + })); + + return union(points.map(point => ({ ...point, width: 0, height: 0 }))); +} + +function measureElementBox(element: Element, inherited: Matrix = IDENTITY): Box { + const matrix = multiplyMatrices( + inherited, + parseTransform(element.getAttribute("transform")) + ); + + const tag = element.localName; + + if (tag === "text" || tag === "tspan") { + return transformBox(measureTextBox(element), matrix); + } + + if (tag === "rect") { + return transformBox( + { + x: coordinateOf(element.getAttribute("x"), DEFAULT_FONT_SIZE) || 0, + y: coordinateOf(element.getAttribute("y"), DEFAULT_FONT_SIZE) || 0, + width: coordinateOf(element.getAttribute("width"), DEFAULT_FONT_SIZE) || 0, + height: coordinateOf(element.getAttribute("height"), DEFAULT_FONT_SIZE) || 0 + }, + matrix + ); + } + + if (tag === "circle") { + const r = coordinateOf(element.getAttribute("r"), DEFAULT_FONT_SIZE) || 0; + const cx = coordinateOf(element.getAttribute("cx"), DEFAULT_FONT_SIZE) || 0; + const cy = coordinateOf(element.getAttribute("cy"), DEFAULT_FONT_SIZE) || 0; + return transformBox({ x: cx - r, y: cy - r, width: r * 2, height: r * 2 }, matrix); + } + + if (tag === "ellipse") { + const rx = coordinateOf(element.getAttribute("rx"), DEFAULT_FONT_SIZE) || 0; + const ry = coordinateOf(element.getAttribute("ry"), DEFAULT_FONT_SIZE) || 0; + const cx = coordinateOf(element.getAttribute("cx"), DEFAULT_FONT_SIZE) || 0; + const cy = coordinateOf(element.getAttribute("cy"), DEFAULT_FONT_SIZE) || 0; + return transformBox({ x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 }, matrix); + } + + if (tag === "line") { + const x1 = coordinateOf(element.getAttribute("x1"), DEFAULT_FONT_SIZE) || 0; + const y1 = coordinateOf(element.getAttribute("y1"), DEFAULT_FONT_SIZE) || 0; + const x2 = coordinateOf(element.getAttribute("x2"), DEFAULT_FONT_SIZE) || 0; + const y2 = coordinateOf(element.getAttribute("y2"), DEFAULT_FONT_SIZE) || 0; + return transformBox( + union([ + { x: x1, y: y1, width: 0, height: 0 }, + { x: x2, y: y2, width: 0, height: 0 } + ]), + matrix + ); + } + + if (tag === "path") { + return pathBox(element, matrix); + } + + const boxes = Array.from(element.children) + .filter(child => !NON_RENDERED_ELEMENTS.has(child.localName)) + .map(child => measureElementBox(child, matrix)); + if (boxes.length === 0) { + return transformBox({ x: 0, y: 0, width: 0, height: 0 }, matrix); + } + return union(boxes); +} + +function pathBox(element: Element, matrix: Matrix): Box { + const commands = element.getAttribute("d") || ""; + const pattern = /([MLCQTAZmlcqtaz])([^MLCQTAZmlcqtaz]*)/g; + let currentX = 0; + let currentY = 0; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + const track = (x: number, y: number) => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + }; + + let match: RegExpExecArray | null; + while ((match = pattern.exec(commands))) { + const command = match[1]; + const args = match[2] + .trim() + .split(/[\s,]+/) + .filter(part => part !== "") + .map(Number) + .filter(number => !isNaN(number)); + + if (command === "M" || command === "L" || command === "T") { + for (let index = 0; index + 1 < args.length; index += 2) { + currentX = args[index]; + currentY = args[index + 1]; + track(currentX, currentY); + } + } else if (command === "m" || command === "l" || command === "t") { + for (let index = 0; index + 1 < args.length; index += 2) { + currentX += args[index]; + currentY += args[index + 1]; + track(currentX, currentY); + } + } else if (command === "C") { + for (let index = 0; index + 5 < args.length; index += 6) { + track(args[index], args[index + 1]); + track(args[index + 2], args[index + 3]); + currentX = args[index + 4]; + currentY = args[index + 5]; + track(currentX, currentY); + } + } else if (command === "c") { + for (let index = 0; index + 5 < args.length; index += 6) { + track(currentX + args[index], currentY + args[index + 1]); + track(currentX + args[index + 2], currentY + args[index + 3]); + currentX += args[index + 4]; + currentY += args[index + 5]; + track(currentX, currentY); + } + } else if (command === "Q" || command === "S") { + for (let index = 0; index + 3 < args.length; index += 4) { + track(args[index], args[index + 1]); + currentX = args[index + 2]; + currentY = args[index + 3]; + track(currentX, currentY); + } + } else if (command === "q" || command === "s") { + for (let index = 0; index + 3 < args.length; index += 4) { + track(currentX + args[index], currentY + args[index + 1]); + currentX += args[index + 2]; + currentY += args[index + 3]; + track(currentX, currentY); + } + } else if (command === "H") { + for (const value of args) { + currentX = value; + track(currentX, currentY); + } + } else if (command === "h") { + for (const value of args) { + currentX += value; + track(currentX, currentY); + } + } else if (command === "V") { + for (const value of args) { + currentY = value; + track(currentX, currentY); + } + } else if (command === "v") { + for (const value of args) { + currentY += value; + track(currentX, currentY); + } + } else if (command === "A") { + for (let index = 5; index < args.length; index += 7) { + currentX = args[index]; + currentY = args[index + 1]; + track(currentX, currentY); + } + } else if (command === "a") { + for (let index = 5; index < args.length; index += 7) { + currentX += args[index]; + currentY += args[index + 1]; + track(currentX, currentY); + } + } + } + + if (minX === Infinity) { + return transformBox({ x: 0, y: 0, width: 0, height: 0 }, matrix); + } + + return transformBox( + { x: minX, y: minY, width: maxX - minX, height: maxY - minY }, + matrix + ); +} + +export function measureContentBounds(root: Element): Box { + const boxes = Array.from(root.children) + .filter(child => !NON_RENDERED_ELEMENTS.has(child.localName)) + .map(child => measureElementBox(child)); + if (boxes.length === 0) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + return union(boxes); +} + +export function parseSvgDocument(svg: string): Document { + const window = ensureDomEnvironment(); + const document = new window.DOMParser().parseFromString(svg, "image/svg+xml"); + if (document.documentElement.localName !== "svg") { + throw new Error("The rendered diagram is not a valid SVG document"); + } + return document; +} + +export function serializeSvgDocument(document: Document): string { + const window = ensureDomEnvironment(); + return new window.XMLSerializer().serializeToString(document.documentElement); +} + +export { SVG_NAMESPACE }; diff --git a/packages/description-renderer/src/index.ts b/packages/description-renderer/src/index.ts new file mode 100644 index 00000000..6b66f3ac --- /dev/null +++ b/packages/description-renderer/src/index.ts @@ -0,0 +1,31 @@ +export { + makeRenderedImagesResponsive, + renderDescription +} from "./description"; +export { + clearMermaidRenderCache, + DESCRIPTION_RENDERER_VERSION, + invalidateMermaidRenderCache, + renderMermaidDiagram +} from "./render"; +export type { DescriptionTheme, RenderedDiagram } from "./render"; +export { sanitizeSvg } from "./sanitize"; +export { findDiagramFences } from "./parse"; +export type { DiagramFence } from "./parse"; +export { + ALLOWED_DIAGRAM_KINDS, + MAX_DIAGRAMS_PER_DESCRIPTION, + MAX_DIAGRAM_SOURCE_BYTES, + diagramKindOf, + diagramSourceByteLength, + evaluateDiagramFence, + isAllowedDiagramKind, + isMermaidFenceInfo, + matchDiagramCaption +} from "./rules"; +export type { + AllowedDiagramKind, + DiagramFenceEvaluation, + DiagramFenceInput, + DiagramRuleReason +} from "./rules"; diff --git a/packages/description-renderer/src/measure.ts b/packages/description-renderer/src/measure.ts new file mode 100644 index 00000000..3174fa97 --- /dev/null +++ b/packages/description-renderer/src/measure.ts @@ -0,0 +1,66 @@ +const NARROW_CHARACTERS = new Set([ + "i", + "I", + "l", + "j", + "t", + "f", + ".", + ",", + ":", + ";", + "'", + "`", + "|", + "!", + "(", + ")", + "[", + "]", + "{", + "}", + "/", + "\\", + "-" +]); + +const WIDE_CHARACTERS = new Set(["m", "M", "w", "W", "@", "%", "&"]); + +export const DEFAULT_FONT_SIZE = 16; +export const LINE_HEIGHT_FACTOR = 1.25; + +function characterWidthFactor(character: string): number { + if (character === " ") { + return 0.3; + } + if (character === "\t") { + return 1.2; + } + if (NARROW_CHARACTERS.has(character)) { + return 0.32; + } + if (WIDE_CHARACTERS.has(character)) { + return 0.95; + } + if (character >= "A" && character <= "Z") { + return 0.72; + } + if (character >= "0" && character <= "9") { + return 0.58; + } + if (character === "—" || character === "–") { + return 1; + } + if (character.charCodeAt(0) > 0x2000) { + return 1; + } + return 0.55; +} + +export function measureTextWidth(text: string, fontSize: number): number { + let width = 0; + for (const character of text) { + width += characterWidthFactor(character); + } + return width * fontSize; +} diff --git a/packages/description-renderer/src/parse.ts b/packages/description-renderer/src/parse.ts new file mode 100644 index 00000000..ce613641 --- /dev/null +++ b/packages/description-renderer/src/parse.ts @@ -0,0 +1,81 @@ +import { isMermaidFenceInfo, matchDiagramCaption } from "./rules"; + +const FENCE_INFO_PATTERN = /^\s*```(.*)$/; +const FENCE_CLOSE_PATTERN = /^\s*```[ \t]*$/; +const FENCE_OPEN_PATTERN = /^\s*```/; + +export interface DiagramFence { + caption?: string; + source: string; + closed: boolean; + start: number; + end: number; +} + +function findCaption(lines: string[], fenceIndex: number): string | undefined { + for (let index = fenceIndex - 1; index >= 0; index--) { + const line = lines[index]; + if (line.trim() === "") { + continue; + } + return matchDiagramCaption(line); + } + return undefined; +} + +export function findDiagramFences(description: string): DiagramFence[] { + const lines = description.split("\n"); + const lineOffsets: number[] = []; + let offset = 0; + for (const line of lines) { + lineOffsets.push(offset); + offset += line.length + 1; + } + + const fences: DiagramFence[] = []; + + let index = 0; + while (index < lines.length) { + if (!FENCE_OPEN_PATTERN.test(lines[index])) { + index++; + continue; + } + + let closeIndex = -1; + for (let candidate = index + 1; candidate < lines.length; candidate++) { + if (FENCE_CLOSE_PATTERN.test(lines[candidate])) { + closeIndex = candidate; + break; + } + } + + if (closeIndex === -1) { + const infoMatch = lines[index].match(FENCE_INFO_PATTERN); + if (infoMatch && isMermaidFenceInfo(infoMatch[1])) { + fences.push({ + caption: findCaption(lines, index), + source: lines.slice(index + 1).join("\n"), + closed: false, + start: lineOffsets[index], + end: description.length + }); + } + break; + } + + const infoMatch = lines[index].match(FENCE_INFO_PATTERN); + if (infoMatch && isMermaidFenceInfo(infoMatch[1])) { + fences.push({ + caption: findCaption(lines, index), + source: lines.slice(index + 1, closeIndex).join("\n"), + closed: true, + start: lineOffsets[index], + end: lineOffsets[closeIndex] + lines[closeIndex].length + }); + } + + index = closeIndex + 1; + } + + return fences; +} diff --git a/packages/description-renderer/src/rasterize.ts b/packages/description-renderer/src/rasterize.ts new file mode 100644 index 00000000..a5b0a7f4 --- /dev/null +++ b/packages/description-renderer/src/rasterize.ts @@ -0,0 +1,21 @@ +import { Resvg } from "@resvg/resvg-js"; + +const MAX_RASTERIZED_WIDTH = 2000; + +export function rasterizeSvg( + svg: string, + naturalWidth: number, + maxRasterizedWidth: number = MAX_RASTERIZED_WIDTH +): Buffer { + const resvg = new Resvg(svg, { + font: { + loadSystemFonts: true + }, + fitTo: + naturalWidth > maxRasterizedWidth + ? { mode: "width", value: maxRasterizedWidth } + : { mode: "original" } + }); + + return resvg.render().asPng(); +} diff --git a/packages/description-renderer/src/render.ts b/packages/description-renderer/src/render.ts new file mode 100644 index 00000000..b9b89616 --- /dev/null +++ b/packages/description-renderer/src/render.ts @@ -0,0 +1,218 @@ +import { ensureDomEnvironment, measureContentBounds } from "./dom"; +import { rasterizeSvg } from "./rasterize"; +import { sanitizeSvg } from "./sanitize"; +import { diagramKindOf, isAllowedDiagramKind } from "./rules"; + +export type DescriptionTheme = "light" | "dark"; + +export interface RenderedDiagram { + svg: string; + png: Buffer; +} + +/** + * Bump this when the renderer's output contract changes. The value is part of + * the in-memory cache key so a long-lived extension host never reuses output + * produced by an older renderer implementation. + */ +export const DESCRIPTION_RENDERER_VERSION = "0.1.0"; + +interface MermaidRenderResult { + svg: string; +} + +interface MermaidApi { + initialize(config: unknown): void; + parse(text: string): Promise; + render(id: string, text: string): Promise; +} + +const MERMAID_THEMES: Record = { + light: "default", + dark: "dark" +}; + +const MERMAID_RENDER_OPTIONS = Object.freeze({ + securityLevel: "strict", + htmlLabels: false, + flowchartHtmlLabels: false, + rasterizer: "@resvg/resvg-js@2.6.2", + maxRasterizedWidth: 2000, + viewBoxPadding: 2 +} as const); + +let mermaid: MermaidApi | undefined; +let mermaidLoad: Promise | undefined; +let diagramCounter = 0; +const renderedDiagramCache = new Map>(); +let renderQueue = Promise.resolve(); + +async function loadMermaid(): Promise { + const imported = await import("mermaid"); + return imported.default as unknown as MermaidApi; +} + +async function getMermaid(): Promise { + if (mermaid) { + return mermaid; + } + + const load = (mermaidLoad ??= loadMermaid()); + try { + mermaid = await load; + return mermaid; + } catch (error) { + if (mermaidLoad === load) { + mermaidLoad = undefined; + } + throw error; + } +} + +function mermaidConfiguration(theme: DescriptionTheme) { + return { + startOnLoad: false, + securityLevel: MERMAID_RENDER_OPTIONS.securityLevel as "strict", + theme: MERMAID_THEMES[theme], + htmlLabels: MERMAID_RENDER_OPTIONS.htmlLabels, + flowchart: { + htmlLabels: MERMAID_RENDER_OPTIONS.flowchartHtmlLabels + } + }; +} + +function normalizeSvgSize( + svg: string, + viewBoxPadding: number +): { svg: string; width: number } { + const window = ensureDomEnvironment(); + const document = new window.DOMParser().parseFromString(svg, "image/svg+xml"); + const root = document.documentElement; + + const viewBox = (root.getAttribute("viewBox") || "") + .split(/[\s,]+/) + .map(part => Number(part)) + .filter(part => !isNaN(part)); + + let bounds = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + + if (viewBox.length === 4) { + bounds = { x: viewBox[0], y: viewBox[1], width: viewBox[2], height: viewBox[3] }; + } else { + bounds.width = Number(root.getAttribute("width")) || 800; + bounds.height = Number(root.getAttribute("height")) || 600; + } + + const content = measureContentBounds(root); + bounds = { + x: Math.min(bounds.x, content.x - viewBoxPadding), + y: Math.min(bounds.y, content.y - viewBoxPadding), + width: 0, + height: 0 + }; + bounds.width = Math.max( + viewBox.length === 4 ? viewBox[0] + viewBox[2] : bounds.width, + content.x + content.width + viewBoxPadding + ) - bounds.x; + bounds.height = Math.max( + viewBox.length === 4 ? viewBox[1] + viewBox[3] : bounds.height, + content.y + content.height + viewBoxPadding + ) - bounds.y; + + root.setAttribute( + "viewBox", + `${bounds.x} ${bounds.y} ${Math.ceil(bounds.width)} ${Math.ceil(bounds.height)}` + ); + root.setAttribute("width", String(Math.ceil(bounds.width))); + root.setAttribute("height", String(Math.ceil(bounds.height))); + root.removeAttribute("style"); + + const normalized = new window.XMLSerializer().serializeToString(root); + return { svg: normalized, width: bounds.width }; +} + +async function renderMermaidDiagramUncached( + source: string, + theme: DescriptionTheme +): Promise { + const render = async () => { + const kind = diagramKindOf(source); + if (!kind || !isAllowedDiagramKind(kind)) { + throw new Error( + `Unsupported Mermaid diagram kind: ${kind ?? "(none detected)"}` + ); + } + + ensureDomEnvironment(); + + const renderer = await getMermaid(); + renderer.initialize(mermaidConfiguration(theme)); + await renderer.parse(source); + const { svg } = await renderer.render( + `codetour-diagram-${++diagramCounter}`, + source + ); + + const sanitized = sanitizeSvg(svg); + const normalized = normalizeSvgSize( + sanitized, + MERMAID_RENDER_OPTIONS.viewBoxPadding + ); + const png = rasterizeSvg( + normalized.svg, + normalized.width, + MERMAID_RENDER_OPTIONS.maxRasterizedWidth + ); + + return { svg: normalized.svg, png }; + }; + + const queued = renderQueue.then(render, render); + renderQueue = queued.then( + () => undefined, + () => undefined + ); + return queued; +} + +function renderCacheKey(source: string, theme: DescriptionTheme): string { + return JSON.stringify({ + source, + theme, + rendererVersion: DESCRIPTION_RENDERER_VERSION, + options: MERMAID_RENDER_OPTIONS + }); +} + +/** Clear all in-memory output, normally after VS Code changes its theme. */ +export function clearMermaidRenderCache(): void { + renderedDiagramCache.clear(); +} + +export const invalidateMermaidRenderCache = clearMermaidRenderCache; + +export function renderMermaidDiagram( + source: string, + theme: DescriptionTheme +): Promise { + const key = renderCacheKey(source, theme); + const cached = renderedDiagramCache.get(key); + if (cached) { + return cached; + } + + const rendered = renderMermaidDiagramUncached(source, theme); + renderedDiagramCache.set(key, rendered); + void rendered.catch(() => { + if (renderedDiagramCache.get(key) === rendered) { + renderedDiagramCache.delete(key); + } + }); + + return rendered; +} diff --git a/packages/description-renderer/src/rules.ts b/packages/description-renderer/src/rules.ts new file mode 100644 index 00000000..00e0dccb --- /dev/null +++ b/packages/description-renderer/src/rules.ts @@ -0,0 +1,76 @@ +export const ALLOWED_DIAGRAM_KINDS = [ + "flowchart", + "sequenceDiagram", + "stateDiagram-v2", + "classDiagram", + "erDiagram" +] as const; + +export type AllowedDiagramKind = (typeof ALLOWED_DIAGRAM_KINDS)[number]; + +export const MAX_DIAGRAMS_PER_DESCRIPTION = 3; + +export const MAX_DIAGRAM_SOURCE_BYTES = 20 * 1024; + +export type DiagramRuleReason = "caption" | "size" | "kind"; + +const MERMAID_FENCE_INFO_PATTERN = /^mermaid[ \t]*$/; + +const CAPTION_PATTERN = /^\s*\*\*(Diagram — (?:(?!\*\*).)+)\*\*[ \t]*$/; + +const SIGNIFICANT_LINE_PATTERN = /^(?!\s*%%)[^\s]/; + +export function isMermaidFenceInfo(info: string): boolean { + return MERMAID_FENCE_INFO_PATTERN.test(info); +} + +export function isAllowedDiagramKind(kind: string): kind is AllowedDiagramKind { + return (ALLOWED_DIAGRAM_KINDS as readonly string[]).includes(kind); +} + +export function matchDiagramCaption(line: string): string | undefined { + const match = line.match(CAPTION_PATTERN); + return match ? match[1].trim() : undefined; +} + +export function diagramKindOf(source: string): string | undefined { + for (const line of source.split("\n")) { + if (!SIGNIFICANT_LINE_PATTERN.test(line)) { + continue; + } + return line.trim().split(/\s+/)[0]; + } + return undefined; +} + +export function diagramSourceByteLength(source: string): number { + return Buffer.byteLength(source, "utf8"); +} + +export interface DiagramFenceInput { + caption?: string; + source: string; +} + +export type DiagramFenceEvaluation = + | { allowed: true; kind: AllowedDiagramKind } + | { allowed: false; reason: DiagramRuleReason; kind?: string }; + +export function evaluateDiagramFence( + fence: DiagramFenceInput +): DiagramFenceEvaluation { + if (!fence.caption) { + return { allowed: false, reason: "caption" }; + } + + if (diagramSourceByteLength(fence.source) > MAX_DIAGRAM_SOURCE_BYTES) { + return { allowed: false, reason: "size" }; + } + + const kind = diagramKindOf(fence.source); + if (!kind || !isAllowedDiagramKind(kind)) { + return { allowed: false, reason: "kind", kind }; + } + + return { allowed: true, kind }; +} diff --git a/packages/description-renderer/src/sanitize.ts b/packages/description-renderer/src/sanitize.ts new file mode 100644 index 00000000..fb27702f --- /dev/null +++ b/packages/description-renderer/src/sanitize.ts @@ -0,0 +1,55 @@ +import { parseSvgDocument, serializeSvgDocument } from "./dom"; + +const BLOCKED_ELEMENTS = new Set([ + "script", + "foreignObject", + "iframe", + "object", + "embed", + "use", + "link", + "meta", + "base", + "image", + "img" +]); + +const BLOCKED_ATTRIBUTE_PATTERN = /^on/i; +const EXTERNAL_REFERENCE_PATTERN = /^(?!#)/; + +function sanitizeElement(element: Element): void { + for (const child of Array.from(element.children)) { + if (child.localName === "a") { + sanitizeElement(child); + for (const grandChild of Array.from(child.children)) { + element.insertBefore(grandChild, child); + } + element.removeChild(child); + } else if (BLOCKED_ELEMENTS.has(child.localName)) { + element.removeChild(child); + } else { + sanitizeElement(child); + } + } + + for (const attribute of Array.from(element.attributes)) { + const name = attribute.name; + if (BLOCKED_ATTRIBUTE_PATTERN.test(name)) { + element.removeAttribute(name); + continue; + } + + if ( + (name === "href" || name === "xlink:href") && + EXTERNAL_REFERENCE_PATTERN.test(attribute.value.trim()) + ) { + element.removeAttribute(name); + } + } +} + +export function sanitizeSvg(svg: string): string { + const document = parseSvgDocument(svg); + sanitizeElement(document.documentElement); + return serializeSvgDocument(document); +} diff --git a/packages/description-renderer/test/cache.test.ts b/packages/description-renderer/test/cache.test.ts new file mode 100644 index 00000000..3f941d25 --- /dev/null +++ b/packages/description-renderer/test/cache.test.ts @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + clearMermaidRenderCache, + invalidateMermaidRenderCache, + renderMermaidDiagram +} from "../src/render"; +import { FLOWCHART_SOURCE } from "./helpers/fixtures"; + +test.beforeEach(() => { + clearMermaidRenderCache(); +}); + +test("renderMermaidDiagram reuses the exact source and effective theme", async () => { + const first = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + const second = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + + assert.strictEqual(second, first); +}); + +test("renderMermaidDiagram keeps exact source and theme variants separate", async () => { + const light = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + const dark = await renderMermaidDiagram(FLOWCHART_SOURCE, "dark"); + const sourceVariant = await renderMermaidDiagram( + `${FLOWCHART_SOURCE}\n`, + "light" + ); + + assert.notStrictEqual(dark, light); + assert.notStrictEqual(sourceVariant, light); + assert.notEqual(dark.svg, light.svg); +}); + +test("theme invalidation drops cached rendered diagrams", async () => { + const first = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + + invalidateMermaidRenderCache(); + + const second = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + assert.notStrictEqual(second, first); +}); diff --git a/packages/description-renderer/test/helpers/fixtures.ts b/packages/description-renderer/test/helpers/fixtures.ts new file mode 100644 index 00000000..83a4ff3e --- /dev/null +++ b/packages/description-renderer/test/helpers/fixtures.ts @@ -0,0 +1,213 @@ +export const PNG_SIGNATURE = Buffer.from([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a +]); + +export function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function extractPngDataUri(markdown: string, altText: string): Buffer { + const pattern = new RegExp( + `!\\[${escapeRegExp(altText)}\\]\\(data:image\\/png;base64,([A-Za-z0-9+/=]+)\\)` + ); + const match = markdown.match(pattern); + if (!match) { + throw new Error( + `Expected a PNG data URI image with alt text "${altText}" in:\n${markdown.slice(0, 400)}` + ); + } + return Buffer.from(match[1], "base64"); +} + +export function assertValidPng(png: Buffer): void { + if (png.length < 200) { + throw new Error(`Expected a non-trivial PNG, got ${png.length} bytes`); + } + if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new Error("Expected a PNG signature at the start of the image"); + } + const width = png.readUInt32BE(16); + const height = png.readUInt32BE(20); + if (width < 20 || height < 20) { + throw new Error(`Expected a visible diagram size, got ${width}x${height}`); + } + return void [width, height]; +} + +export function captionedDiagram(caption: string, source: string): string { + return ["**" + caption + "**", "", "```mermaid", source, "```"].join("\n"); +} + +export function findImageLine(markdown: string, altText: string): string { + const prefix = escapeRegExp(altText).replace( + /(\\\[|\\\])/g, + "\\\\?$1" + ); + const pattern = new RegExp(`^!\\[${prefix}`); + const line = markdown.split("\n").find(line => pattern.test(line)); + if (!line) { + throw new Error( + `Expected an image line with alt text "${altText}" in:\n${markdown.slice(0, 400)}` + ); + } + return line; +} + +export const FLOWCHART_SOURCE = [ + "flowchart TD", + " Client --> Gateway --> Service", + " Service --> Database" +].join("\n"); + +export const CAPTIONED_FLOWCHART_DESCRIPTION = [ + "The request flows through three layers:", + "", + "**Diagram — Request lifecycle**", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + "That is the whole story." +].join("\n"); + +export const ALLOWED_KIND_SOURCES: Record = { + flowchart: FLOWCHART_SOURCE, + sequenceDiagram: [ + "sequenceDiagram", + " autonumber", + " participant User", + " participant Player as Tour Player", + " User->>Player: Start tour", + " Player-->>User: Step ready", + " note right of Player: Diagrams render per fence" + ].join("\n"), + "stateDiagram-v2": [ + "stateDiagram-v2", + " [*] --> Idle", + " Idle --> Loading: start tour", + " Loading --> Playing: step found", + " note right of Loading : waiting for the file", + " Playing --> Done: finish", + " Done --> [*]" + ].join("\n"), + classDiagram: [ + "classDiagram", + " class CodeTour {", + " +string title", + " +Step[] steps", + " +start() void", + " -persist()", + " }", + " class Step {", + " +string description", + " }", + " CodeTour \"1\" *-- \"many\" Step : contains" + ].join("\n"), + erDiagram: [ + "erDiagram", + " TOUR ||--o{ STEP : contains", + " STEP ||--|| FILE : anchors", + " TOUR {", + " string title \"the tour title\"", + " }", + " STEP {", + " string description", + " int line", + " }" + ].join("\n") +}; + +export const ALLOWED_KIND_CAPTIONS: Record = { + flowchart: "Diagram — Request lifecycle", + sequenceDiagram: "Diagram — Tour playback sequence", + "stateDiagram-v2": "Diagram — Tour playback states", + classDiagram: "Diagram — Tour data model", + erDiagram: "Diagram — Tour entity relationships" +}; + +export const UNSUPPORTED_KIND_SOURCES: Record = { + pie: ['pie title Pets adopted', ' "Dogs" : 386', ' "Cats" : 85'].join( + "\n" + ), + gantt: [ + "gantt", + " title Release plan", + " dateFormat YYYY-MM-DD", + " section Build", + " Package :p1, 2024-01-01, 3d" + ].join("\n"), + gitGraph: ["gitGraph", " commit", " branch feature", " commit"].join( + "\n" + ), + mindmap: ["mindmap", " root((tour))", " steps"].join("\n"), + journey: [ + "journey", + " title Taking a tour", + " section Start", + " Me: 5: Me" + ].join("\n"), + "unknown kind": ["squarewave TD", " A --> B"].join("\n") +}; + +export function flowchartOfExactByteLength(bytes: number): string { + const base = "flowchart TD\n A --> B\n%%"; + const padding = bytes - Buffer.byteLength(base, "utf8"); + if (padding < 0) { + throw new Error(`Cannot build a flowchart of only ${bytes} bytes`); + } + return base + "x".repeat(padding); +} + +export const HOSTILE_LABEL_SOURCES: Record = { + flowchart: [ + "flowchart TD", + ' A[""] --> B[""]' + ].join("\n"), + sequenceDiagram: [ + "sequenceDiagram", + ' participant U as ""', + ' U->>U: ""' + ].join("\n"), + "stateDiagram-v2": [ + "stateDiagram-v2", + " [*] --> Idle", + ' Idle --> Playing: ""', + ' state "" as S', + " Playing --> S" + ].join("\n"), + classDiagram: [ + "classDiagram", + ' class Foo[""] {', + " +string bar", + " }", + ' note for Foo "a note with "' + ].join("\n"), + erDiagram: [ + "erDiagram", + " TOUR {", + ' string title "comment "', + " }" + ].join("\n") +}; + +export const HOSTILE_INTERACTION_SOURCE = [ + "flowchart TD", + " A --> B", + ' click A "https://example.com" "tooltip"', + ' click B href "https://example.com/linked" "tooltip"', + ' click A call alert(1) "tooltip"', + " linkStyle 0 stroke:#f66,stroke-width:2px" +].join("\n"); + +export const HOSTILE_MARKDOWN_LABEL_SOURCE = [ + "flowchart TD", + ' A["See [docs](https://evil.example) and [run](command:codetour.nextTourStep)"] --> B' +].join("\n"); diff --git a/packages/description-renderer/test/mermaid-save.test.ts b/packages/description-renderer/test/mermaid-save.test.ts new file mode 100644 index 00000000..3aae9e35 --- /dev/null +++ b/packages/description-renderer/test/mermaid-save.test.ts @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { renderDescription } from "../src/description"; +import { clearMermaidRenderCache, renderMermaidDiagram } from "../src/render"; +import { + assertValidPng, + captionedDiagram, + extractPngDataUri, + FLOWCHART_SOURCE +} from "./helpers/fixtures"; + +// Regression for the "Save Step" flow not re-rendering Mermaid. After the +// source is edited and saved, the preview must show a PNG for the new source, +// hide the fence, and no longer match the previous diagram. +const PRIOR_SOURCE = FLOWCHART_SOURCE; // Client --> Gateway --> Service +const SAVED_SOURCE = [ + "flowchart TD", + " Author --> Reviewer --> Merge" +].join("\n"); +const CAPTION = "Diagram — Saved step"; + +test.beforeEach(() => { + clearMermaidRenderCache(); +}); + +test("the preview after a save renders the new source as a PNG without a fence", async () => { + // Before the edit, the step previewed the prior diagram. + const priorPreview = await renderDescription( + captionedDiagram(CAPTION, PRIOR_SOURCE), + "light" + ); + assertValidPng(extractPngDataUri(priorPreview, CAPTION)); + + // After saving the edited source, the preview is regenerated from it. + const savedPreview = await renderDescription( + captionedDiagram(CAPTION, SAVED_SOURCE), + "light" + ); + + assertValidPng(extractPngDataUri(savedPreview, CAPTION)); + assert.ok(!savedPreview.includes("```mermaid")); + assert.ok(!savedPreview.includes("flowchart TD")); + assert.ok(!savedPreview.includes("Author --> Reviewer")); + assert.ok(!savedPreview.includes("Client --> Gateway")); +}); + +test("the regenerated image matches the saved source, not the prior one", async () => { + const prior = await renderMermaidDiagram(PRIOR_SOURCE, "light"); + const saved = await renderMermaidDiagram(SAVED_SOURCE, "light"); + + assert.ok(prior.svg.includes("Client")); + assert.ok(!prior.svg.includes("Author")); + assert.ok(saved.svg.includes("Author")); + assert.ok(!saved.svg.includes("Client")); + assert.ok(Buffer.compare(saved.png, prior.png) !== 0); +}); diff --git a/packages/description-renderer/test/offline.test.ts b/packages/description-renderer/test/offline.test.ts new file mode 100644 index 00000000..0f9b103b --- /dev/null +++ b/packages/description-renderer/test/offline.test.ts @@ -0,0 +1,141 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { renderDescription } from "../src/description"; +import { + ALLOWED_KIND_CAPTIONS, + ALLOWED_KIND_SOURCES, + CAPTIONED_FLOWCHART_DESCRIPTION, + assertValidPng, + captionedDiagram, + extractPngDataUri +} from "./helpers/fixtures"; + +function blockNetwork(): { attempts: () => number; restore: () => void } { + const originalFetch = globalThis.fetch; + const originalConnect = net.Socket.prototype.connect; + let attempted = 0; + + const countAttempt = () => { + attempted++; + }; + + const failingConnect = (function( + this: net.Socket, + ...args: unknown[] + ): net.Socket { + countAttempt(); + throw new Error("Network access was attempted during rendering"); + } as unknown as typeof net.Socket.prototype.connect); + + globalThis.fetch = (async () => { + countAttempt(); + throw new Error("Network access was attempted during rendering"); + }) as typeof fetch; + + Object.defineProperty(net.Socket.prototype, "connect", { + value: failingConnect, + configurable: true, + writable: true, + enumerable: false + }); + + return { + attempts: () => attempted, + restore() { + globalThis.fetch = originalFetch; + Object.defineProperty(net.Socket.prototype, "connect", { + value: originalConnect, + configurable: true, + writable: true, + enumerable: false + }); + } + }; +} + +function listFilesRecursive(directory: string): string[] { + const entries: string[] = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + entries.push(...listFilesRecursive(entryPath)); + } else { + entries.push(entryPath); + } + } + return entries; +} + +test("renderDescription renders a flowchart with network access blocked", async () => { + const network = blockNetwork(); + + try { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + assertValidPng(extractPngDataUri(content, "Diagram — Request lifecycle")); + assert.equal(network.attempts(), 0); + } finally { + network.restore(); + } +}); + +test("renderDescription renders the other allowed kinds with network access blocked", async () => { + const network = blockNetwork(); + + try { + for (const kind of [ + "sequenceDiagram", + "stateDiagram-v2", + "classDiagram", + "erDiagram" + ]) { + const caption = ALLOWED_KIND_CAPTIONS[kind]; + const content = await renderDescription( + captionedDiagram(caption, ALLOWED_KIND_SOURCES[kind]), + "light" + ); + assertValidPng(extractPngDataUri(content, caption)); + } + assert.equal(network.attempts(), 0); + } finally { + network.restore(); + } +}); + +test("renderDescription creates no generated SVG or PNG file", async () => { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "codetour-renderer-")); + const previousCwd = process.cwd(); + + try { + process.chdir(sandbox); + const description = [ + CAPTIONED_FLOWCHART_DESCRIPTION, + "", + captionedDiagram( + ALLOWED_KIND_CAPTIONS["stateDiagram-v2"], + ALLOWED_KIND_SOURCES["stateDiagram-v2"] + ) + ].join("\n"); + const content = await renderDescription(description, "dark"); + assertValidPng(extractPngDataUri(content, "Diagram — Request lifecycle")); + assertValidPng( + extractPngDataUri(content, ALLOWED_KIND_CAPTIONS["stateDiagram-v2"]) + ); + + const files = listFilesRecursive(sandbox); + assert.deepEqual( + files, + [], + "Expected the renderer to leave the workspace without generated assets" + ); + } finally { + process.chdir(previousCwd); + fs.rmSync(sandbox, { recursive: true, force: true }); + } +}); diff --git a/packages/description-renderer/test/rules.test.ts b/packages/description-renderer/test/rules.test.ts new file mode 100644 index 00000000..bf66b6b6 --- /dev/null +++ b/packages/description-renderer/test/rules.test.ts @@ -0,0 +1,160 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + ALLOWED_DIAGRAM_KINDS, + MAX_DIAGRAMS_PER_DESCRIPTION, + MAX_DIAGRAM_SOURCE_BYTES, + diagramKindOf, + diagramSourceByteLength, + evaluateDiagramFence, + isAllowedDiagramKind, + isMermaidFenceInfo, + matchDiagramCaption +} from "../src/rules"; + +test("the allowlist accepts exactly the five first-version diagram kinds", () => { + assert.deepEqual(ALLOWED_DIAGRAM_KINDS, [ + "flowchart", + "sequenceDiagram", + "stateDiagram-v2", + "classDiagram", + "erDiagram" + ]); + assert.equal(MAX_DIAGRAMS_PER_DESCRIPTION, 3); + assert.equal(MAX_DIAGRAM_SOURCE_BYTES, 20 * 1024); +}); + +test("diagramKindOf reads the kind from the first significant line", () => { + assert.equal(diagramKindOf("flowchart TD\n A --> B"), "flowchart"); + assert.equal(diagramKindOf("sequenceDiagram\n A->>B: hi"), "sequenceDiagram"); + assert.equal( + diagramKindOf("stateDiagram-v2\n [*] --> Idle"), + "stateDiagram-v2" + ); + assert.equal(diagramKindOf("classDiagram\n class A"), "classDiagram"); + assert.equal(diagramKindOf("erDiagram\n A ||--o{ B : c"), "erDiagram"); +}); + +test("diagramKindOf skips blank lines, comments and init directives", () => { + assert.equal( + diagramKindOf("\n\n%% a comment\n%%{init: {'theme':'base'}}%%\nflowchart TD\n A --> B"), + "flowchart" + ); +}); + +test("diagramKindOf returns undefined for empty or comment-only sources", () => { + assert.equal(diagramKindOf(""), undefined); + assert.equal(diagramKindOf("%% only a comment"), undefined); + assert.equal(diagramKindOf(" \n \n"), undefined); +}); + +test("diagramKindOf takes the first token, not the whole line", () => { + assert.equal(diagramKindOf("pie title Pets"), "pie"); + assert.equal(diagramKindOf("squarewave TD"), "squarewave"); +}); + +test("the allowlist is exact: aliases and other kinds are unsupported", () => { + assert.equal(isAllowedDiagramKind("graph"), false); + assert.equal(isAllowedDiagramKind("stateDiagram"), false); + for (const kind of ["pie", "gantt", "mindmap", "journey", "gitGraph"]) { + assert.equal(isAllowedDiagramKind(kind), false); + } + assert.equal(isAllowedDiagramKind("Flowchart"), false); +}); + +test("isMermaidFenceInfo accepts only the bare mermaid info string", () => { + assert.equal(isMermaidFenceInfo("mermaid"), true); + assert.equal(isMermaidFenceInfo("mermaid "), true); + assert.equal(isMermaidFenceInfo("mermaid\t"), true); + assert.equal(isMermaidFenceInfo("ts"), false); + assert.equal(isMermaidFenceInfo("mermaid x"), false); + assert.equal(isMermaidFenceInfo("Mermaid"), false); + assert.equal(isMermaidFenceInfo(""), false); +}); + +test("matchDiagramCaption accepts a visible Diagram caption line", () => { + assert.equal( + matchDiagramCaption("**Diagram — Request lifecycle**"), + "Diagram — Request lifecycle" + ); + assert.equal( + matchDiagramCaption(" **Diagram — Padded** "), + "Diagram — Padded" + ); + assert.equal( + matchDiagramCaption("**Diagram — Multi word caption with — dashes**"), + "Diagram — Multi word caption with — dashes" + ); +}); + +test("matchDiagramCaption rejects malformed caption lines", () => { + assert.equal(matchDiagramCaption("*Diagram — single stars*"), undefined); + assert.equal(matchDiagramCaption("**Diagram – hyphen**"), undefined); + assert.equal(matchDiagramCaption("**Diagram — **"), undefined); + assert.equal(matchDiagramCaption("**Diagram —**"), undefined); + assert.equal(matchDiagramCaption("Diagram — bare"), undefined); + assert.equal(matchDiagramCaption("**Diagram — a** and **b**"), undefined); + assert.equal(matchDiagramCaption("**Not a diagram — caption**"), undefined); + assert.equal(matchDiagramCaption("Some intro text"), undefined); + assert.equal(matchDiagramCaption("```"), undefined); + assert.equal(matchDiagramCaption(""), undefined); +}); + +test("diagramSourceByteLength counts UTF-8 bytes, not characters", () => { + assert.equal(diagramSourceByteLength("flowchart TD"), 12); + assert.equal(diagramSourceByteLength("ééé"), 6); + assert.equal(diagramSourceByteLength("—"), 3); +}); + +test("evaluateDiagramFence allows an in-bounds supported diagram", () => { + assert.deepEqual( + evaluateDiagramFence({ caption: "Diagram — x", source: "flowchart TD\n A --> B" }), + { allowed: true, kind: "flowchart" } + ); +}); + +test("evaluateDiagramFence rejects a missing caption before anything else", () => { + const evaluation = evaluateDiagramFence({ + source: "pie title way too large" + "x".repeat(MAX_DIAGRAM_SOURCE_BYTES) + }); + assert.deepEqual(evaluation, { allowed: false, reason: "caption" }); +}); + +test("evaluateDiagramFence rejects an oversized source before reading its kind", () => { + const evaluation = evaluateDiagramFence({ + caption: "Diagram — too big", + source: "flowchart TD\n%%" + "x".repeat(MAX_DIAGRAM_SOURCE_BYTES) + }); + assert.deepEqual(evaluation, { allowed: false, reason: "size" }); +}); + +test("evaluateDiagramFence rejects an unsupported kind", () => { + const evaluation = evaluateDiagramFence({ + caption: "Diagram — pie", + source: "pie title Pets\n \"Dogs\" : 386" + }); + assert.deepEqual(evaluation, { allowed: false, reason: "kind", kind: "pie" }); +}); + +test("evaluateDiagramFence reports the detected kind alongside the rejection", () => { + const evaluation = evaluateDiagramFence({ + caption: "Diagram — gantt", + source: "gantt\n dateFormat YYYY-MM-DD" + }); + assert.equal(evaluation.allowed, false); + if (!evaluation.allowed) { + assert.equal(evaluation.reason, "kind"); + assert.equal(evaluation.kind, "gantt"); + } +}); + +test("evaluateDiagramFence accepts a source of exactly 20 KB", () => { + const source = "flowchart TD\n%%" + "x".repeat( + MAX_DIAGRAM_SOURCE_BYTES - Buffer.byteLength("flowchart TD\n%%", "utf8") + ); + const evaluation = evaluateDiagramFence({ + caption: "Diagram — boundary", + source + }); + assert.deepEqual(evaluation, { allowed: true, kind: "flowchart" }); +}); diff --git a/packages/description-renderer/test/seam.test.ts b/packages/description-renderer/test/seam.test.ts new file mode 100644 index 00000000..cb271e18 --- /dev/null +++ b/packages/description-renderer/test/seam.test.ts @@ -0,0 +1,410 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + makeRenderedImagesResponsive, + renderDescription +} from "../src/description"; +import { renderMermaidDiagram } from "../src/render"; +import { + ALLOWED_KIND_CAPTIONS, + ALLOWED_KIND_SOURCES, + CAPTIONED_FLOWCHART_DESCRIPTION, + FLOWCHART_SOURCE, + UNSUPPORTED_KIND_SOURCES, + captionedDiagram, + extractPngDataUri, + flowchartOfExactByteLength, + assertValidPng +} from "./helpers/fixtures"; + +const COUNT_NOTICE_MARKER = "at most three Mermaid diagrams"; +const CAPTION_NOTICE_MARKER = "**Diagram — …** caption"; +const SIZE_NOTICE_MARKER = "20 KB limit"; +const KIND_NOTICE_MARKER = "Unsupported Mermaid diagram kind"; +const RENDER_NOTICE_MARKER = "could not be rendered"; + +function assertNotice(content: string, marker: string): void { + assert.ok( + content.includes(marker), + `Expected the notice "${marker}" in:\n${content.slice(0, 400)}` + ); +} + +test("renderDescription renders a captioned flowchart as a PNG image", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + const png = extractPngDataUri(content, "Diagram — Request lifecycle"); + assertValidPng(png); +}); + +test("renderDescription keeps the caption visible and uses it as alt text", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + assert.ok(content.includes("**Diagram — Request lifecycle**")); + assert.ok(content.includes("![Diagram — Request lifecycle](data:image/png")); +}); + +test("renderDescription hides the Mermaid source during playback", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes("flowchart TD")); + assert.ok(!content.includes("Client --> Gateway")); +}); + +test("renderDescription returns descriptions without Mermaid unchanged", async () => { + const description = [ + "A plain description with text.", + "", + "```ts", + "const answer = 42;", + "```", + "", + "[Next tour](command:codetour.startTourByTitle?%5B%22Next%22%5D)" + ].join("\n"); + + assert.equal(await renderDescription(description, "light"), description); + assert.equal(await renderDescription(description, "dark"), description); +}); + +test("makeRenderedImagesResponsive scales rendered PNGs to their container", () => { + const content = + "![Diagram — Reads \\[docs\\]](data:image/png;base64,iVBORw0KGgo=)"; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Diagram — Reads [docs]' + ); +}); + +test("makeRenderedImagesResponsive escapes HTML-sensitive alt text", () => { + const content = + '![Diagram — A & "quoted"](data:image/png;base64,iVBORw0KGgo=)'; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Diagram — A & "quoted"' + ); +}); + +test("makeRenderedImagesResponsive scales ordinary image links too", () => { + const content = "![Screenshot](https://example.com/screenshot.png)"; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Screenshot' + ); +}); + +test("makeRenderedImagesResponsive preserves image titles", () => { + const content = '![Screenshot](https://example.com/screenshot.png "Full size")'; + + assert.equal( + makeRenderedImagesResponsive(content), + 'Screenshot' + ); +}); + +test("makeRenderedImagesResponsive does not rewrite PNGs inside code fences", () => { + const content = [ + "```markdown", + "![Screenshot](data:image/png;base64,iVBORw0KGgo=)", + "```" + ].join("\n"); + + assert.equal(makeRenderedImagesResponsive(content), content); +}); + +test("renderDescription renders every allowed diagram kind through the seam", async () => { + for (const [kind, source] of Object.entries(ALLOWED_KIND_SOURCES)) { + const caption = ALLOWED_KIND_CAPTIONS[kind]; + const content = await renderDescription(captionedDiagram(caption, source), "light"); + + const png = extractPngDataUri(content, caption); + assertValidPng(png); + assert.ok(content.includes(`**${caption}**`), kind); + assert.ok(!content.includes("```mermaid"), kind); + assert.ok(!content.includes(source), kind); + } +}); + +test("renderDescription renders a sequence diagram with message arrows", async () => { + const content = await renderDescription( + captionedDiagram( + ALLOWED_KIND_CAPTIONS.sequenceDiagram, + ALLOWED_KIND_SOURCES.sequenceDiagram + ), + "light" + ); + + assertValidPng( + extractPngDataUri(content, ALLOWED_KIND_CAPTIONS.sequenceDiagram) + ); +}); + +test("renderDescription renders a class diagram with members", async () => { + const content = await renderDescription( + captionedDiagram( + ALLOWED_KIND_CAPTIONS.classDiagram, + ALLOWED_KIND_SOURCES.classDiagram + ), + "light" + ); + + assertValidPng( + extractPngDataUri(content, ALLOWED_KIND_CAPTIONS.classDiagram) + ); +}); + +test("renderDescription fails an unsupported kind locally, keeping the caption as alternative text", async () => { + for (const [label, source] of Object.entries(UNSUPPORTED_KIND_SOURCES)) { + const caption = `Diagram — ${label} attempt`; + const content = await renderDescription(captionedDiagram(caption, source), "light"); + + assertNotice(content, KIND_NOTICE_MARKER); + assert.ok(content.includes(`**${caption}**`), label); + assert.ok(!content.includes("```mermaid"), label); + assert.ok(!content.includes(source), label); + } +}); + +test("renderDescription fails a Mermaid fence without a caption, without exposing its source", async () => { + const description = [ + "Some text that is not a caption.", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes(FLOWCHART_SOURCE)); +}); + +test("renderDescription fails a Mermaid fence whose caption is malformed", async () => { + for (const captionLine of [ + "*Diagram — single stars*", + "**Diagram – wrong dash**", + "**Diagram —**", + "**Diagram — a** and **b**" + ]) { + const description = [captionLine, "", "```mermaid", FLOWCHART_SOURCE, "```"].join("\n"); + + const content = await renderDescription(description, "light"); + + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid"), captionLine); + assert.ok(!content.includes(FLOWCHART_SOURCE), captionLine); + } +}); + +test("renderDescription fails a Mermaid fence with markdown between the caption and the fence", async () => { + const description = [ + "**Diagram — Interrupted**", + "", + "Read this first:", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes(FLOWCHART_SOURCE)); +}); + +test("a caption can only introduce a single fence", async () => { + const description = [ + "**Diagram — First**", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "```mermaid", + FLOWCHART_SOURCE, + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — First")); + assertNotice(content, CAPTION_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); +}); + +test("renderDescription fails a diagram source over 20 KB", async () => { + const source = flowchartOfExactByteLength(20 * 1024 + 1); + const content = await renderDescription( + captionedDiagram("Diagram — Too big", source), + "light" + ); + + assertNotice(content, SIZE_NOTICE_MARKER); + assert.ok(content.includes("**Diagram — Too big**")); + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes(source.slice(0, 200))); +}); + +test("renderDescription renders a diagram source of exactly 20 KB", async () => { + const source = flowchartOfExactByteLength(20 * 1024); + const content = await renderDescription( + captionedDiagram("Diagram — Boundary", source), + "light" + ); + + assertValidPng(extractPngDataUri(content, "Diagram — Boundary")); +}); + +test("renderDescription renders at most three diagrams and fails the excess locally", async () => { + const description = [1, 2, 3, 4, 5] + .map(number => + captionedDiagram(`Diagram — Chart ${number}`, FLOWCHART_SOURCE) + ) + .join("\n\n"); + + const content = await renderDescription(description, "light"); + + for (const number of [1, 2, 3]) { + assertValidPng( + extractPngDataUri(content, `Diagram — Chart ${number}`) + ); + } + const excessNotices = content + .split("\n") + .filter(line => line.includes(COUNT_NOTICE_MARKER)); + assert.equal(excessNotices.length, 2); + assert.ok(content.includes("**Diagram — Chart 4**")); + assert.ok(content.includes("**Diagram — Chart 5**")); + assert.ok(!content.includes("```mermaid")); +}); + +test("the first three fences count toward the limit even when they fail other rules", async () => { + const description = [ + "Intro text", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + captionedDiagram("Diagram — Unsupported", UNSUPPORTED_KIND_SOURCES.pie), + captionedDiagram("Diagram — Valid", FLOWCHART_SOURCE), + captionedDiagram("Diagram — Fourth", FLOWCHART_SOURCE) + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — Valid")); + assertNotice(content, CAPTION_NOTICE_MARKER); + assertNotice(content, KIND_NOTICE_MARKER); + assertNotice(content, COUNT_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); +}); + +test("renderDescription replaces an invalid diagram with a warning, not its source", async () => { + const description = [ + "**Diagram — Broken diagram**", + "", + "```mermaid", + "flowchart TD", + " this is not mermaid at all (((", + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assert.ok(!content.includes("```mermaid")); + assert.ok(!content.includes("this is not mermaid at all")); + assertNotice(content, RENDER_NOTICE_MARKER); + assert.ok(content.includes("**Diagram — Broken diagram**")); +}); + +test("renderDescription renders diagrams in one description independently", async () => { + const description = [ + "**Diagram — Valid diagram**", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + "**Diagram — Broken diagram**", + "", + "```mermaid", + "flowchart TD", + " this is not mermaid at all (((", + "```" + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — Valid diagram")); + assertNotice(content, RENDER_NOTICE_MARKER); + assert.ok(!content.includes("this is not mermaid at all")); +}); + +test("one rejected diagram never hides its valid siblings", async () => { + const description = [ + "Plain text above", + "", + "```mermaid", + FLOWCHART_SOURCE, + "```", + "", + captionedDiagram("Diagram — Supported", FLOWCHART_SOURCE), + captionedDiagram("Diagram — Unsupported", UNSUPPORTED_KIND_SOURCES.gitGraph) + ].join("\n"); + + const content = await renderDescription(description, "light"); + + assertValidPng(extractPngDataUri(content, "Diagram — Supported")); + assertNotice(content, CAPTION_NOTICE_MARKER); + assertNotice(content, KIND_NOTICE_MARKER); + assert.ok(!content.includes("```mermaid")); +}); + +test("renderDescription adapts the rendered diagram to the theme", async () => { + const light = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + const dark = await renderMermaidDiagram(FLOWCHART_SOURCE, "dark"); + + assertValidPng(light.png); + assertValidPng(dark.png); + assert.notEqual(light.svg, dark.svg); + assert.ok(Buffer.compare(light.png, dark.png) !== 0); +}); + +test("renderMermaidDiagram returns a sanitized SVG and an in-memory PNG", async () => { + const { svg, png } = await renderMermaidDiagram(FLOWCHART_SOURCE, "light"); + + assert.ok(svg.startsWith("Client")); + assertValidPng(png); +}); + +test("renderMermaidDiagram refuses unsupported diagram kinds itself", async () => { + for (const source of [ + UNSUPPORTED_KIND_SOURCES.pie, + UNSUPPORTED_KIND_SOURCES.mindmap, + UNSUPPORTED_KIND_SOURCES["unknown kind"] + ]) { + await assert.rejects( + () => renderMermaidDiagram(source, "light"), + /Unsupported Mermaid diagram kind/u, + source.split("\n")[0] + ); + } +}); diff --git a/packages/description-renderer/test/security.test.ts b/packages/description-renderer/test/security.test.ts new file mode 100644 index 00000000..ff00381e --- /dev/null +++ b/packages/description-renderer/test/security.test.ts @@ -0,0 +1,211 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { renderDescription } from "../src/description"; +import { renderMermaidDiagram } from "../src/render"; +import { sanitizeSvg } from "../src/sanitize"; +import { + CAPTIONED_FLOWCHART_DESCRIPTION, + HOSTILE_INTERACTION_SOURCE, + HOSTILE_LABEL_SOURCES, + HOSTILE_MARKDOWN_LABEL_SOURCE, + assertValidPng, + captionedDiagram, + extractPngDataUri, + findImageLine +} from "./helpers/fixtures"; + +const IMAGE_LINE_PATTERN = /^!\[[^\n]*\]\(data:image\/png;base64,[A-Za-z0-9+/=]+\)$/; + +function assertStaticImageLine(content: string, caption: string): void { + const line = findImageLine(content, caption); + assert.match(line, IMAGE_LINE_PATTERN); + assert.ok(!line.includes("<")); +} + +test("strict security encodes hostile labels instead of embedding HTML", async () => { + const source = [ + "flowchart TD", + ' A[""] --> B[""]' + ].join("\n"); + + const { svg } = await renderMermaidDiagram(source, "light"); + + assert.ok(!svg.includes(" { + const source = [ + "flowchart TD", + " A --> B", + ' click A "https://example.com" "tooltip"' + ].join("\n"); + + const { svg } = await renderMermaidDiagram(source, "light"); + + assert.ok(!svg.includes(" { + const { svg } = await renderMermaidDiagram( + "flowchart TD\n A[Label] --> B", + "light" + ); + + assert.ok(!svg.includes("foreignObject")); + assert.ok(svg.includes(" { + for (const [kind, source] of Object.entries(HOSTILE_LABEL_SOURCES)) { + const caption = `Diagram — Hostile ${kind}`; + const content = await renderDescription(captionedDiagram(caption, source), "light"); + + assertStaticImageLine(content, caption); + assertValidPng(extractPngDataUri(content, caption)); + assert.ok(!content.includes(" { + const caption = "Diagram — Hostile parse failure"; + const content = await renderDescription( + captionedDiagram( + caption, + 'flowchart TD\n A[""] is totally broken (((' + ), + "light" + ); + + assert.ok(content.includes("could not be rendered")); + assert.ok(!content.includes(" { + const caption = "Diagram — Hostile interactions"; + const content = await renderDescription( + captionedDiagram(caption, HOSTILE_INTERACTION_SOURCE), + "light" + ); + + assertStaticImageLine(content, caption); + assertValidPng(extractPngDataUri(content, caption)); + assert.ok(!content.includes(" { + const caption = "Diagram — Hostile markdown label"; + const content = await renderDescription( + captionedDiagram(caption, HOSTILE_MARKDOWN_LABEL_SOURCE), + "light" + ); + + assertStaticImageLine(content, caption); + assertValidPng(extractPngDataUri(content, caption)); + assert.ok(!content.includes("](https://evil.example)")); + assert.ok(!content.includes("](command:")); +}); + +test("a caption containing brackets cannot break out of the image markdown", async () => { + const caption = "Diagram — Reads [docs](https://example.com) and [run](command:x)"; + const content = await renderDescription( + captionedDiagram(caption, "flowchart TD\n A --> B"), + "light" + ); + + assertStaticImageLine(content, caption); + const line = findImageLine(content, caption); + assert.equal(line.match(/!\[/g)!.length, 1); + assert.equal(line.match(/\]\(data:image\/png;base64,/g)!.length, 1); +}); + +test("the final comment content contains no diagram HTML or commands", async () => { + const content = await renderDescription( + CAPTIONED_FLOWCHART_DESCRIPTION, + "light" + ); + + const imageLine = findImageLine(content, "Diagram — Request lifecycle"); + assert.ok(!imageLine.includes("<")); + assert.ok(!imageLine.includes("command:")); + assertValidPng(extractPngDataUri(content, "Diagram — Request lifecycle")); +}); + +test("sanitizeSvg removes scripts, handlers, foreign content and anchors", () => { + const hostile = [ + '', + "", + '
html
', + '', + '', + '', + "
" + ].join(""); + + const sanitized = sanitizeSvg(hostile); + + assert.ok(!sanitized.includes(" { + const hostile = [ + '', + '', + '', + '', + '', + "" + ].join(""); + + const sanitized = sanitizeSvg(hostile); + + assert.ok(!sanitized.includes(" { + const ordinary = [ + '', + '', + 'Label', + "" + ].join(""); + + const sanitized = sanitizeSvg(ordinary); + + assert.ok(sanitized.includes('xlink:href="#label-path"')); + assert.ok(sanitized.includes(" { + const ordinary = [ + '', + 'Label', + '', + "" + ].join(""); + + const sanitized = sanitizeSvg(ordinary); + + assert.ok(sanitized.includes("Label")); + assert.ok(sanitized.includes('fill="#ECECFF"')); + assert.ok(sanitized.includes('transform="translate(1,1)"')); +}); diff --git a/packages/description-renderer/tsconfig.json b/packages/description-renderer/tsconfig.json new file mode 100644 index 00000000..11d5bb8b --- /dev/null +++ b/packages/description-renderer/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "node16", + "moduleResolution": "node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": true, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/packages/mcp-server/LICENSE.txt b/packages/mcp-server/LICENSE.txt new file mode 100644 index 00000000..b2f52a2b --- /dev/null +++ b/packages/mcp-server/LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 00000000..63b980bf --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,164 @@ +# codetour-mcp + +A local MCP (Model Context Protocol) server that lets an AI agent create +[CodeTour](https://github.com/microsoft/codetour) tours deterministically. +The agent analyzes the code and writes the explanations; the server validates +the proposal, applies the Git and security rules, and atomically replaces the +reserved tour file. + +Two specialized tools are exposed: + +- `create_project_tour` — a **Project Tour** that explains a codebase as a + whole, written to `.tours/project.tour`. +- `create_changes_tour` — a **Changes Tour** that explains the committed + changes on a branch since it diverged from a base ref, written to + `.tours/changes.tour`. + +Both outputs are compatible with the general CodeTour schema, within a +deliberately stricter V1 subset: explanatory Markdown and workspace-internal +locations only. CodeTour `commands`, root-level `when` expressions, external +`uri` steps, and active Markdown schemes (`command:`, `file:`, `vscode:`, +`vscode-insiders:`, `javascript:`) are rejected. + +## Requirements + +- Node.js >= 18 +- Git (only for `create_changes_tour`) + +## Installation and local validation + +```bash +npm install +npm run build +npm test +``` + +Run the server locally: + +```bash +node dist/src/cli.js --workspace-root /path/to/workspace +``` + +The `--workspace-root` argument is required. One server instance handles +exactly one workspace root, and every operation is confined to it: real paths +are resolved before any read or write, symlinks that escape the root are +rejected, and the server performs no network access. + +The package exposes the `codetour-mcp` binary (available after `npm install` +or `npm link`). + +## MCP client configuration + +```json +{ + "mcpServers": { + "codetour": { + "command": "node", + "args": ["/path/to/codetour/packages/mcp-server/dist/src/cli.js", "--workspace-root", "/path/to/workspace"] + } + } +} +``` + +The transport is `stdio` only. + +## Tools + +### `create_project_tour` + +| Argument | Type | Required | Description | +| ------------- | -------- | -------- | ------------------------------------------------------- | +| `title` | string | no | Defaults to `Project Overview`. | +| `description` | string | no | Optional tour description. | +| `steps` | object[] | yes | Non-empty list of steps (see below). | + +A good Project Tour covers the project's purpose, its main entry points, its +important components, and its main execution flows. + +### `create_changes_tour` + +| Argument | Type | Required | Description | +| -------------------- | -------- | -------- | ------------------------------------------------------------- | +| `baseRef` | string | yes | Git ref the branch diverged from. | +| `headRef` | string | yes | Full 40-character SHA of the analyzed commit; must equal the current `HEAD`. | +| `includeUncommittedChanges` | boolean | no | Include uncommitted changes explicitly (default `false`). | +| `title` | string | no | Defaults to `Changes on `. | +| `description` | string | no | Optional description; provenance is always appended. | +| `steps` | object[] | yes | Non-empty list of steps (see below). | + +A good Changes Tour covers the intent of the changes, the major +modifications, their impact, and the relevant tests. + +### Steps + +| Field | Type | Description | +| ------------- | ------ | ---------------------------------------------------------------------------- | +| `title` | string | Optional step title. | +| `description` | string | Required Markdown explanation. | +| `file` | string | Workspace-relative path; at most one of `file`/`directory` per step. | +| `directory` | string | Workspace-relative path; at most one of `file`/`directory` per step. | +| `line` | number | 1-based line; only valid with `file`, mutually exclusive with `pattern`. | +| `pattern` | string | Regular expression matching exactly one occurrence; only valid with `file`. | +| `selection` | object | `{ start: {line, character}, end: {line, character} }`, 1-based; only valid with `file`. | + +Steps without any locator are allowed (general context, deleted files). +Every anchor is validated against the real workspace state. All validation +errors are aggregated and reported in a single response; the previous tour +file is preserved on failure. + +### Result + +Each successful tool call returns a human-readable message and a structure: + +```json +{ "status": "created", "path": ".tours/project.tour", "stepCount": 3, "warnings": [] } +``` + +Failures return `{ "status": "error", "code", "message", "issues" }` with one +of these codes: + +| Code | Meaning | +| -------------------------- | -------------------------------------------------------------- | +| `TOUR_STEPS_REQUIRED` | The steps list is missing or empty. | +| `INVALID_PROPOSAL` | The proposal has validation issues (all listed in `issues`). | +| `GIT_REPOSITORY_REQUIRED` | `create_changes_tour` was called outside a Git repository. | +| `STALE_HEAD` | `headRef` does not match the current `HEAD`. | +| `INVALID_BASE_REF` | The merge-base between `baseRef` and `headRef` cannot be computed. | +| `NO_CHANGES` | No committed changes between the merge-base and `headRef`; the previous tour file is preserved. | +| `SCHEMA_VALIDATION_FAILED` | Internal: the generated tour did not validate against the CodeTour schema. | +| `OUTPUT_PATH_ESCAPES_WORKSPACE` | The output directory resolves outside the workspace root. | + +Non-blocking warnings: + +| Code | Meaning | +| ------------------------------- | ------------------------------------------------------------------------ | +| `STEP_LIMIT_EXCEEDED` | The tour has more than fifteen steps. | +| `NO_CHANGED_FILE_ANCHOR` | No step anchors a file modified by the changes. | +| `UNCOMMITTED_CHANGES_EXCLUDED` | Staged, unstaged or untracked changes were excluded (default). | +| `UNCOMMITTED_CHANGES_INCLUDED` | Uncommitted changes were included; the tour describes a non-reproducible local state. | + +## Git reference policies + +- A Project Tour has no CodeTour `ref`, so it stays usable as the project + evolves. +- A reproducible Changes Tour records the exact analyzed head SHA as its + `ref`, and generation fails with `STALE_HEAD` if `HEAD` changed since the + analysis. Uncommitted changes are excluded by default (with a warning). +- With `includeUncommittedChanges: true`, the Changes Tour has no `ref` and warns + that it describes a non-reproducible local state. + +The reserved tour files (`.tours/project.tour` and `.tours/changes.tour`) are +always replaced after a complete, successful validation, via an atomic +rename. They are excluded from the dirty-workspace detection so a previous +generation does not warn about itself. + +## Development + +```bash +npm run typecheck # type-check only +npm run build # compile to dist/ +npm test # build + integration tests over the stdio MCP seam +``` + +Integration tests launch the server as an MCP client would, over `stdio`, +against temporary workspaces and temporary Git repositories. diff --git a/packages/mcp-server/package-lock.json b/packages/mcp-server/package-lock.json new file mode 100644 index 00000000..3544f610 --- /dev/null +++ b/packages/mcp-server/package-lock.json @@ -0,0 +1,3212 @@ +{ + "name": "codetour-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codetour-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "@resvg/resvg-js": "2.6.2", + "ajv": "^6.12.6", + "codetour-description-renderer": "file:../description-renderer", + "jsdom": "26.1.0", + "mermaid": "11.12.2", + "zod": "^3.25.76" + }, + "bin": { + "codetour-mcp": "dist/src/cli.js" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "../description-renderer": { + "name": "codetour-description-renderer", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@resvg/resvg-js": "2.6.2", + "jsdom": "26.1.0", + "mermaid": "11.12.2" + }, + "devDependencies": { + "@types/jsdom": "27.0.0", + "@types/node": "24.13.3", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "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==", + "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/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", + "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.0.3", + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/gast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", + "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/@chevrotain/gast/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", + "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", + "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", + "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "license": "Apache-2.0" + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@mermaid-js/parser": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", + "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", + "license": "MIT", + "dependencies": { + "langium": "3.3.1" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "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==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chevrotain": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", + "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.0.3", + "@chevrotain/gast": "11.0.3", + "@chevrotain/regexp-to-ast": "11.0.3", + "@chevrotain/types": "11.0.3", + "@chevrotain/utils": "11.0.3", + "lodash-es": "4.17.21" + } + }, + "node_modules/chevrotain-allstar": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", + "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "chevrotain": "^11.0.0" + } + }, + "node_modules/chevrotain/node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/codetour-description-renderer": { + "resolved": "../description-renderer", + "link": true + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "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==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cytoscape": { + "version": "3.34.2", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.2.tgz", + "integrity": "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/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==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", + "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "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", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "license": "MIT", + "engines": { + "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==", + "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", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "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==", + "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==", + "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", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "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/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/langium": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", + "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "license": "MIT", + "dependencies": { + "chevrotain": "~11.0.3", + "chevrotain-allstar": "~0.3.0", + "vscode-languageserver": "~9.0.1", + "vscode-languageserver-textdocument": "~1.0.11", + "vscode-uri": "~3.0.8" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mermaid": { + "version": "11.12.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", + "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.1", + "@mermaid-js/parser": "^0.6.3", + "@types/d3": "^7.4.3", + "cytoscape": "^3.29.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.13", + "dayjs": "^1.11.18", + "dompurify": "^3.2.5", + "katex": "^0.16.22", + "khroma": "^2.1.0", + "lodash-es": "^4.17.21", + "marked": "^16.2.1", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "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", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "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==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "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==", + "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==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", + "integrity": "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", + "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "license": "MIT" + }, + "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==", + "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==", + "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", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 00000000..4ad03742 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,50 @@ +{ + "name": "codetour-mcp", + "version": "0.1.0", + "description": "Local MCP server that deterministically validates and persists AI-generated CodeTour Project Tours and Changes Tours", + "license": "MIT", + "author": { + "name": "Microsoft Corporation" + }, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/codetour", + "directory": "packages/mcp-server" + }, + "bin": { + "codetour-mcp": "dist/src/cli.js" + }, + "main": "dist/src/server.js", + "files": [ + "dist/src", + "dist/node_modules/codetour-description-renderer", + "dist/package.json", + "dist/schema.json", + "schema.json", + "README.md", + "LICENSE.txt" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "tsc -p tsconfig.json && node ../../scripts/stage-mcp-renderer.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepack": "node ../../scripts/stage-mcp-renderer.js", + "test": "npm run build && node ../../scripts/run-compiled-tests.js dist/test", + "start": "node dist/src/cli.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "@resvg/resvg-js": "2.6.2", + "ajv": "^6.12.6", + "codetour-description-renderer": "file:../description-renderer", + "jsdom": "26.1.0", + "mermaid": "11.12.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.8.0" + } +} diff --git a/packages/mcp-server/schema.json b/packages/mcp-server/schema.json new file mode 100644 index 00000000..ea8e63ab --- /dev/null +++ b/packages/mcp-server/schema.json @@ -0,0 +1,149 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Schema for CodeTour tour files", + "type": "object", + "required": ["title", "steps"], + "properties": { + "title": { + "type": "string", + "description": "Specifies the title of the code tour." + }, + "description": { + "type": "string", + "description": "Specifies an optional description for the code tour." + }, + "ref": { + "type": "string", + "description": "Indicates the git ref (branch/commit/tag) that this tour associate with." + }, + "isPrimary": { + "type": "boolean", + "description": "Specifies whether the tour represents the primary tour for this codebase." + }, + "steps": { + "type": "array", + "description": "Specifies the list of steps that are included in the code tour.", + "default": [], + "items": { + "type": "object", + "required": ["description"], + "properties": { + "file": { + "type": "string", + "description": "File path (relative to the workspace root) that the step is associated with." + }, + "directory": { + "type": "string", + "description": "Directory path (relative to the workspace root) that the step is associated with." + }, + "view": { + "anyOf": [ + { + "type": "string", + "enum": [ + "debug", + "debug:breakpoints", + "debug:callstack", + "debug:variables", + "debug:watch", + "explorer", + "extensions", + "extensions:disabled", + "extensions:enabled", + "output", + "problems", + "scm", + "search", + "terminal" + ], + "description": "The view ID (e.g. gistpad.gists) that this step is associated with." + }, + { + "type": "string", + "minLength": 1, + "description": "The view ID (e.g. gistpad.gists) that this step is associated with." + } + ] + }, + "uri": { + "type": "string", + "description": "Absolute URI that is associated with the step." + }, + "line": { + "type": "number", + "description": "Line number that the step is associated with." + }, + "pattern": { + "type": "string", + "description": "A regular expression to associate the step with. This is only considered when the line property isn't set, and allows you to associate steps with line content as opposed to ordinal." + }, + "title": { + "type": "string", + "description": "An optional title for the step." + }, + "description": { + "type": "string", + "description": "Description of the step." + }, + "selection": { + "type": "object", + "required": ["start", "end"], + "description": "Text selection that's associated with the step.", + "properties": { + "start": { + "type": "object", + "required": ["line", "character"], + "description": "Starting position (line, column) of the text selection range.", + "properties": { + "line": { + "type": "number", + "description": "Line number (1-based) that the text selection begins on." + }, + "character": { + "type": "number", + "description": "Column number (1-based) that the text selection begins on." + } + } + }, + "end": { + "type": "object", + "required": ["line", "character"], + "description": "Ending position (line, column) of the text selection range.", + "properties": { + "line": { + "type": "number", + "description": "Line number (1-based) that the text selection ends on." + }, + "character": { + "type": "number", + "description": "Column number (1-based) that the text selection end on." + } + } + } + } + }, + "commands": { + "type": "array", + "description": "Specifies an array of command URIs that will be executed when this step is navigated to.", + "default": [], + "items": { + "type": "string" + } + } + } + } + }, + "stepMarker": { + "type": "string", + "description": "Specifies the 'marker' that indicates a line of code represents a step for this tour." + }, + "nextTour": { + "type": "string", + "description": "Specifies the title of the tour that is meant to follow this tour." + }, + "when": { + "type": "string", + "description": "Specifies the condition that must be met before this tour is shown. The value of this property is a string that is evaluated as JavaScript." + } + } +} diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts new file mode 100644 index 00000000..acb1d787 --- /dev/null +++ b/packages/mcp-server/src/cli.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createContext } from "./context"; +import { createServer } from "./server"; +import packageJson from "../package.json"; + +interface ParsedArgs { + workspaceRoot?: string; + help: boolean; + version: boolean; +} + +function usage(): string { + return [ + `codetour-mcp v${packageJson.version}`, + "Local MCP server for AI-generated CodeTour Project Tours and Changes Tours.", + "", + "Usage: codetour-mcp --workspace-root ", + "", + "Options:", + " --workspace-root Workspace root that all operations are confined to (required)", + " --help, -h Show this help", + " --version, -v Show the version", + ].join("\n"); +} + +function parseArgs(argv: string[]): ParsedArgs { + const result: ParsedArgs = { help: false, version: false }; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") { + result.help = true; + } else if (argument === "--version" || argument === "-v") { + result.version = true; + } else if (argument === "--workspace-root") { + if (result.workspaceRoot !== undefined) { + console.error( + `Error: --workspace-root must be provided exactly once.\n\n${usage()}` + ); + process.exit(1); + } + result.workspaceRoot = argv[++index]; + } else if (argument.startsWith("--workspace-root=")) { + if (result.workspaceRoot !== undefined) { + console.error( + `Error: --workspace-root must be provided exactly once.\n\n${usage()}` + ); + process.exit(1); + } + result.workspaceRoot = argument.slice("--workspace-root=".length); + } else { + console.error(`Unknown argument: ${argument}\n\n${usage()}`); + process.exit(1); + } + } + return result; +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + if (args.version) { + console.log(packageJson.version); + return; + } + if (!args.workspaceRoot) { + console.error(`Error: --workspace-root is required.\n\n${usage()}`); + process.exit(1); + } + try { + createContext(args.workspaceRoot); + } catch { + console.error( + `Error: the workspace root is not an accessible directory: ${args.workspaceRoot}` + ); + process.exit(1); + } + + const server = createServer(args.workspaceRoot); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/mcp-server/src/codetour-schema.ts b/packages/mcp-server/src/codetour-schema.ts new file mode 100644 index 00000000..d2fc2312 --- /dev/null +++ b/packages/mcp-server/src/codetour-schema.ts @@ -0,0 +1,8 @@ +import Ajv from "ajv"; +import draft04MetaSchema from "ajv/lib/refs/json-schema-draft-04.json"; +import codetourSchema from "../schema.json"; + +const ajv = new Ajv({ allErrors: true, meta: false, schemaId: "id" }); +ajv.addMetaSchema(draft04MetaSchema); + +export const validateCodetourTour = ajv.compile(codetourSchema); diff --git a/packages/mcp-server/src/context.ts b/packages/mcp-server/src/context.ts new file mode 100644 index 00000000..adaa3f1f --- /dev/null +++ b/packages/mcp-server/src/context.ts @@ -0,0 +1,9 @@ +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +export function createContext(workspaceRoot: string): WorkspaceContext { + const root = path.resolve(workspaceRoot); + const realRoot = fs.realpathSync(root); + return { root, realRoot }; +} diff --git a/packages/mcp-server/src/git.ts b/packages/mcp-server/src/git.ts new file mode 100644 index 00000000..56a196f7 --- /dev/null +++ b/packages/mcp-server/src/git.ts @@ -0,0 +1,164 @@ +import { execFile } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +export interface GitResult { + stdout: string; + stderr: string; + code: number; +} + +export function git(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + execFile( + "git", + args, + { cwd, encoding: "utf8", maxBuffer: 128 * 1024 * 1024 }, + (error, stdout, stderr) => { + const code = + error == null + ? 0 + : typeof (error as { code?: unknown }).code === "number" + ? ((error as { code?: unknown }).code as number) + : 1; + resolve({ stdout: stdout ?? "", stderr: stderr ?? "", code }); + } + ); + }); +} + +export async function isGitRepository(ctx: WorkspaceContext): Promise { + const result = await git(["rev-parse", "--is-inside-work-tree"], ctx.root); + return result.code === 0 && result.stdout.trim() === "true"; +} + +export async function currentHeadSha( + ctx: WorkspaceContext +): Promise { + const result = await git(["rev-parse", "HEAD"], ctx.root); + return result.code === 0 ? result.stdout.trim() : null; +} + +export async function currentBranchName( + ctx: WorkspaceContext +): Promise { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], ctx.root); + return result.code === 0 ? result.stdout.trim() : null; +} + +export async function mergeBase( + ctx: WorkspaceContext, + a: string, + b: string +): Promise { + const result = await git(["merge-base", a, b], ctx.root); + if (result.code !== 0) { + throw new Error( + result.stderr.trim() || `git merge-base failed (exit code ${result.code})` + ); + } + return result.stdout.trim(); +} + +export async function committedDiffIsEmpty( + ctx: WorkspaceContext, + a: string, + b: string +): Promise { + const result = await git(["diff", "--quiet", a, b], ctx.root); + return result.code === 0; +} + +export async function changedFiles( + ctx: WorkspaceContext, + a: string, + b: string +): Promise { + const result = await git(["diff", "--name-only", "-z", a, b], ctx.root); + if (result.code !== 0) { + throw new Error( + result.stderr.trim() || `git diff failed (exit code ${result.code})` + ); + } + return result.stdout.split("\0").filter((entry) => entry.length > 0); +} + +export async function workspacePrefix(ctx: WorkspaceContext): Promise { + const result = await git(["rev-parse", "--show-prefix"], ctx.root); + return result.code === 0 ? result.stdout.trim() : ""; +} + +export const RESERVED_TOUR_FILES = [ + ".tours/project.tour", + ".tours/changes.tour", +]; + +export interface UncommittedEntry { + path: string; + status: string; +} + +export function normalizeSlashes(value: string): string { + return value.replace(/\\/g, "/"); +} + +export async function uncommittedChanges( + ctx: WorkspaceContext +): Promise { + const result = await git( + ["status", "--porcelain=v1", "--untracked-files=normal"], + ctx.root + ); + if (result.code !== 0) { + return []; + } + const prefix = normalizeSlashes(await workspacePrefix(ctx)); + const entries: UncommittedEntry[] = []; + for (const line of result.stdout.split("\n")) { + if (line.length < 4) { + continue; + } + const status = line.slice(0, 2); + let repoPath = line.slice(3); + if ((status.includes("R") || status.includes("C")) && repoPath.includes(" -> ")) { + repoPath = repoPath.slice(0, repoPath.indexOf(" -> ")); + } + if (!normalizeSlashes(repoPath).startsWith(prefix)) { + continue; + } + let workspacePath = normalizeSlashes(repoPath).slice(prefix.length); + if (status === "??" && workspacePath.endsWith("/")) { + const directory = workspacePath.slice(0, -1); + if (directory === ".tours" && toursDirectoryOnlyContainsReserved(ctx, directory)) { + continue; + } + } + if (RESERVED_TOUR_FILES.includes(workspacePath)) { + continue; + } + entries.push({ path: workspacePath, status }); + } + return entries; +} + +function toursDirectoryOnlyContainsReserved( + ctx: WorkspaceContext, + directory: string +): boolean { + const absolute = path.resolve(ctx.root, directory); + let files: string[]; + try { + files = fs.readdirSync(absolute, { recursive: true }) as string[]; + } catch { + return false; + } + return ( + files.length > 0 && + files.every((file) => + RESERVED_TOUR_FILES.includes( + normalizeSlashes(path.posix.join(directory, file)) + ) + ) + ); +} diff --git a/packages/mcp-server/src/mermaid-validation.ts b/packages/mcp-server/src/mermaid-validation.ts new file mode 100644 index 00000000..93d840bb --- /dev/null +++ b/packages/mcp-server/src/mermaid-validation.ts @@ -0,0 +1,159 @@ +import { + ALLOWED_DIAGRAM_KINDS, + MAX_DIAGRAMS_PER_DESCRIPTION, + MAX_DIAGRAM_SOURCE_BYTES, + diagramKindOf, + evaluateDiagramFence, + findDiagramFences, + matchDiagramCaption, + renderMermaidDiagram, +} from "codetour-description-renderer"; +import { Issue } from "./types"; + +const MERMAID_TOOL_GUIDANCE = + `Use Mermaid when it materially clarifies a relationship, flow, state, sequence, class, or entity. ` + + `For architecture, workflow, lifecycle, or multi-module Tours, include at least one Mermaid diagram ` + + `when it can summarize relationships spanning several steps. Omit diagrams only when the Tour has ` + + `no meaningful relationship, flow, state transition, or sequence to visualize. ` + + "If you use one, put the nearest non-blank line before a bare ```mermaid fence in the form " + + "**Diagram — …**; keep that caption visible and descriptive. " + + `Only these Mermaid kinds are allowed: ${ALLOWED_DIAGRAM_KINDS.join(", ")}. ` + + `Each description accepts at most ${MAX_DIAGRAMS_PER_DESCRIPTION} Mermaid fences, each source is limited to ` + + `${MAX_DIAGRAM_SOURCE_BYTES / 1024} KB, and every source must have valid Mermaid syntax. ` + + `Validation is offline, reports all diagram errors with their description, fence, and source location, ` + + `and preserves the previous Tour when any error is found.`; + +export { MERMAID_TOOL_GUIDANCE }; + +async function parseMermaid(source: string): Promise { + // renderMermaidDiagram uses the exact locked Mermaid engine and the same + // strict configuration as playback. Its PNG is intentionally discarded: + // this call is the offline syntax-validation seam for the MCP server. + await renderMermaidDiagram(source, "light"); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function lineAndColumnOf(description: string, offset: number): string { + const beforeFence = description.slice(0, offset); + const lastLineBreak = beforeFence.lastIndexOf("\n"); + const line = beforeFence.split("\n").length; + const column = offset - lastLineBreak; + return `line ${line}, column ${column}`; +} + +function fencePath(descriptionPath: string, index: number, field?: string): string { + const path = `${descriptionPath}.mermaid[${index}]`; + return field ? `${path}.${field}` : path; +} + +function canonicalCaption(caption: string | undefined): string | undefined { + // findDiagramFences already applies the shared caption parser. Running the + // captured value through that same public function keeps this boundary tied + // to the renderer's caption grammar rather than reimplementing it here. + return caption === undefined + ? undefined + : matchDiagramCaption(`**${caption}**`); +} + +async function validateDescription( + description: string, + descriptionPath: string +): Promise { + const issues: Issue[] = []; + const fences = findDiagramFences(description); + + for (let index = 0; index < fences.length; index++) { + const fence = fences[index]; + const location = lineAndColumnOf(description, fence.start); + if (index >= MAX_DIAGRAMS_PER_DESCRIPTION) { + issues.push({ + path: fencePath(descriptionPath, index), + message: + `the Mermaid fence at ${location} exceeds the limit of ${MAX_DIAGRAMS_PER_DESCRIPTION} ` + + "fences per description", + }); + continue; + } + + if (!fence.closed) { + issues.push({ + path: fencePath(descriptionPath, index, "source"), + message: `the Mermaid fence at ${location} is not closed` + }); + continue; + } + + const evaluation = evaluateDiagramFence({ + caption: canonicalCaption(fence.caption), + source: fence.source, + }); + if (!evaluation.allowed) { + const field = + evaluation.reason === "caption" + ? "caption" + : evaluation.reason === "kind" + ? "kind" + : "source"; + let message: string; + if (evaluation.reason === "caption") { + message = + `the Mermaid fence at ${location} requires a nearest non-blank caption matching ` + + "**Diagram — …**"; + } else if (evaluation.reason === "size") { + message = + `the Mermaid source at ${location} is ${Buffer.byteLength(fence.source, "utf8")} bytes; ` + + `the limit is ${MAX_DIAGRAM_SOURCE_BYTES} bytes`; + } else { + const detectedKind = diagramKindOf(fence.source); + message = + `the Mermaid source at ${location} uses unsupported kind ` + + `${detectedKind ? `"${detectedKind}"` : "(none detected)"}; ` + + `supported kinds are ${ALLOWED_DIAGRAM_KINDS.join(", ")}`; + } + issues.push({ path: fencePath(descriptionPath, index, field), message }); + continue; + } + + try { + await parseMermaid(fence.source); + } catch { + issues.push({ + path: fencePath(descriptionPath, index, "source"), + message: `the Mermaid source at ${location} has invalid Mermaid syntax`, + }); + } + } + + return issues; +} + +export async function validateMermaidDescriptions(raw: unknown): Promise { + if (!isPlainObject(raw)) { + return []; + } + + const descriptions: Array<{ value: string; path: string }> = []; + if (typeof raw.description === "string") { + descriptions.push({ value: raw.description, path: "description" }); + } + if (Array.isArray(raw.steps)) { + for (let index = 0; index < raw.steps.length; index++) { + const step = raw.steps[index]; + if (isPlainObject(step) && typeof step.description === "string") { + descriptions.push({ + value: step.description, + path: `steps[${index}].description`, + }); + } + } + } + + const issues: Issue[] = []; + for (const description of descriptions) { + issues.push(...(await validateDescription(description.value, description.path))); + } + return issues; +} diff --git a/packages/mcp-server/src/persistence.ts b/packages/mcp-server/src/persistence.ts new file mode 100644 index 00000000..25c492cf --- /dev/null +++ b/packages/mcp-server/src/persistence.ts @@ -0,0 +1,48 @@ +import * as fs from "fs"; +import * as path from "path"; +import { WorkspaceContext } from "./types"; + +export class OutputPathError extends Error {} + +export async function writeTourAtomic( + ctx: WorkspaceContext, + relativeTarget: string, + content: string +): Promise { + const target = path.resolve(ctx.root, relativeTarget); + const directory = path.dirname(target); + await fs.promises.mkdir(directory, { recursive: true }); + + let realDirectory: string; + try { + realDirectory = fs.realpathSync(directory); + } catch (error) { + throw new OutputPathError( + `Unable to resolve the output directory: ${(error as Error).message}` + ); + } + const relative = path.relative(ctx.realRoot, realDirectory); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new OutputPathError( + `${relativeTarget} resolves outside the workspace root` + ); + } + + const tempFile = path.join( + directory, + `.${path.basename(target)}.tmp-${process.pid}-${Date.now()}` + ); + const handle = await fs.promises.open(tempFile, "w"); + try { + await handle.writeFile(content, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.promises.rename(tempFile, target); + } catch (error) { + await fs.promises.unlink(tempFile).catch(() => undefined); + throw error; + } +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 00000000..60afa49e --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,527 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createContext } from "./context"; +import { + changedFiles, + committedDiffIsEmpty, + currentBranchName, + currentHeadSha, + isGitRepository, + mergeBase, + normalizeSlashes, + uncommittedChanges, + workspacePrefix, +} from "./git"; +import { OutputPathError, writeTourAtomic } from "./persistence"; +import { validateCodetourTour } from "./codetour-schema"; +import { + ErrorResult, + Issue, + SuccessResult, + TourFile, + TourStep, + Warning, + WorkspaceContext, +} from "./types"; +import { + MAX_RECOMMENDED_STEPS, + validateChangesParams, + validateProjectParams, + validateSteps, +} from "./validation"; +import { + MERMAID_TOOL_GUIDANCE, + validateMermaidDescriptions, +} from "./mermaid-validation"; +import packageJson from "../package.json"; + + +const CODETOUR_SCHEMA_URI = "https://aka.ms/codetour-schema"; +const SERVER_NAME = "codetour-mcp"; +const SERVER_VERSION = packageJson.version; + +const PROJECT_TOUR_PATH = ".tours/project.tour"; +const CHANGES_TOUR_PATH = ".tours/changes.tour"; + +const MARKDOWN_WRITING_GUIDANCE = + "Write the Tour-level description and every step description as readable Markdown. " + + "Split distinct ideas into short paragraphs separated by blank lines (`\\n\\n`), because " + + "a single newline (`\\n`) is a Markdown soft break and may render as a space. Use a short " + + "heading when a description covers multiple topics, and use bullet or numbered lists " + + "for collections, alternatives, or sequences. Use **bold** sparingly for important " + + "concepts and backticks for code identifiers. Avoid dense monolithic paragraphs, and " + + "keep each explanation concise and tied to the current Tour Anchor. Do not add " + + "structure mechanically: a short, single-purpose description may remain one paragraph. "; + +const PROJECT_TOUR_DESCRIPTION = + "Creates a CodeTour Project Tour that explains a codebase as a whole, persisted at " + + ".tours/project.tour (replacing any previously generated tour of the same kind). " + + "You provide the fully written content; the server only validates and persists it deterministically. " + + "A good Project Tour ideally covers: the project's purpose, its main entry points, its important " + + "components, and its main execution flows. Begin with a directory-anchored overview step whenever " + + "the project has a meaningful directory structure. If the tour is scoped to a subdirectory, anchor " + + "that first step to the exact workspace-relative directory. Use additional directory-anchored steps " + + "to introduce major components before moving into detailed file anchors. " + + "Arguments: an optional title (defaults to \"Project Overview\"), an optional description, and a " + + "required non-empty steps array. Each step takes an optional title, a required Markdown description, " + + "and at most one locator: a file or a directory (workspace-relative paths). A step may also target a " + + "line, a unique stable pattern, or a selection, but only together with a file; line and pattern are " + + "mutually exclusive. Prefer a unique stable pattern over a line number so the anchor resists file " + + "evolution, and use a line only as a fallback. Steps without any locator are allowed for general " + + "context. Every anchor is " + + "validated against the real workspace state, and all validation errors are reported in a single " + + "response. On failure, the previous tour file is preserved. " + + MARKDOWN_WRITING_GUIDANCE + + MERMAID_TOOL_GUIDANCE; + +const CHANGES_TOUR_DESCRIPTION = + "Creates a CodeTour Changes Tour that explains the committed changes on the current branch since it " + + "diverged from a base ref, persisted at .tours/changes.tour (replacing any previously generated tour " + + "of the same kind). You provide the fully written content; the server only validates and persists it " + + "deterministically. A good Changes Tour ideally covers: the intent of the changes, the major " + + "modifications, their impact, and the relevant tests. " + + "Arguments: baseRef (required Git ref), headRef (required full 40-character SHA of the analyzed commit, " + + "which must equal the current HEAD), includeUncommittedChanges (optional boolean, default false), an optional " + + "title (defaults to \"Changes on \"), an optional description, and a required non-empty steps " + + "array. Steps follow the same rules as the Project Tour (prefer a unique stable pattern over a line " + + "number); steps may anchor unchanged files when they " + + "provide essential context, and deleted files must be explained with steps that have no locator. " + + "Uncommitted changes are excluded by default and reported as a warning; pass includeUncommittedChanges to " + + "include them explicitly. The description is automatically enriched with the base, merge-base and " + + "head. On failure, the previous tour file is preserved. " + + MARKDOWN_WRITING_GUIDANCE + + MERMAID_TOOL_GUIDANCE; + +const warningSchema = z.object({ + code: z.string(), + message: z.string(), +}); +const issueSchema = z.object({ + path: z.string(), + message: z.string(), +}); +const toolResultSchema = z.object({ + status: z.enum(["created", "error"]), + path: z.string().optional(), + stepCount: z.number().int().nonnegative().optional(), + warnings: z.array(warningSchema).optional(), + code: z.string().optional(), + message: z.string().optional(), + issues: z.array(issueSchema).optional(), +}); + +const projectToolInputSchema = z + .object({ + title: z.unknown().optional(), + description: z + .unknown() + .optional() + .describe( + "Optional readable Markdown overview; separate distinct ideas with blank lines between paragraphs." + ), + steps: z + .unknown() + .optional() + .describe( + "Non-empty array whose descriptions use concise Markdown with blank lines between paragraphs." + ), + }) + .passthrough(); + +const changesToolInputSchema = z + .object({ + title: z.unknown().optional(), + description: z + .unknown() + .optional() + .describe( + "Optional readable Markdown overview; separate distinct ideas with blank lines between paragraphs." + ), + steps: z + .unknown() + .optional() + .describe( + "Non-empty array whose descriptions use concise Markdown with blank lines between paragraphs." + ), + baseRef: z.unknown().optional(), + headRef: z.unknown().optional(), + includeUncommittedChanges: z.unknown().optional(), + }) + .passthrough(); + +export function createServer(workspaceRoot: string): McpServer { + const ctx = createContext(workspaceRoot); + const server = new McpServer( + { name: SERVER_NAME, version: SERVER_VERSION }, + { capabilities: { tools: {} } } + ); + + server.registerTool( + "create_project_tour", + { + description: PROJECT_TOUR_DESCRIPTION, + inputSchema: projectToolInputSchema, + outputSchema: toolResultSchema, + }, + (args) => handleCreateProjectTour(ctx, args) + ); + + server.registerTool( + "create_changes_tour", + { + description: CHANGES_TOUR_DESCRIPTION, + inputSchema: changesToolInputSchema, + outputSchema: toolResultSchema, + }, + (args) => handleCreateChangesTour(ctx, args) + ); + + return server; +} + +async function handleCreateProjectTour( + ctx: WorkspaceContext, + args: unknown +): Promise { + const rawSteps = extractSteps(args); + const { params, issues: paramIssues } = validateProjectParams(args); + const mermaidIssues = await validateMermaidDescriptions(args); + if (rawSteps === undefined || (Array.isArray(rawSteps) && rawSteps.length === 0)) { + if (mermaidIssues.length === 0) { + return errorResponse("TOUR_STEPS_REQUIRED", "A tour requires at least one step."); + } + + return errorResponse( + "INVALID_PROPOSAL", + "The create_project_tour arguments are invalid.", + [ + ...mermaidIssues, + { path: "steps", message: "is required and must contain at least one step" } + ] + ); + } + + const allIssues = [...paramIssues, ...mermaidIssues]; + let steps: TourStep[] | undefined; + if (Array.isArray(rawSteps)) { + const validated = validateSteps(rawSteps, ctx); + steps = validated.steps; + allIssues.push(...validated.issues); + } + if (!params || allIssues.length > 0) { + return errorResponse( + "INVALID_PROPOSAL", + "The create_project_tour arguments are invalid.", + allIssues + ); + } + const finalSteps = steps as TourStep[]; + + const warnings = stepLimitWarnings(finalSteps); + + const tour: TourFile = { + $schema: CODETOUR_SCHEMA_URI, + title: params.title ?? "Project Overview", + ...(params.description !== undefined ? { description: params.description } : {}), + steps: finalSteps, + }; + + const writeFailure = await persistTour(ctx, PROJECT_TOUR_PATH, tour); + if (writeFailure) { + return writeFailure; + } + + return successResponse( + PROJECT_TOUR_PATH, + finalSteps.length, + warnings, + `Created Project Tour at ${PROJECT_TOUR_PATH} with ${finalSteps.length} step(s).` + ); +} + +async function handleCreateChangesTour( + ctx: WorkspaceContext, + args: unknown +): Promise { + const rawSteps = extractSteps(args); + const { params, issues: paramIssues } = validateChangesParams(args); + const mermaidIssues = await validateMermaidDescriptions(args); + if (rawSteps === undefined || (Array.isArray(rawSteps) && rawSteps.length === 0)) { + if (mermaidIssues.length === 0) { + return errorResponse("TOUR_STEPS_REQUIRED", "A tour requires at least one step."); + } + + return errorResponse( + "INVALID_PROPOSAL", + "The create_changes_tour arguments are invalid.", + [ + ...mermaidIssues, + { path: "steps", message: "is required and must contain at least one step" } + ] + ); + } + + const allIssues = [...paramIssues, ...mermaidIssues]; + let steps: TourStep[] | undefined; + if (Array.isArray(rawSteps)) { + const validated = validateSteps(rawSteps, ctx); + steps = validated.steps; + allIssues.push(...validated.issues); + } + if (!params || allIssues.length > 0) { + return errorResponse( + "INVALID_PROPOSAL", + "The create_changes_tour arguments are invalid.", + allIssues + ); + } + + const baseRef = params.baseRef as string; + const headRef = params.headRef as string; + const includeUncommittedChanges = params.includeUncommittedChanges === true; + + if (!(await isGitRepository(ctx))) { + return errorResponse( + "GIT_REPOSITORY_REQUIRED", + "create_changes_tour requires a Git repository, but the workspace root is not inside one." + ); + } + + const currentHead = await currentHeadSha(ctx); + if (currentHead === null) { + return errorResponse( + "STALE_HEAD", + "The repository has no commits, so there is no HEAD to analyze." + ); + } + if (headRef !== currentHead) { + return errorResponse( + "STALE_HEAD", + `The provided headRef ${headRef} does not match the current HEAD (${currentHead}). The analysis is stale; re-run it against the current HEAD.` + ); + } + + let mergeBaseSha: string; + try { + mergeBaseSha = await mergeBase(ctx, baseRef, headRef); + } catch (error) { + return errorResponse( + "INVALID_BASE_REF", + `Unable to compute the merge-base between ${baseRef} and ${headRef}: ${(error as Error).message}` + ); + } + + const uncommitted = await uncommittedChanges(ctx); + if ( + (await committedDiffIsEmpty(ctx, mergeBaseSha, headRef)) && + (!includeUncommittedChanges || uncommitted.length === 0) + ) { + return errorResponse( + "NO_CHANGES", + `No committed changes between the merge-base of ${baseRef} (${mergeBaseSha}) and ${headRef}. The previous tour file was preserved.` + ); + } + + const warnings: Warning[] = []; + if (includeUncommittedChanges) { + warnings.push({ + code: "UNCOMMITTED_CHANGES_INCLUDED", + message: + "Uncommitted changes were explicitly included; the tour describes a local state that is not reproducible from Git.", + }); + } else if (uncommitted.length > 0) { + warnings.push({ + code: "UNCOMMITTED_CHANGES_EXCLUDED", + message: `${uncommitted.length} uncommitted change(s) were excluded from the analysis (staged, unstaged or untracked).`, + }); + } + + const finalSteps = steps as TourStep[]; + const warningsFromStepLimit = stepLimitWarnings(finalSteps); + warnings.push(...warningsFromStepLimit); + + const changedInWorkspace = await changedFilesInWorkspace(ctx, mergeBaseSha, headRef); + if (includeUncommittedChanges) { + for (const entry of uncommitted) { + if (!changedInWorkspace.includes(entry.path)) { + changedInWorkspace.push(entry.path); + } + } + } + if ( + changedInWorkspace.length > 0 && + !finalSteps.some( + (step) => + step.file !== undefined && + changedInWorkspace.includes(normalizeSlashes(step.file)) + ) + ) { + warnings.push({ + code: "NO_CHANGED_FILE_ANCHOR", + message: + "No step anchors a file modified by these changes; consider anchoring steps on changed files.", + }); + } + + const title = params.title ?? (await defaultChangesTitle(ctx, headRef)); + const provenance = includeUncommittedChanges + ? `Generated from the merge-base of \`${baseRef}\` (\`${mergeBaseSha}\`) to \`${headRef}\`, including uncommitted changes (non-reproducible local state).` + : `Generated from the merge-base of \`${baseRef}\` (\`${mergeBaseSha}\`) to \`${headRef}\`.`; + const description = + params.description !== undefined + ? `${params.description}\n\n${provenance}` + : provenance; + + const tour: TourFile = { + $schema: CODETOUR_SCHEMA_URI, + title, + description, + ...(!includeUncommittedChanges ? { ref: headRef } : {}), + steps: finalSteps, + }; + + const headImmediatelyBeforePersistence = await currentHeadSha(ctx); + if (headImmediatelyBeforePersistence !== headRef) { + return errorResponse( + "STALE_HEAD", + headImmediatelyBeforePersistence === null + ? "The repository no longer has a HEAD to persist this analysis against. The previous tour file was preserved." + : `The provided head ${headRef} no longer matches the current HEAD (${headImmediatelyBeforePersistence}). The analysis became stale before persistence; re-run it against the current HEAD.` + ); + } + + const writeFailure = await persistTour(ctx, CHANGES_TOUR_PATH, tour); + if (writeFailure) { + return writeFailure; + } + + return successResponse( + CHANGES_TOUR_PATH, + finalSteps.length, + warnings, + `Created Changes Tour at ${CHANGES_TOUR_PATH} with ${finalSteps.length} step(s) for head ${headRef} (base ${baseRef}).` + ); +} + +function stepLimitWarnings(steps: TourStep[]): Warning[] { + if (steps.length <= MAX_RECOMMENDED_STEPS) { + return []; + } + return [ + { + code: "STEP_LIMIT_EXCEEDED", + message: `The tour has ${steps.length} steps; the recommended maximum is ${MAX_RECOMMENDED_STEPS}.`, + }, + ]; +} + +async function persistTour( + ctx: WorkspaceContext, + relativePath: string, + tour: TourFile +): Promise { + const schemaResult = validateCodetourTour(tour); + if (!schemaResult) { + return errorResponse( + "SCHEMA_VALIDATION_FAILED", + "The generated tour did not validate against the CodeTour schema.", + [] + ); + } + try { + await writeTourAtomic(ctx, relativePath, serializeTour(tour)); + } catch (error) { + if (error instanceof OutputPathError) { + return errorResponse("OUTPUT_PATH_ESCAPES_WORKSPACE", error.message); + } + throw error; + } + return null; +} + +function extractSteps(args: unknown): unknown { + if (typeof args !== "object" || args === null || Array.isArray(args)) { + return undefined; + } + return (args as Record).steps; +} + +async function changedFilesInWorkspace( + ctx: WorkspaceContext, + mergeBaseSha: string, + headRef: string +): Promise { + const files = await changedFiles(ctx, mergeBaseSha, headRef); + const prefix = normalizeSlashes(await workspacePrefix(ctx)); + return files + .map(normalizeSlashes) + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length)); +} + +async function defaultChangesTitle( + ctx: WorkspaceContext, + headRef: string +): Promise { + const branch = await currentBranchName(ctx); + if (branch && branch !== "HEAD") { + return `Changes on ${branch}`; + } + return `Changes at ${headRef.slice(0, 7)}`; +} + +function serializeTour(tour: TourFile): string { + return JSON.stringify(tour, null, 2) + "\n"; +} + +interface ToolResponse { + [key: string]: unknown; + content: { type: "text"; text: string }[]; + structuredContent: Record; + isError?: boolean; +} + +function successResponse( + relativePath: string, + stepCount: number, + warnings: Warning[], + message: string +): ToolResponse { + const result: SuccessResult = { + status: "created", + path: relativePath, + stepCount, + warnings, + }; + const text = [ + message, + ...warnings.map((warning) => `Warning (${warning.code}): ${warning.message}`), + ].join("\n"); + return { + content: [{ type: "text", text }], + structuredContent: result as unknown as Record, + }; +} + +function errorResponse( + code: string, + message: string, + issues?: Issue[] +): ToolResponse { + const result: ErrorResult = { + status: "error", + code, + message, + ...(issues && issues.length > 0 ? { issues } : {}), + }; + const text = + issues && issues.length > 0 + ? `Error (${code}): ${message}\n` + + issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n") + : `Error (${code}): ${message}`; + return { + content: [{ type: "text", text }], + structuredContent: result as unknown as Record, + isError: true, + }; +} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts new file mode 100644 index 00000000..c66367a6 --- /dev/null +++ b/packages/mcp-server/src/types.ts @@ -0,0 +1,70 @@ +export interface Position { + line: number; + character: number; +} + +export interface Selection { + start: Position; + end: Position; +} + +export interface TourStep { + title?: string; + description: string; + file?: string; + directory?: string; + line?: number; + pattern?: string; + selection?: Selection; +} + +export interface ProjectParams { + title?: string; + description?: string; + steps?: unknown[]; +} + +export interface ChangesParams extends ProjectParams { + baseRef?: string; + headRef?: string; + includeUncommittedChanges?: boolean; +} + +export interface TourFile { + $schema?: string; + title: string; + description?: string; + ref?: string; + steps: TourStep[]; +} + +export interface Issue { + path: string; + message: string; +} + +export interface Warning { + code: string; + message: string; +} + +export interface SuccessResult { + status: "created"; + path: string; + stepCount: number; + warnings: Warning[]; +} + +export interface ErrorResult { + status: "error"; + code: string; + message: string; + issues?: Issue[]; +} + +export type ToolResult = SuccessResult | ErrorResult; + +export interface WorkspaceContext { + root: string; + realRoot: string; +} diff --git a/packages/mcp-server/src/validation.ts b/packages/mcp-server/src/validation.ts new file mode 100644 index 00000000..9501318a --- /dev/null +++ b/packages/mcp-server/src/validation.ts @@ -0,0 +1,470 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + ChangesParams, + Issue, + Position, + ProjectParams, + Selection, + TourStep, + WorkspaceContext, +} from "./types"; + +export const STEP_FIELDS = [ + "title", + "description", + "file", + "directory", + "line", + "pattern", + "selection", +] as const; + +export const MAX_RECOMMENDED_STEPS = 15; + +const FORBIDDEN_URI_SCHEME = + /(?:command|file|vscode|vscode-insiders|javascript):/i; + +const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/; + +type RawObject = Record; + +function isPlainObject(value: unknown): value is RawObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + +function isOptionalString( + raw: RawObject, + key: string, + errorPath: string, + issues: Issue[] +): string | undefined { + const value = raw[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + issues.push({ path: errorPath, message: "must be a string" }); + return undefined; + } + return value; +} + +function reportUnknownFields( + raw: RawObject, + allowed: readonly string[], + errorPath: string, + issues: Issue[] +): void { + for (const key of Object.keys(raw)) { + if (!allowed.includes(key)) { + issues.push({ + path: errorPath === "$" ? key : `${errorPath}.${key}`, + message: + "unknown field (V1 tours only allow title, description, file, directory, line, pattern and selection)", + }); + } + } +} + +const PROJECT_PARAM_FIELDS = ["title", "description", "steps"] as const; +const CHANGES_PARAM_FIELDS = [ + "title", + "description", + "steps", + "baseRef", + "headRef", + "includeUncommittedChanges", +] as const; + +export function validateProjectParams( + raw: unknown +): { params?: ProjectParams; issues: Issue[] } { + const issues: Issue[] = []; + if (!isPlainObject(raw)) { + issues.push({ path: "$", message: "the arguments must be an object" }); + return { issues }; + } + reportUnknownFields(raw, PROJECT_PARAM_FIELDS, "$", issues); + const params = validateCommonParams(raw, issues); + return { params, issues }; +} + +export function validateChangesParams( + raw: unknown +): { params?: ChangesParams; issues: Issue[] } { + const issues: Issue[] = []; + if (!isPlainObject(raw)) { + issues.push({ path: "$", message: "the arguments must be an object" }); + return { issues }; + } + reportUnknownFields(raw, CHANGES_PARAM_FIELDS, "$", issues); + const params = validateCommonParams(raw, issues) as ChangesParams; + if (typeof raw.baseRef !== "string" || raw.baseRef.trim() === "") { + issues.push({ path: "baseRef", message: "is required and must be a non-empty string" }); + } else { + params.baseRef = raw.baseRef; + } + if (typeof raw.headRef !== "string") { + issues.push({ path: "headRef", message: "is required and must be the full 40-character commit SHA" }); + } else if (!FULL_SHA_PATTERN.test(raw.headRef)) { + issues.push({ path: "headRef", message: "must be the full 40-character commit SHA" }); + } else { + params.headRef = raw.headRef; + } + if (raw.includeUncommittedChanges !== undefined) { + if (typeof raw.includeUncommittedChanges !== "boolean") { + issues.push({ path: "includeUncommittedChanges", message: "must be a boolean" }); + } else { + params.includeUncommittedChanges = raw.includeUncommittedChanges; + } + } + return { params, issues }; +} + +function validateCommonParams(raw: RawObject, issues: Issue[]): ProjectParams { + const params: ProjectParams = {}; + const title = isOptionalString(raw, "title", "title", issues); + if (title !== undefined) { + params.title = title; + } + const description = isOptionalString(raw, "description", "description", issues); + if (description !== undefined) { + if (FORBIDDEN_URI_SCHEME.test(description)) { + issues.push({ + path: "description", + message: + "contains a forbidden URI scheme (command:, file:, vscode:, vscode-insiders:, javascript:)", + }); + } + params.description = description; + } + if (raw.steps === undefined) { + issues.push({ path: "steps", message: "is required" }); + } else if (!Array.isArray(raw.steps)) { + issues.push({ path: "steps", message: "must be an array" }); + } else { + params.steps = raw.steps; + } + return params; +} + +export function validateSteps( + rawSteps: unknown[], + ctx: WorkspaceContext +): { steps?: TourStep[]; issues: Issue[] } { + const allIssues: Issue[] = []; + const steps: TourStep[] = []; + for (let index = 0; index < rawSteps.length; index++) { + const { step, issues } = validateStep(rawSteps[index], index, ctx); + allIssues.push(...issues); + if (step) { + steps.push(step); + } + } + if (allIssues.length > 0) { + return { issues: allIssues }; + } + return { steps, issues: [] }; +} + +function validateStep( + raw: unknown, + index: number, + ctx: WorkspaceContext +): { step?: TourStep; issues: Issue[] } { + const issues: Issue[] = []; + const base = `steps[${index}]`; + if (!isPlainObject(raw)) { + issues.push({ path: base, message: "must be an object" }); + return { issues }; + } + reportUnknownFields(raw, STEP_FIELDS, base, issues); + + const description = isOptionalString(raw, "description", `${base}.description`, issues); + if (description === undefined && raw.description === undefined) { + issues.push({ path: `${base}.description`, message: "is required and must be a string" }); + } + + const title = isOptionalString(raw, "title", `${base}.title`, issues); + + const file = isOptionalString(raw, "file", `${base}.file`, issues); + const directory = isOptionalString(raw, "directory", `${base}.directory`, issues); + + if (file !== undefined && directory !== undefined) { + issues.push({ path: base, message: "a step cannot have both a file and a directory" }); + } + + let line: number | undefined; + if (raw.line !== undefined) { + if (!isPositiveInteger(raw.line)) { + issues.push({ path: `${base}.line`, message: "must be a positive integer" }); + } else { + line = raw.line; + } + if (file === undefined) { + issues.push({ path: `${base}.line`, message: "is only valid together with a file" }); + } + } + + let pattern: string | undefined; + if (raw.pattern !== undefined) { + if (typeof raw.pattern !== "string") { + issues.push({ path: `${base}.pattern`, message: "must be a string" }); + } else { + pattern = raw.pattern; + } + if (file === undefined) { + issues.push({ path: `${base}.pattern`, message: "is only valid together with a file" }); + } + } + + if (line !== undefined && pattern !== undefined) { + issues.push({ path: base, message: "line and pattern are mutually exclusive" }); + } + + let selection: Selection | undefined; + if (raw.selection !== undefined) { + selection = validateSelection(raw.selection, `${base}.selection`, issues); + if (file === undefined) { + issues.push({ path: `${base}.selection`, message: "is only valid together with a file" }); + } + } + + if (description !== undefined && FORBIDDEN_URI_SCHEME.test(description)) { + issues.push({ + path: `${base}.description`, + message: + "contains a forbidden URI scheme (command:, file:, vscode:, vscode-insiders:, javascript:)", + }); + } + + let fileContent: string | undefined; + let fileAnchorOk = true; + if (file !== undefined) { + const anchor = checkAnchor(ctx, file, "file", `${base}.file`, issues); + if (anchor.ok && anchor.realPath !== undefined) { + try { + fileContent = fs.readFileSync(anchor.realPath, "utf8"); + } catch { + issues.push({ path: `${base}.file`, message: "could not be read" }); + } + } + fileAnchorOk = anchor.ok; + } + if (directory !== undefined) { + checkAnchor(ctx, directory, "directory", `${base}.directory`, issues); + } + + if (fileContent === undefined && !fileAnchorOk) { + for (const field of ["line", "pattern", "selection"] as const) { + if (raw[field] !== undefined) { + issues.push({ + path: `${base}.${field}`, + message: "cannot be validated because the file anchor is invalid", + }); + } + } + } + + if (line !== undefined && fileContent !== undefined) { + const lineCount = fileContent.split("\n").length; + if (line > lineCount) { + issues.push({ + path: `${base}.line`, + message: `is out of range: the file has ${lineCount} line(s)`, + }); + } + } + + if (pattern !== undefined && fileContent !== undefined) { + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (error) { + issues.push({ + path: `${base}.pattern`, + message: `is not a valid regular expression: ${(error as Error).message}`, + }); + regex = undefined as unknown as RegExp; + } + if (regex) { + const matches = fileContent.match(new RegExp(regex.source, "g")) ?? []; + if (matches.length !== 1) { + issues.push({ + path: `${base}.pattern`, + message: `must match exactly one occurrence in the file (matched ${matches.length})`, + }); + } + } + } + + if (selection && fileContent !== undefined) { + validateSelectionBounds(selection, fileContent, base, issues); + } + + if (issues.length > 0) { + return { issues }; + } + return { + step: { + title, + description: description as string, + file, + directory, + line, + pattern, + selection, + }, + issues: [], + }; +} + +function validateSelection( + raw: unknown, + base: string, + issues: Issue[] +): Selection | undefined { + if (!isPlainObject(raw)) { + issues.push({ path: base, message: "must be an object with start and end" }); + return undefined; + } + reportUnknownFields(raw, ["start", "end"], base, issues); + const start = validatePosition(raw.start, `${base}.start`, issues); + const end = validatePosition(raw.end, `${base}.end`, issues); + if (!start || !end) { + return undefined; + } + if ( + start.line > end.line || + (start.line === end.line && start.character > end.character) + ) { + issues.push({ path: base, message: "the start position must be before the end position" }); + return undefined; + } + return { start, end }; +} + +function validatePosition( + raw: unknown, + base: string, + issues: Issue[] +): Position | undefined { + if (!isPlainObject(raw)) { + issues.push({ path: base, message: "must be an object with line and character" }); + return undefined; + } + reportUnknownFields(raw, ["line", "character"], base, issues); + let line: number | undefined; + if (!isPositiveInteger(raw.line)) { + issues.push({ path: `${base}.line`, message: "is required and must be a positive integer" }); + } else { + line = raw.line; + } + let character: number | undefined; + if (!isPositiveInteger(raw.character)) { + issues.push({ path: `${base}.character`, message: "is required and must be a positive integer" }); + } else { + character = raw.character; + } + if (line === undefined || character === undefined) { + return undefined; + } + return { line, character }; +} + +function validateSelectionBounds( + selection: Selection, + fileContent: string, + base: string, + issues: Issue[] +): void { + const lines = fileContent.split("\n"); + const maxLine = lines.length; + if (selection.start.line > maxLine) { + issues.push({ + path: `${base}.selection.start.line`, + message: `is out of range: the file has ${maxLine} line(s)`, + }); + } else { + const maxCharacter = lines[selection.start.line - 1].length + 1; + if (selection.start.character > maxCharacter) { + issues.push({ + path: `${base}.selection.start.character`, + message: `is out of range: line ${selection.start.line} has ${maxCharacter} character slot(s)`, + }); + } + } + if (selection.end.line > maxLine) { + issues.push({ + path: `${base}.selection.end.line`, + message: `is out of range: the file has ${maxLine} line(s)`, + }); + } else { + const maxCharacter = lines[selection.end.line - 1].length + 1; + if (selection.end.character > maxCharacter) { + issues.push({ + path: `${base}.selection.end.character`, + message: `is out of range: line ${selection.end.line} has ${maxCharacter} character slot(s)`, + }); + } + } +} + +interface AnchorResult { + ok: boolean; + realPath?: string; +} + +function checkAnchor( + ctx: WorkspaceContext, + value: string, + kind: "file" | "directory", + errorPath: string, + issues: Issue[] +): AnchorResult { + if (value.trim() === "") { + issues.push({ path: errorPath, message: "must not be empty" }); + return { ok: false }; + } + if (path.isAbsolute(value)) { + issues.push({ path: errorPath, message: "must be relative to the workspace root" }); + return { ok: false }; + } + const target = path.resolve(ctx.root, value); + let realPath: string; + try { + realPath = fs.realpathSync(target); + } catch { + issues.push({ path: errorPath, message: "does not exist in the workspace" }); + return { ok: false }; + } + const relative = path.relative(ctx.realRoot, realPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + issues.push({ + path: errorPath, + message: "resolves outside the workspace root (symlinks escaping the workspace are not allowed)", + }); + return { ok: false }; + } + let stat: fs.Stats; + try { + stat = fs.statSync(realPath); + } catch { + issues.push({ path: errorPath, message: "does not exist in the workspace" }); + return { ok: false }; + } + if (kind === "file" && !stat.isFile()) { + issues.push({ path: errorPath, message: "is not a file" }); + return { ok: false }; + } + if (kind === "directory" && !stat.isDirectory()) { + issues.push({ path: errorPath, message: "is not a directory" }); + return { ok: false }; + } + return { ok: true, realPath }; +} diff --git a/packages/mcp-server/test/helpers/mermaid-fixtures.ts b/packages/mcp-server/test/helpers/mermaid-fixtures.ts new file mode 100644 index 00000000..f2d78860 --- /dev/null +++ b/packages/mcp-server/test/helpers/mermaid-fixtures.ts @@ -0,0 +1,23 @@ +export const ALLOWED_DIAGRAM_SOURCES: Record = { + flowchart: "flowchart TD\n A --> B", + sequenceDiagram: "sequenceDiagram\n A->>B: Hello", + "stateDiagram-v2": "stateDiagram-v2\n [*] --> Idle", + classDiagram: "classDiagram\n class Account {\n +String id\n }", + erDiagram: "erDiagram\n CUSTOMER ||--o{ ORDER : places", +}; + +export function captionedDiagram(caption: string, source: string): string { + return [`**Diagram — ${caption}**`, "", "```mermaid", source, "```"].join( + "\n" + ); +} + +export function oversizedFlowchartSource(): string { + const prefix = "flowchart TD\n A --> B\n%%"; + return prefix + "x".repeat(20 * 1024 + 1 - Buffer.byteLength(prefix, "utf8")); +} + +export const INVALID_FLOWCHART_SOURCE = [ + "flowchart TD", + " this is not mermaid at all (((", +].join("\n"); diff --git a/packages/mcp-server/test/helpers/test-utils.ts b/packages/mcp-server/test/helpers/test-utils.ts new file mode 100644 index 00000000..a9a1544c --- /dev/null +++ b/packages/mcp-server/test/helpers/test-utils.ts @@ -0,0 +1,159 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { validateCodetourTour } from "../../src/codetour-schema"; +import { git } from "../../src/git"; + +export { git }; + +export const CLI_PATH = path.join(__dirname, "..", "..", "src", "cli.js"); + +export interface ToolResponse { + isError: boolean; + text: string; + structured: Record; +} + +export async function startServer( + workspaceRoot: string, + envOverrides: Record = {} +): Promise { + const client = new Client( + { name: "codetour-mcp-test-client", version: "0.0.0" }, + { capabilities: {} } + ); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [CLI_PATH, "--workspace-root", workspaceRoot], + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + ), + ...envOverrides, + }, + }); + await client.connect(transport); + return client; +} + +export async function withServer( + root: string, + run: (client: Client) => Promise, + envOverrides: Record = {} +): Promise { + const client = await startServer(root, envOverrides); + try { + return await run(client); + } finally { + await stopServer(client); + } +} + +export async function callTool( + client: Client, + name: string, + args: unknown +): Promise { + const result = (await client.callTool({ + name, + arguments: args as Record, + })) as unknown as { + content?: Array<{ type: string; text?: string }>; + isError?: boolean; + structuredContent?: Record; + }; + const text = (result.content ?? []) + .filter((item) => item.type === "text" && item.text !== undefined) + .map((item) => item.text as string) + .join("\n"); + return { + isError: result.isError ?? false, + text, + structured: result.structuredContent ?? {}, + }; +} + +export async function stopServer(client: Client): Promise { + await client.close(); +} + +export function tempDir(prefix = "codetour-mcp-test-"): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +export function rmrf(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); +} + +export function writeFile(workspaceRoot: string, relativePath: string, content: string): void { + const target = path.join(workspaceRoot, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +export async function initGitRepo(dir: string, branch = "main"): Promise { + await git(["init", "-b", branch], dir); + await git(["config", "user.email", "test@example.com"], dir); + await git(["config", "user.name", "Test User"], dir); + await git(["config", "commit.gpgsign", "false"], dir); + await git(["config", "tag.gpgsign", "false"], dir); +} + +export async function commitFile( + dir: string, + relativePath: string, + content: string, + message: string +): Promise { + const target = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + await git(["add", "-A"], dir); + await git(["commit", "-m", message], dir); +} + +export async function headSha(dir: string): Promise { + const result = await git(["rev-parse", "HEAD"], dir); + return result.stdout.trim(); +} + +export function readTourFile( + workspaceRoot: string, + relativePath: string +): Record { + return JSON.parse( + fs.readFileSync(path.join(workspaceRoot, relativePath), "utf8") + ); +} + +export function tourFileValidAgainstSchema( + workspaceRoot: string, + relativePath: string +): boolean { + const content = readTourFile(workspaceRoot, relativePath); + return validateCodetourTour(content) as boolean; +} + +export function structuredCode(response: ToolResponse): string { + return (response.structured.code ?? "") as string; +} + +export function structuredIssues(response: ToolResponse): Array<{ path: string; message: string }> { + return (response.structured.issues ?? []) as Array<{ path: string; message: string }>; +} + +export function structuredWarnings(response: ToolResponse): Array<{ code: string; message: string }> { + return (response.structured.warnings ?? []) as Array<{ code: string; message: string }>; +} + +export function warningCodes(response: ToolResponse): string[] { + return structuredWarnings(response).map((warning) => warning.code); +} + +export function issuePaths(response: ToolResponse): string[] { + return structuredIssues(response).map((issue) => issue.path); +} diff --git a/packages/mcp-server/test/integration/changes-tour.test.ts b/packages/mcp-server/test/integration/changes-tour.test.ts new file mode 100644 index 00000000..88623434 --- /dev/null +++ b/packages/mcp-server/test/integration/changes-tour.test.ts @@ -0,0 +1,684 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + callTool, + commitFile, + git, + headSha, + initGitRepo, + readTourFile, + rmrf, + structuredCode, + tempDir, + tourFileValidAgainstSchema, + warningCodes, + withServer, + writeFile, +} from "../helpers/test-utils"; +import { + ALLOWED_DIAGRAM_SOURCES, + INVALID_FLOWCHART_SOURCE, + captionedDiagram, +} from "../helpers/mermaid-fixtures"; + +async function setupRepo(): Promise<{ root: string; baseSha: string; head: string }> { + const root = tempDir(); + await initGitRepo(root); + await commitFile(root, "base.txt", "base content\n", "add base file"); + const baseSha = await headSha(root); + await git(["checkout", "-b", "feature"], root); + await commitFile(root, "feature.txt", "feature content\n", "add feature file"); + const head = await headSha(root); + return { root, baseSha, head }; +} + +test("accepts all five allowed Mermaid kinds in a Changes Tour", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: Object.entries(ALLOWED_DIAGRAM_SOURCES).map(([kind, source]) => ({ + description: captionedDiagram(`${kind} change`, source), + })), + }); + assert.equal(response.isError, false, response.text); + assert.equal(response.structured.stepCount, 5); + }); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("preserves the previous Changes Tour when Mermaid syntax is invalid", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Original.", file: "feature.txt" }], + }); + assert.equal(first.isError, false); + const before = readTourFile(root, ".tours/changes.tour"); + + const second = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [ + { + description: captionedDiagram("Broken", INVALID_FLOWCHART_SOURCE), + }, + ], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "INVALID_PROPOSAL"); + assert.deepEqual(readTourFile(root, ".tours/changes.tour"), before); + }); + } finally { + rmrf(root); + } +}); + +test("creates a changes tour with the exact head SHA as ref", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + description: "Explains the feature branch.", + steps: [ + { description: "Intent of the change." }, + { description: "The new file.", file: "feature.txt", line: 1 }, + ], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + assert.equal(response.structured.path, ".tours/changes.tour"); + assert.equal(response.structured.stepCount, 2); + assert.deepEqual(response.structured.warnings, []); + }); + const tour = readTourFile(root, ".tours/changes.tour"); + assert.equal(tour.ref, head); + assert.equal(tour.title, "Changes on feature"); + const description = tour.description as string; + assert.ok(description.includes("Explains the feature branch.")); + assert.ok(description.includes(baseSha)); + assert.ok(description.includes(head)); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("uses the provided title when given", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + title: "Custom Title", + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + }); + assert.equal(readTourFile(root, ".tours/changes.tour").title, "Custom Title"); + } finally { + rmrf(root); + } +}); + +test("fails with STALE_HEAD when HEAD moved since the analysis", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await commitFile(root, "extra.txt", "extra\n", "extra commit"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "STALE_HEAD"); + }); + } finally { + rmrf(root); + } +}); + +test( + "preserves the previous tour when HEAD moves immediately before persistence", + { + skip: + process.platform === "win32" && + "the deterministic Git interception uses POSIX process semantics", + }, + async () => { + const { root, baseSha, head } = await setupRepo(); + const shimDir = tempDir("codetour-git-shim-"); + const reachedFinalCheck = path.join(shimDir, "reached-final-check"); + const continueRequest = path.join(shimDir, "continue-request"); + const gitExecutable = process.platform === "win32" ? "git.exe" : "git"; + const realGit = (process.env.PATH ?? "") + .split(path.delimiter) + .map((directory) => path.join(directory, gitExecutable)) + .find((candidate) => fs.existsSync(candidate)); + assert.ok(realGit, "git must be available on PATH"); + const gitShim = path.join( + shimDir, + process.platform === "win32" ? "git.cmd" : "git" + ); + writeFile( + shimDir, + path.basename(gitShim), + process.platform === "win32" + ? `@echo off +if not "%*" == "rev-parse --abbrev-ref HEAD" goto run +type nul > "${reachedFinalCheck}" +:wait +if exist "${continueRequest}" goto run +ping -n 1 -w 10 127.0.0.1 > nul +goto wait +:run +"${realGit}" %* +` + : `#!/bin/sh +if [ "$*" = "rev-parse --abbrev-ref HEAD" ]; then + : > "${reachedFinalCheck}" + while [ ! -e "${continueRequest}" ]; do sleep 0.01; done +fi +exec "${realGit}" "$@" +` + ); + if (process.platform !== "win32") { + fs.chmodSync(gitShim, 0o755); + } + + try { + writeFile(root, ".tours/changes.tour", JSON.stringify({ title: "Previous tour" })); + const before = readTourFile(root, ".tours/changes.tour"); + + await withServer( + root, + async (client) => { + const request = callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Should not land.", file: "feature.txt" }], + }); + while (!fs.existsSync(reachedFinalCheck)) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await commitFile(root, "late.txt", "late change\n", "move HEAD during request"); + writeFile(shimDir, "continue-request", "continue\n"); + + const response = await request; + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "STALE_HEAD"); + }, + { PATH: `${shimDir}:${process.env.PATH ?? ""}` } + ); + assert.deepEqual(readTourFile(root, ".tours/changes.tour"), before); + } finally { + rmrf(root); + rmrf(shimDir); + } + } +); + +test("fails with STALE_HEAD when headRef is not the current HEAD", async () => { + const { root, baseSha } = await setupRepo(); + try { + const other = baseSha; + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: other, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "STALE_HEAD"); + }); + } finally { + rmrf(root); + } +}); + +test("fails with GIT_REPOSITORY_REQUIRED outside a git repository", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: "main", + headRef: "a".repeat(40), + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "GIT_REPOSITORY_REQUIRED"); + }); + } finally { + rmrf(root); + } +}); + +test("fails with NO_CHANGES when the range has no committed changes", async () => { + const { root, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: head, + headRef: head, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "NO_CHANGES"); + }); + } finally { + rmrf(root); + } +}); + +test("fails with INVALID_BASE_REF when the merge-base cannot be computed", async () => { + const { root, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: "no-such-branch", + headRef: head, + steps: [{ description: "d" }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_BASE_REF"); + }); + } finally { + rmrf(root); + } +}); + +test("keeps the previous tour when NO_CHANGES occurs", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Original.", file: "feature.txt" }], + }); + assert.equal(first.isError, false); + const second = await callTool(client, "create_changes_tour", { + baseRef: head, + headRef: head, + steps: [{ description: "Should not replace." }], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "NO_CHANGES"); + }); + assert.equal( + readTourFile(root, ".tours/changes.tour").ref, + head + ); + } finally { + rmrf(root); + } +}); + +test("warns when uncommitted changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "feature.txt", "modified locally\n"); + writeFile(root, "untracked.txt", "new local file\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + assert.equal(readTourFile(root, ".tours/changes.tour").ref, head); + } finally { + rmrf(root); + } +}); + +test("ignores the reserved tour files when detecting a dirty workspace", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, ".tours/project.tour", "{ generated earlier }"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +test("includes uncommitted changes explicitly and drops the ref", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "work-in-progress.txt", "local work\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + includeUncommittedChanges: true, + steps: [ + { description: "Local work.", file: "work-in-progress.txt" }, + ], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_INCLUDED")); + }); + const tour = readTourFile(root, ".tours/changes.tour"); + assert.equal(tour.ref, undefined); + assert.ok((tour.description as string).includes("non-reproducible")); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("warns with NO_CHANGED_FILE_ANCHOR when no step anchors a changed file", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Only context, no anchors." }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test("does not warn when a step anchors a changed file", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "The new file.", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test("allows deletion-only branches with content-only steps", async () => { + const root = tempDir(); + await initGitRepo(root); + await commitFile(root, "victim.txt", "to be deleted\n", "add victim"); + const baseSha = await headSha(root); + await git(["checkout", "-b", "cleanup"], root); + await git(["rm", "victim.txt"], root); + await git(["commit", "-m", "remove victim"], root); + const head = await headSha(root); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "We removed victim.txt entirely." }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + assert.equal(readTourFile(root, ".tours/changes.tour").ref, head); + assert.ok(tourFileValidAgainstSchema(root, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("allows anchoring unchanged files for essential context", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [ + { description: "Context from the base file.", file: "base.txt" }, + { description: "The new file.", file: "feature.txt" }, + ], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test("aggregates step validation errors for a changes tour", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [ + { description: "d", file: "missing.txt" }, + { description: "d", file: "feature.txt", line: 99 }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const issues = (response.structured.issues ?? []) as Array<{ path: string }>; + assert.ok(issues.some((issue) => issue.path === "steps[0].file")); + assert.ok(issues.some((issue) => issue.path === "steps[1].line")); + }); + } finally { + rmrf(root); + } +}); + +test("works when the workspace root is a subdirectory of the repository", async () => { + const root = tempDir(); + await initGitRepo(root); + await commitFile(root, "packages/app/main.ts", "export {};\n", "add app"); + await commitFile(root, "packages/app/util.ts", "export {};\n", "add util"); + const baseSha = await headSha(root); + await git(["checkout", "-b", "sub", "-q"], root); + await commitFile(root, "packages/app/new.ts", "export {};\n", "add new file"); + const head = await headSha(root); + const subRoot = `${root}/packages/app`; + try { + await withServer(subRoot, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "The new file.", file: "new.ts" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + assert.ok(tourFileValidAgainstSchema(subRoot, ".tours/changes.tour")); + } finally { + rmrf(root); + } +}); + +test("warns when the tour exceeds fifteen steps for a changes tour", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + const steps = Array.from({ length: 16 }, (_, index) => ({ + description: `Step ${index + 1}.`, + })); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps, + }); + assert.equal(response.isError, false); + assert.equal(response.structured.stepCount, 16); + assert.ok(warningCodes(response).includes("STEP_LIMIT_EXCEEDED")); + }); + } finally { + rmrf(root); + } +}); + +test("warns when only staged changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "staged.txt", "staged but not committed\n"); + await git(["add", "staged.txt"], root); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "d", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +test("warns when only unstaged changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "feature.txt", "committed feature\nlocal edit\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "The committed feature.", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +test("warns when only untracked changes are excluded", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "untracked.txt", "not committed\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "The committed feature.", file: "feature.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_EXCLUDED")); + }); + } finally { + rmrf(root); + } +}); + +test("explains local work when only uncommitted changes exist", async () => { + const { root, head } = await setupRepo(); + try { + writeFile(root, "work-in-progress.txt", "local work\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: head, + headRef: head, + includeUncommittedChanges: true, + steps: [{ description: "Local work.", file: "work-in-progress.txt" }], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + assert.ok(warningCodes(response).includes("UNCOMMITTED_CHANGES_INCLUDED")); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + const tour = readTourFile(root, ".tours/changes.tour"); + assert.equal(tour.ref, undefined); + } finally { + rmrf(root); + } +}); + +test("counts uncommitted files as changed for NO_CHANGED_FILE_ANCHOR", async () => { + const { root, baseSha, head } = await setupRepo(); + try { + writeFile(root, "local-only.txt", "local work\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + includeUncommittedChanges: true, + steps: [{ description: "Local work.", file: "local-only.txt" }], + }); + assert.equal(response.isError, false); + assert.ok(!warningCodes(response).includes("NO_CHANGED_FILE_ANCHOR")); + }); + } finally { + rmrf(root); + } +}); + +test( + "preserves the previous tour when the write fails", + { + skip: + process.platform === "win32" && + "POSIX directory permissions are unavailable", + }, + async () => { + const { root, baseSha, head } = await setupRepo(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Original.", file: "feature.txt" }], + }); + assert.equal(first.isError, false); + }); + const before = readTourFile(root, ".tours/changes.tour"); + const toursDir = `${root}/.tours`; + const fs = await import("node:fs"); + fs.chmodSync(toursDir, 0o555); + try { + await withServer(root, async (client) => { + const second = await callTool(client, "create_changes_tour", { + baseRef: baseSha, + headRef: head, + steps: [{ description: "Should not land.", file: "feature.txt" }], + }); + assert.equal(second.isError, true); + }); + assert.deepEqual(readTourFile(root, ".tours/changes.tour"), before); + const leftovers = fs + .readdirSync(toursDir) + .filter((name) => name.includes(".tmp-")); + assert.deepEqual(leftovers, []); + } finally { + fs.chmodSync(toursDir, 0o755); + } + } finally { + rmrf(root); + } + } +); diff --git a/packages/mcp-server/test/integration/cli.test.ts b/packages/mcp-server/test/integration/cli.test.ts new file mode 100644 index 00000000..e6a3892a --- /dev/null +++ b/packages/mcp-server/test/integration/cli.test.ts @@ -0,0 +1,25 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import { CLI_PATH, rmrf, tempDir } from "../helpers/test-utils"; + +test("rejects more than one --workspace-root argument", () => { + const root = tempDir(); + try { + const result = spawnSync( + process.execPath, + [ + CLI_PATH, + "--workspace-root", + root, + `--workspace-root=${root}`, + ], + { encoding: "utf8", timeout: 5_000 } + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--workspace-root must be provided exactly once/); + } finally { + rmrf(root); + } +}); diff --git a/packages/mcp-server/test/integration/packaged-binary.test.ts b/packages/mcp-server/test/integration/packaged-binary.test.ts new file mode 100644 index 00000000..7b215c6e --- /dev/null +++ b/packages/mcp-server/test/integration/packaged-binary.test.ts @@ -0,0 +1,179 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import * as assert from "node:assert"; +import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { test } from "node:test"; +import { + callTool, + commitFile, + headSha, + initGitRepo, + readTourFile, + rmrf, + tempDir, +} from "../helpers/test-utils"; + +const execFileAsync = promisify(execFile); +const packageRoot = path.join(__dirname, "..", "..", ".."); + +async function npm(args: string[], cwd: string, cache: string): Promise { + const npmCli = process.env.npm_execpath; + const command = npmCli ? process.execPath : "npm"; + const commandArgs = npmCli ? [npmCli, ...args] : args; + const result = await execFileAsync(command, commandArgs, { + cwd, + encoding: "utf8", + env: { + ...process.env, + npm_config_cache: cache, + npm_config_update_notifier: "false", + }, + }); + return result.stdout; +} + +test("the installed codetour-mcp binary serves both public MCP tools", async () => { + const sandbox = tempDir("codetour-mcp-package-test-"); + const archiveDir = path.join(sandbox, "archive"); + const installationRoot = path.join(sandbox, "consumer"); + const workspaceRoot = path.join(sandbox, "workspace"); + const npmCache = path.join(sandbox, "npm-cache"); + + try { + fs.mkdirSync(archiveDir, { recursive: true }); + await npm( + ["pack", "--json", "--pack-destination", archiveDir], + packageRoot, + npmCache + ); + const filename = fs + .readdirSync(archiveDir) + .find((entry) => entry.endsWith(".tgz")); + assert.ok(filename, "npm pack should create a package archive"); + const archivePath = path.join(archiveDir, filename); + + // Seed the consumer with the already-installed dependency tree, then pass + // every registry dependency exposed by the packed manifest as a local + // source. npm still resolves direct dependencies during an install even + // when their directories already exist; without the local sources, a cold + // platform-specific cache (notably macOS ARM) fails in --offline mode. + fs.mkdirSync(installationRoot, { recursive: true }); + fs.cpSync( + path.join(packageRoot, "node_modules"), + path.join(installationRoot, "node_modules"), + { recursive: true } + ); + await npm( + [ + "install", + "--prefix", + installationRoot, + "--ignore-scripts", + "--offline", + "--package-lock=false", + "--no-audit", + "--no-fund", + archivePath, + path.join(packageRoot, "node_modules", "@modelcontextprotocol", "sdk"), + path.join(packageRoot, "node_modules", "ajv"), + path.join(packageRoot, "node_modules", "@resvg", "resvg-js"), + path.join(packageRoot, "node_modules", "jsdom"), + path.join(packageRoot, "node_modules", "mermaid"), + path.join(packageRoot, "node_modules", "zod"), + ], + sandbox, + npmCache + ); + + // The shared renderer is a local monorepo package, so npm pack cannot + // carry its sibling path dependency into the isolated consumer. Seed the + // consumer with the already-built package and its already-installed + // dependency tree, preserving the test's fully offline contract. + const rendererRoot = path.join(packageRoot, "..", "description-renderer"); + const rendererTarget = path.join( + installationRoot, + "node_modules", + "codetour-description-renderer" + ); + let rendererIsAlreadyLinked = false; + try { + rendererIsAlreadyLinked = fs.lstatSync(rendererTarget).isSymbolicLink(); + } catch { + // The package installer may not have created the local dependency. + } + if (rendererIsAlreadyLinked) { + fs.unlinkSync(rendererTarget); + } + if (!rendererIsAlreadyLinked || !fs.existsSync(rendererTarget)) { + fs.cpSync(rendererRoot, rendererTarget, { + recursive: true, + filter: (source) => + path.basename(source) !== "node_modules" && + !source.includes(`${path.sep}node_modules${path.sep}`), + }); + fs.cpSync( + path.join(rendererRoot, "node_modules"), + path.join(installationRoot, "node_modules"), + { recursive: true } + ); + } + + fs.mkdirSync(workspaceRoot, { recursive: true }); + await initGitRepo(workspaceRoot); + await commitFile(workspaceRoot, "base.txt", "base\n", "add base"); + const baseRef = await headSha(workspaceRoot); + await commitFile(workspaceRoot, "feature.txt", "feature\n", "add feature"); + const headRef = await headSha(workspaceRoot); + assert.match(baseRef, /^[0-9a-f]{40}$/); + assert.match(headRef, /^[0-9a-f]{40}$/); + + const binaryPath = path.join( + installationRoot, + "node_modules", + ".bin", + "codetour-mcp" + ); + const client = new Client( + { name: "packaged-binary-test-client", version: "0.0.0" }, + { capabilities: {} } + ); + const transport = new StdioClientTransport({ + command: binaryPath, + args: ["--workspace-root", workspaceRoot], + }); + await client.connect(transport); + try { + const tools = await client.listTools(); + const changesTool = tools.tools.find((tool) => tool.name === "create_changes_tour"); + assert.ok(changesTool); + assert.ok( + Object.prototype.hasOwnProperty.call( + (changesTool.inputSchema.properties ?? {}) as object, + "headRef" + ), + JSON.stringify(changesTool.inputSchema) + ); + const changesTour = await callTool(client, "create_changes_tour", { + baseRef, + headRef, + steps: [{ description: "The feature.", file: "feature.txt" }], + }); + assert.equal(changesTour.isError, false, changesTour.text); + + const projectTour = await callTool(client, "create_project_tour", { + steps: [{ description: "The project base.", file: "base.txt" }], + }); + assert.equal(projectTour.isError, false); + } finally { + await client.close(); + } + + assert.equal(readTourFile(workspaceRoot, ".tours/project.tour").title, "Project Overview"); + assert.equal(readTourFile(workspaceRoot, ".tours/changes.tour").ref, headRef); + } finally { + rmrf(sandbox); + } +}); diff --git a/packages/mcp-server/test/integration/project-tour.test.ts b/packages/mcp-server/test/integration/project-tour.test.ts new file mode 100644 index 00000000..f5be7714 --- /dev/null +++ b/packages/mcp-server/test/integration/project-tour.test.ts @@ -0,0 +1,554 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + callTool, + issuePaths, + readTourFile, + rmrf, + structuredCode, + structuredIssues, + tempDir, + tourFileValidAgainstSchema, + warningCodes, + withServer, + writeFile, +} from "../helpers/test-utils"; +import { + ALLOWED_DIAGRAM_SOURCES, + INVALID_FLOWCHART_SOURCE, + captionedDiagram, + oversizedFlowchartSource, +} from "../helpers/mermaid-fixtures"; + +test("accepts all five allowed Mermaid kinds in a Project Tour", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: Object.entries(ALLOWED_DIAGRAM_SOURCES).map(([kind, source]) => ({ + description: captionedDiagram(`${kind} overview`, source), + })), + }); + assert.equal(response.isError, false, response.text); + assert.equal(response.structured.stepCount, 5); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal((tour.steps as unknown[]).length, 5); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("aggregates Mermaid errors with fence paths and source locations", async () => { + const root = tempDir(); + try { + const description = [ + ["Introductory text", "", "```mermaid", ALLOWED_DIAGRAM_SOURCES.flowchart, "```"].join("\n"), + captionedDiagram("Unsupported", "pie title Pets\n \"Dogs\" : 1"), + captionedDiagram("Oversized", oversizedFlowchartSource()), + captionedDiagram("Fourth", ALLOWED_DIAGRAM_SOURCES.flowchart), + ].join("\n\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description, + steps: [ + { + description: captionedDiagram("Broken syntax", INVALID_FLOWCHART_SOURCE), + }, + { + description: [ + "**Diagram – malformed caption**", + "", + "```mermaid", + ALLOWED_DIAGRAM_SOURCES.flowchart, + "```", + ].join("\n"), + }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const issues = structuredIssues(response); + assert.deepEqual( + issues.map((issue) => issue.path), + [ + "description.mermaid[0].caption", + "description.mermaid[1].kind", + "description.mermaid[2].source", + "description.mermaid[3]", + "steps[0].description.mermaid[0].source", + "steps[1].description.mermaid[0].caption", + ] + ); + assert.ok(issues.every((issue) => issue.message.includes("line"))); + }); + assert.equal(fs.existsSync(path.join(root, ".tours/project.tour")), false); + } finally { + rmrf(root); + } +}); + +test("preserves the previous Project Tour when Mermaid validation fails", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_project_tour", { + title: "Original", + steps: [{ description: "Original step." }], + }); + assert.equal(first.isError, false); + const before = readTourFile(root, ".tours/project.tour"); + + const second = await callTool(client, "create_project_tour", { + title: "Broken", + steps: [ + { + description: captionedDiagram("Broken", INVALID_FLOWCHART_SOURCE), + }, + ], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "INVALID_PROPOSAL"); + assert.deepEqual(readTourFile(root, ".tours/project.tour"), before); + }); + } finally { + rmrf(root); + } +}); + +test("creates a project tour with the default title and no git ref", async () => { + const root = tempDir(); + try { + writeFile(root, "src/index.ts", "export const answer = 42;\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: "An overview of the project.", + steps: [ + { description: "Intro step." }, + { description: "The entry point.", file: "src/index.ts", line: 1 }, + ], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + assert.equal(response.structured.path, ".tours/project.tour"); + assert.equal(response.structured.stepCount, 2); + assert.deepEqual(response.structured.warnings, []); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "Project Overview"); + assert.equal(tour.description, "An overview of the project."); + assert.equal(tour.ref, undefined); + assert.equal(tour.$schema, "https://aka.ms/codetour-schema"); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("uses the provided title and omits the description when absent", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + title: "My Tour", + steps: [{ description: "Sole step." }], + }); + assert.equal(response.isError, false); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "My Tour"); + assert.equal(tour.description, undefined); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("works in a workspace that is not a git repository", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "No git here." }], + }); + assert.equal(response.isError, false); + assert.equal(response.structured.status, "created"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an empty steps array with TOUR_STEPS_REQUIRED", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "TOUR_STEPS_REQUIRED"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects a missing steps array with TOUR_STEPS_REQUIRED", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", {}); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "TOUR_STEPS_REQUIRED"); + }); + } finally { + rmrf(root); + } +}); + +test("aggregates top-level Mermaid errors even when steps are missing", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: captionedDiagram("Broken", INVALID_FLOWCHART_SOURCE), + steps: [], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + assert.ok(issuePaths(response).includes("description.mermaid[0].source")); + assert.ok(issuePaths(response).includes("steps")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an unterminated Mermaid fence", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: [ + "**Diagram — Unterminated**", + "", + "```mermaid", + ALLOWED_DIAGRAM_SOURCES.flowchart, + ].join("\n"), + steps: [{ description: "A valid step." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + assert.ok(issuePaths(response).includes("description.mermaid[0].source")); + }); + } finally { + rmrf(root); + } +}); + +test("aggregates all step validation errors in one response", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\ntwo\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "Missing file.", file: "nope.ts", line: 1 }, + { file: "a.ts" }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const paths = issuePaths(response); + assert.ok(paths.includes("steps[0].file")); + assert.ok(paths.includes("steps[0].line")); + assert.ok(paths.includes("steps[1].description")); + }); + } finally { + rmrf(root); + } +}); + +test("keeps the previous tour when the new proposal is invalid", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const first = await callTool(client, "create_project_tour", { + title: "Original", + steps: [{ description: "Original step." }], + }); + assert.equal(first.isError, false); + + const second = await callTool(client, "create_project_tour", { + title: "Broken", + steps: [{ description: "Broken step.", file: "missing.ts" }], + }); + assert.equal(second.isError, true); + assert.equal(structuredCode(second), "INVALID_PROPOSAL"); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "Original"); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("replaces the previous tour atomically on success", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\n"); + await withServer(root, async (client) => { + await callTool(client, "create_project_tour", { + title: "First", + steps: [{ description: "First step." }], + }); + const second = await callTool(client, "create_project_tour", { + title: "Second", + steps: [{ description: "Second step.", file: "a.ts" }], + }); + assert.equal(second.isError, false); + }); + const tour = readTourFile(root, ".tours/project.tour"); + assert.equal(tour.title, "Second"); + assert.equal((tour.steps as unknown[]).length, 1); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("anchors a step on a directory", async () => { + const root = tempDir(); + try { + writeFile(root, "lib/util.ts", "export {};\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "The lib area.", directory: "lib" }], + }); + assert.equal(response.isError, false); + }); + assert.ok(tourFileValidAgainstSchema(root, ".tours/project.tour")); + } finally { + rmrf(root); + } +}); + +test("anchors a step on a unique pattern", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "const x = 1;\nconst y = 2;\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "The y declaration.", file: "a.ts", pattern: "const y" }, + ], + }); + assert.equal(response.isError, false); + }); + } finally { + rmrf(root); + } +}); + +test("anchors a step on a valid selection", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "export const answer = 42;\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { + description: "The answer.", + file: "a.ts", + selection: { + start: { line: 1, character: 21 }, + end: { line: 1, character: 23 }, + }, + }, + ], + }); + assert.equal(response.isError, false); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an out-of-range selection", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "short\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { + description: "Too long.", + file: "a.ts", + selection: { + start: { line: 1, character: 1 }, + end: { line: 1, character: 99 }, + }, + }, + ], + }); + assert.equal(response.isError, true); + assert.ok( + structuredIssues(response).some((issue) => + issue.path.includes("selection.end.character") + ) + ); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an out-of-range line", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Too far.", file: "a.ts", line: 10 }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].line")); + }); + } finally { + rmrf(root); + } +}); + +test("warns without blocking when the tour exceeds fifteen steps", async () => { + const root = tempDir(); + try { + const steps = Array.from({ length: 16 }, (_, index) => ({ + description: `Step ${index + 1}.`, + })); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { steps }); + assert.equal(response.isError, false); + assert.equal(response.structured.stepCount, 16); + assert.ok(warningCodes(response).includes("STEP_LIMIT_EXCEEDED")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects CodeTour commands, when expressions and uri fields", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + when: "true", + steps: [ + { description: "d", commands: ["workbench.action.quit"] }, + { description: "d", uri: "https://example.com" }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const paths = issuePaths(response); + assert.ok(paths.includes("when")); + assert.ok(paths.includes("steps[0].commands")); + assert.ok(paths.includes("steps[1].uri")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects active markdown URI schemes in descriptions", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "Run this: [click](command:workbench.action.quit)" }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + assert.ok(issuePaths(response).includes("steps[0].description")); + }); + } finally { + rmrf(root); + } +}); + +test("allows ordinary https links and images in descriptions", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { + description: + "See [the docs](https://example.com/docs) and ![diagram](https://example.com/diagram.png).", + }, + ], + }); + assert.equal(response.isError, false); + }); + } finally { + rmrf(root); + } +}); + +test("rejects absolute paths as anchors", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "d", file: "/etc/passwd" }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].file")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects relative paths escaping the workspace root", async () => { + const root = tempDir(); + try { + writeFile(root, "a.ts", "one\n"); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "d", file: "../escape.ts" }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].file")); + }); + } finally { + rmrf(root); + } +}); + +test("rejects symlinks that escape the workspace root", async () => { + const root = tempDir(); + const outside = tempDir(); + try { + writeFile(outside, "secret.ts", "top secret\n"); + writeFile(root, "a.ts", "one\n"); + const fs = await import("node:fs"); + fs.symlinkSync(outside, `${root}/link-out`); + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "d", file: "link-out/secret.ts" }], + }); + assert.equal(response.isError, true); + assert.ok(issuePaths(response).includes("steps[0].file")); + }); + } finally { + rmrf(root); + rmrf(outside); + } +}); diff --git a/packages/mcp-server/test/integration/security.test.ts b/packages/mcp-server/test/integration/security.test.ts new file mode 100644 index 00000000..13081fa1 --- /dev/null +++ b/packages/mcp-server/test/integration/security.test.ts @@ -0,0 +1,149 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { + callTool, + rmrf, + structuredCode, + tempDir, + withServer, +} from "../helpers/test-utils"; + +test("rejects a file: scheme in a step description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Open [the file](file:///etc/passwd)." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects vscode: and javascript: schemes in a step description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [ + { description: "Click [here](vscode://file/x)." }, + { description: "Execute [this](javascript:alert(1))." }, + ], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects a command scheme in a changes tour description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Run [this](command:workbench.action.quit)." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + }); + } finally { + rmrf(root); + } +}); + +test("rejects an active scheme in the tour-level description", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + description: "See [the launch command](command:workbench.action.quit).", + steps: [{ description: "Fine step." }], + }); + assert.equal(response.isError, true); + assert.equal(structuredCode(response), "INVALID_PROPOSAL"); + const issues = (response.structured.issues ?? []) as Array<{ path: string }>; + assert.ok(issues.some((issue) => issue.path === "description")); + }); + } finally { + rmrf(root); + } +}); + +test("exposes exactly two tools", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name).sort(); + assert.deepEqual(names, ["create_changes_tour", "create_project_tour"]); + const project = tools.tools.find((tool) => tool.name === "create_project_tour"); + assert.ok(project); + assert.ok(project!.description!.includes("Project Tour")); + assert.ok(project!.description!.includes("readable Markdown")); + assert.ok( + project!.description!.includes("short paragraphs separated by blank lines") + ); + assert.ok(project!.description!.includes("bullet or numbered lists")); + assert.ok(project!.description!.includes("Avoid dense monolithic paragraphs")); + assert.ok( + project!.description!.includes("Begin with a directory-anchored overview step") + ); + assert.ok(project!.description!.includes("tour is scoped to a subdirectory")); + assert.ok(project!.description!.includes("Use Mermaid when it materially clarifies")); + assert.ok( + project!.description!.includes( + "For architecture, workflow, lifecycle, or multi-module Tours, include at least one Mermaid diagram" + ) + ); + assert.ok(project!.description!.includes("flowchart, sequenceDiagram")); + assert.ok(project!.description!.includes("at most 3 Mermaid fences")); + assert.ok(project!.description!.includes("20 KB")); + const changes = tools.tools.find((tool) => tool.name === "create_changes_tour"); + assert.ok(changes); + assert.ok(changes!.description!.includes("Changes Tour")); + assert.ok(changes!.description!.includes("short paragraphs separated by blank lines")); + assert.ok(changes!.description!.includes("**Diagram — …**")); + + for (const tool of [project, changes]) { + const properties = (tool!.inputSchema.properties ?? {}) as Record< + string, + unknown + >; + assert.match( + (properties.description as { description?: string }).description ?? "", + /readable Markdown overview/ + ); + assert.match( + (properties.steps as { description?: string }).description ?? "", + /blank lines between paragraphs/ + ); + } + }); + } finally { + rmrf(root); + } +}); + +test("returns a human-readable message and a structured result", async () => { + const root = tempDir(); + try { + await withServer(root, async (client) => { + const response = await callTool(client, "create_project_tour", { + steps: [{ description: "Hello." }], + }); + assert.equal(response.isError, false); + assert.ok(response.text.includes(".tours/project.tour")); + assert.equal(response.structured.status, "created"); + assert.equal(response.structured.path, ".tours/project.tour"); + assert.equal(response.structured.stepCount, 1); + assert.ok(Array.isArray(response.structured.warnings)); + }); + } finally { + rmrf(root); + } +}); diff --git a/packages/mcp-server/test/unit/validation.test.ts b/packages/mcp-server/test/unit/validation.test.ts new file mode 100644 index 00000000..ed3c0015 --- /dev/null +++ b/packages/mcp-server/test/unit/validation.test.ts @@ -0,0 +1,192 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createContext } from "../../src/context"; +import { + MAX_RECOMMENDED_STEPS, + validateChangesParams, + validateProjectParams, + validateSteps, +} from "../../src/validation"; +import { rmrf, tempDir, writeFile } from "../helpers/test-utils"; + +test("validateProjectParams rejects non-object arguments", () => { + const { issues } = validateProjectParams("not-an-object"); + assert.ok(issues.some((issue) => issue.path === "$")); +}); + +test("validateProjectParams rejects unknown root fields", () => { + const { issues } = validateProjectParams({ when: "true", steps: [] }); + assert.ok(issues.some((issue) => issue.path === "when")); +}); + +test("validateProjectParams requires a string title and description", () => { + const { issues } = validateProjectParams({ + title: 5, + description: false, + steps: [], + }); + const paths = issues.map((issue) => issue.path); + assert.ok(paths.includes("title")); + assert.ok(paths.includes("description")); +}); + +test("validateChangesParams requires baseRef and a full headRef SHA", () => { + const { issues } = validateChangesParams({ steps: [] }); + const paths = issues.map((issue) => issue.path); + assert.ok(paths.includes("baseRef")); + assert.ok(paths.includes("headRef")); +}); + +test("validateChangesParams accepts the public Changes Tour contract", () => { + const headRef = "a".repeat(40); + const { params, issues } = validateChangesParams({ + baseRef: "main", + headRef, + includeUncommittedChanges: true, + steps: [{ description: "Explain the change." }], + }); + + assert.deepEqual(issues, []); + assert.equal(params?.baseRef, "main"); + assert.equal(params?.headRef, headRef); + assert.equal(params?.includeUncommittedChanges, true); +}); + +test("validateChangesParams rejects a short head SHA", () => { + const { issues } = validateChangesParams({ + baseRef: "main", + headRef: "deadbeef", + steps: [], + }); + assert.ok(issues.some((issue) => issue.path === "headRef")); +}); + +test("validateChangesParams rejects a non-boolean includeUncommittedChanges", () => { + const { issues } = validateChangesParams({ + baseRef: "main", + headRef: "a".repeat(40), + includeUncommittedChanges: "yes", + steps: [], + }); + assert.ok(issues.some((issue) => issue.path === "includeUncommittedChanges")); +}); + +function workspaceWithFiles(files: Record): string { + const dir = tempDir(); + for (const [name, content] of Object.entries(files)) { + writeFile(dir, name, content); + } + return dir; +} + +function validate(rawSteps: unknown[], root: string) { + return validateSteps(rawSteps, createContext(root)); +} + +test("a step rejects a file and a directory together", () => { + const root = workspaceWithFiles({ "a.ts": "x" }); + fs.mkdirSync(path.join(root, "sub")); + const { issues } = validate( + [{ description: "d", file: "a.ts", directory: "sub" }], + root + ); + assert.ok(issues.some((issue) => issue.path === "steps[0]")); + rmrf(root); +}); + +test("line and pattern are mutually exclusive", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\n" }); + const { issues } = validate( + [{ description: "d", file: "a.ts", line: 1, pattern: "two" }], + root + ); + assert.ok(issues.some((issue) => issue.message.includes("mutually exclusive"))); + rmrf(root); +}); + +test("line, pattern and selection require a file", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\n" }); + const { issues } = validate( + [ + { description: "d", line: 1 }, + { description: "d", pattern: "one" }, + { + description: "d", + selection: { start: { line: 1, character: 1 }, end: { line: 1, character: 2 } }, + }, + ], + root + ); + assert.equal(issues.length, 3); + rmrf(root); +}); + +test("line must be a positive integer", () => { + const root = workspaceWithFiles({ "a.ts": "one\n" }); + const { issues } = validate( + [ + { description: "d", file: "a.ts", line: 0 }, + { description: "d", file: "a.ts", line: 1.5 }, + ], + root + ); + assert.equal(issues.filter((issue) => issue.path.endsWith(".line")).length, 2); + rmrf(root); +}); + +test("a pattern must match exactly one occurrence", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\none\n" }); + const { issues } = validate( + [ + { description: "d", file: "a.ts", pattern: "one" }, + { description: "d", file: "a.ts", pattern: "three" }, + ], + root + ); + assert.equal(issues.length, 2); + rmrf(root); +}); + +test("an invalid regular expression is rejected", () => { + const root = workspaceWithFiles({ "a.ts": "one\n" }); + const { issues } = validate( + [{ description: "d", file: "a.ts", pattern: "(unclosed" }], + root + ); + assert.equal(issues.length, 1); + rmrf(root); +}); + +test("steps may anchor files and directories without line targeting", () => { + const root = workspaceWithFiles({ "a.ts": "one\ntwo\n" }); + fs.mkdirSync(path.join(root, "sub")); + const { steps, issues } = validate( + [ + { description: "d", file: "a.ts" }, + { description: "d", directory: "sub" }, + ], + root + ); + assert.equal(issues.length, 0); + assert.ok(steps); + assert.equal(steps!.length, 2); + rmrf(root); +}); + +test("content-only steps without any anchor are allowed", () => { + const root = workspaceWithFiles({ "a.ts": "one\n" }); + const { steps, issues } = validate( + [{ description: "intro" }, { description: "deleted file context" }], + root + ); + assert.equal(issues.length, 0); + assert.ok(steps); + assert.equal(steps!.length, 2); + rmrf(root); +}); + +test("the recommended step maximum is fifteen", () => { + assert.equal(MAX_RECOMMENDED_STEPS, 15); +}); diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 00000000..d69a0d76 --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "node16", + "moduleResolution": "node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "declaration": false, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/scripts/package-extension.js b/scripts/package-extension.js new file mode 100644 index 00000000..bdfa5c8e --- /dev/null +++ b/scripts/package-extension.js @@ -0,0 +1,89 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { TARGETS, hostTarget } = require("./prepare-resvg-runtime"); + +const root = path.resolve(__dirname, ".."); +const target = process.env.CODETOUR_TARGET || hostTarget(); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: root, + env: { ...process.env, CODETOUR_TARGET: target }, + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + shell: process.platform === "win32", + encoding: "utf8" + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} exited with status ${result.status}`); + } + return options.capture ? `${result.stdout || ""}${result.stderr || ""}` : ""; +} + +function main() { + if (!TARGETS[target]) { + throw new Error(`Unsupported CodeTour packaging target: ${target}`); + } + + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const vsce = path.join( + root, + "node_modules", + ".bin", + process.platform === "win32" ? "vsce.cmd" : "vsce" + ); + + run(npm, ["run", "build"]); + const listing = run(vsce, ["ls"], { capture: true }); + const packageJson = JSON.parse( + fs.readFileSync(path.join(root, "package.json"), "utf8") + ); + if (packageJson.browser) { + throw new Error("The packaged extension must not contain a browser entry point."); + } + + const manifest = JSON.parse( + fs.readFileSync(path.join(root, "dist", "resvg-runtime", "manifest.json"), "utf8") + ); + if (manifest.target !== target || manifest.binary !== TARGETS[target].binary) { + throw new Error("The staged rasterizer runtime does not match the requested VSIX target."); + } + const files = listing + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + const expectedBinary = `dist/resvg-runtime/resvg-js/${manifest.binary}`; + if (!files.some((file) => file === expectedBinary || file.endsWith(`/${expectedBinary}`))) { + throw new Error(`VSIX file list does not contain ${expectedBinary}.`); + } + if (files.some((file) => file.includes("extension-web"))) { + throw new Error("The packaged extension contains the removed Web artifact."); + } + if (files.length > 2000) { + throw new Error(`VSIX contains ${files.length} files; the packaging budget is 2000.`); + } + + run(vsce, ["package", "--target", target]); + const version = packageJson.version; + const artifact = fs + .readdirSync(root) + .filter((file) => file.endsWith(`-${target}-${version}.vsix`)) + .map((file) => path.join(root, file)) + .find((file) => fs.statSync(file).isFile()); + if (!artifact) { + throw new Error(`vsce did not create the ${version}-${target} artifact.`); + } + const size = fs.statSync(artifact).size; + if (size >= 38 * 1024 * 1024) { + throw new Error(`VSIX is ${size} bytes; it exceeds the 38 MiB packaging budget.`); + } + run(process.execPath, [path.join(root, "scripts", "verify-vsix.js"), artifact, target]); + console.log( + `Packaged ${path.basename(artifact)} for ${target}: ${size} bytes.` + ); +} + +main(); diff --git a/scripts/prepare-resvg-runtime.js b/scripts/prepare-resvg-runtime.js new file mode 100644 index 00000000..91c84c98 --- /dev/null +++ b/scripts/prepare-resvg-runtime.js @@ -0,0 +1,138 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const RESVG_VERSION = "2.6.2"; + +// VS Code's standard desktop/remote Node targets. Linux artifacts are built +// against glibc; the package must be built again on a musl host if that host +// is added to the published target matrix. +const TARGETS = Object.freeze({ + "darwin-arm64": { + packageName: "@resvg/resvg-js-darwin-arm64", + binary: "resvgjs.darwin-arm64.node" + }, + "darwin-x64": { + packageName: "@resvg/resvg-js-darwin-x64", + binary: "resvgjs.darwin-x64.node" + }, + "linux-arm64": { + packageName: "@resvg/resvg-js-linux-arm64-gnu", + binary: "resvgjs.linux-arm64-gnu.node" + }, + "linux-x64": { + packageName: "@resvg/resvg-js-linux-x64-gnu", + binary: "resvgjs.linux-x64-gnu.node" + }, + "win32-arm64": { + packageName: "@resvg/resvg-js-win32-arm64-msvc", + binary: "resvgjs.win32-arm64-msvc.node" + }, + "win32-x64": { + packageName: "@resvg/resvg-js-win32-x64-msvc", + binary: "resvgjs.win32-x64-msvc.node" + } +}); + +function hostTarget() { + return `${process.platform}-${process.arch}`; +} + +function requestedTarget() { + const argumentIndex = process.argv.indexOf("--target"); + return argumentIndex >= 0 && process.argv[argumentIndex + 1] + ? process.argv[argumentIndex + 1] + : process.env.CODETOUR_TARGET || hostTarget(); +} + +function dependencyRoot() { + const candidates = [ + path.join(root, "node_modules"), + path.join(root, "packages", "description-renderer", "node_modules") + ]; + const selected = candidates.find((candidate) => + fs.existsSync(path.join(candidate, "@resvg", "resvg-js", "js-binding.js")) + ); + if (!selected) { + throw new Error( + "Unable to find @resvg/resvg-js. Install the root and description-renderer dependencies first." + ); + } + return selected; +} + +function prepareRuntime(target = requestedTarget()) { + const specification = TARGETS[target]; + if (!specification) { + throw new Error( + `Unsupported CodeTour packaging target ${target}. Supported targets: ${Object.keys(TARGETS).join(", ")}` + ); + } + if (target !== hostTarget()) { + throw new Error( + `The ${target} artifact must be built on a ${target} Node host; current host is ${hostTarget()}. ` + + "Build each VSIX on its matching CI runner so its native rasterizer is executable." + ); + } + + const modulesRoot = dependencyRoot(); + const basePackage = path.join(modulesRoot, "@resvg", "resvg-js"); + const nativePackage = path.join( + modulesRoot, + "@resvg", + specification.packageName.slice("@resvg/".length) + ); + const nativeSource = path.join(nativePackage, specification.binary); + if (!fs.existsSync(nativeSource)) { + throw new Error( + `Missing rasterizer binary ${nativeSource}. Run npm ci on a ${target} host before packaging.` + ); + } + + const baseManifest = JSON.parse( + fs.readFileSync(path.join(basePackage, "package.json"), "utf8") + ); + if (baseManifest.version !== RESVG_VERSION) { + throw new Error( + `Expected @resvg/resvg-js ${RESVG_VERSION}, found ${baseManifest.version}.` + ); + } + + const runtimeRoot = path.join(root, "dist", "resvg-runtime"); + const runtimePackage = path.join(runtimeRoot, "resvg-js"); + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + fs.mkdirSync(runtimePackage, { recursive: true }); + for (const file of ["index.js", "js-binding.js"]) { + fs.copyFileSync(path.join(basePackage, file), path.join(runtimePackage, file)); + } + fs.copyFileSync(nativeSource, path.join(runtimePackage, specification.binary)); + fs.writeFileSync( + path.join(runtimePackage, "package.json"), + `${JSON.stringify({ name: "@resvg/resvg-js", version: RESVG_VERSION, main: "index.js" }, null, 2)}\n` + ); + fs.writeFileSync( + path.join(runtimeRoot, "manifest.json"), + `${JSON.stringify( + { + target, + packageName: specification.packageName, + binary: specification.binary, + version: RESVG_VERSION + }, + null, + 2 + )}\n` + ); + + return { + target, + binary: specification.binary, + runtimeRoot + }; +} + +if (require.main === module) { + console.log(JSON.stringify(prepareRuntime(), null, 2)); +} + +module.exports = { TARGETS, hostTarget, prepareRuntime }; diff --git a/scripts/publish-vsix.js b/scripts/publish-vsix.js new file mode 100644 index 00000000..0c7aaf74 --- /dev/null +++ b/scripts/publish-vsix.js @@ -0,0 +1,34 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const artifacts = fs + .readdirSync(root) + .filter((file) => file.endsWith(".vsix")) + .map((file) => path.join(root, file)) + .filter((file) => fs.statSync(file).isFile()); + +if (artifacts.length !== 1) { + throw new Error(`Expected exactly one VSIX to publish, found ${artifacts.length}.`); +} + +const vsce = path.join( + root, + "node_modules", + ".bin", + process.platform === "win32" ? "vsce.cmd" : "vsce" +); +const result = spawnSync(vsce, ["publish", "--packagePath", artifacts[0]], { + cwd: root, + stdio: "inherit", + shell: process.platform === "win32" +}); +if (result.error) { + throw result.error; +} +if (result.status !== 0) { + process.exit(result.status || 1); +} + +console.log(`Published ${path.basename(artifacts[0])}.`); diff --git a/scripts/run-compiled-tests.js b/scripts/run-compiled-tests.js new file mode 100644 index 00000000..9dab205e --- /dev/null +++ b/scripts/run-compiled-tests.js @@ -0,0 +1,31 @@ +const { spawnSync } = require("node:child_process"); +const { readdirSync } = require("node:fs"); +const { join, resolve } = require("node:path"); + +const testDirectory = resolve(process.argv[2]); +function findTestFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap(entry => { + const entryPath = join(directory, entry.name); + return entry.isDirectory() + ? findTestFiles(entryPath) + : entry.name.endsWith(".test.js") + ? [entryPath] + : []; + }); +} + +const testFiles = findTestFiles(testDirectory).sort(); + +if (testFiles.length === 0) { + console.error(`No compiled test files found in ${testDirectory}`); + process.exit(1); +} + +// Some integration tests package the MCP server, whose prepack hook refreshes +// its staged renderer. Run test files serially so that refresh cannot race +// with another test process loading the staged package. +const result = spawnSync(process.execPath, ["--test", "--test-concurrency=1", ...testFiles], { + stdio: "inherit" +}); + +process.exit(result.status ?? 1); diff --git a/scripts/stage-mcp-renderer.js b/scripts/stage-mcp-renderer.js new file mode 100644 index 00000000..bea95fcd --- /dev/null +++ b/scripts/stage-mcp-renderer.js @@ -0,0 +1,39 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const packageRoot = path.resolve(__dirname, ".."); +const rendererRoot = path.join(packageRoot, "packages", "description-renderer"); +const rendererDist = path.join(rendererRoot, "dist"); +const stagedRoot = path.join( + packageRoot, + "packages", + "mcp-server", + "dist", + "node_modules", + "codetour-description-renderer" +); + +if (!fs.existsSync(rendererDist)) { + throw new Error( + "The description renderer must be built before the MCP server can be packaged." + ); +} + +fs.rmSync(stagedRoot, { recursive: true, force: true }); +fs.mkdirSync(stagedRoot, { recursive: true }); +fs.cpSync(rendererDist, path.join(stagedRoot, "dist"), { recursive: true }); + +const rendererPackage = JSON.parse( + fs.readFileSync(path.join(rendererRoot, "package.json"), "utf8") +); +fs.writeFileSync( + path.join(stagedRoot, "package.json"), + `${JSON.stringify({ + name: rendererPackage.name, + version: rendererPackage.version, + main: rendererPackage.main, + types: rendererPackage.types + }, null, 2)}\n` +); + +console.log(`Staged ${rendererPackage.name} in the MCP package.`); diff --git a/scripts/verify-resvg-runtime.js b/scripts/verify-resvg-runtime.js new file mode 100644 index 00000000..3f5284e3 --- /dev/null +++ b/scripts/verify-resvg-runtime.js @@ -0,0 +1,19 @@ +const path = require("node:path"); + +function fail(message) { + throw new Error(message); +} + +const runtime = process.argv[2]; +if (!runtime) { + fail("Usage: node scripts/verify-resvg-runtime.js "); +} + +const { Resvg } = require(path.resolve(runtime)); +const png = new Resvg( + '', + {} +).render().asPng(); +if (png.readUInt32BE(0) !== 0x89504e47) { + fail("The rasterizer did not produce a PNG."); +} diff --git a/scripts/verify-vsix.js b/scripts/verify-vsix.js new file mode 100644 index 00000000..aec8adc8 --- /dev/null +++ b/scripts/verify-vsix.js @@ -0,0 +1,119 @@ +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function fail(message) { + throw new Error(message); +} + +function extract(artifact, destination) { + const result = process.platform === "win32" + ? spawnSync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -LiteralPath '${artifact.replace(/'/gu, "''")}' -DestinationPath '${destination.replace(/'/gu, "''")}' -Force` + ], + { stdio: "inherit" } + ) + : spawnSync("unzip", ["-q", "-o", artifact, "-d", destination], { + stdio: "inherit" + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + fail(`Unable to extract ${artifact}`); + } +} + +function listFiles(directory, prefix = "") { + const files = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relative = path.join(prefix, entry.name); + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(fullPath, relative)); + } else { + files.push(relative); + } + } + return files; +} + +function verify(artifact, target) { + const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "codetour-vsix-")); + try { + extract(artifact, extractionRoot); + const extensionRoot = path.join(extractionRoot, "extension"); + const files = listFiles(extensionRoot).map((file) => file.split(path.sep).join("/")); + if (files.some((file) => file.includes("extension-web"))) { + fail("The VSIX contains the removed extension-web artifact."); + } + + const packageJson = JSON.parse( + fs.readFileSync(path.join(extensionRoot, "package.json"), "utf8") + ); + if (packageJson.browser || packageJson.main !== "./dist/extension-node.js") { + fail("The VSIX package manifest is not Node-extension-only."); + } + + const manifest = JSON.parse( + fs.readFileSync(path.join(extensionRoot, "dist", "resvg-runtime", "manifest.json"), "utf8") + ); + if (manifest.target !== target) { + fail(`The VSIX runtime target is ${manifest.target}, expected ${target}.`); + } + const binary = path.join( + extensionRoot, + "dist", + "resvg-runtime", + "resvg-js", + manifest.binary + ); + if (!fs.existsSync(binary)) { + fail(`The VSIX is missing its native rasterizer: ${manifest.binary}.`); + } + + const resvg = spawnSync( + process.execPath, + [ + path.join(__dirname, "verify-resvg-runtime.js"), + path.join(extensionRoot, "dist", "resvg-runtime", "resvg-js") + ], + { encoding: "utf8" } + ); + if (resvg.error) { + throw resvg.error; + } + if (resvg.status !== 0) { + fail( + `The unpacked VSIX rasterizer did not produce a PNG: ${resvg.stderr || resvg.stdout}` + ); + } + + const mcp = spawnSync( + process.execPath, + [path.join(extensionRoot, "dist", "mcp-server.js"), "--version"], + { cwd: extensionRoot, encoding: "utf8" } + ); + if (mcp.status !== 0 || !/^\d+\.\d+\.\d+/u.test((mcp.stdout || "").trim())) { + fail(`The unpacked MCP server did not start: ${mcp.stderr || mcp.stdout}`); + } + + const size = fs.statSync(artifact).size; + console.log(`Verified unpacked ${path.basename(artifact)}: ${files.length} files, ${size} bytes, PNG OK.`); + } finally { + fs.rmSync(extractionRoot, { recursive: true, force: true }); + } +} + +const artifact = process.argv[2]; +const target = process.argv[3]; +if (!artifact || !target) { + fail("Usage: node scripts/verify-vsix.js "); +} +verify(path.resolve(artifact), target); diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..c85f6fe5 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,227 @@ +{ + "version": 1, + "skills": { + "ask-matt": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/ask-matt/SKILL.md", + "computedHash": "e5404aef8525ca0c8fc76692567a38dfdd5d0cc1a9f8d47c02c18f29f60d92a6" + }, + "claude-handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/claude-handoff/SKILL.md", + "computedHash": "c0fa5d0eede556bc7809c8461a25ec2c7db5726f338970458aa7bbf702b8ea8c" + }, + "code-review": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/code-review/SKILL.md", + "computedHash": "b4f17857c85ca60af1df7d0b623dd03c2a48419f6123e714f3d9748ca744a1bf" + }, + "codebase-design": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/codebase-design/SKILL.md", + "computedHash": "caea3cb8a8281ff829fd1a2b985044e77601b62f301adf43f689ccfc74c15f6f" + }, + "diagnosing-bugs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/diagnosing-bugs/SKILL.md", + "computedHash": "dcaaa3eb81195329f65f27574d9a67dc776160dd2e4d7798d1afb1e4a5f3695a" + }, + "domain-modeling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/domain-modeling/SKILL.md", + "computedHash": "336547f3ff285e822fc70b69b170dc58bceef9c8ed5fad18de0046287d6be837" + }, + "git-guardrails-claude-code": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/git-guardrails-claude-code/SKILL.md", + "computedHash": "fa22f1aa2708d95cc3149640f33e085381b184a0322609930f7971c1dca11835" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "5e0c683385eafd83f106ac6c9d67dfbbfe5aa4b3fe65aad114eb1055a99c818f" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "a610223c9796f755b603f15ec114849a4b38b9ba3006acfa9bdf3cc56dd44dad" + }, + "grilling": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grilling/SKILL.md", + "computedHash": "4fa026e5979770347b3357ff5139e1e41d21c3a9f7335e9cd2811cb5b8d32f2f" + }, + "handoff": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/handoff/SKILL.md", + "computedHash": "9ea2a5de5ca2d717f913356de982fdc4fe27d28a300c16598cd43a4865491008" + }, + "implement": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/implement/SKILL.md", + "computedHash": "130cac2d72bfde8cd526bf3b754211e2fe00e84bc4d9f9c56f749b9541a3afad" + }, + "implement-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/implement-spec/SKILL.md", + "computedHash": "1bbece7b74c00d938ecb79e2337310d8c3c2c307b48f105026e161601b1c6be8" + }, + "improve-codebase-architecture": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", + "computedHash": "016f12709bff30a27419e8add978a9be70f5ca1fb6738b3be6793038e0aba631" + }, + "loop-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/loop-me/SKILL.md", + "computedHash": "d7fbcf41b5af203a6f2a7937afaaaebdd9941b0aa4cc3654b52daec01d3fb564" + }, + "migrate-to-shoehorn": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md", + "computedHash": "7d31e494a384d1a5b88d1dec6e1f50e673ef1a60f7b0b6e92babfbb242f79b62" + }, + "prototype": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/prototype/SKILL.md", + "computedHash": "62979fe039ef64407b258f8824db41b9185f788638747df803032df4153c2aae" + }, + "research": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/research/SKILL.md", + "computedHash": "2c8b768d5f0309eaea9f92ef740101e813645b39aef88fb82733a52eae23dc0b" + }, + "resolving-merge-conflicts": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/resolving-merge-conflicts/SKILL.md", + "computedHash": "436698459190d8e9f03dc32ada08401b56e352fb6a19bf71a2227fad8b80d98f" + }, + "retro": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/retro/SKILL.md", + "computedHash": "371bfc87b0fbb221e2e802e51385b5e1461c052cfa1186b18d4b441c00383eb5" + }, + "scaffold-exercises": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/scaffold-exercises/SKILL.md", + "computedHash": "0f8154a7be9fc5ebe6ae0550d520259fadfdd512c166cb7f137f8e697aae4017" + }, + "setup-matt-pocock-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md", + "computedHash": "a45163cba56f72f5f224c46557c047d51547147a29e383e5a769ec39eecd1188" + }, + "setup-pre-commit": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/misc/setup-pre-commit/SKILL.md", + "computedHash": "4ae1ad6988eb61dbc600627bc12e061215d96ced504632d00f0d9de5905eadfa" + }, + "setup-ts-deep-modules": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/setup-ts-deep-modules/SKILL.md", + "computedHash": "0b0e7124ad1272d59e44bcb4224275e76421351294a91480ada62234158c1023" + }, + "tdd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/tdd/SKILL.md", + "computedHash": "c1ed8cd854c64d4d226097255d3fa662dae4a758a1462fadd63cc10e413d88d7" + }, + "teach": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/teach/SKILL.md", + "computedHash": "d5f74fa9a34961cf4df339019f7cd1ebf84163cf8b789829be26342e5ceba5a1" + }, + "to-questionnaire": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/to-questionnaire/SKILL.md", + "computedHash": "e2cb00bfeb4243f1384bf7e106185ccb69fd7d45ff6a3b04bec4102f505aba31" + }, + "to-spec": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-spec/SKILL.md", + "computedHash": "0ee8caf20fc7df94db53e76f42588fb01eee88c6f8dc97e7a3a0565be8978e74" + }, + "to-tickets": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-tickets/SKILL.md", + "computedHash": "4aba8639f46b55ede011866f83e9e87ae10482b72c14848389e5801eba0b37bf" + }, + "triage": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/triage/SKILL.md", + "computedHash": "363ca4f1b97ca5227d41d3973178ab301d7c498ba41b466dfe30b6163f6d72b5" + }, + "wait-what": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/wait-what/SKILL.md", + "computedHash": "179a857fabd08c894e8e5b30baa7488147760ee1d58696ebd24bd4f6ead21699" + }, + "wayfinder": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wayfinder/SKILL.md", + "computedHash": "f53b17850d8d4d68e8b861cfc7de69580335851d7fe7bc3acdf37505f46c9940" + }, + "wizard": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/wizard/SKILL.md", + "computedHash": "6b98bd36db34eb5584d937b745e6e68a317afa0807a426fe8717ab196242ae70" + }, + "writing-beats": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-beats/SKILL.md", + "computedHash": "1d1c32479fa0774738c7baf264026e937f4a75ef7c20bd6c1b585681cadb523c" + }, + "writing-for-agents": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/writing-for-agents/SKILL.md", + "computedHash": "831e996c177c6c5233eb3bb472a33edee2b3ce1ee5a6d7e8b696c1c3fb4eda3d" + }, + "writing-fragments": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-fragments/SKILL.md", + "computedHash": "e97c829457cb9119aa6a636b989b3c3e273e55c9bcf27b6d281d8132e04ca972" + }, + "writing-shape": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/in-progress/writing-shape/SKILL.md", + "computedHash": "175889218686932bda7724425b97a0a10a7679db51447c40a1a0d3a5e6f2130e" + } + } +} diff --git a/src/extension.ts b/src/extension.ts index b72a4b8c..155e80aa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,8 @@ import * as vscode from "vscode"; import { initializeApi } from "./api"; import { initializeGitApi } from "./git"; import { registerLiveShareModule } from "./liveShare"; +import { registerMcpProvider } from "./mcp"; +import { registerNotebookProvider } from "./notebook"; import { registerPlayerModule } from "./player"; import { registerRecorderModule } from "./recorder"; import { store } from "./store"; @@ -75,6 +77,13 @@ class URIHandler implements vscode.UriHandler { } export async function activate(context: vscode.ExtensionContext) { + if (vscode.env.uiKind === vscode.UIKind.Desktop) { + registerMcpProvider(context); + } + const notebookProvider = registerNotebookProvider(); + if (notebookProvider) { + context.subscriptions.push(notebookProvider); + } registerPlayerModule(context); registerRecorderModule(); registerLiveShareModule(); diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 00000000..66d59803 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,27 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +export const MCP_PROVIDER_ID = "codetour.tour-generator"; + +export function bundledMcpServerPath(context: vscode.ExtensionContext): string { + return path.join(context.extensionPath, "dist", "mcp-server.js"); +} + +export function registerMcpProvider(context: vscode.ExtensionContext): void { + const serverPath = bundledMcpServerPath(context); + const extensionVersion = String(context.extension.packageJSON.version); + context.subscriptions.push( + vscode.lm.registerMcpServerDefinitionProvider(MCP_PROVIDER_ID, { + provideMcpServerDefinitions: () => + (vscode.workspace.workspaceFolders ?? []).map(folder => + new vscode.McpStdioServerDefinition( + `CodeTour (${folder.name})`, + process.execPath, + [serverPath, "--workspace-root", folder.uri.fsPath], + { ELECTRON_RUN_AS_NODE: "1" }, + extensionVersion + ) + ) + }) + ); +} diff --git a/src/notebook/index.ts b/src/notebook/index.ts index 439c080f..2bdd103e 100644 --- a/src/notebook/index.ts +++ b/src/notebook/index.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { EXTENSION_NAME, SMALL_ICON_URL } from "../constants"; +import { renderPreviewDescription } from "../player/description"; import { CodeTour } from "../store"; import { getStepFileUri, getWorkspaceUri } from "../utils"; @@ -35,20 +36,31 @@ class CodeTourNotebookProvider implements vscode.NotebookSerializer { steps.push({ contents, language: document.languageId, - description: item.description, + description: await renderPreviewDescription(item.description, undefined, { + tour, + workspaceRoot + }), uri }); } let cells: vscode.NotebookCellData[] = []; + const titleDescription = + tour.description === undefined + ? "" + : await renderPreviewDescription(tour.description, undefined, { + tour, + workspaceRoot + }); + // Title cell cells.push( new vscode.NotebookCellData( 1, `## ![Icon](${SMALL_ICON_URL})   CodeTour (${tour.title}) - ${ steps.length - } steps\n\n${tour.description === undefined ? "" : tour.description}`, + } steps\n\n${titleDescription}`, "markdown" ) ); @@ -67,6 +79,7 @@ class CodeTourNotebookProvider implements vscode.NotebookSerializer { ) ]) ]; + cells.push(cell); }); return new vscode.NotebookData(cells); @@ -80,8 +93,12 @@ class CodeTourNotebookProvider implements vscode.NotebookSerializer { } } -export function registerNotebookProvider() { - vscode.notebook.registerNotebookSerializer( +export function registerNotebookProvider(): vscode.Disposable | undefined { + if (typeof vscode.notebook === "undefined") { + return undefined; + } + + return vscode.notebook.registerNotebookSerializer( EXTENSION_NAME, new CodeTourNotebookProvider() ); diff --git a/src/player/commands.ts b/src/player/commands.ts index 6bf7bbb5..721258a8 100644 --- a/src/player/commands.ts +++ b/src/player/commands.ts @@ -17,12 +17,30 @@ import { } from "../store/actions"; import { progress } from "../store/storage"; import { readUriContents } from "../utils"; +import { showStepDescription } from "./descriptionWebview"; import { CodeTourNode } from "./tree/nodes"; let terminal: vscode.Terminal | null; export function registerPlayerCommands() { - // This is a "private" command that's used exclusively - // by the hover description for tour markers. + vscode.commands.registerCommand( + `${EXTENSION_NAME}.showStepDescription`, + showStepDescription + ); + + vscode.commands.registerCommand( + `${EXTENSION_NAME}._getActiveCommentBody`, + () => { + const comment = store.activeTour?.thread?.comments[0]; + if (!comment) { + return undefined; + } + + return comment.body instanceof vscode.MarkdownString + ? comment.body.value + : comment.body; + } + ); + vscode.commands.registerCommand( `${EXTENSION_NAME}._startTourById`, async (id: string, stepNumber: number) => { @@ -33,7 +51,6 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link vscode.commands.registerCommand( `${EXTENSION_NAME}.startTourByTitle`, async (title: string, stepNumber?: number) => { @@ -68,7 +85,6 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link vscode.commands.registerCommand( `${EXTENSION_NAME}.navigateToStep`, async (stepNumber: number) => { @@ -83,7 +99,6 @@ export function registerPlayerCommands() { } ); - // Purpose: Command link and the ">>" syntax vscode.commands.registerCommand( `${EXTENSION_NAME}.sendTextToTerminal`, async (text: string) => { diff --git a/src/player/commentMenu.test.ts b/src/player/commentMenu.test.ts new file mode 100644 index 00000000..3b4a4750 --- /dev/null +++ b/src/player/commentMenu.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +interface MenuContribution { + command: string; + group?: string; + when?: string; +} + +test("the comment toolbar exposes expanded description without replacing End Tour", () => { + const manifest = JSON.parse( + readFileSync(resolve(__dirname, "../../../package.json"), "utf8") + ); + const actions = manifest.contributes.menus[ + "comments/commentThread/title" + ] as MenuContribution[]; + + assert.ok( + actions.some( + action => + action.command === "codetour.showStepDescription" && + action.group === "inline@0" && + action.when === "commentController == codetour" + ) + ); + assert.ok( + actions.some( + action => + action.command === "codetour.endTour" && action.group === "inline@3" + ) + ); +}); diff --git a/src/player/description.ts b/src/player/description.ts new file mode 100644 index 00000000..3949e6a6 --- /dev/null +++ b/src/player/description.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ColorThemeKind, Uri, window, workspace } from "vscode"; +import { + DescriptionTheme, + makeRenderedImagesResponsive, + renderDescription +} from "codetour-description-renderer"; +import { CodeTour, store } from "../store"; +import { getTourTitle } from "../utils"; +import { appendInsertCodeLinks } from "./insertCode"; + +const SHELL_SCRIPT_PATTERN = /^>>\s+(?
Bad link', + "Tour description", + "test-nonce" + ); + + assert.doesNotMatch(html, /alert\("unsafe"\)/); + assert.doesNotMatch(html, /href="javascript:/); + assert.match(html, />Bad link<\/a>/); +}); diff --git a/src/player/descriptionWebview.ts b/src/player/descriptionWebview.ts new file mode 100644 index 00000000..a23bce03 --- /dev/null +++ b/src/player/descriptionWebview.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomBytes } from "crypto"; +import * as vscode from "vscode"; +import { store } from "../store"; +import { getStepLabel } from "../utils"; +import { renderPreviewDescription } from "./description"; +import { createDescriptionWebviewHtml } from "./markdown"; + +const VIEW_TYPE = "codetour.description"; +const ALLOWED_COMMANDS = /^(codetour\.|vscode\.open$)/; + +let panel: vscode.WebviewPanel | undefined; +let linkWorkspaceRoot: vscode.Uri | undefined; + +function reviveCommandArgument(value: unknown): unknown { + if ( + value && + typeof value === "object" && + "scheme" in value && + "path" in value + ) { + const components = value as { + scheme: string; + authority?: string; + path?: string; + query?: string; + fragment?: string; + }; + return vscode.Uri.from({ + scheme: components.scheme, + authority: components.authority, + path: components.path, + query: components.query, + fragment: components.fragment + }); + } + + return value; +} + +async function openLink( + href: string, + workspaceRoot?: vscode.Uri +): Promise { + const uri = vscode.Uri.parse(href, true); + if (uri.scheme === "command" && ALLOWED_COMMANDS.test(uri.path)) { + const parsedArgs: unknown = uri.query + ? JSON.parse(decodeURIComponent(uri.query)) + : []; + const args = (Array.isArray(parsedArgs) ? parsedArgs : [parsedArgs]).map( + reviveCommandArgument + ); + await vscode.commands.executeCommand(uri.path, ...args); + } else if (["http", "https", "mailto"].includes(uri.scheme)) { + await vscode.env.openExternal(uri); + } else if (!uri.scheme && workspaceRoot) { + await vscode.commands.executeCommand( + "vscode.open", + vscode.Uri.joinPath(workspaceRoot, uri.path) + ); + } +} + +export async function showStepDescription(): Promise { + const activeTour = store.activeTour; + if (!activeTour) { + return; + } + + const { tour, step, workspaceRoot } = activeTour; + const currentStep = tour.steps[step]; + if (!currentStep?.description) { + return; + } + + const title = `Description — ${getStepLabel(tour, step)}`; + const content = await renderPreviewDescription(currentStep.description, undefined, { + tour, + tours: activeTour.tours, + workspaceRoot + }); + + if (!panel) { + panel = vscode.window.createWebviewPanel( + VIEW_TYPE, + title, + vscode.ViewColumn.Beside, + { enableScripts: true } + ); + panel.onDidDispose(() => { + panel = undefined; + linkWorkspaceRoot = undefined; + }); + panel.webview.onDidReceiveMessage(async message => { + if (message?.type === "openLink" && typeof message.href === "string") { + try { + await openLink(message.href, linkWorkspaceRoot); + } catch { + vscode.window.showErrorMessage("Unable to open this description link."); + } + } + }); + } else { + panel.reveal(vscode.ViewColumn.Beside); + panel.title = title; + } + + linkWorkspaceRoot = workspaceRoot; + panel.webview.html = createDescriptionWebviewHtml( + content, + title, + randomBytes(16).toString("base64") + ); +} diff --git a/src/player/index.ts b/src/player/index.ts index 2e5935b0..c32483f3 100644 --- a/src/player/index.ts +++ b/src/player/index.ts @@ -21,6 +21,7 @@ import { window, workspace } from "vscode"; +import { clearMermaidRenderCache } from "codetour-description-renderer"; import { SMALL_ICON_URL } from "../constants"; import { CodeTour, store } from "../store"; import { initializeStorage } from "../store/storage"; @@ -37,6 +38,8 @@ import { registerPlayerCommands } from "./commands"; import { registerDecorators } from "./decorator"; import { registerFileSystemProvider } from "./fileSystem"; import { registerTextDocumentContentProvider } from "./fileSystem/documentProvider"; +import { renderPreviewDescription } from "./description"; +import { appendCommentNavigation } from "./navigation"; import { registerStatusBar } from "./status"; import { registerTreeProvider } from "./tree"; @@ -44,71 +47,9 @@ const CONTROLLER_ID = "codetour"; const CONTROLLER_LABEL = "CodeTour"; let id = 0; +let renderRequest = 0; -const SHELL_SCRIPT_PATTERN = /^>>\s+(? + +`; +} diff --git a/src/player/navigation.test.ts b/src/player/navigation.test.ts new file mode 100644 index 00000000..43f5dbbc --- /dev/null +++ b/src/player/navigation.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { appendCommentNavigation } from "./navigation"; + +test("appendCommentNavigation gives each action its own wrapping block", () => { + const content = appendCommentNavigation("Description", [ + '← [Previous (A very long previous step)](command:codetour.previousTourStep "Previous")', + '[Finish Tour](command:codetour.finishTour "Finish")' + ]); + + assert.equal( + content, + [ + "Description", + "", + "---", + "", + '- ← [Previous (A very long previous step)](command:codetour.previousTourStep "Previous")', + '- [Finish Tour](command:codetour.finishTour "Finish")' + ].join("\n") + ); + assert.doesNotMatch(content, / \| /); +}); + +test("appendCommentNavigation leaves content unchanged without actions", () => { + assert.equal(appendCommentNavigation("Description", []), "Description"); +}); diff --git a/src/player/navigation.ts b/src/player/navigation.ts new file mode 100644 index 00000000..03f9afbe --- /dev/null +++ b/src/player/navigation.ts @@ -0,0 +1,16 @@ +/** + * Appends comment navigation as separate Markdown blocks so each action can + * wrap independently in the native VS Code comment widget. + */ +export function appendCommentNavigation( + content: string, + links: readonly string[] +): string { + if (links.length === 0) { + return content; + } + + return `${content}\n\n---\n\n${links + .map(link => `- ${link}`) + .join("\n")}`; +} diff --git a/src/player/tree/index.ts b/src/player/tree/index.ts index bd143402..686a3174 100644 --- a/src/player/tree/index.ts +++ b/src/player/tree/index.ts @@ -12,9 +12,9 @@ import { window } from "vscode"; import { EXTENSION_NAME } from "../../constants"; -import { generatePreviewContent } from ".."; import { store } from "../../store"; import { CodeTourNode, CodeTourStepNode } from "./nodes"; +import { renderPreviewDescription } from "../description"; class CodeTourTreeProvider implements TreeDataProvider, Disposable { private _disposables: Disposable[] = []; @@ -53,6 +53,10 @@ class CodeTourTreeProvider implements TreeDataProvider, Disposable { getTreeItem = (node: TreeItem) => node; + refresh() { + this._onDidChangeTreeData.fire(undefined); + } + async getChildren(element?: TreeItem): Promise { if (!element) { if (!store.hasTours && !store.activeTour) { @@ -104,18 +108,50 @@ class CodeTourTreeProvider implements TreeDataProvider, Disposable { } } - // This is called whenever a tree item is hovered over, and we're - // using it to generate preview tooltips for tour steps on-demand. async resolveTreeItem(element: TreeItem): Promise { - if (element instanceof CodeTourStepNode) { - const content = generatePreviewContent( - element.tour.steps[element.stepNumber].description + if (element instanceof CodeTourNode && element.tour.description) { + const content = await renderPreviewDescription( + element.tour.description, + undefined, + { + tour: element.tour, + tours: + element.tour === store.activeTour?.tour + ? store.activeTour.tours + : undefined, + workspaceRoot: + element.tour === store.activeTour?.tour + ? store.activeTour.workspaceRoot + : undefined + } ); const tooltip = new MarkdownString(content); tooltip.isTrusted = true; + tooltip.supportHtml = true; + + element.tooltip = tooltip; + } else if (element instanceof CodeTourStepNode) { + const content = await renderPreviewDescription( + element.tour.steps[element.stepNumber].description, + undefined, + { + tour: element.tour, + tours: + element.tour === store.activeTour?.tour + ? store.activeTour.tours + : undefined, + workspaceRoot: + element.tour === store.activeTour?.tour + ? store.activeTour.workspaceRoot + : undefined + } + ); + + const tooltip = new MarkdownString(content); + tooltip.isTrusted = true; + tooltip.supportHtml = true; - // @ts-ignore element.tooltip = tooltip; } @@ -127,7 +163,10 @@ class CodeTourTreeProvider implements TreeDataProvider, Disposable { } } -export function registerTreeProvider(extensionPath: string) { +export function registerTreeProvider( + extensionPath: string, + onThemeChanged: () => void +): Disposable { const treeDataProvider = new CodeTourTreeProvider(extensionPath); const treeView = window.createTreeView(`${EXTENSION_NAME}.tours`, { showCollapseAll: true, @@ -135,6 +174,11 @@ export function registerTreeProvider(extensionPath: string) { canSelectMany: true }); + const themeChange = window.onDidChangeActiveColorTheme(() => { + onThemeChanged(); + treeDataProvider.refresh(); + }); + let isRevealPending = false; treeView.onDidChangeVisibility(e => { if (e.visible && isRevealPending) { @@ -173,10 +217,16 @@ export function registerTreeProvider(extensionPath: string) { revealCurrentStepNode(); } else { - // TODO: Once VS Code supports it, we want - // to de-select the step node once the tour ends. treeView.message = undefined; } } ); + + return { + dispose() { + themeChange.dispose(); + treeView.dispose(); + treeDataProvider.dispose(); + } + }; } diff --git a/src/player/tree/nodes.ts b/src/player/tree/nodes.ts index be9368d3..b46dcbf3 100644 --- a/src/player/tree/nodes.ts +++ b/src/player/tree/nodes.ts @@ -36,7 +36,6 @@ export class CodeTourNode extends TreeItem { : TreeItemCollapsibleState.Collapsed ); - this.tooltip = tour.description; this.description = `${tour.steps.length} steps`; const contextValues = ["codetour.tour"]; diff --git a/src/recorder/commands.ts b/src/recorder/commands.ts index f9d782c7..9d147ac0 100644 --- a/src/recorder/commands.ts +++ b/src/recorder/commands.ts @@ -8,6 +8,7 @@ import { workspace } from "vscode"; import { EXTENSION_NAME, FS_SCHEME_CONTENT } from "../constants"; import { api, RefType } from "../git"; import { CodeTourComment } from "../player"; +import { renderPreviewDescription } from "../player/description"; import { CodeTourNode, CodeTourStepNode } from "../player/tree/nodes"; import { CodeTour, CodeTourStep, store } from "../store"; import { @@ -18,6 +19,7 @@ import { startCodeTour } from "../store/actions"; import { getActiveWorkspacePath, getRelativePath } from "../utils"; +import { planSaveStep } from "./saveStep"; export async function saveTour(tour: CodeTour) { const uri = vscode.Uri.parse(tour.id); @@ -373,6 +375,10 @@ export function registerRecorderCommands() { const tour = store.activeTour!.tour; const thread = store.activeTour!.thread; + const range = thread.range; + if (!range) { + throw new Error("A file Tour step requires a source range."); + } const workspaceRoot = getActiveWorkspacePath(); const file = getRelativePath(workspaceRoot, thread!.uri.path); @@ -391,7 +397,7 @@ export function registerRecorderCommands() { editor => editor.document && editor.document.uri.scheme === "file" ); const contents = fileEditors?.[0]?.document - .lineAt(thread.range.start.line) + .lineAt(range.start.line) .text.trim(); const pattern = @@ -407,10 +413,10 @@ export function registerRecorderCommands() { step.pattern = pattern; } else { // TODO: Try to get smarter about how to handle this. - step.line = thread.range.start.line + 1; + step.line = range.start.line + 1; } } else { - step.line = thread.range.start.line + 1; + step.line = range.start.line + 1; } store.activeTour!.step++; @@ -441,9 +447,14 @@ export function registerRecorderCommands() { } thread!.contextValue = contextValues.join("."); + const content = await renderPreviewDescription(reply.text, undefined, { + tour, + tours: store.activeTour?.tours, + workspaceRoot: store.activeTour?.workspaceRoot + }); thread!.comments = [ new CodeTourComment( - reply.text, + content, label, thread!, vscode.CommentMode.Preview @@ -557,9 +568,42 @@ export function registerRecorderCommands() { } }); + // The Markdown source is saved before leaving edit mode. On failure the + // editor and its content are kept untouched so nothing is lost, and the + // preview is not regenerated. + let saved: boolean; + try { + await saveTour(store.activeTour!.tour); + saved = true; + } catch (error) { + vscode.window.showErrorMessage(`Could not save the tour step: ${error}`); + saved = false; + } + + const plan = planSaveStep({ saved }); + if (!plan.relaunchPreview) { + return; + } + store.isEditing = false; vscode.commands.executeCommand("setContext", EDITING_KEY, false); - await saveTour(store.activeTour!.tour); + + // Mermaid is only rendered into an image in preview mode. Re-launch the + // tour on the same step — without re-entering edit mode — so the comment + // is recreated through the normal start/preview path and the new Mermaid + // source becomes an image. The tour, its workspace, the tour list and the + // active step index are all preserved. + const activeTour = store.activeTour; + if (activeTour) { + startCodeTour( + activeTour.tour, + activeTour.step, + activeTour.workspaceRoot, + false, + true, + activeTour.tours + ); + } } ); diff --git a/src/recorder/saveStep.test.ts b/src/recorder/saveStep.test.ts new file mode 100644 index 00000000..7f74bf40 --- /dev/null +++ b/src/recorder/saveStep.test.ts @@ -0,0 +1,17 @@ +import { test } from "node:test"; +import * as assert from "node:assert"; +import { planSaveStep } from "./saveStep"; + +test("planSaveStep re-renders the preview after a successful save", () => { + const plan = planSaveStep({ saved: true }); + + assert.equal(plan.keepEditing, false); + assert.equal(plan.relaunchPreview, true); +}); + +test("planSaveStep keeps the editor and its content when the save fails", () => { + const plan = planSaveStep({ saved: false }); + + assert.equal(plan.keepEditing, true); + assert.equal(plan.relaunchPreview, false); +}); diff --git a/src/recorder/saveStep.ts b/src/recorder/saveStep.ts new file mode 100644 index 00000000..03651d81 --- /dev/null +++ b/src/recorder/saveStep.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Decides what to do after a "Save Step" attempt. + * + * A tour step's Markdown source is only turned into an image (Mermaid + * included) once its comment leaves edit mode. Refreshing the preview must + * therefore wait for a successful save: on failure the editor and its content + * are kept untouched so nothing is lost, and the preview is not regenerated. + */ +export interface SaveStepOutcome { + saved: boolean; +} + +export interface SaveStepPlan { + /** When true, the comment stays in edit mode with its current content. */ + keepEditing: boolean; + /** When true, the tour is relaunched on the same step to render the preview. */ + relaunchPreview: boolean; +} + +export function planSaveStep(outcome: SaveStepOutcome): SaveStepPlan { + if (outcome.saved) { + return { keepEditing: false, relaunchPreview: true }; + } + return { keepEditing: true, relaunchPreview: false }; +} diff --git a/src/store/index.ts b/src/store/index.ts index 0abc8786..dabfb5e4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -14,19 +14,12 @@ export interface CodeTourStep { description: string; icon?: string; - // If any of the following are set, then only - // one of them can be, since these properties - // indicate the "type" of step. file?: string; directory?: string; contents?: string; uri?: string; view?: string; - // A line number and selection is only relevant for file-based - // steps. And even then, they're optional. If a file-based step - // doesn't have a line number, then the description is attached - // to the last line in the file, assuming it's describing the file itself line?: number; selection?: { start: CodeTourStepPosition; end: CodeTourStepPosition }; @@ -52,20 +45,10 @@ export interface ActiveTour { tour: CodeTour; step: number; - // When recording, a tour can be active, without - // having created an actual comment yet. thread: CommentThread | null | undefined; - // In order to resolve relative file - // paths, we need to know the workspace root workspaceRoot?: Uri; - // In order to resolve inter-tour - // links, the active tour might need - // the context of its sibling tours, if - // they're coming from somewhere other - // then the active workspace (e.g. a - // GistPad-managed repo). tours?: CodeTour[]; } diff --git a/src/store/provider.ts b/src/store/provider.ts index 72ae4f93..39548a73 100644 --- a/src/store/provider.ts +++ b/src/store/provider.ts @@ -67,14 +67,9 @@ export async function discoverTours(): Promise { if (tour) { if (!comparer.structural(store.activeTour.tour, tour)) { - // Since the active tour could be already observed, - // we want to update it in place with the new properties. set(store.activeTour.tour, tour); } } else { - // The user deleted the tour - // file that's associated with - // the active tour, so end it endCurrentCodeTour(); } } @@ -101,7 +96,7 @@ async function discoverMainTours( }) ); - return tours.filter(tour => tour); + return tours.filter((tour): tour is CodeTour => tour !== undefined); } async function readTourDirectory(uri: vscode.Uri): Promise { @@ -113,15 +108,14 @@ async function readTourDirectory(uri: vscode.Uri): Promise { if (type === vscode.FileType.File) { return readTourFile(fileUri); } else if (type === vscode.FileType.SymbolicLink) { - return readTourFile(fileUri) + return readTourFile(fileUri); } else { return readTourDirectory(fileUri); } }) ); - // @ts-ignore - return tours.flat().filter(tour => tour); + return tours.flat().filter((tour): tour is CodeTour => tour !== undefined); } catch { return []; } diff --git a/tsconfig.json b/tsconfig.json index e451c7ae..ade520ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,20 @@ { "compilerOptions": { "module": "commonjs", - "target": "es6", + "target": "ES2019", "outDir": "out", "lib": ["ES2019", "DOM"], "sourceMap": true, "rootDir": "src", "strict": true, - "noUnusedLocals": true + "noUnusedLocals": true, + "baseUrl": ".", + "paths": { + "codetour-description-renderer": [ + "packages/description-renderer/dist/src/index" + ] + } }, + "include": ["src"], "exclude": ["node_modules"] } diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..f810d6cc --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/test", + "types": ["node"] + }, + "include": [ + "src/player/insertCode.ts", + "src/player/insertCode.test.ts", + "src/player/commentMenu.test.ts", + "src/player/navigation.ts", + "src/player/navigation.test.ts", + "src/player/descriptionWebview.test.ts", + "src/player/markdown.ts", + "src/recorder/saveStep.ts", + "src/recorder/saveStep.test.ts" + ] +} diff --git a/webpack.config.js b/webpack.config.js index f66c0105..87ef33c3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,18 +1,35 @@ const path = require("path"); const webpack = require("webpack"); +const RESVG_EXTERNAL = "commonjs ./resvg-runtime/resvg-js"; + +// Optional native accelerators of ws/jsdom. They are always required inside +// try/catch, so the runtime require fails cleanly when they are not installed +// (VSIX ships no node_modules) and the JS fallbacks take over. +const OPTIONAL_NATIVE_EXTERNALS = { + canvas: "commonjs canvas", + bufferutil: "commonjs bufferutil", + "utf-8-validate": "commonjs utf-8-validate" +}; + const config = { entry: "./src/extension.ts", devtool: "source-map", externals: { - vscode: "commonjs vscode" + vscode: "commonjs vscode", + child_process: "commonjs child_process", + util: "commonjs util", + "@resvg/resvg-js": RESVG_EXTERNAL, + ...OPTIONAL_NATIVE_EXTERNALS }, resolve: { - fallback: { - os: require.resolve("os-browserify/browser"), - path: require.resolve("path-browserify") - }, - extensions: [".ts", ".js", ".json"] + extensions: [".ts", ".js", ".json"], + alias: { + "codetour-description-renderer": path.resolve( + __dirname, + "packages/description-renderer/dist/src/index.js" + ) + } }, node: { __filename: false, @@ -49,18 +66,26 @@ const nodeConfig = { filename: 'extension-node.js', libraryTarget: "commonjs2", devtoolModuleFilenameTemplate: "../[resource-path]", - } + }, + plugins: [ + ...config.plugins, + new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }) + ] }; -const webConfig = { - ...config, - target: 'webworker', +const mcpConfig = { + mode: "production", + target: "node18", + entry: "./packages/mcp-server/dist/src/cli.js", + externals: { + "@resvg/resvg-js": RESVG_EXTERNAL, + ...OPTIONAL_NATIVE_EXTERNALS + }, + devtool: "source-map", output: { - path: path.resolve(__dirname, 'dist'), - filename: 'extension-web.js', - libraryTarget: "commonjs2", - devtoolModuleFilenameTemplate: "../[resource-path]", + path: path.resolve(__dirname, "dist"), + filename: "mcp-server.js" } }; -module.exports = [nodeConfig, webConfig]; \ No newline at end of file +module.exports = [nodeConfig, mcpConfig];