From 98dda679ad715ddc00622732888328e58728385f Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Tue, 11 Aug 2026 01:16:47 +0000 Subject: [PATCH 1/5] docs: define the preview enhancement contract --- docs/plans/002-renderer-implementation.md | 242 ++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 docs/plans/002-renderer-implementation.md diff --git a/docs/plans/002-renderer-implementation.md b/docs/plans/002-renderer-implementation.md new file mode 100644 index 0000000..9b13c26 --- /dev/null +++ b/docs/plans/002-renderer-implementation.md @@ -0,0 +1,242 @@ +# Better Markdown Preview Implementation Plan + +## Objective + +Turn the product-empty foundation into Better Markdown Preview: an extension +that augments VS Code's built-in Markdown preview with full GFM authoring +behavior, Airplan-derived document layout and table of contents, selected +jimeh.me Markdown affordances, local Mermaid rendering, and richer fenced-code +presentation while preserving native highlighting and the active VS Code theme. + +Canonical references: + +- [VS Code Markdown extension guide](https://code.visualstudio.com/api/extension-guides/markdown-extension) +- [Airplan Markdown rendering contract](https://github.com/jimeh/airplan/blob/main/SPEC.md#3-markdown-rendering) +- [jimeh.me Markdown configuration](https://github.com/jimeh/jimeh.me/blob/main/astro.config.ts) + +## Architectural Constraints + +- Extend the built-in preview through `markdown.markdownItPlugins`, + `markdown.previewStyles`, and `markdown.previewScripts`; do not create a + replacement webview. +- Preserve native source synchronization, heading IDs, link/image resolution, + workspace-trust behavior, preview security settings, code-copy controls, and + user `markdown.styles` precedence. +- Keep VS Code's native Highlight.js rendering so syntax colors follow the + active editor token theme. Do not add Shiki. +- Wrap existing Markdown-It rules instead of assuming this extension is the + only contributor. Preserve compatible earlier and later plugin behavior. +- Keep extension-host code browser-safe and continue producing desktop and web + bundles. +- Scope every owned class and data attribute with `better-markdown-preview` or + `bmp` to avoid collisions. +- Use VS Code `--vscode-*` colors and light, dark, and high-contrast body + classes. Do not add a separate theme toggle or fixed page palette. +- Bundle Mermaid locally. Do not load executable code, fonts, icons, or styles + from a CDN. +- Keep readable, escaped Mermaid source visible until rendering succeeds and + after any load or render failure. Use Mermaid's strict security mode. +- Keep preview enhancement initialization idempotent across VS Code content + replacement and theme changes. +- Add dependencies only when they replace material parser work; implement + narrow Airplan-specific syntax locally. + +## Rendering Contract + +### GFM and Markdown Extensions + +- Preserve CommonMark and the native raw-HTML behavior. +- Provide complete GFM-visible behavior: tables, strikethrough, task lists, + extended URL/email autolinks with balanced punctuation, and GFM tag filtering. + This behavior must not silently depend on `markdown.preview.linkify` being + enabled. +- Add definition lists and footnotes. +- Add GitHub alerts `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, and `CAUTION`. + Alerts may contain normal block Markdown. Unknown markers remain ordinary + blockquotes. +- Preserve existing heading IDs and source-map attributes generated by VS Code. + +### Frontmatter + +- Preserve VS Code's native YAML frontmatter recognition. +- Recognize TOML frontmatter only at document byte zero, after an optional UTF-8 + BOM, with exact matching `+++` delimiter lines. +- Render recognized frontmatter as a collapsed native details block containing + its escaped original text. Exclude it from headings and the TOC. +- In an interactive preview, malformed or unclosed frontmatter remains ordinary + Markdown instead of making the complete preview fail. + +### Responsive Columns + +Implement Airplan's narrow Pandoc fenced-div subset: + +- An outer delimiter is at least four colons followed only by `{.columns}`. +- It contains at least two direct child divs using a shorter delimiter of at + least three colons and `{.column}` with an optional `width=N%` attribute. +- Widths accept integer or decimal percentages greater than zero and at most + 100. Unweighted columns share space equally. +- Normal block Markdown works inside each direct child. +- Unknown attributes, invalid widths, nesting, one-child containers, + orphaned/unterminated delimiters, and malformed structures remain ordinary + Markdown without partial transformation. +- Columns prevent overflow and stack at narrow viewport and print widths. + +### Mermaid + +- Only an exact lowercase `mermaid` fence is a diagram. `Mermaid`, + `mermaid-js`, and extra info remain ordinary highlighted code. +- Emit escaped source fallback without a nested `` wrapper that would + trigger the native code-copy presentation. +- Load the locally bundled Mermaid module only when a diagram exists. +- Render explicitly with strict security and colors derived from the current + VS Code theme variables. +- Re-render after a VS Code theme-class change. Print and high-contrast output + must remain legible. +- A missing asset, CSP/import failure, invalid diagram, or Mermaid exception + leaves source visible and marks the block as failed without breaking other + preview enhancements. + +### Rich Fenced Code + +Support the useful jimeh.me fence metadata while retaining the native highlighter: + +- `title="path/to/file"` +- line ranges such as `{1,3-5}` +- literal word emphasis such as `/needle/` +- `showLineNumbers` +- `[!code ++]` and `[!code --]` annotations on changed lines + +The Markdown-It fence wrapper must delegate syntax highlighting to the existing +renderer, strip only recognized metadata/annotations, escape metadata used in +HTML, and preserve native language classes and source mapping. The preview +script may split highlighted DOM into visual lines, but line numbers must be +CSS-only and copied code must remain the authored code without annotation +markers. Do not add a second copy button. + +Malformed or unsupported fence metadata remains harmless and must not turn a +code block into executable HTML. + +## Presentation Contract + +- Base the composition on Airplan's clean page: centered shell around 54rem, + prose measure around 78ch, comfortable line height, restrained borders and + surfaces, and wider code/tables/media where useful. +- Use a three-column wide layout so the document remains centered while a + sticky H1-H3 TOC occupies the left rail. +- Omit a leading document-title H1 from the TOC and hide the TOC with fewer + than two remaining entries. +- Highlight the active heading with requestAnimationFrame-throttled scroll + tracking. +- At narrow widths, move the TOC inline above the document. Once that inline + TOC scrolls away, expose a compact accessible floating control/dialog. +- Restore focus after closing the TOC dialog; support Escape, outside click, + keyboard navigation, and reduced motion. +- Style tables, task lists, alerts, frontmatter, footnotes, definition lists, + columns, code metadata, Mermaid fallback, images, and blockquotes. +- Use `overflow-wrap`, constrained media, and scrollable code/tables to prevent + horizontal page overflow. +- Preserve focus visibility and high-contrast borders. +- Do not port Airplan's toolbar, source/download/raw controls, independent theme + selector, or transition suppression. Do not port blog-only MDX media, + Fancybox, or external-link icons. + +## Expected Scope + +- Extension activation and Markdown-It composition under `src/`. +- Narrow parser/renderer modules for GFM gaps, alerts/frontmatter, columns, + Mermaid fences, and code metadata. +- Preview runtime modules for lifecycle, TOC, code presentation, and Mermaid. +- Theme-aware preview CSS. +- Esbuild targets for extension hosts and preview assets, including a + conditionally imported local Mermaid bundle. +- Manifest contributions and exact VSIX inventory updates. +- Kitchen-sink and focused Markdown fixtures. +- Unit, DOM, Extension Host, web-host where stable, package, and browser/runtime + evidence described below. +- README, changelog, architecture, testing, and agent-map updates matching the + actual feature. + +## Non-goals + +- A custom Markdown editor or custom webview. +- Exact GitHub HTML byte parity; observable GFM behavior is the contract. +- Repository issue/commit-reference linkification. +- Inline-code language annotations such as ``code{:js}``. +- Shiki or a selectable syntax-highlighting backend. +- User settings for individually disabling features in the first release. +- Marketplace publication, release automation, or release creation. +- Replacing VS Code's Markdown language service, outline, or editor commands. + +## Test Strategy and Closure Matrix + +Prefer test-first failures for parser rules and preview lifecycle behavior. +Confirm every new test runs by name or count. + +| Behavior or failure path | Evidence | +| --- | --- | +| Extension contributes only supported Markdown hooks | Manifest contract test and Extension Host activation/export test | +| GFM task lists, literal autolinks, punctuation, and tag filtering | Focused Markdown-It fixtures including disabled native linkify | +| Definition lists and footnotes | Focused render fixtures | +| Known alerts render; unknown markers remain blockquotes | Success and fallback fixtures | +| YAML/TOML frontmatter recognition and malformed fallback | Byte-zero, BOM, delimiter, escaped-content, and unclosed fixtures | +| Valid columns render Markdown and weights | Focused parser fixtures | +| Malformed, nested, invalid-width, and one-child columns remain literal | Boundary/regression fixtures with no partial transformation | +| Exact lowercase Mermaid fence and escaped fallback | Renderer fixtures for matching, nonmatching, and unsafe source | +| Mermaid conditional import, success, failure, and theme rerender | DOM tests with a controlled loader/renderer boundary | +| Native highlighter remains authoritative | Fence test with a sentinel prior renderer/highlight callback | +| Fence title, lines, words, numbering, and diff annotations | Parser plus DOM tests, including copied text and malformed metadata | +| TOC leading-H1 omission, minimum entries, links, and idempotence | DOM tests | +| TOC active state and narrow dialog keyboard/focus lifecycle | DOM tests with controlled geometry/intersection boundaries | +| Preview reinitializes after body replacement without duplicates | Mutation/update lifecycle regression test | +| CSS uses VS Code variables and avoids fixed theme ownership | Focused stylesheet contract inspection plus runtime theme evidence | +| Desktop Extension Host activates and extends Markdown-It | Named `test:extension` behavior test | +| Web Extension Host activates when the official harness is stable | Named web-host test; otherwise structural web build plus documented residual risk | +| VSIX contains only runtime bundles/assets and required metadata | Exact package-content assertion | +| Light, dark, high-contrast, wide, and narrow preview behavior | Real VS Code runtime inspection with screenshots or recorded computed/DOM evidence | +| Clean-environment complete gate | GitHub Actions `mise run verify` on the final pushed head | + +## Focused and Final Gates + +- Parser/unit loop: `pnpm test -- ` or the repository's resulting + named focused task. +- DOM loop: dedicated preview-runtime test task. +- Fast handoff: `mise run check`. +- Extension integration: `mise run test:extension` and the web-host task if + added. +- Artifact: `mise run package:validate`. +- Workflow: `mise run ci:workflows`. +- Intended-final-head local gate: `mise run verify`. +- Final clean-environment gate: required GitHub Actions checks on the exact + pushed head. + +## Risks + +- Markdown-It contributor ordering can cause renderer wrappers to overwrite one + another. Tests must install sentinel rules before this extension and prove + delegation. +- DOM rewriting can break native code copying or source synchronization. Keep + `data-line` elements intact and assert copied text/source-map attributes. +- Conditional Mermaid loading may conflict with the preview CSP. Verify it in a + real VS Code preview; if the current supported hook cannot load a local chunk + conditionally, prefer a documented local always-loaded asset over any CDN. +- Mermaid is a large dependency. Keep it out of the extension-host bundles and + verify package contents and bundle sizes. +- VS Code replaces preview body content without recreating the webview. A + one-shot `DOMContentLoaded` handler is insufficient. +- User styles load after contributed styles. The layout must degrade safely when + users override it rather than assuming exclusive CSS ownership. +- Web-host and Electron automation can be environment-sensitive. Do not claim a + supported runtime from build success alone; record the exact runtime evidence + and any residual platform limitation. + +## Evidence Ownership + +- Implementer: test-first failures, focused parser/DOM/integration execution, + runtime evidence, and completed closure matrix. +- Orchestrator: implementation inspection, evidence sufficiency, and only + missing or invalidated checks. +- Codex and Claude reviewers: independent requirement, security, lifecycle, + compatibility, and test-quality reasoning. +- CodeRabbit: external PR review after internal acceptance. +- CI: clean-environment final-head verification. + From d35f4667b8841095c9e263011a5886ce23d48d9f Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Tue, 11 Aug 2026 02:25:11 +0000 Subject: [PATCH 2/5] feat: enhance the built-in Markdown preview --- .markdownlint-cli2.jsonc | 1 + .vscode/tasks.json | 19 +- .vscodeignore | 3 + AGENTS.md | 13 + CHANGELOG.md | 7 + README.md | 55 +- docs/architecture.md | 48 +- docs/testing.md | 16 +- esbuild.js | 43 +- media/preview.css | 377 ++++++++ mise.toml | 4 +- package.json | 39 +- pnpm-lock.yaml | 1559 +++++++++++++++++++++++++++++++- src/extension.ts | 11 +- src/markdown/compose.test.ts | 165 ++++ src/markdown/compose.ts | 430 +++++++++ src/preview/index.ts | 3 + src/preview/mermaid-runtime.ts | 30 + src/preview/runtime.test.ts | 240 +++++ src/preview/runtime.ts | 559 ++++++++++++ src/test/extension.test.ts | 18 +- src/types/vendor.d.ts | 11 + test/fixtures/kitchen-sink.md | 79 ++ test/harness.test.mjs | 37 + test/manifest.test.mjs | 10 +- test/package-content.test.mjs | 14 +- test/presentation.test.mjs | 33 + tsconfig.extension-tests.json | 15 + tsconfig.json | 5 +- vitest.config.mjs | 8 + 30 files changed, 3815 insertions(+), 37 deletions(-) create mode 100644 media/preview.css create mode 100644 src/markdown/compose.test.ts create mode 100644 src/markdown/compose.ts create mode 100644 src/preview/index.ts create mode 100644 src/preview/mermaid-runtime.ts create mode 100644 src/preview/runtime.test.ts create mode 100644 src/preview/runtime.ts create mode 100644 src/types/vendor.d.ts create mode 100644 test/fixtures/kitchen-sink.md create mode 100644 test/harness.test.mjs create mode 100644 test/presentation.test.mjs create mode 100644 tsconfig.extension-tests.json create mode 100644 vitest.config.mjs diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index c5aec15..09a92c4 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -16,5 +16,6 @@ "dist/**", "node_modules/**", "out/**", + "test/fixtures/**", ], } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 34d241e..a1d111e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,7 +5,12 @@ "tasks": [ { "label": "watch", - "dependsOn": ["pnpm: watch:node", "pnpm: watch:tsc", "pnpm: watch:web"], + "dependsOn": [ + "pnpm: watch:node", + "pnpm: watch:preview", + "pnpm: watch:tsc", + "pnpm: watch:web" + ], "presentation": { "reveal": "never" }, @@ -14,6 +19,18 @@ "isDefault": true } }, + { + "type": "shell", + "command": "pnpm run watch:preview", + "group": "build", + "problemMatcher": "$esbuild-watch", + "isBackground": true, + "label": "pnpm: watch:preview", + "presentation": { + "group": "watch", + "reveal": "never" + } + }, { "type": "shell", "command": "pnpm run watch:node", diff --git a/.vscodeignore b/.vscodeignore index 3fb35b7..36a36b8 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -3,6 +3,7 @@ .vscode-test/** artifacts/** docs/** +media/** out/** node_modules/** scripts/** @@ -23,7 +24,9 @@ pnpm-lock.yaml pnpm-workspace.yaml vsc-extension-quickstart.md **/tsconfig.json +**/tsconfig.*.json **/eslint.config.mjs +**/vitest.config.mjs **/*.map **/*.ts **/.vscode-test.* diff --git a/AGENTS.md b/AGENTS.md index 1688842..1752474 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,10 @@ Better Markdown Preview extends VS Code's built-in Markdown preview. Read ## Project Map - `src/extension.ts`: shared, browser-safe extension lifecycle entry point. +- `src/markdown/`: Markdown-It composition and focused parser tests. +- `src/preview/`: idempotent browser runtime, local Mermaid adapter, and DOM + lifecycle tests. +- `media/preview.css`: source for the theme-aware contributed preview style. - `src/test/`: tests executed in a real VS Code Extension Host. - `test/`: fast manifest and packaged-artifact contract tests. - `scripts/`: small cross-platform harness helpers. @@ -32,6 +36,11 @@ Better Markdown Preview extends VS Code's built-in Markdown preview. Read - `.vscode-test/` contains a complete downloaded VS Code distribution, including its own tool configs. Keep it excluded from repository-wide format and lint discovery. +- Native preview typography uses `--markdown-font-size` and + `--markdown-line-height` (without a `--vscode-` prefix). VS Code 1.125 injects + Markdown alert IDs as custom properties such as + `--vscode-markdownAlert-note.foreground`; escape the dot in CSS and retain + the normalized hyphen form as a compatibility fallback. - Use `mise run check` during implementation and `mise run verify` on the intended final head. Run focused tasks while iterating. @@ -50,6 +59,10 @@ VSCE normalizes packaged README and changelog paths to lowercase and renames the license to `LICENSE.txt`. Package-content assertions should target the archive's normalized names, not the source filenames. +VSCE accepts only `ui` and `workspace` in `extensionKind`. Web-host eligibility +comes from the manifest's `browser` entry point; do not add a synthetic `web` +extension kind. + GitHub Actions must declare restricted permissions and pin every action to a full commit SHA. `mise run ci:workflows` enforces syntax, security posture, and pin freshness. diff --git a/CHANGELOG.md b/CHANGELOG.md index cb6f5de..eb873aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,10 @@ All notable changes to Better Markdown Preview will be documented here. - Scaffold the behavior-free desktop and web extension foundation. - Add the repository validation, test, packaging, and CI harness. +- Enhance the built-in Markdown preview with GFM, alerts, footnotes, definition + lists, TOML frontmatter, responsive columns, and local Mermaid diagrams. +- Add a theme-aware document layout, responsive table of contents, and rich + native-highlighted code-fence presentation. +- Support the desktop Extension Host and a browser-compatible web bundle with + focused parser, DOM lifecycle, Extension Host, and packaged-artifact + validation. diff --git a/README.md b/README.md index 4ee0bbe..2424a1f 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,52 @@ # Better Markdown Preview -Better Markdown Preview will enhance Visual Studio Code's built-in Markdown -preview while preserving its theme, security, resource resolution, and editor -synchronization behavior. +Better Markdown Preview enhances Visual Studio Code's built-in Markdown preview +without replacing it. Native source synchronization, resource resolution, +security settings, code-copy controls, syntax highlighting, and user preview +styles continue to work. -The repository currently contains the generated extension foundation and its -validation harness. It deliberately contributes no Markdown rendering behavior -yet. +It adds: + +- Complete visible GFM behavior, including task lists, literal autolinks, and + tag filtering. +- GitHub alerts, footnotes, definition lists, and collapsed TOML frontmatter. +- Responsive Pandoc-style columns and locally bundled Mermaid diagrams. +- Code-block titles, highlighted lines and words, line numbers, and diff-line + annotations while retaining VS Code's native highlighter. +- A responsive H1-H3 table of contents with active-heading tracking. +- A clean layout driven entirely by the active VS Code theme, including high + contrast and print presentation. + +Open a Markdown file and run **Markdown: Open Preview** or **Markdown: Open +Preview to the Side**. The built-in preview is enhanced automatically. + +## Extended syntax + +TOML frontmatter uses exact `+++` delimiter lines at the start of a document. +Columns use the supported Pandoc fenced-div subset: + +```markdown +:::: {.columns} +::: {.column width=40%} +Left column +::: +::: {.column} +Right column +::: +:::: +``` + +Rich code metadata follows the language identifier: + +````markdown +```ts title="src/example.ts" {1,3-5} /needle/ showLineNumbers +const needle = true; // [!code ++] +``` +```` + +Only an exact lowercase `mermaid` fence renders as a diagram. Mermaid is loaded +from the extension package only when the document contains such a block; source +remains visible if loading or rendering fails. ## Development @@ -22,7 +62,8 @@ mise run verify Use `mise tasks` to discover the complete task surface. The most common loops are: -- `mise run dev` watches the desktop and web bundles. +- `mise run dev` watches the desktop, web, preview runtime, Mermaid, CSS, and + TypeScript targets. - `mise run check` runs the fast formatter, linter, type, and unit gate. - `mise run test:extension` exercises activation in a real Extension Host. - `mise run package:validate` builds and inspects the VSIX. diff --git a/docs/architecture.md b/docs/architecture.md index aee4e69..b292727 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,16 +7,53 @@ the supported Markdown extension contribution points. It does not own a custom webview. This keeps native source synchronization, theme integration, resource resolution, workspace trust, and user preview styles intact. -The foundation does not declare those contributions yet. `contributes` remains -empty until the rendering implementation lands as an independently reviewed -change. +The manifest declares `markdown.markdownItPlugins`, `markdown.previewStyles`, +and `markdown.previewScripts`. The extension activation export composes narrow +rules onto the Markdown-It instance supplied by VS Code; it does not construct +the native renderer itself. + +## Rendering Boundary + +`src/markdown/compose.ts` installs standard Markdown-It plugins for task lists, +definition lists, footnotes, and GitHub alerts. Local rules cover the behaviors +that are specific to this extension: GFM literal autolinks and tag filtering, +TOML frontmatter, responsive columns, exact Mermaid fences, and rich fence +metadata. + +Renderer wrappers retain and invoke the rule already installed on the supplied +Markdown-It instance. In particular, fenced code delegates to VS Code's native +renderer after recognized metadata and diff annotations are removed. This keeps +Highlight.js, language classes, source maps, and native copy controls +authoritative. + +All emitted classes and data attributes are scoped with +`better-markdown-preview` or `bmp`. Invalid extension syntax falls back to +ordinary Markdown rather than partially transforming a document. + +## Preview Boundary + +`src/preview/runtime.ts` owns idempotent DOM enhancement. It wraps the existing +`.markdown-body` in a layout without replacing that element, preserves heading +and `data-line` nodes, rebuilds the TOC after content replacement, and augments +rich code blocks while retaining their authored text. + +`media/preview.css` uses VS Code webview color variables and body theme classes; +it does not own a light or dark palette. VS Code loads user `markdown.styles` +after contributed styles, so user overrides retain precedence. + +Mermaid is absent from both Extension Host bundles. The small preview runtime +dynamically imports `dist/preview/mermaid-runtime.js` only after finding an +exact Mermaid block. The renderer uses strict security, derives colors from +VS Code variables, and restores escaped source on every failure path. ## Runtime Boundary `src/extension.ts` is the shared lifecycle entry point. Esbuild emits it twice: - `dist/node/extension.js` targets the desktop Extension Host. -- `dist/web/extension.js` targets browser Extension Hosts such as vscode.dev. +- `dist/web/extension.js` is browser-compatible for eligible web Extension + Hosts; the current harness provides structural build evidence rather than an + executed vscode.dev host. Code reachable from the shared entry point must avoid Node-only APIs. If a future feature genuinely needs platform-specific code, split the entry points @@ -35,3 +72,6 @@ extensions and user styles can coexist. The accepted bootstrap scope is recorded in `docs/plans/001-bootstrap-foundation.md`. Plans document intent; source, tests, and shipped artifacts describe the current implementation. + +The rendering contract is recorded in +`docs/plans/002-renderer-implementation.md`. diff --git a/docs/testing.md b/docs/testing.md index 124f15d..2880136 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -4,8 +4,10 @@ Use the narrowest task that proves the behavior under development: -- `mise run test` checks fast manifest contracts. -- `mise run build` compiles production desktop and web bundles. +- `mise run test` checks manifest contracts plus focused parser and preview DOM + behavior. +- `mise run build` compiles production desktop, web, preview runtime, Mermaid, + and CSS bundles. - `mise run test:extension` compiles tests, builds both targets, and runs the named activation smoke test on the declared VS Code engine floor. - `mise run package:validate` produces a VSIX and asserts its exact runtime @@ -30,6 +32,16 @@ Presentation-only changes should additionally be exercised in a real VS Code preview across light, dark, and high-contrast themes. Record manual or visual evidence when automated assertions would not meaningfully prove the result. +The parser suite lives beside `src/markdown/compose.ts` and renders with native +linkification disabled so extension-owned autolinks are explicit. The DOM suite +uses a controlled browser DOM and Mermaid adapter to cover conditional loading, +failure fallback, theme rerender, body replacement, TOC focus behavior, and +source-preserving code presentation. These focused suites do not replace the +real Extension Host activation test or runtime presentation inspection. + +`test/fixtures/kitchen-sink.md` is the shared manual/runtime document for light, +dark, high-contrast, wide, and narrow preview checks. + ## CI CI installs the exact mise-managed tools, performs a frozen pnpm install, and diff --git a/esbuild.js b/esbuild.js index fa4b14b..6d0cdf3 100644 --- a/esbuild.js +++ b/esbuild.js @@ -39,21 +39,56 @@ function esbuildProblemMatcherPlugin(target) { const targets = [ { name: 'node', + group: 'node', + entryPoints: ['src/extension.ts'], platform: 'node', format: 'cjs', outfile: 'dist/node/extension.js', + external: ['vscode'], }, { name: 'web', + group: 'web', + entryPoints: ['src/extension.ts'], platform: 'browser', format: 'cjs', outfile: 'dist/web/extension.js', + external: ['vscode'], + }, + { + name: 'preview-script', + group: 'preview', + entryPoints: ['src/preview/index.ts'], + platform: 'browser', + format: 'iife', + outfile: 'dist/preview/preview.js', + external: ['./mermaid-runtime.js'], + }, + { + name: 'mermaid', + group: 'preview', + entryPoints: ['src/preview/mermaid-runtime.ts'], + platform: 'browser', + format: 'esm', + outfile: 'dist/preview/mermaid-runtime.js', + external: [], + }, + { + name: 'preview-css', + group: 'preview', + entryPoints: ['media/preview.css'], + platform: 'browser', + outfile: 'dist/preview/preview.css', + external: [], }, ]; async function main() { const selectedTargets = requestedTarget - ? targets.filter((target) => target.name === requestedTarget) + ? targets.filter( + (target) => + target.name === requestedTarget || target.group === requestedTarget, + ) : targets; if (selectedTargets.length === 0) { @@ -63,13 +98,13 @@ async function main() { const contexts = await Promise.all( selectedTargets.map((target) => esbuild.context({ - entryPoints: ['src/extension.ts'], + entryPoints: target.entryPoints, bundle: true, - format: target.format, + ...(target.format ? { format: target.format } : {}), minify: production, outfile: target.outfile, platform: target.platform, - external: ['vscode'], + external: target.external, logLevel: 'silent', plugins: [esbuildProblemMatcherPlugin(target.name)], sourcemap: !production, diff --git a/media/preview.css b/media/preview.css new file mode 100644 index 0000000..29fc994 --- /dev/null +++ b/media/preview.css @@ -0,0 +1,377 @@ +.better-markdown-preview-layout { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(0, 54rem) minmax(12rem, 1fr); + column-gap: 2rem; + align-items: start; + width: 100%; + box-sizing: border-box; +} + +.better-markdown-preview-layout > .markdown-body { + grid-column: 2; + min-width: 0; + width: 100%; + font-size: var(--markdown-font-size, 14px); + line-height: var(--markdown-line-height, 22px); +} + +.better-markdown-preview-layout + > .markdown-body + > :where(p, ul, ol, blockquote, dl) { + max-width: 78ch; +} + +.better-markdown-preview-toc { + grid-column: 1; + grid-row: 1; + position: sticky; + top: 1.5rem; + justify-self: end; + width: min(100%, 15rem); + max-height: calc(100vh - 3rem); + overflow: auto; + padding: 0.75rem; + border: 1px solid var(--vscode-panel-border); + border-radius: 0.45rem; + background: var(--vscode-sideBar-background); + color: var(--vscode-sideBar-foreground); + box-sizing: border-box; +} + +.better-markdown-preview-toc-title { + display: block; + margin-bottom: 0.45rem; + font-size: 0.85em; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); +} + +.better-markdown-preview-toc ol { + list-style: none; + margin: 0; + padding: 0; +} + +.better-markdown-preview-toc li + li { + margin-top: 0.2rem; +} + +.better-markdown-preview-toc-level-2 { + padding-left: 0.75rem; +} + +.better-markdown-preview-toc-level-3 { + padding-left: 1.5rem; +} + +.better-markdown-preview-toc a, +.better-markdown-preview-toc-dialog a { + display: block; + padding: 0.2rem 0.35rem; + border-left: 2px solid transparent; + color: var(--vscode-textLink-foreground); + text-decoration: none; + overflow-wrap: anywhere; +} + +.better-markdown-preview-toc a:hover, +.better-markdown-preview-toc-dialog a:hover, +.better-markdown-preview-toc-active { + border-left-color: var(--vscode-focusBorder) !important; + color: var(--vscode-textLink-activeForeground) !important; +} + +.better-markdown-preview-toc-trigger { + display: none; + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 10; + padding: 0.45rem 0.7rem; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 999px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + font: inherit; + cursor: pointer; +} + +.better-markdown-preview-toc-dialog { + position: fixed; + width: min(26rem, calc(100vw - 2rem)); + max-height: min(70vh, 34rem); + margin: auto; + padding: 1rem; + border: 1px solid var(--vscode-panel-border); + border-radius: 0.5rem; + background: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); + box-shadow: 0 0.5rem 2rem var(--vscode-widget-shadow); +} + +.better-markdown-preview-toc-dialog::backdrop { + background: var(--vscode-diffEditor-unchangedRegionShadow); +} + +.better-markdown-preview-toc-dialog ol { + list-style: none; + padding: 0; +} + +.better-markdown-preview-frontmatter { + margin: 1rem 0; + border: 1px solid var(--vscode-panel-border); + border-radius: 0.4rem; + background: var(--vscode-textCodeBlock-background); +} + +.better-markdown-preview-frontmatter summary { + padding: 0.5rem 0.75rem; + cursor: pointer; + color: var(--vscode-descriptionForeground); +} + +.better-markdown-preview-frontmatter pre { + margin: 0; + border: 0; + border-top: 1px solid var(--vscode-panel-border); + border-radius: 0; +} + +.better-markdown-preview-columns { + display: flex; + gap: 1rem; + margin: 1rem 0; + min-width: 0; +} + +.better-markdown-preview-column { + flex: 1 1 var(--bmp-column-width, 0); + min-width: 0; + overflow-wrap: anywhere; +} + +.better-markdown-preview-alert { + margin: 1rem 0; + padding: 0.75rem 1rem; + border: 1px solid currentColor; + border-left-width: 0.25rem; + border-radius: 0.35rem; +} + +.better-markdown-preview-alert-title { + margin: 0 0 0.4rem; + font-weight: 600; +} + +.better-markdown-preview-alert-note { + color: var( + --vscode-markdownAlert-note\.foreground, + var(--vscode-markdownAlert-note-foreground, var(--vscode-editor-foreground)) + ); +} + +.better-markdown-preview-alert-tip { + color: var( + --vscode-markdownAlert-tip\.foreground, + var(--vscode-markdownAlert-tip-foreground, var(--vscode-editor-foreground)) + ); +} + +.better-markdown-preview-alert-important { + color: var( + --vscode-markdownAlert-important\.foreground, + var( + --vscode-markdownAlert-important-foreground, + var(--vscode-editor-foreground) + ) + ); +} + +.better-markdown-preview-alert-warning { + color: var( + --vscode-markdownAlert-warning\.foreground, + var( + --vscode-markdownAlert-warning-foreground, + var(--vscode-editor-foreground) + ) + ); +} + +.better-markdown-preview-alert-caution { + color: var( + --vscode-markdownAlert-caution\.foreground, + var( + --vscode-markdownAlert-caution-foreground, + var(--vscode-editor-foreground) + ) + ); +} + +.better-markdown-preview-code { + margin: 1rem 0; + min-width: 0; + border: 1px solid var(--vscode-panel-border); + border-radius: 0.4rem; + overflow: hidden; + background: var(--vscode-textCodeBlock-background); +} + +.better-markdown-preview-code figcaption { + padding: 0.45rem 0.75rem; + border-bottom: 1px solid var(--vscode-panel-border); + font-family: var(--vscode-editor-font-family); + font-size: 0.9em; + color: var(--vscode-descriptionForeground); +} + +.better-markdown-preview-code pre { + margin: 0; + border: 0; + border-radius: 0; + overflow: auto; +} + +.better-markdown-preview-code-line { + display: inline-block; + min-width: 100%; + box-sizing: border-box; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.better-markdown-preview-code[data-bmp-line-numbers='true'] { + counter-reset: bmp-code-line; +} + +.better-markdown-preview-code[data-bmp-line-numbers='true'] + .better-markdown-preview-code-line { + counter-increment: bmp-code-line; +} + +.better-markdown-preview-code[data-bmp-line-numbers='true'] + .better-markdown-preview-code-line::before { + content: counter(bmp-code-line); + display: inline-block; + width: 2.75em; + margin-right: 0.8em; + color: var(--vscode-editorLineNumber-foreground); + text-align: right; + user-select: none; +} + +.better-markdown-preview-code-line-highlighted { + background: var(--vscode-editor-rangeHighlightBackground); +} + +.better-markdown-preview-code-line-added { + background: var(--vscode-diffEditor-insertedLineBackground); +} + +.better-markdown-preview-code-line-removed { + background: var(--vscode-diffEditor-removedLineBackground); +} + +.better-markdown-preview-code-word { + border: 1px solid var(--vscode-editor-findMatchBorder); + border-radius: 0.2rem; + background: var(--vscode-editor-findMatchHighlightBackground); + color: inherit; +} + +.better-markdown-preview-mermaid { + min-height: 3rem; + overflow: auto; + border: 1px solid var(--vscode-panel-border); + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + text-align: center; +} + +.better-markdown-preview-mermaid[data-bmp-mermaid-state='failed'] { + border-color: var(--vscode-inputValidation-errorBorder); + text-align: left; +} + +.better-markdown-preview-mermaid svg, +.markdown-body img, +.markdown-body video { + max-width: 100%; + height: auto; +} + +.markdown-body table, +.markdown-body pre { + max-width: 100%; + overflow: auto; +} + +.markdown-body .contains-task-list, +.markdown-body .task-list-item { + list-style: none; +} + +.markdown-body .task-list-item::marker { + content: ''; +} + +.markdown-body .task-list-item-checkbox { + margin: 0 0.45em 0 0; + vertical-align: -0.1em; +} + +.markdown-body :where(a, button, summary):focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +@media (max-width: 82rem) { + .better-markdown-preview-layout { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 54rem; + margin-inline: auto; + } + + .better-markdown-preview-toc { + position: static; + order: -1; + align-self: stretch; + width: 100%; + max-height: none; + } + + .better-markdown-preview-toc-trigger-visible { + display: block; + } +} + +@media (max-width: 44rem), print { + .better-markdown-preview-columns { + flex-direction: column; + } + + .better-markdown-preview-column { + flex-basis: auto; + width: 100%; + } +} + +@media print { + .better-markdown-preview-toc-trigger, + .better-markdown-preview-toc-dialog { + display: none !important; + } + + .better-markdown-preview-mermaid { + break-inside: avoid; + } +} + +@media (prefers-reduced-motion: reduce) { + .better-markdown-preview-layout * { + scroll-behavior: auto !important; + } +} diff --git a/mise.toml b/mise.toml index bfdac25..0052a6f 100644 --- a/mise.toml +++ b/mise.toml @@ -14,11 +14,11 @@ description = "Install pinned tools and locked dependencies" run = ["mise install", "pnpm install --frozen-lockfile"] [tasks.dev] -description = "Watch desktop and web extension builds" +description = "Watch extension hosts, preview assets, and TypeScript" run = "pnpm run watch" [tasks.build] -description = "Build production desktop and web extension bundles" +description = "Build production Extension Host and preview bundles" run = "pnpm run build" [tasks.format] diff --git a/package.json b/package.json index da0cf29..0b9bbb3 100644 --- a/package.json +++ b/package.json @@ -16,19 +16,31 @@ "Other" ], "activationEvents": [], + "extensionKind": [ + "workspace" + ], "main": "./dist/node/extension.js", "browser": "./dist/web/extension.js", - "contributes": {}, + "contributes": { + "markdown.markdownItPlugins": true, + "markdown.previewStyles": [ + "./dist/preview/preview.css" + ], + "markdown.previewScripts": [ + "./dist/preview/preview.js" + ] + }, "scripts": { "vscode:prepublish": "pnpm run build", "build": "node esbuild.js --production", "compile": "pnpm run typecheck && pnpm run lint && node esbuild.js", - "watch": "npm-run-all -p watch:*", + "watch": "npm-run-all -p watch:node watch:preview watch:tsc watch:web", "watch:node": "node esbuild.js --watch --target=node", + "watch:preview": "node esbuild.js --watch --target=preview", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "watch:web": "node esbuild.js --watch --target=web", - "compile-tests": "tsc -p . --outDir out", - "watch-tests": "tsc -p . -w --outDir out", + "compile-tests": "tsc -p tsconfig.extension-tests.json", + "watch-tests": "tsc -p tsconfig.extension-tests.json -w", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "npm-run-all -s lint:code lint:markdown", @@ -38,7 +50,9 @@ "lint:markdown": "markdownlint-cli2", "lint:markdown:fix": "markdownlint-cli2 --fix", "typecheck": "tsc --noEmit", - "test": "node --test test/commands.test.mjs test/manifest.test.mjs", + "test": "pnpm run test:contracts && pnpm run test:unit", + "test:contracts": "node --test test/commands.test.mjs test/harness.test.mjs test/manifest.test.mjs test/presentation.test.mjs", + "test:unit": "vitest run", "test:extension": "pnpm run compile-tests && pnpm run build && node scripts/run-extension-tests.mjs", "test:package": "node --test test/package-content.test.mjs", "package": "node scripts/package.mjs" @@ -46,6 +60,8 @@ "packageManager": "pnpm@11.18.0", "devDependencies": { "@eslint/js": "^10.0.0", + "@types/markdown-it": "14.1.2", + "@types/markdown-it-footnote": "3.0.4", "@types/mocha": "^10.0.10", "@types/node": "24.x", "@types/vscode": "^1.125.0", @@ -56,10 +72,21 @@ "eslint": "^10.5.0", "fflate": "^0.8.2", "globals": "^17.0.0", + "happy-dom": "20.10.6", + "markdown-it": "14.3.0", "markdownlint-cli2": "0.23.2", "npm-run-all": "^4.1.5", "prettier": "^3.6.2", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.1" + "typescript-eslint": "^8.61.1", + "vitest": "4.1.9" + }, + "dependencies": { + "markdown-it-deflist": "4.0.0", + "markdown-it-footnote": "4.0.0", + "markdown-it-github-alerts": "1.0.1", + "markdown-it-task-lists": "2.1.1", + "mermaid": "11.16.0", + "smol-toml": "1.7.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ae8192..392d0b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,10 +11,35 @@ overrides: importers: .: + dependencies: + markdown-it-deflist: + specifier: 4.0.0 + version: 4.0.0 + markdown-it-footnote: + specifier: 4.0.0 + version: 4.0.0 + markdown-it-github-alerts: + specifier: 1.0.1 + version: 1.0.1(markdown-it@14.3.0) + markdown-it-task-lists: + specifier: 2.1.1 + version: 2.1.1 + mermaid: + specifier: 11.16.0 + version: 11.16.0 + smol-toml: + specifier: 1.7.1 + version: 1.7.1 devDependencies: '@eslint/js': specifier: ^10.0.0 version: 10.0.1(eslint@10.8.0(supports-color@8.1.1)) + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 + '@types/markdown-it-footnote': + specifier: 3.0.4 + version: 3.0.4 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -45,6 +70,12 @@ importers: globals: specifier: ^17.0.0 version: 17.9.0 + happy-dom: + specifier: 20.10.6 + version: 20.10.6 + markdown-it: + specifier: 14.3.0 + version: 14.3.0 markdownlint-cli2: specifier: 0.23.2 version: 0.23.2(supports-color@8.1.1) @@ -60,9 +91,15 @@ importers: typescript-eslint: specifier: ^8.61.1 version: 8.66.0(eslint@10.8.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + vitest: + specifier: 4.1.9 + version: 4.1.9(@types/node@24.13.3)(happy-dom@20.10.6)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)) packages: + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@azu/format-text@1.0.2': resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} @@ -125,6 +162,12 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -340,6 +383,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -358,6 +407,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -370,10 +422,106 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@secretlint/config-creator@10.2.2': resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} engines: {node: '>=20.0.0'} @@ -427,6 +575,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@textlint/ast-node-types@15.8.0': resolution: {integrity: sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==} @@ -443,15 +594,117 @@ packages: '@textlint/types@15.8.0': resolution: {integrity: sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -461,6 +714,18 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it-footnote@3.0.4': + resolution: {integrity: sha512-XJ6n+v+2u+2gmYLSHcxyoNT/YrgrKvHuDJQlykFjuxCQCr86P2/fx1V6/0lcKxv5cSIlCaJ6sUcNS3zDI7I+LA==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} @@ -476,12 +741,21 @@ packages: '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} '@types/vscode@1.125.0': resolution: {integrity: sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.66.0': resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -545,6 +819,38 @@ packages: resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} engines: {node: '>=22.0.0'} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vscode/test-cli@0.0.15': resolution: {integrity: sha512-nAxk2X79wuXS7aOhyFFhFcCqd7EBUoMesu7ZgsYE/4eFjyBMuyIweVE94BxdKH1RieN8eOz2SIrljrZt6Lk9fQ==} engines: {node: '>=22'} @@ -662,6 +968,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -726,6 +1036,10 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -759,6 +1073,10 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -839,6 +1157,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -852,6 +1174,12 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -867,6 +1195,162 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -879,6 +1363,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -926,6 +1413,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -955,6 +1445,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -1026,6 +1519,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1038,6 +1534,9 @@ packages: resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -1093,6 +1592,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1101,6 +1603,10 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1174,6 +1680,11 @@ packages: resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} engines: {node: '>=14.14'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1251,6 +1762,13 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1329,6 +1847,9 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -1347,6 +1868,13 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -1604,6 +2132,15 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -1615,6 +2152,80 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} @@ -1626,6 +2237,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -1672,10 +2286,27 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it-deflist@4.0.0: + resolution: {integrity: sha512-06186ZWDwNLg638THD70MxBeFrDMF0+nL7IWU/qwCGHGOcUIm4MTYXbfjpv0ooWnVjkSnAjW8msKaOQmNRfY6Q==} + + markdown-it-footnote@4.0.0: + resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} + + markdown-it-github-alerts@1.0.1: + resolution: {integrity: sha512-NNATF4QdoGI07hyCitoB2YqJ1YcNVCKT89ut2VtfFY9rkeFCXe/V2lOonKQLpJiq5DjiZZepf97BJx5xOjFIAw==} + peerDependencies: + markdown-it: '>= 13.0.0' + + markdown-it-task-lists@2.1.1: + resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} + markdown-it@14.3.0: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true @@ -1694,6 +2325,11 @@ packages: resolution: {integrity: sha512-qHKeU2E1bdyNAT077go2FVTNXvYcktN5IHtF6XyeD1l0PClxzSp2tUApAV14ORI8DGX4H9bNKZEzelZp4qn8IA==} engines: {node: '>=22'} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1709,6 +2345,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -1841,6 +2480,11 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -1888,6 +2532,10 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1926,6 +2574,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -1952,6 +2603,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1983,6 +2637,9 @@ packages: resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} engines: {node: '>=18'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -2013,10 +2670,20 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -2116,6 +2783,17 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -2123,6 +2801,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -2217,6 +2898,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2239,6 +2923,14 @@ packages: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -2251,6 +2943,12 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -2324,6 +3022,9 @@ packages: structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -2378,10 +3079,21 @@ packages: resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} engines: {node: '>=4'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tmp@0.2.7: resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} engines: {node: '>=14.14'} @@ -2396,6 +3108,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2487,6 +3203,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} @@ -2498,11 +3218,99 @@ packages: resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} engines: {node: '>=4'} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -2532,6 +3340,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2554,6 +3367,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.2: + resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==} + 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 + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -2606,6 +3431,11 @@ packages: snapshots: + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.0 + '@azu/format-text@1.0.2': {} '@azu/style-format@1.0.1': @@ -2704,6 +3534,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/types@11.1.2': {} + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -2832,6 +3666,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2852,6 +3694,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2864,9 +3710,55 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oxc-project/types@0.142.0': {} + '@pkgjs/parseargs@0.11.0': optional: true + '@rolldown/binding-android-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@secretlint/config-creator@10.2.2': dependencies: '@secretlint/types': 10.2.2 @@ -2945,6 +3837,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@textlint/ast-node-types@15.8.0': {} '@textlint/linter-formatter@15.8.0(supports-color@8.1.1)': @@ -2973,20 +3867,159 @@ snapshots: dependencies: '@textlint/ast-node-types': 15.8.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.1': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.1 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/json-schema@7.0.15': {} '@types/katex@0.16.8': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it-footnote@3.0.4': + dependencies: + '@types/markdown-it': 14.1.2 + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/mocha@10.0.10': {} '@types/ms@2.1.0': {} @@ -2999,10 +4032,19 @@ snapshots: '@types/sarif@2.1.7': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/vscode@1.125.0': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.13.3 + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.0(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3102,6 +4144,52 @@ snapshots: transitivePeerDependencies: - supports-color + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.9(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@vscode/test-cli@0.0.15': dependencies: '@types/mocha': 10.0.10 @@ -3258,6 +4346,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + astral-regex@2.0.0: {} async-function@1.0.0: {} @@ -3318,6 +4408,10 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.13.3 + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -3361,6 +4455,8 @@ snapshots: camelcase@6.3.0: {} + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -3452,6 +4548,8 @@ snapshots: commander@12.1.0: {} + commander@7.2.0: {} + commander@8.3.0: {} concat-map@0.0.1: {} @@ -3460,6 +4558,14 @@ snapshots: core-util-is@1.0.3: {} + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -3484,6 +4590,190 @@ snapshots: css-what@6.2.2: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -3502,6 +4792,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.21: {} + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -3545,12 +4837,15 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + delayed-stream@1.0.0: {} dequal@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} devlop@1.1.0: dependencies: @@ -3570,6 +4865,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -3693,6 +4992,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -3713,6 +5014,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.50.0: {} + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -3810,11 +5113,17 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} expand-template@2.0.3: optional: true + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -3889,6 +5198,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -3990,6 +5302,21 @@ snapshots: graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} + + happy-dom@20.10.6: + dependencies: + '@types/node': 24.13.3 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -4062,6 +5389,8 @@ snapshots: immediate@3.0.6: {} + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} index-to-position@1.2.0: {} @@ -4077,6 +5406,10 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + internmap@1.0.1: {} + + internmap@2.0.3: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -4336,6 +5669,12 @@ snapshots: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + leven@3.1.0: {} levn@0.4.1: @@ -4347,6 +5686,55 @@ snapshots: dependencies: immediate: 3.0.6 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -4362,6 +5750,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -4398,10 +5788,24 @@ snapshots: dependencies: yallist: 4.0.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@4.0.0: dependencies: semver: 7.8.5 + markdown-it-deflist@4.0.0: {} + + markdown-it-footnote@4.0.0: {} + + markdown-it-github-alerts@1.0.1(markdown-it@14.3.0): + dependencies: + markdown-it: 14.3.0 + + markdown-it-task-lists@2.1.1: {} + markdown-it@14.3.0: dependencies: argparse: 2.0.1 @@ -4443,6 +5847,8 @@ snapshots: transitivePeerDependencies: - supports-color + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdurl@2.1.0: {} @@ -4451,6 +5857,30 @@ snapshots: merge2@1.4.1: {} + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.13 + es-toolkit: 1.50.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -4689,6 +6119,8 @@ snapshots: mute-stream@0.0.8: {} + nanoid@3.3.17: {} + napi-build-utils@2.0.0: optional: true @@ -4751,6 +6183,8 @@ snapshots: has-symbols: 1.1.0 object-keys: 1.1.1 + obug@2.1.4: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -4807,6 +6241,8 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.8.0: {} + pako@1.0.11: {} parse-entities@4.0.2: @@ -4847,6 +6283,8 @@ snapshots: dependencies: entities: 6.0.1 + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@2.0.1: {} @@ -4871,6 +6309,8 @@ snapshots: path-type@6.0.0: {} + pathe@2.0.3: {} + pend@1.2.0: {} picocolors@1.1.1: {} @@ -4887,8 +6327,21 @@ snapshots: pluralize@8.0.0: {} + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} + postcss@8.5.25: + dependencies: + nanoid: 3.3.17 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -5022,12 +6475,43 @@ snapshots: reusify@1.1.0: {} + robust-predicates@3.0.3: {} + + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + run-applescript@7.1.0: {} run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -5139,6 +6623,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} simple-concat@1.0.1: @@ -5161,6 +6647,10 @@ snapshots: smol-toml@1.7.0: {} + smol-toml@1.7.1: {} + + source-map-js@1.2.1: {} + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -5175,6 +6665,10 @@ snapshots: spdx-license-ids@3.0.23: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + stdin-discarder@0.2.2: {} stop-iteration-iterator@1.1.0: @@ -5269,6 +6763,8 @@ snapshots: dependencies: boundary: 2.0.0 + stylis@4.4.0: {} + supports-color@10.2.2: {} supports-color@5.5.0: @@ -5334,11 +6830,17 @@ snapshots: dependencies: editions: 6.22.0 + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + tmp@0.2.7: {} to-regex-range@5.0.1: @@ -5349,6 +6851,8 @@ snapshots: dependencies: typescript: 6.0.3 + ts-dedent@2.3.0: {} + tslib@2.8.1: {} tunnel-agent@0.6.0: @@ -5447,6 +6951,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@14.0.1: {} + v8-to-istanbul@9.3.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -5460,10 +6966,52 @@ snapshots: version-range@4.15.0: {} + vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + + vitest@4.1.9(@types/node@24.13.3)(happy-dom@20.10.6)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + happy-dom: 20.10.6 + transitivePeerDependencies: + - msw + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} which-boxed-primitive@1.1.1: @@ -5515,6 +7063,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} workerpool@9.3.4: {} @@ -5540,6 +7093,8 @@ snapshots: wrappy@1.0.2: optional: true + ws@8.21.2: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 diff --git a/src/extension.ts b/src/extension.ts index 712ddc2..eb05119 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,3 +1,12 @@ -export function activate(): void {} +import type MarkdownIt from 'markdown-it'; +import { extendMarkdownIt } from './markdown/compose'; + +export interface MarkdownExtensionApi { + extendMarkdownIt(markdownIt: MarkdownIt): MarkdownIt; +} + +export function activate(): MarkdownExtensionApi { + return { extendMarkdownIt }; +} export function deactivate(): void {} diff --git a/src/markdown/compose.test.ts b/src/markdown/compose.test.ts new file mode 100644 index 0000000..db0fd5a --- /dev/null +++ b/src/markdown/compose.test.ts @@ -0,0 +1,165 @@ +import MarkdownIt from 'markdown-it'; +import { describe, expect, test, vi } from 'vitest'; +import { extendMarkdownIt } from './compose'; + +function render(source: string, configure?: (md: MarkdownIt) => void): string { + const md = new MarkdownIt({ html: true, linkify: false }); + configure?.(md); + extendMarkdownIt(md); + return md.render(source); +} + +describe('Markdown composition', () => { + test('adds GFM task lists, literal autolinks, punctuation, and tag filtering', () => { + const html = render( + '- [x] done\n\nVisit https://example.com/a_(b). Email dev@example.com.\n\n\n', + ); + expect(html).toContain('task-list-item'); + expect(html).toContain('href="https://example.com/a_(b)"'); + expect(html).toContain('href="mailto:dev@example.com"'); + expect(html).toContain('<script>'); + expect(html).not.toContain('\nStill allowed\n', + ); + expect(html).toContain('
Allowed
'); + expect(html).toContain('<script>alert(1)</script>'); + expect(html).toContain('Still allowed'); + }); + + test('renders definition lists, footnotes, and only known alerts', () => { + const html = render( + 'Term\n: Definition\n\nFootnote[^1].\n\n[^1]: Detail\n\n> [!NOTE]\n> **Known**\n\n> [!NOTICE]\n> Unknown\n', + ); + expect(html).toContain('
'); + expect(html).toContain('footnote-ref'); + expect(html).toContain('better-markdown-preview-alert-note'); + expect(html).toContain('
'); + expect(html).toContain('[!NOTICE]'); + }); + + test('recognizes byte-zero and BOM TOML but leaves malformed frontmatter literal', () => { + const escaped = render('+++\ntitle = ""\n+++\n\n# Body\n'); + expect(escaped).toContain('better-markdown-preview-frontmatter'); + expect(escaped).toContain('<unsafe>'); + expect(escaped).not.toContain('

title'); + expect(render('\uFEFF+++\ntitle = "BOM"\n+++\n')).toContain( + 'better-markdown-preview-frontmatter', + ); + expect(render('\n+++\ntitle = "late"\n+++\n')).not.toContain( + 'better-markdown-preview-frontmatter', + ); + expect(render('+++\nunclosed = true\n')).not.toContain( + 'better-markdown-preview-frontmatter', + ); + for (const ordinary of [ + ' +++\ntitle = "indented"\n+++\n', + '+++\ntitle = "spaced closer"\n+++ \n', + '+++\ntitle = [\n+++\n', + ]) { + expect(render(ordinary)).not.toContain( + 'better-markdown-preview-frontmatter', + ); + } + expect(render('---\ntitle: native yaml\n---\n')).not.toContain( + 'better-markdown-preview-frontmatter', + ); + }); + + test('renders valid columns and leaves malformed structures literal', () => { + const validSource = + ':::: {.columns}\n::: {.column width=40%}\n**Left**\n:::\n::: {.column}\nRight\n:::\n::::\n'; + const valid = render(validSource); + expect(valid).toContain('better-markdown-preview-columns'); + expect(valid).toContain('--bmp-column-width: 40%'); + expect(valid).toContain('Left'); + const md = new MarkdownIt({ html: true }); + extendMarkdownIt(md); + expect( + md.parse(validSource, {}).find((token) => token.type === 'paragraph_open') + ?.map, + ).toEqual([2, 3]); + + for (const malformed of [ + ':::: {.columns}\n::: {.column}\nOnly\n:::\n::::\n', + ':::: {.columns}\n::: {.column width=0%}\nA\n:::\n::: {.column}\nB\n:::\n::::\n', + ':::: {.columns}\n::: {.column nope=yes}\nA\n:::\n::: {.column}\nB\n:::\n::::\n', + ':::: {.columns}\n::: {.column}\n::: {.column}\nNested\n:::\n:::\n::: {.column}\nB\n:::\n::::\n', + ':::: {.columns}\n::: {.column}\nA\n::: \n::: {.column}\nB\n:::\n::::\n', + ':::: {.columns}\n::: {.column}\nA\n:::\n::: {.column}\nB\n:::\n:::: \n', + ]) { + expect(render(malformed)).not.toContain( + 'better-markdown-preview-columns', + ); + } + + const whitespaceAndWidths = render( + '::::\t {.columns}\t\n::: {.column width=".5%"}\t\nTiny\n:::\n::: \t{.column width=99.5%} \nWide\n:::\n::: {.column width=\'1%\'}\nQuoted\n:::\n::::\n', + ); + expect(whitespaceAndWidths).toContain('better-markdown-preview-columns'); + expect(whitespaceAndWidths).toContain('--bmp-column-width: .5%'); + expect(whitespaceAndWidths).toContain('--bmp-column-width: 99.5%'); + expect(whitespaceAndWidths).toContain('--bmp-column-width: 1%'); + expect( + render( + ' ::::\t{.columns}\n::: {.column}\nA\n:::\n::: {.column}\nB\n:::\n::::\n', + ), + ).not.toContain('better-markdown-preview-columns'); + }); + + test('matches only exact lowercase Mermaid fences and escapes fallback source', () => { + const html = render('```mermaid\ngraph TD\nA[]-->B\n```\n'); + expect(html).toContain('data-bmp-mermaid-source'); + expect(html).toContain('A[<unsafe>]'); + expect(html).not.toContain(' { + let delegatedContent = ''; + const sentinel = vi.fn( + (tokens: Array<{ content: string }>, index: number) => { + delegatedContent = tokens[index].content; + return '
native
'; + }, + ); + const html = render( + '```ts title="safe.ts" {1,3-4} /needle/ showLineNumbers\nconst needle = 1; // [!code ++]\n```\n', + (md) => { + md.renderer.rules.fence = sentinel; + }, + ); + expect(sentinel).toHaveBeenCalledOnce(); + expect(html).toContain('data-sentinel="yes"'); + expect(html).toContain('better-markdown-preview-code'); + expect(html).toContain('data-bmp-lines="1,3-4"'); + expect(html).toContain('data-bmp-line-numbers="true"'); + expect(html).not.toContain('[!code ++]'); + expect(html).not.toContain('onclick="bad"'); + expect(delegatedContent).not.toContain('[!code ++]'); + + const escaped = render('```js title=""\nalert(1)\n```\n'); + expect(escaped).toContain('<img src=x>'); + expect(escaped).not.toContain(''); + expect(render('```js {not-lines}\nvalue\n```\n')).not.toContain( + 'better-markdown-preview-code', + ); + expect(render('```js\nvalue [!code ++]\n```\n')).toContain( + 'value [!code ++]', + ); + }); +}); diff --git a/src/markdown/compose.ts b/src/markdown/compose.ts new file mode 100644 index 0000000..8c994e7 --- /dev/null +++ b/src/markdown/compose.ts @@ -0,0 +1,430 @@ +import type MarkdownIt from 'markdown-it'; +import type Token from 'markdown-it/lib/token.mjs'; +import definitionList from 'markdown-it-deflist'; +import footnote from 'markdown-it-footnote'; +import githubAlerts from 'markdown-it-github-alerts'; +import taskLists from 'markdown-it-task-lists'; +import { parse as parseToml } from 'smol-toml'; + +const blockedGfmTags = + /<(?=\/?(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)(?:\s|>|\/))/gi; +const columnsOpen = /^(:{4,})[ \t]+\{\.columns\}[ \t]*$/; +const columnOpen = + /^(:{3,})[ \t]+\{\.column(?:[ \t]+width=(?:"((?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+))%"|'((?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+))%'|((?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+))%))?\}[ \t]*$/; +type FenceRenderRule = NonNullable; + +export function extendMarkdownIt(md: MarkdownIt): MarkdownIt { + md.use(taskLists, { enabled: false }); + md.use(definitionList); + md.use(footnote); + md.use(githubAlerts, { + classPrefix: 'better-markdown-preview-alert', + matchCaseSensitive: true, + icons: {}, + }); + installTomlFrontmatter(md); + installColumns(md); + installGfmAutolinks(md); + installGfmTagFilter(md); + installFenceRenderer(md); + return md; +} + +function installGfmAutolinks(md: MarkdownIt): void { + const linkify = md.linkify; + md.core.ruler.after( + 'inline', + 'better_markdown_preview_autolinks', + (state) => { + for (const block of state.tokens) { + if (!block.children) { + continue; + } + let linkDepth = 0; + const children: Token[] = []; + for (const child of block.children) { + if (child.type === 'link_open') { + linkDepth += 1; + } + if (child.type !== 'text' || linkDepth > 0) { + children.push(child); + } else { + children.push(...linkifyText(child, state.Token, linkify)); + } + if (child.type === 'link_close') { + linkDepth -= 1; + } + } + block.children = children; + } + }, + ); +} + +function linkifyText( + token: Token, + TokenConstructor: typeof Token, + linkify: MarkdownIt['linkify'], +): Token[] { + const matches = linkify.match(token.content); + if (!matches) { + return [token]; + } + const output: Token[] = []; + let offset = 0; + for (const match of matches) { + if (match.index > offset) { + output.push( + textToken(TokenConstructor, token.content.slice(offset, match.index)), + ); + } + const open = new TokenConstructor('link_open', 'a', 1); + open.attrSet('href', match.url); + output.push(open, textToken(TokenConstructor, match.text)); + output.push(new TokenConstructor('link_close', 'a', -1)); + offset = match.lastIndex; + } + if (offset < token.content.length) { + output.push(textToken(TokenConstructor, token.content.slice(offset))); + } + return output; +} + +function textToken(TokenConstructor: typeof Token, content: string): Token { + const token = new TokenConstructor('text', '', 0); + token.content = content; + return token; +} + +function installGfmTagFilter(md: MarkdownIt): void { + md.core.ruler.after( + 'better_markdown_preview_autolinks', + 'better_markdown_preview_tagfilter', + (state) => { + const visit = (tokens: Token[]): void => { + for (const token of tokens) { + if (token.type === 'html_block' || token.type === 'html_inline') { + token.content = token.content.replace(blockedGfmTags, '<'); + } + if (token.children) { + visit(token.children); + } + } + }; + visit(state.tokens); + }, + ); +} + +function installTomlFrontmatter(md: MarkdownIt): void { + md.block.ruler.before( + 'fence', + 'better_markdown_preview_toml_frontmatter', + (state, startLine, endLine, silent) => { + if (startLine !== 0) { + return false; + } + const opening = rawLineAt(state, startLine).replace(/^\uFEFF/, ''); + if (opening !== '+++') { + return false; + } + let closeLine = -1; + for (let line = 1; line < endLine; line += 1) { + if (rawLineAt(state, line) === '+++') { + closeLine = line; + break; + } + } + if (closeLine < 0) { + return false; + } + try { + parseToml(state.getLines(1, closeLine, 0, false)); + } catch { + return false; + } + if (silent) { + return true; + } + const raw = state + .getLines(0, closeLine + 1, 0, false) + .replace(/^\uFEFF/, ''); + const token = state.push('better_markdown_preview_frontmatter', '', 0); + token.block = true; + token.map = [0, closeLine + 1]; + token.content = raw; + state.line = closeLine + 1; + return true; + }, + ); + md.renderer.rules.better_markdown_preview_frontmatter = (tokens, index) => { + const source = md.utils.escapeHtml(tokens[index].content); + return `
Frontmatter
${source}
\n`; + }; +} + +interface Column { + contentStart: number; + contentEnd: number; + width?: string; +} + +function installColumns(md: MarkdownIt): void { + md.block.ruler.before( + 'fence', + 'better_markdown_preview_columns', + (state, startLine, endLine, silent) => { + const opener = columnsOpen.exec(rawLineAt(state, startLine)); + if (!opener) { + return false; + } + const outer = opener[1]; + let closeLine = -1; + for (let line = startLine + 1; line < endLine; line += 1) { + if (rawLineAt(state, line) === outer) { + closeLine = line; + break; + } + } + if (closeLine < 0) { + return false; + } + const columns = parseColumns( + state, + startLine + 1, + closeLine, + outer.length, + ); + if (!columns || columns.length < 2) { + return false; + } + if (silent) { + return true; + } + const open = state.push('html_block', '', 0); + open.content = '
\n'; + open.map = [startLine, startLine + 1]; + for (const column of columns) { + const style = column.width + ? ` style="--bmp-column-width: ${column.width}%"` + : ''; + const columnOpen = state.push('html_block', '', 0); + columnOpen.content = `
\n`; + const childTokens: Token[] = []; + const content = state.getLines( + column.contentStart, + column.contentEnd, + 0, + false, + ); + state.md.block.parse(content, state.md, state.env, childTokens); + for (const child of childTokens) { + if (child.map) { + child.map = child.map.map((line) => line + column.contentStart) as [ + number, + number, + ]; + } + state.tokens.push(child); + } + const columnClose = state.push('html_block', '', 0); + columnClose.content = '
\n'; + } + const close = state.push('html_block', '', 0); + close.content = '
\n'; + close.map = [closeLine, closeLine + 1]; + state.line = closeLine + 1; + return true; + }, + ); +} + +function parseColumns( + state: Parameters[2]>[0], + startLine: number, + endLine: number, + outerLength: number, +): Column[] | undefined { + const columns: Column[] = []; + let line = startLine; + while (line < endLine) { + if (/^[ \t]*$/.test(rawLineAt(state, line))) { + line += 1; + continue; + } + const opening = columnOpen.exec(rawLineAt(state, line)); + if (!opening || opening[1].length >= outerLength) { + return undefined; + } + const width = opening[2] ?? opening[3] ?? opening[4]; + const numericWidth = width === undefined ? undefined : Number(width); + if ( + numericWidth !== undefined && + (numericWidth <= 0 || numericWidth > 100) + ) { + return undefined; + } + const delimiter = opening[1]; + let closeLine = -1; + for (let childLine = line + 1; childLine < endLine; childLine += 1) { + const child = rawLineAt(state, childLine); + if (columnsOpen.test(child) || columnOpen.test(child)) { + return undefined; + } + if (child === delimiter) { + closeLine = childLine; + break; + } + } + if (closeLine < 0) { + return undefined; + } + columns.push({ contentStart: line + 1, contentEnd: closeLine, width }); + line = closeLine + 1; + } + return columns; +} + +function rawLineAt( + state: { bMarks: number[]; eMarks: number[]; src: string }, + line: number, +): string { + return state.src.slice(state.bMarks[line], state.eMarks[line]); +} + +interface FenceMetadata { + info: string; + title?: string; + lines?: string; + word?: string; + lineNumbers: boolean; +} + +function installFenceRenderer(md: MarkdownIt): void { + const previous = md.renderer.rules.fence; + md.renderer.rules.fence = (tokens, index, options, env, renderer) => { + const token = tokens[index]; + if (token.info === 'mermaid') { + return `
${md.utils.escapeHtml(token.content)}
\n`; + } + const metadata = parseFenceMetadata(token.info); + const annotations = parseDiffAnnotations(token.content); + if ( + !metadata.title && + !metadata.lines && + !metadata.word && + !metadata.lineNumbers && + !annotations.changed + ) { + return renderPrevious(previous, tokens, index, options, env, renderer); + } + const clonedTokens = tokens.slice(); + const cloned = Object.assign( + Object.create(Object.getPrototypeOf(token)) as Token, + token, + ); + cloned.info = metadata.info; + cloned.content = annotations.content; + clonedTokens[index] = cloned; + const attributes = [ + metadata.lines ? ` data-bmp-lines="${metadata.lines}"` : '', + metadata.word + ? ` data-bmp-word="${md.utils.escapeHtml(metadata.word)}"` + : '', + metadata.lineNumbers ? ' data-bmp-line-numbers="true"' : '', + annotations.added.length + ? ` data-bmp-added="${annotations.added.join(',')}"` + : '', + annotations.removed.length + ? ` data-bmp-removed="${annotations.removed.join(',')}"` + : '', + ].join(''); + const caption = metadata.title + ? `
${md.utils.escapeHtml(metadata.title)}
` + : ''; + return `
${caption}${renderPrevious(previous, clonedTokens, index, options, env, renderer)}
\n`; + }; +} + +function renderPrevious( + previous: FenceRenderRule | undefined, + tokens: Token[], + index: number, + options: Parameters[2], + env: unknown, + renderer: Parameters[4], +): string { + return previous + ? previous(tokens, index, options, env, renderer) + : `
${escapeHtml(tokens[index].content)}
\n`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function parseFenceMetadata(info: string): FenceMetadata { + let remaining = info.trim(); + let title: string | undefined; + let lines: string | undefined; + let word: string | undefined; + remaining = remaining.replace( + /(?:^|\s)title="([^"\n]*)"/, + (_match, value: string) => { + title = value; + return ' '; + }, + ); + remaining = remaining.replace( + /(?:^|\s)\{(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\}/, + (_match, value: string) => { + lines = value; + return ' '; + }, + ); + remaining = remaining.replace( + /(?:^|\s)\/([^/\n]+)\//, + (_match, value: string) => { + word = value; + return ' '; + }, + ); + const lineNumbers = /(?:^|\s)showLineNumbers(?:\s|$)/.test(remaining); + remaining = remaining.replace(/(?:^|\s)showLineNumbers(?=\s|$)/, ' '); + return { + info: remaining.trim().replace(/\s+/g, ' '), + title, + lines, + word, + lineNumbers, + }; +} + +function parseDiffAnnotations(content: string): { + content: string; + added: number[]; + removed: number[]; + changed: boolean; +} { + const added: number[] = []; + const removed: number[] = []; + const lines = content.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const match = /\s*(?:\/\/|#)\s*\[!code (\+\+|--)\]\s*$/.exec(lines[index]); + if (!match) { + continue; + } + (match[1] === '++' ? added : removed).push(index + 1); + lines[index] = lines[index].slice(0, match.index).trimEnd(); + } + return { + content: lines.join('\n'), + added, + removed, + changed: added.length + removed.length > 0, + }; +} diff --git a/src/preview/index.ts b/src/preview/index.ts new file mode 100644 index 0000000..952cdce --- /dev/null +++ b/src/preview/index.ts @@ -0,0 +1,3 @@ +import { enhancePreview } from './runtime'; + +enhancePreview(document); diff --git a/src/preview/mermaid-runtime.ts b/src/preview/mermaid-runtime.ts new file mode 100644 index 0000000..99c4497 --- /dev/null +++ b/src/preview/mermaid-runtime.ts @@ -0,0 +1,30 @@ +import mermaid from 'mermaid'; +import type { MermaidTheme } from './runtime'; + +let diagramId = 0; + +export async function render( + element: HTMLElement, + source: string, + theme: MermaidTheme, +): Promise { + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme: 'base', + themeVariables: { + background: theme.background, + primaryColor: theme.background, + primaryTextColor: theme.foreground, + primaryBorderColor: theme.border, + lineColor: theme.foreground, + secondaryColor: theme.background, + tertiaryColor: theme.background, + fontFamily: 'var(--vscode-font-family)', + }, + }); + const id = `better-markdown-preview-mermaid-${diagramId++}`; + const result = await mermaid.render(id, source); + element.innerHTML = result.svg; + result.bindFunctions?.(element); +} diff --git a/src/preview/runtime.test.ts b/src/preview/runtime.test.ts new file mode 100644 index 0000000..94d0239 --- /dev/null +++ b/src/preview/runtime.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { enhancePreview, parseLineSet, type MermaidAdapter } from './runtime'; + +function setDocument(html: string): void { + document.body.innerHTML = `
${html}
`; +} + +describe('preview runtime', () => { + beforeEach(() => { + setDocument(''); + vi.restoreAllMocks(); + }); + + test('builds an idempotent TOC, omits the leading H1, and hides for fewer than two entries', async () => { + setDocument( + '

Title

One

Two

', + ); + const first = enhancePreview(document); + const second = enhancePreview(document); + await first.ready; + await second.ready; + expect(document.querySelectorAll('[data-bmp-layout]')).toHaveLength(1); + expect(document.querySelectorAll('[data-bmp-toc]')).toHaveLength(1); + expect(document.querySelector('[data-bmp-toc]')?.textContent).not.toContain( + 'Title', + ); + expect(document.querySelectorAll('[data-bmp-toc] a')).toHaveLength(2); + first.dispose(); + second.dispose(); + + setDocument('

Title

One

'); + const sparse = enhancePreview(document); + await sparse.ready; + expect(document.querySelector('[data-bmp-toc]')).toBeNull(); + sparse.dispose(); + }); + + test('presents and closes the narrow TOC dialog with focus restoration', async () => { + setDocument( + '

Title

One

Two

', + ); + const controller = enhancePreview(document); + await controller.ready; + const trigger = document.querySelector( + '[data-bmp-toc-trigger]', + )!; + const dialog = document.querySelector( + '[data-bmp-toc-dialog]', + )!; + const showModal = vi.fn(() => dialog.setAttribute('open', '')); + const close = vi.fn(() => dialog.removeAttribute('open')); + dialog.showModal = showModal; + dialog.close = close; + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + trigger.focus(); + trigger.click(); + expect(showModal).toHaveBeenCalledOnce(); + expect(dialog.hasAttribute('open')).toBe(true); + expect(document.activeElement?.tagName).toBe('A'); + dialog.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + expect(dialog.hasAttribute('open')).toBe(false); + expect(document.activeElement).toBe(trigger); + trigger.click(); + vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue({ + left: 10, + right: 100, + top: 10, + bottom: 100, + } as DOMRect); + dialog.dispatchEvent( + new MouseEvent('click', { bubbles: true, clientX: 5, clientY: 5 }), + ); + expect(close).toHaveBeenCalledTimes(2); + expect(dialog.hasAttribute('open')).toBe(false); + expect(document.activeElement).toBe(trigger); + controller.dispose(); + }); + + test('tracks the active heading and exposes the floating control after inline TOC scrolls away', async () => { + setDocument('

One

Two

'); + const headings = document.querySelectorAll('h2'); + vi.spyOn(headings[0], 'getBoundingClientRect').mockReturnValue({ + top: -10, + bottom: 10, + } as DOMRect); + vi.spyOn(headings[1], 'getBoundingClientRect').mockReturnValue({ + top: 300, + bottom: 320, + } as DOMRect); + const controller = enhancePreview(document); + await controller.ready; + const toc = document.querySelector('[data-bmp-toc]')!; + vi.spyOn(toc, 'getBoundingClientRect').mockReturnValue({ + top: -300, + bottom: -10, + } as DOMRect); + window.dispatchEvent(new Event('scroll')); + await new Promise((resolve) => requestAnimationFrame(resolve)); + expect( + document + .querySelector('[data-bmp-heading-id="one"]') + ?.getAttribute('aria-current'), + ).toBe('location'); + expect( + document + .querySelector('[data-bmp-toc-trigger]') + ?.classList.contains('better-markdown-preview-toc-trigger-visible'), + ).toBe(true); + controller.dispose(); + }); + + test('re-enhances replaced body content without duplicate controls', async () => { + setDocument('

One

Two

'); + const controller = enhancePreview(document); + await controller.ready; + const body = document.querySelector('.markdown-body')!; + body.innerHTML = '

Three

Four

'; + const replacement = body.querySelectorAll('h2'); + vi.spyOn(replacement[0], 'getBoundingClientRect').mockReturnValue({ + top: -10, + } as DOMRect); + vi.spyOn(replacement[1], 'getBoundingClientRect').mockReturnValue({ + top: 300, + } as DOMRect); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(document.querySelectorAll('[data-bmp-toc]')).toHaveLength(1); + expect(document.querySelector('[data-bmp-toc]')?.textContent).toContain( + 'Four', + ); + expect( + document + .querySelector('[data-bmp-heading-id="three"]') + ?.getAttribute('aria-current'), + ).toBe('location'); + controller.dispose(); + }); + + test('computes floating TOC visibility during initial enhancement', async () => { + setDocument('

One

Two

'); + const geometry = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: HTMLElement) { + return { bottom: this.dataset.bmpToc === '' ? -1 : 100 } as DOMRect; + }); + const controller = enhancePreview(document); + await controller.ready; + expect( + document + .querySelector('[data-bmp-toc-trigger]') + ?.classList.contains('better-markdown-preview-toc-trigger-visible'), + ).toBe(true); + geometry.mockRestore(); + controller.dispose(); + }); + + test('loads Mermaid conditionally, keeps fallback on failure, and rerenders on theme change', async () => { + setDocument('

No diagrams

'); + const loader = vi.fn<() => Promise>(); + const none = enhancePreview(document, { loadMermaid: loader }); + await none.ready; + expect(loader).not.toHaveBeenCalled(); + none.dispose(); + + setDocument('
graph TD\nA-->B
'); + const render = vi.fn(async (element: HTMLElement) => { + element.innerHTML = ''; + }); + loader.mockResolvedValue({ render }); + const success = enhancePreview(document, { loadMermaid: loader }); + await success.ready; + expect(loader).toHaveBeenCalledOnce(); + expect(render).toHaveBeenCalledOnce(); + document.body.classList.add('vscode-dark'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(render).toHaveBeenCalledTimes(2); + success.dispose(); + + setDocument('
broken source
'); + const failing = enhancePreview(document, { + loadMermaid: async () => ({ + render: async () => Promise.reject(new Error('invalid')), + }), + }); + await failing.ready; + const fallback = document.querySelector( + '[data-bmp-mermaid-source]', + )!; + expect(fallback.textContent).toBe('broken source'); + expect(fallback.dataset.bmpMermaidState).toBe('failed'); + failing.dispose(); + }); + + test('serializes Mermaid passes across theme changes', async () => { + setDocument('
graph TD\nA-->B
'); + let finishFirst: (() => void) | undefined; + const firstRender = new Promise((resolve) => { + finishFirst = resolve; + }); + const render = vi + .fn() + .mockImplementationOnce(() => firstRender) + .mockResolvedValue(undefined); + const controller = enhancePreview(document, { + loadMermaid: async () => ({ render }), + }); + await vi.waitFor(() => expect(render).toHaveBeenCalledOnce()); + document.body.classList.add('vscode-dark'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(render).toHaveBeenCalledOnce(); + finishFirst?.(); + await controller.ready; + await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(2)); + controller.dispose(); + }); + + test('adds code line presentation without changing authored copy text or source maps', async () => { + setDocument( + '
first\nneedle
', + ); + const controller = enhancePreview(document); + await controller.ready; + const pre = document.querySelector('pre')!; + expect(pre.dataset.line).toBe('7'); + expect(pre.textContent).toBe('first\nneedle'); + expect(document.querySelectorAll('[data-bmp-code-line]')).toHaveLength(2); + expect( + document.querySelector('[data-bmp-code-line="2"]')?.classList, + ).toContain('better-markdown-preview-code-line-highlighted'); + controller.dispose(); + }); + + test('caps authored line ranges to the visual line count', () => { + expect([...parseLineSet('1-10000', 2)]).toEqual([1, 2]); + expect([...parseLineSet('9999-10000', 2)]).toEqual([]); + }); +}); diff --git a/src/preview/runtime.ts b/src/preview/runtime.ts new file mode 100644 index 0000000..8d9091a --- /dev/null +++ b/src/preview/runtime.ts @@ -0,0 +1,559 @@ +export interface MermaidTheme { + dark: boolean; + background: string; + foreground: string; + border: string; + accent: string; +} + +export interface MermaidAdapter { + render( + element: HTMLElement, + source: string, + theme: MermaidTheme, + ): Promise; +} + +export interface PreviewOptions { + loadMermaid?: () => Promise; +} + +export interface PreviewController { + ready: Promise; + dispose(): void; +} + +interface SharedController { + references: number; + ready: Promise; + dispose(): void; +} + +const controllers = new WeakMap(); + +export function enhancePreview( + document: Document, + options: PreviewOptions = {}, +): PreviewController { + let shared = controllers.get(document); + if (!shared) { + shared = createController(document, options); + controllers.set(document, shared); + } + shared.references += 1; + let disposed = false; + return { + ready: shared.ready, + dispose() { + if (disposed) { + return; + } + disposed = true; + if (!shared) { + return; + } + shared.references -= 1; + if (shared.references === 0) { + shared.dispose(); + controllers.delete(document); + } + }, + }; +} + +function createController( + document: Document, + options: PreviewOptions, +): SharedController { + let disposed = false; + let scheduled = false; + let scrollFrame = 0; + let mermaidAdapter: MermaidAdapter | undefined; + let mermaidQueue = Promise.resolve(); + const mermaidSources = new WeakMap(); + const cleanups: Array<() => void> = []; + const tocCleanups: Array<() => void> = []; + const loadMermaid = options.loadMermaid ?? defaultMermaidLoader; + + const renderMermaidPass = async (force = false): Promise => { + const blocks = Array.from( + document.querySelectorAll('[data-bmp-mermaid-source]'), + ); + const pending = blocks.filter( + (block) => + force || + (block.dataset.bmpMermaidState !== 'rendered' && + block.dataset.bmpMermaidState !== 'failed'), + ); + if (pending.length === 0) { + return; + } + try { + mermaidAdapter ??= await loadMermaid(); + } catch { + for (const block of pending) { + block.dataset.bmpMermaidState = 'failed'; + } + return; + } + const theme = readTheme(document); + for (const block of pending) { + if (!mermaidSources.has(block)) { + mermaidSources.set(block, block.textContent ?? ''); + } + const source = mermaidSources.get(block) ?? ''; + block.dataset.bmpMermaidState = 'rendering'; + try { + await mermaidAdapter?.render(block, source, theme); + block.dataset.bmpMermaidState = 'rendered'; + } catch { + block.textContent = source; + block.dataset.bmpMermaidState = 'failed'; + } + } + }; + + const renderMermaid = (force = false): Promise => { + const queued = mermaidQueue.then(() => renderMermaidPass(force)); + mermaidQueue = queued.catch(() => undefined); + return queued; + }; + + const enhance = async (): Promise => { + if (disposed) { + return; + } + const markdownBody = document.querySelector('.markdown-body'); + if (!markdownBody) { + return; + } + const layout = ensureLayout(document, markdownBody); + for (const cleanup of tocCleanups.splice(0)) { + cleanup(); + } + buildToc(document, layout, markdownBody, tocCleanups); + enhanceCodeBlocks(document, markdownBody); + updateActiveHeading(); + await renderMermaid(); + }; + + const schedule = (): void => { + if (scheduled || disposed) { + return; + } + scheduled = true; + queueMicrotask(() => { + scheduled = false; + void enhance(); + }); + }; + + const contentObserver = new MutationObserver((mutations) => { + if ( + mutations.some( + (mutation) => + mutation.type === 'childList' && + (mutation.target as Element).closest?.('.markdown-body'), + ) + ) { + schedule(); + } + }); + contentObserver.observe(document.body, { childList: true, subtree: true }); + + const themeObserver = new MutationObserver(() => { + void renderMermaid(true); + }); + themeObserver.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); + + const updateActiveHeading = (): void => { + scrollFrame = 0; + const headings = Array.from( + document.querySelectorAll( + '.markdown-body h1[id], .markdown-body h2[id], .markdown-body h3[id]', + ), + ); + let active = headings[0]; + for (const heading of headings) { + if (heading.getBoundingClientRect().top <= 96) { + active = heading; + } + } + for (const link of document.querySelectorAll( + '[data-bmp-heading-id]', + )) { + const selected = link.dataset.bmpHeadingId === active?.id; + link.classList.toggle('better-markdown-preview-toc-active', selected); + if (selected) { + link.setAttribute('aria-current', 'location'); + } else { + link.removeAttribute('aria-current'); + } + } + }; + const onScroll = (): void => { + if (!scrollFrame) { + scrollFrame = requestAnimationFrame(updateActiveHeading); + } + }; + window.addEventListener('scroll', onScroll, { passive: true }); + cleanups.push(() => window.removeEventListener('scroll', onScroll)); + + const ready = enhance(); + return { + references: 0, + ready, + dispose() { + disposed = true; + contentObserver.disconnect(); + themeObserver.disconnect(); + if (scrollFrame) { + cancelAnimationFrame(scrollFrame); + } + for (const cleanup of cleanups.splice(0)) { + cleanup(); + } + for (const cleanup of tocCleanups.splice(0)) { + cleanup(); + } + }, + }; +} + +function ensureLayout( + document: Document, + markdownBody: HTMLElement, +): HTMLElement { + const existing = markdownBody.closest('[data-bmp-layout]'); + if (existing) { + return existing; + } + const layout = document.createElement('div'); + layout.className = 'better-markdown-preview-layout'; + layout.dataset.bmpLayout = ''; + markdownBody.before(layout); + layout.append(markdownBody); + return layout; +} + +function buildToc( + document: Document, + layout: HTMLElement, + markdownBody: HTMLElement, + cleanups: Array<() => void>, +): void { + for (const owned of layout.querySelectorAll( + ':scope > [data-bmp-toc], :scope > [data-bmp-toc-trigger], :scope > [data-bmp-toc-dialog]', + )) { + owned.remove(); + } + const allHeadings = Array.from( + markdownBody.querySelectorAll('h1[id], h2[id], h3[id]'), + ); + const headings = + allHeadings[0]?.tagName === 'H1' ? allHeadings.slice(1) : allHeadings; + if (headings.length < 2) { + return; + } + const nav = document.createElement('nav'); + nav.className = 'better-markdown-preview-toc'; + nav.dataset.bmpToc = ''; + nav.setAttribute('aria-label', 'Table of contents'); + nav.append(createTocList(document, headings)); + layout.prepend(nav); + + const trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.className = 'better-markdown-preview-toc-trigger'; + trigger.dataset.bmpTocTrigger = ''; + trigger.textContent = 'Contents'; + trigger.setAttribute('aria-haspopup', 'dialog'); + trigger.setAttribute('aria-expanded', 'false'); + layout.append(trigger); + + const dialog = document.createElement('dialog'); + dialog.className = 'better-markdown-preview-toc-dialog'; + dialog.dataset.bmpTocDialog = ''; + dialog.setAttribute('aria-label', 'Table of contents'); + dialog.append(createTocList(document, headings)); + layout.append(dialog); + + const close = (): void => { + if (dialog.hasAttribute('open')) { + if (typeof dialog.close === 'function') { + dialog.close(); + } else { + dialog.removeAttribute('open'); + } + } + trigger.setAttribute('aria-expanded', 'false'); + trigger.focus(); + }; + const open = (): void => { + if (!dialog.hasAttribute('open')) { + if (typeof dialog.showModal === 'function') { + try { + dialog.showModal(); + } catch { + dialog.setAttribute('open', ''); + } + } else { + dialog.setAttribute('open', ''); + } + } + trigger.setAttribute('aria-expanded', 'true'); + dialog.querySelector('a')?.focus(); + }; + trigger.addEventListener('click', open); + dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + close(); + }); + dialog.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + close(); + } + }); + dialog.addEventListener('click', (event) => { + const bounds = dialog.getBoundingClientRect(); + const outside = + event.clientX < bounds.left || + event.clientX > bounds.right || + event.clientY < bounds.top || + event.clientY > bounds.bottom; + if (event.target === dialog && outside) { + close(); + } + }); + for (const link of dialog.querySelectorAll('a')) { + link.addEventListener('click', close); + } + const updateFloatingTrigger = (): void => { + trigger.classList.toggle( + 'better-markdown-preview-toc-trigger-visible', + nav.getBoundingClientRect().bottom < 0, + ); + }; + window.addEventListener('scroll', updateFloatingTrigger, { passive: true }); + updateFloatingTrigger(); + cleanups.push(() => + window.removeEventListener('scroll', updateFloatingTrigger), + ); +} + +function createTocList( + document: Document, + headings: HTMLElement[], +): HTMLElement { + const container = document.createElement('div'); + const title = document.createElement('strong'); + title.className = 'better-markdown-preview-toc-title'; + title.textContent = 'On this page'; + container.append(title); + const list = document.createElement('ol'); + for (const heading of headings) { + const item = document.createElement('li'); + item.className = `better-markdown-preview-toc-level-${heading.tagName.slice(1)}`; + const link = document.createElement('a'); + link.href = `#${encodeURIComponent(heading.id)}`; + link.dataset.bmpHeadingId = heading.id; + link.textContent = heading.textContent?.trim() || heading.id; + item.append(link); + list.append(item); + } + container.append(list); + return container; +} + +function enhanceCodeBlocks( + document: Document, + markdownBody: HTMLElement, +): void { + for (const figure of markdownBody.querySelectorAll( + 'figure.better-markdown-preview-code:not([data-bmp-code-enhanced])', + )) { + figure.dataset.bmpCodeEnhanced = ''; + const code = figure.querySelector('pre > code'); + if (!code) { + continue; + } + const lines = splitHighlightedLines(document, code); + const highlighted = parseLineSet(figure.dataset.bmpLines, lines.length); + const added = parseLineSet(figure.dataset.bmpAdded, lines.length); + const removed = parseLineSet(figure.dataset.bmpRemoved, lines.length); + const word = figure.dataset.bmpWord; + for (let index = 0; index < lines.length; index += 1) { + const number = index + 1; + const line = lines[index]; + line.dataset.bmpCodeLine = String(number); + line.classList.toggle( + 'better-markdown-preview-code-line-highlighted', + highlighted.has(number), + ); + line.classList.toggle( + 'better-markdown-preview-code-line-added', + added.has(number), + ); + line.classList.toggle( + 'better-markdown-preview-code-line-removed', + removed.has(number), + ); + if (word) { + highlightLiteral(document, line, word); + } + } + } +} + +function splitHighlightedLines( + document: Document, + code: HTMLElement, +): HTMLElement[] { + const endsWithNewline = code.textContent?.endsWith('\n') ?? false; + const sourceNodes = Array.from(code.childNodes); + const fragments: DocumentFragment[] = [document.createDocumentFragment()]; + let lineIndex = 0; + const append = (node: Node, ancestors: Element[] = []): void => { + if (node.nodeType === Node.TEXT_NODE) { + const pieces = (node.textContent ?? '').split('\n'); + for (let index = 0; index < pieces.length; index += 1) { + if (pieces[index]) { + appendTextWithAncestors( + document, + fragments[lineIndex], + pieces[index], + ancestors, + ); + } + if (index < pieces.length - 1) { + lineIndex += 1; + fragments[lineIndex] = document.createDocumentFragment(); + } + } + return; + } + if (node instanceof Element) { + if (node.tagName === 'BR') { + lineIndex += 1; + fragments[lineIndex] = document.createDocumentFragment(); + return; + } + for (const child of Array.from(node.childNodes)) { + append(child, [...ancestors, node]); + } + } + }; + for (const node of sourceNodes) { + append(node); + } + if ( + endsWithNewline && + fragments.length > 1 && + fragments[fragments.length - 1].childNodes.length === 0 + ) { + fragments.pop(); + } + code.replaceChildren(); + const lines = fragments.map((fragment, index) => { + const line = document.createElement('span'); + line.className = 'better-markdown-preview-code-line'; + line.append(fragment); + code.append(line); + if (index < fragments.length - 1 || endsWithNewline) { + code.append(document.createTextNode('\n')); + } + return line; + }); + return lines; +} + +function appendTextWithAncestors( + document: Document, + fragment: DocumentFragment, + text: string, + ancestors: Element[], +): void { + let parent: Node = fragment; + for (const ancestor of ancestors) { + const clone = ancestor.cloneNode(false); + parent.appendChild(clone); + parent = clone; + } + parent.appendChild(document.createTextNode(text)); +} + +function highlightLiteral( + document: Document, + root: HTMLElement, + literal: string, +): void { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; + while (walker.nextNode()) { + textNodes.push(walker.currentNode as Text); + } + for (const node of textNodes) { + let current: Text | undefined = node; + while (current) { + const index = current.data.indexOf(literal); + if (index < 0) { + break; + } + const match = current.splitText(index); + const rest = match.splitText(literal.length); + const mark = document.createElement('mark'); + mark.className = 'better-markdown-preview-code-word'; + mark.textContent = match.data; + match.replaceWith(mark); + current = rest; + } + } +} + +export function parseLineSet( + value: string | undefined, + maximum: number, +): Set { + const output = new Set(); + for (const range of value?.split(',') ?? []) { + const [startText, endText] = range.split('-'); + const start = Number(startText); + const end = endText ? Number(endText) : start; + if ( + !Number.isInteger(start) || + !Number.isInteger(end) || + start < 1 || + end < start + ) { + continue; + } + if (start > maximum) { + continue; + } + for (let line = start; line <= Math.min(end, maximum); line += 1) { + output.add(line); + } + } + return output; +} + +function readTheme(document: Document): MermaidTheme { + const styles = getComputedStyle(document.body); + const read = (name: string, fallback: string): string => + styles.getPropertyValue(name).trim() || fallback; + return { + dark: document.body.classList.contains('vscode-dark'), + background: read('--vscode-editor-background', '#ffffff'), + foreground: read('--vscode-editor-foreground', '#1f2328'), + border: read('--vscode-panel-border', '#8c8c8c'), + accent: read('--vscode-textLink-foreground', '#0969da'), + }; +} + +async function defaultMermaidLoader(): Promise { + return import('./mermaid-runtime.js'); +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 96e8341..0d513a2 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,17 +1,29 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; +import MarkdownIt from 'markdown-it'; + +interface MarkdownExtensionApi { + extendMarkdownIt(markdownIt: MarkdownIt): MarkdownIt; +} suite('Better Markdown Preview extension', () => { - test('activates without registering product behavior', async () => { + test('activates and exports the Markdown-It extension contract', async () => { const extension = vscode.extensions.all.find( (candidate) => candidate.packageJSON.name === 'better-markdown-preview', ); assert.ok(extension, 'the extension is available in the development host'); - assert.deepStrictEqual(extension.packageJSON.contributes, {}); + assert.strictEqual( + extension.packageJSON.contributes['markdown.markdownItPlugins'], + true, + ); - await extension.activate(); + const api = (await extension.activate()) as MarkdownExtensionApi; assert.strictEqual(extension.isActive, true); + const html = api + .extendMarkdownIt(new MarkdownIt({ html: true, linkify: false })) + .render('- [x] native preview extension\n'); + assert.match(html, /task-list-item/); }); }); diff --git a/src/types/vendor.d.ts b/src/types/vendor.d.ts new file mode 100644 index 0000000..6782ae1 --- /dev/null +++ b/src/types/vendor.d.ts @@ -0,0 +1,11 @@ +declare module 'markdown-it-task-lists' { + import type { PluginWithOptions } from 'markdown-it'; + const plugin: PluginWithOptions<{ enabled?: boolean; label?: boolean }>; + export default plugin; +} + +declare module 'markdown-it-deflist' { + import type { PluginSimple } from 'markdown-it'; + const plugin: PluginSimple; + export default plugin; +} diff --git a/test/fixtures/kitchen-sink.md b/test/fixtures/kitchen-sink.md new file mode 100644 index 0000000..7a31293 --- /dev/null +++ b/test/fixtures/kitchen-sink.md @@ -0,0 +1,79 @@ ++++ +title = "Better Markdown Preview Kitchen Sink" +unsafe = "" ++++ + +# Better Markdown Preview + +This document exercises **GFM**, https://example.com/a_(b), and +preview@example.com. + +## Tasks and tables + +- [x] Native-looking task +- [ ] Pending task + +| Feature | State | +| ------------- | ----------------- | +| Strikethrough | ~~old~~ current | +| Theme | VS Code variables | + +## Alerts and notes + +> [!NOTE] +> Alerts contain **normal block Markdown**. + +> [!WARNING] +> The preview keeps source visible when a diagram fails. + +Definition +: A definition-list value. + +Footnotes work too.[^one] + +[^one]: A footnote with a return link. + +## Responsive columns + +:::: {.columns} +::: {.column width=40%} + +### Left column + +Narrower weighted content. +::: +::: {.column} + +### Right column + +Flexible content that stacks on narrow previews. +::: +:::: + +## Native highlighting with presentation + +```ts title="src/example.ts" {1,3-4} /needle/ showLineNumbers +const first = true; +const needle = 'highlight me'; // [!code ++] +const removed = false; // [!code --] +console.log(first, needle, removed); +``` + +## Mermaid + +```mermaid +flowchart LR + Source[Escaped source] --> Rendered[Local Mermaid] +``` + +```Mermaid +This deliberately remains an ordinary code block. +``` + +## Long content + +| A deliberately wide table | Second column | Third column | Fourth column | +| ----------------------------------------------------- | ------------- | ------------ | ------------- | +| Content wraps or scrolls without overflowing the page | B | C | D | + +![Constrained media](https://dummyimage.com/1200x300/888/fff.png&text=Wide+image) diff --git a/test/harness.test.mjs b/test/harness.test.mjs new file mode 100644 index 0000000..63e7dc7 --- /dev/null +++ b/test/harness.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const packageJson = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), +); +const tasks = await readFile( + new URL('../.vscode/tasks.json', import.meta.url), + 'utf8', +); +const esbuild = await readFile( + new URL('../esbuild.js', import.meta.url), + 'utf8', +); + +test('watch tasks rebuild every extension and preview artifact', () => { + assert.equal( + packageJson.scripts.watch, + 'npm-run-all -p watch:node watch:preview watch:tsc watch:web', + ); + assert.equal( + packageJson.scripts['watch:preview'], + 'node esbuild.js --watch --target=preview', + ); + assert.match(tasks, /"pnpm: watch:preview"/); + assert.match(tasks, /"command": "pnpm run watch:preview"/); + assert.match(esbuild, /entryPoints: \['media\/preview\.css'\]/); + assert.match(esbuild, /outfile: 'dist\/preview\/preview\.css'/); +}); + +test('extension test watcher uses the Extension Host compiler project', () => { + assert.equal( + packageJson.scripts['watch-tests'], + 'tsc -p tsconfig.extension-tests.json -w', + ); +}); diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs index 4eadafd..ea65956 100644 --- a/test/manifest.test.mjs +++ b/test/manifest.test.mjs @@ -6,9 +6,15 @@ const manifest = JSON.parse( await readFile(new URL('../package.json', import.meta.url), 'utf8'), ); -test('manifest exposes behavior-free desktop and web entry points', () => { +test('manifest exposes native Markdown preview hooks for desktop and web', () => { assert.equal(manifest.main, './dist/node/extension.js'); assert.equal(manifest.browser, './dist/web/extension.js'); assert.deepEqual(manifest.activationEvents, []); - assert.deepEqual(manifest.contributes, {}); + assert.deepEqual(manifest.extensionKind, ['workspace']); + assert.deepEqual(manifest.contributes, { + 'markdown.markdownItPlugins': true, + 'markdown.previewStyles': ['./dist/preview/preview.css'], + 'markdown.previewScripts': ['./dist/preview/preview.js'], + }); + assert.equal('commands' in manifest.contributes, false); }); diff --git a/test/package-content.test.mjs b/test/package-content.test.mjs index 8e13ad4..69c3648 100644 --- a/test/package-content.test.mjs +++ b/test/package-content.test.mjs @@ -13,6 +13,9 @@ const expectedEntries = [ 'extension/LICENSE.txt', 'extension/changelog.md', 'extension/dist/node/extension.js', + 'extension/dist/preview/mermaid-runtime.js', + 'extension/dist/preview/preview.css', + 'extension/dist/preview/preview.js', 'extension/dist/web/extension.js', 'extension/package.json', 'extension/readme.md', @@ -31,5 +34,14 @@ test('VSIX contains exactly the expected runtime archive', async () => { ); assert.equal(packagedManifest.main, './dist/node/extension.js'); assert.equal(packagedManifest.browser, './dist/web/extension.js'); - assert.deepEqual(packagedManifest.contributes, {}); + assert.deepEqual(packagedManifest.contributes, { + 'markdown.markdownItPlugins': true, + 'markdown.previewStyles': ['./dist/preview/preview.css'], + 'markdown.previewScripts': ['./dist/preview/preview.js'], + }); + assert.ok( + archive['extension/dist/preview/mermaid-runtime.js'].byteLength > + archive['extension/dist/node/extension.js'].byteLength, + 'Mermaid stays in its separately loaded preview bundle', + ); }); diff --git a/test/presentation.test.mjs b/test/presentation.test.mjs new file mode 100644 index 0000000..5a89e89 --- /dev/null +++ b/test/presentation.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const css = await readFile( + new URL('../media/preview.css', import.meta.url), + 'utf8', +); + +test('preview presentation follows native Markdown and alert variables', () => { + assert.match(css, /font-size: var\(--markdown-font-size, 14px\)/); + assert.match(css, /line-height: var\(--markdown-line-height, 22px\)/); + assert.doesNotMatch(css, /--vscode-markdown-preview-/); + for (const alert of ['note', 'tip', 'important', 'warning', 'caution']) { + assert.ok(css.includes(`--vscode-markdownAlert-${alert}\\.foreground`)); + assert.match( + css, + new RegExp( + `--vscode-markdownAlert-${alert}-foreground[\\s\\S]*?--vscode-editor-foreground`, + ), + ); + } +}); + +test('task lists and rich code lines preserve the owned layout contract', () => { + assert.match(css, /\.markdown-body \.task-list-item::marker/); + assert.match(css, /\.markdown-body \.task-list-item-checkbox/); + assert.match( + css, + /\.better-markdown-preview-code-line \{[\s\S]*?display: inline-block;[\s\S]*?min-width: 100%/, + ); + assert.match(css, /@media \(max-width: 82rem\)/); +}); diff --git a/tsconfig.extension-tests.json b/tsconfig.extension-tests.json new file mode 100644 index 0000000..cb64a05 --- /dev/null +++ b/tsconfig.extension-tests.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "lib": ["ES2022"], + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "out", + "rootDir": "src", + "sourceMap": true, + "strict": true, + "target": "ES2022", + "types": ["node", "mocha", "vscode"] + }, + "include": ["src/test/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 2367460..9902d1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,9 @@ { "compilerOptions": { - "module": "Node16", + "module": "ESNext", + "moduleResolution": "Bundler", "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node", "mocha"], "sourceMap": true, "rootDir": "src", diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..fb6f7c3 --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + exclude: ['src/test/**'], + }, +}); From b222dc24adf5009b8e0c1d880538d77e05c5de21 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Tue, 11 Aug 2026 03:16:44 +0000 Subject: [PATCH 3/5] fix: preserve native preview behavior across updates --- AGENTS.md | 18 ++ docs/architecture.md | 17 +- docs/testing.md | 13 +- media/preview.css | 14 +- package.json | 2 + pnpm-lock.yaml | 6 + src/markdown/compose.test.ts | 110 +++++++++++- src/markdown/compose.ts | 327 ++++++++++++++++++++++++----------- src/preview/runtime.test.ts | 92 ++++++++++ src/preview/runtime.ts | 37 +++- src/test/extension.test.ts | 11 +- test/presentation.test.mjs | 15 ++ 12 files changed, 536 insertions(+), 126 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1752474..aaf3620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,24 @@ Better Markdown Preview extends VS Code's built-in Markdown preview. Read Markdown alert IDs as custom properties such as `--vscode-markdownAlert-note.foreground`; escape the dot in CSS and retain the normalized hyphen form as a compatibility fallback. +- Airplan column closing delimiters allow trailing horizontal whitespace; + container-looking lines inside backtick or tilde fences are code, not nested + column syntax. +- VS Code 1.125 configures its supplied Markdown-It's linkifier with + `fuzzyLink: false`; enabling the native linkify rule covers schemes and email, + but GFM `www.` literals need the extension's narrow linkify pass. +- VS Code 1.125 collapses backslash-escaped punctuation into plain inline text + before contributed core rules run. When the resulting token is identical to + authored text (for example `www\.example.com` versus `www.example.com`), do + not guess at raw-source offsets in a core rule; preserve source maps and record + the native-host limitation instead. +- VS Code's source-map core rule adds `data-line`, `code-line`, and `dir` attrs + to mapped non-inline tokens before rendering. Owned block renderers must emit + `renderer.renderAttrs(token)` on the real wrapper; `html_block` attrs otherwise + land on a separate empty mapping element. +- Same-document preview edits can reuse Mermaid `
` nodes, while a preview
+  retarget replaces `.markdown-body` itself. DOM lifecycle code must handle both
+  shapes without retaining stale source or TOC state.
 - Use `mise run check` during implementation and `mise run verify` on the
   intended final head. Run focused tasks while iterating.
 
diff --git a/docs/architecture.md b/docs/architecture.md
index b292727..7ece308 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -15,10 +15,15 @@ the native renderer itself.
 ## Rendering Boundary
 
 `src/markdown/compose.ts` installs standard Markdown-It plugins for task lists,
-definition lists, footnotes, and GitHub alerts. Local rules cover the behaviors
-that are specific to this extension: GFM literal autolinks and tag filtering,
-TOML frontmatter, responsive columns, exact Mermaid fences, and rich fence
-metadata.
+definition lists, footnotes, and GitHub alerts. It enables Markdown-It's native
+linkify rule even when the preview setting disabled it, preserving native
+scheme and email handling. VS Code disables fuzzy links on the supplied
+linkifier, so a narrow `linkify-it` pass adds only GFM `www.` literals while
+retaining native normalization, validation, nesting, and HTML-anchor guards.
+VS Code 1.125 collapses some backslash-escaped punctuation before contributed
+core rules run, so an escaped `www\.` is indistinguishable from authored
+`www.` at this boundary. Local rules cover GFM tag filtering, TOML
+frontmatter, responsive columns, exact Mermaid fences, and rich fence metadata.
 
 Renderer wrappers retain and invoke the rule already installed on the supplied
 Markdown-It instance. In particular, fenced code delegates to VS Code's native
@@ -34,8 +39,8 @@ ordinary Markdown rather than partially transforming a document.
 
 `src/preview/runtime.ts` owns idempotent DOM enhancement. It wraps the existing
 `.markdown-body` in a layout without replacing that element, preserves heading
-and `data-line` nodes, rebuilds the TOC after content replacement, and augments
-rich code blocks while retaining their authored text.
+and `data-line` nodes, rebuilds the TOC after content replacement or body
+retargeting, and augments rich code blocks while retaining their authored text.
 
 `media/preview.css` uses VS Code webview color variables and body theme classes;
 it does not own a light or dark palette. VS Code loads user `markdown.styles`
diff --git a/docs/testing.md b/docs/testing.md
index 2880136..2f6f91e 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -32,12 +32,13 @@ Presentation-only changes should additionally be exercised in a real VS Code
 preview across light, dark, and high-contrast themes. Record manual or visual
 evidence when automated assertions would not meaningfully prove the result.
 
-The parser suite lives beside `src/markdown/compose.ts` and renders with native
-linkification disabled so extension-owned autolinks are explicit. The DOM suite
-uses a controlled browser DOM and Mermaid adapter to cover conditional loading,
-failure fallback, theme rerender, body replacement, TOC focus behavior, and
-source-preserving code presentation. These focused suites do not replace the
-real Extension Host activation test or runtime presentation inspection.
+The parser suite lives beside `src/markdown/compose.ts`; its GFM boundary cases
+also reproduce VS Code's `fuzzyLink: false` configuration. The DOM suite uses a
+controlled browser DOM and Mermaid adapter to cover conditional loading,
+failure fallback, theme rerender, reused Mermaid blocks, body replacement, TOC
+focus behavior, and source-preserving code presentation. These focused suites
+do not replace the real Extension Host activation test or runtime presentation
+inspection.
 
 `test/fixtures/kitchen-sink.md` is the shared manual/runtime document for light,
 dark, high-contrast, wide, and narrow preview checks.
diff --git a/media/preview.css b/media/preview.css
index 29fc994..f7f230b 100644
--- a/media/preview.css
+++ b/media/preview.css
@@ -147,11 +147,15 @@
 }
 
 .better-markdown-preview-column {
-	flex: 1 1 var(--bmp-column-width, 0);
+	flex: 1 1 0;
 	min-width: 0;
 	overflow-wrap: anywhere;
 }
 
+.better-markdown-preview-column[data-bmp-column-width] {
+	flex: 0 1 var(--bmp-column-width);
+}
+
 .better-markdown-preview-alert {
 	margin: 1rem 0;
 	padding: 0.75rem 1rem;
@@ -301,7 +305,13 @@
 	height: auto;
 }
 
-.markdown-body table,
+.markdown-body table {
+	display: block;
+	width: max-content;
+	max-width: 100%;
+	overflow-x: auto;
+}
+
 .markdown-body pre {
 	max-width: 100%;
 	overflow: auto;
diff --git a/package.json b/package.json
index 0b9bbb3..5127c90 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
 	"packageManager": "pnpm@11.18.0",
 	"devDependencies": {
 		"@eslint/js": "^10.0.0",
+		"@types/linkify-it": "5.0.0",
 		"@types/markdown-it": "14.1.2",
 		"@types/markdown-it-footnote": "3.0.4",
 		"@types/mocha": "^10.0.10",
@@ -82,6 +83,7 @@
 		"vitest": "4.1.9"
 	},
 	"dependencies": {
+		"linkify-it": "5.0.2",
 		"markdown-it-deflist": "4.0.0",
 		"markdown-it-footnote": "4.0.0",
 		"markdown-it-github-alerts": "1.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 392d0b7..6f4d499 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,6 +12,9 @@ importers:
 
   .:
     dependencies:
+      linkify-it:
+        specifier: 5.0.2
+        version: 5.0.2
       markdown-it-deflist:
         specifier: 4.0.0
         version: 4.0.0
@@ -34,6 +37,9 @@ importers:
       '@eslint/js':
         specifier: ^10.0.0
         version: 10.0.1(eslint@10.8.0(supports-color@8.1.1))
+      '@types/linkify-it':
+        specifier: 5.0.0
+        version: 5.0.0
       '@types/markdown-it':
         specifier: 14.1.2
         version: 14.1.2
diff --git a/src/markdown/compose.test.ts b/src/markdown/compose.test.ts
index db0fd5a..61ec1ca 100644
--- a/src/markdown/compose.test.ts
+++ b/src/markdown/compose.test.ts
@@ -21,6 +21,60 @@ describe('Markdown composition', () => {
 		expect(html).not.toContain('\nStill allowed\n',
@@ -69,11 +123,24 @@ describe('Markdown composition', () => {
 		);
 	});
 
+	test('does not recognize TOML frontmatter in nested Markdown parses', () => {
+		for (const nested of [
+			'> +++\n> title = "quote"\n> +++\n',
+			'- +++\n  title = "list"\n  +++\n',
+			':::: {.columns}\n::: {.column}\n+++\ntitle = "column"\n+++\n:::\n::: {.column}\nOther\n:::\n::::\n',
+		]) {
+			expect(render(nested)).not.toContain(
+				'better-markdown-preview-frontmatter',
+			);
+		}
+	});
+
 	test('renders valid columns and leaves malformed structures literal', () => {
 		const validSource =
 			':::: {.columns}\n::: {.column width=40%}\n**Left**\n:::\n::: {.column}\nRight\n:::\n::::\n';
 		const valid = render(validSource);
 		expect(valid).toContain('better-markdown-preview-columns');
+		expect(valid).toContain('data-bmp-column-width="40"');
 		expect(valid).toContain('--bmp-column-width: 40%');
 		expect(valid).toContain('Left');
 		const md = new MarkdownIt({ html: true });
@@ -88,8 +155,6 @@ describe('Markdown composition', () => {
 			':::: {.columns}\n::: {.column width=0%}\nA\n:::\n::: {.column}\nB\n:::\n::::\n',
 			':::: {.columns}\n::: {.column nope=yes}\nA\n:::\n::: {.column}\nB\n:::\n::::\n',
 			':::: {.columns}\n::: {.column}\n::: {.column}\nNested\n:::\n:::\n::: {.column}\nB\n:::\n::::\n',
-			':::: {.columns}\n::: {.column}\nA\n::: \n::: {.column}\nB\n:::\n::::\n',
-			':::: {.columns}\n::: {.column}\nA\n:::\n::: {.column}\nB\n:::\n:::: \n',
 		]) {
 			expect(render(malformed)).not.toContain(
 				'better-markdown-preview-columns',
@@ -110,6 +175,17 @@ describe('Markdown composition', () => {
 		).not.toContain('better-markdown-preview-columns');
 	});
 
+	test('accepts spaced closers and ignores container syntax inside code fences', () => {
+		for (const fence of ['```', '~~~']) {
+			const html = render(
+				`:::: {.columns}\n::: {.column width=40%}\n${fence}text\n::: {.column}\n::::\n${fence}\nA\n::: \t\n::: {.column}\nB\n:::  \n::::\t\n`,
+			);
+			expect(html).toContain('better-markdown-preview-columns');
+			expect(html).toContain('');
+			expect(html).toContain('::: {.column}');
+		}
+	});
+
 	test('matches only exact lowercase Mermaid fences and escapes fallback source', () => {
 		const html = render('```mermaid\ngraph TD\nA[]-->B\n```\n');
 		expect(html).toContain('data-bmp-mermaid-source');
@@ -129,6 +205,36 @@ describe('Markdown composition', () => {
 		);
 	});
 
+	test('preserves VS Code source-map attributes on owned block wrappers', () => {
+		const html = render(
+			'+++\ntitle = "Mapped"\n+++\n\n```mermaid\ngraph TD\nA-->B\n```\n\n:::: {.columns}\n::: {.column}\nLeft\n:::\n::: {.column}\nRight\n:::\n::::\n',
+			(md) => {
+				md.core.ruler.push('source_map_like_vscode', (state) => {
+					for (const token of state.tokens) {
+						if (token.map && token.type !== 'inline') {
+							token.attrSet('data-line', String(token.map[0]));
+							token.attrJoin('class', 'code-line');
+							token.attrJoin('dir', 'auto');
+						}
+					}
+				});
+			},
+		);
+		expect(html).toMatch(
+			/]*better-markdown-preview-frontmatter)(?=[^>]*data-line="0")[^>]*>/,
+		);
+		expect(html).toMatch(
+			/]*better-markdown-preview-mermaid)(?=[^>]*data-line="4")[^>]*>/,
+		);
+		expect(html).toMatch(
+			/]*better-markdown-preview-columns)(?=[^>]*data-line="9")[^>]*>/,
+		);
+		expect(html).toMatch(
+			/]*better-markdown-preview-column)(?=[^>]*data-line="10")[^>]*>/,
+		);
+		expect(html).toMatch(/]*data-line="11"[^>]*>Left<\/p>/);
+	});
+
 	test('delegates highlighting and wraps recognized rich fence metadata safely', () => {
 		let delegatedContent = '';
 		const sentinel = vi.fn(
diff --git a/src/markdown/compose.ts b/src/markdown/compose.ts
index 8c994e7..6f457eb 100644
--- a/src/markdown/compose.ts
+++ b/src/markdown/compose.ts
@@ -1,5 +1,6 @@
 import type MarkdownIt from 'markdown-it';
 import type Token from 'markdown-it/lib/token.mjs';
+import LinkifyIt from 'linkify-it';
 import definitionList from 'markdown-it-deflist';
 import footnote from 'markdown-it-footnote';
 import githubAlerts from 'markdown-it-github-alerts';
@@ -11,9 +12,13 @@ const blockedGfmTags =
 const columnsOpen = /^(:{4,})[ \t]+\{\.columns\}[ \t]*$/;
 const columnOpen =
 	/^(:{3,})[ \t]+\{\.column(?:[ \t]+width=(?:"((?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+))%"|'((?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+))%'|((?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+))%))?\}[ \t]*$/;
+const colonClose = /^(:{3,})[ \t]*$/;
+const gfmWwwLinkifier = new LinkifyIt().set({ fuzzyEmail: false });
+let nestedBlockParseDepth = 0;
 type FenceRenderRule = NonNullable;
 
 export function extendMarkdownIt(md: MarkdownIt): MarkdownIt {
+	md.options.linkify = true;
 	md.use(taskLists, { enabled: false });
 	md.use(definitionList);
 	md.use(footnote);
@@ -24,81 +29,118 @@ export function extendMarkdownIt(md: MarkdownIt): MarkdownIt {
 	});
 	installTomlFrontmatter(md);
 	installColumns(md);
-	installGfmAutolinks(md);
+	installGfmWwwAutolinks(md);
 	installGfmTagFilter(md);
 	installFenceRenderer(md);
 	return md;
 }
 
-function installGfmAutolinks(md: MarkdownIt): void {
-	const linkify = md.linkify;
+function installGfmWwwAutolinks(md: MarkdownIt): void {
 	md.core.ruler.after(
-		'inline',
-		'better_markdown_preview_autolinks',
+		'linkify',
+		'better_markdown_preview_gfm_www_autolink',
 		(state) => {
-			for (const block of state.tokens) {
-				if (!block.children) {
+			for (const blockToken of state.tokens) {
+				if (
+					blockToken.type !== 'inline' ||
+					!blockToken.children ||
+					!gfmWwwLinkifier.pretest(blockToken.content)
+				) {
 					continue;
 				}
-				let linkDepth = 0;
-				const children: Token[] = [];
-				for (const child of block.children) {
-					if (child.type === 'link_open') {
-						linkDepth += 1;
+				const tokens = blockToken.children;
+				let htmlLinkLevel = 0;
+				for (let index = tokens.length - 1; index >= 0; index -= 1) {
+					const current = tokens[index];
+					if (current.type === 'link_close') {
+						index -= 1;
+						while (
+							index >= 0 &&
+							(tokens[index].level !== current.level ||
+								tokens[index].type !== 'link_open')
+						) {
+							index -= 1;
+						}
+						continue;
 					}
-					if (child.type !== 'text' || linkDepth > 0) {
-						children.push(child);
-					} else {
-						children.push(...linkifyText(child, state.Token, linkify));
+					if (current.type === 'html_inline') {
+						if (/^\s]/i.test(current.content) && htmlLinkLevel > 0) {
+							htmlLinkLevel -= 1;
+						}
+						if (/^<\/a\s*>/i.test(current.content)) {
+							htmlLinkLevel += 1;
+						}
 					}
-					if (child.type === 'link_close') {
-						linkDepth -= 1;
+					if (htmlLinkLevel > 0 || current.type !== 'text') {
+						continue;
 					}
+					let matches = (gfmWwwLinkifier.match(current.content) ?? []).filter(
+						(match) => match.schema === '' && /^www\./i.test(match.raw),
+					);
+					if (
+						matches[0]?.index === 0 &&
+						index > 0 &&
+						tokens[index - 1].type === 'text_special'
+					) {
+						matches = matches.slice(1);
+					}
+					if (matches.length === 0) {
+						continue;
+					}
+					const replacement: Token[] = [];
+					let level = current.level;
+					let lastPosition = 0;
+					for (const match of matches) {
+						const url = state.md.normalizeLink(match.url);
+						if (!state.md.validateLink(url)) {
+							continue;
+						}
+						if (match.index > lastPosition) {
+							const text = new state.Token('text', '', 0);
+							text.content = current.content.slice(lastPosition, match.index);
+							text.level = level;
+							replacement.push(text);
+						}
+						const open = new state.Token('link_open', 'a', 1);
+						open.attrs = [['href', url]];
+						open.level = level;
+						level += 1;
+						open.markup = 'linkify';
+						open.info = 'auto';
+						replacement.push(open);
+						const text = new state.Token('text', '', 0);
+						text.content = state.md
+							.normalizeLinkText(`http://${match.text}`)
+							.replace(/^http:\/\//, '');
+						text.level = level;
+						replacement.push(text);
+						const close = new state.Token('link_close', 'a', -1);
+						level -= 1;
+						close.level = level;
+						close.markup = 'linkify';
+						close.info = 'auto';
+						replacement.push(close);
+						lastPosition = match.lastIndex;
+					}
+					if (lastPosition === 0) {
+						continue;
+					}
+					if (lastPosition < current.content.length) {
+						const text = new state.Token('text', '', 0);
+						text.content = current.content.slice(lastPosition);
+						text.level = level;
+						replacement.push(text);
+					}
+					tokens.splice(index, 1, ...replacement);
 				}
-				block.children = children;
 			}
 		},
 	);
 }
 
-function linkifyText(
-	token: Token,
-	TokenConstructor: typeof Token,
-	linkify: MarkdownIt['linkify'],
-): Token[] {
-	const matches = linkify.match(token.content);
-	if (!matches) {
-		return [token];
-	}
-	const output: Token[] = [];
-	let offset = 0;
-	for (const match of matches) {
-		if (match.index > offset) {
-			output.push(
-				textToken(TokenConstructor, token.content.slice(offset, match.index)),
-			);
-		}
-		const open = new TokenConstructor('link_open', 'a', 1);
-		open.attrSet('href', match.url);
-		output.push(open, textToken(TokenConstructor, match.text));
-		output.push(new TokenConstructor('link_close', 'a', -1));
-		offset = match.lastIndex;
-	}
-	if (offset < token.content.length) {
-		output.push(textToken(TokenConstructor, token.content.slice(offset)));
-	}
-	return output;
-}
-
-function textToken(TokenConstructor: typeof Token, content: string): Token {
-	const token = new TokenConstructor('text', '', 0);
-	token.content = content;
-	return token;
-}
-
 function installGfmTagFilter(md: MarkdownIt): void {
 	md.core.ruler.after(
-		'better_markdown_preview_autolinks',
+		'better_markdown_preview_gfm_www_autolink',
 		'better_markdown_preview_tagfilter',
 		(state) => {
 			const visit = (tokens: Token[]): void => {
@@ -121,7 +163,11 @@ function installTomlFrontmatter(md: MarkdownIt): void {
 		'fence',
 		'better_markdown_preview_toml_frontmatter',
 		(state, startLine, endLine, silent) => {
-			if (startLine !== 0) {
+			if (
+				startLine !== 0 ||
+				nestedBlockParseDepth > 0 ||
+				state.parentType !== 'root'
+			) {
 				return false;
 			}
 			const opening = rawLineAt(state, startLine).replace(/^\uFEFF/, '');
@@ -157,13 +203,22 @@ function installTomlFrontmatter(md: MarkdownIt): void {
 			return true;
 		},
 	);
-	md.renderer.rules.better_markdown_preview_frontmatter = (tokens, index) => {
-		const source = md.utils.escapeHtml(tokens[index].content);
-		return `
Frontmatter
${source}
\n`; + md.renderer.rules.better_markdown_preview_frontmatter = ( + tokens, + index, + _options, + _env, + renderer, + ) => { + const token = tokens[index]; + token.attrJoin('class', 'better-markdown-preview-frontmatter'); + const source = md.utils.escapeHtml(token.content); + return `Frontmatter
${source}
\n`; }; } interface Column { + openLine: number; contentStart: number; contentEnd: number; width?: string; @@ -179,37 +234,25 @@ function installColumns(md: MarkdownIt): void { return false; } const outer = opener[1]; - let closeLine = -1; - for (let line = startLine + 1; line < endLine; line += 1) { - if (rawLineAt(state, line) === outer) { - closeLine = line; - break; - } - } - if (closeLine < 0) { - return false; - } - const columns = parseColumns( - state, - startLine + 1, - closeLine, - outer.length, - ); - if (!columns || columns.length < 2) { + const parsed = parseColumns(state, startLine + 1, endLine, outer.length); + if (!parsed) { return false; } if (silent) { return true; } - const open = state.push('html_block', '', 0); - open.content = '
\n'; + const open = state.push('better_markdown_preview_columns_open', 'div', 1); + open.block = true; open.map = [startLine, startLine + 1]; - for (const column of columns) { - const style = column.width - ? ` style="--bmp-column-width: ${column.width}%"` - : ''; - const columnOpen = state.push('html_block', '', 0); - columnOpen.content = `
\n`; + for (const column of parsed.columns) { + const columnOpen = state.push( + 'better_markdown_preview_column_open', + 'div', + 1, + ); + columnOpen.block = true; + columnOpen.map = [column.openLine, column.openLine + 1]; + columnOpen.meta = { width: column.width }; const childTokens: Token[] = []; const content = state.getLines( column.contentStart, @@ -217,7 +260,12 @@ function installColumns(md: MarkdownIt): void { 0, false, ); - state.md.block.parse(content, state.md, state.env, childTokens); + nestedBlockParseDepth += 1; + try { + state.md.block.parse(content, state.md, state.env, childTokens); + } finally { + nestedBlockParseDepth -= 1; + } for (const child of childTokens) { if (child.map) { child.map = child.map.map((line) => line + column.contentStart) as [ @@ -227,16 +275,49 @@ function installColumns(md: MarkdownIt): void { } state.tokens.push(child); } - const columnClose = state.push('html_block', '', 0); - columnClose.content = '
\n'; + state.push('better_markdown_preview_column_close', 'div', -1).block = + true; } - const close = state.push('html_block', '', 0); - close.content = '
\n'; - close.map = [closeLine, closeLine + 1]; - state.line = closeLine + 1; + state.push('better_markdown_preview_columns_close', 'div', -1).block = + true; + state.line = parsed.closeLine + 1; return true; }, ); + md.renderer.rules.better_markdown_preview_columns_open = ( + tokens, + index, + _options, + _env, + renderer, + ) => { + const token = tokens[index]; + token.attrJoin('class', 'better-markdown-preview-columns'); + return `\n`; + }; + md.renderer.rules.better_markdown_preview_columns_close = () => '\n'; + md.renderer.rules.better_markdown_preview_column_open = ( + tokens, + index, + _options, + _env, + renderer, + ) => { + const token = tokens[index]; + const width = (token.meta as { width?: string } | null)?.width; + token.attrJoin('class', 'better-markdown-preview-column'); + if (width) { + token.attrSet('data-bmp-column-width', width); + token.attrSet('style', `--bmp-column-width: ${width}%`); + } + return `\n`; + }; + md.renderer.rules.better_markdown_preview_column_close = () => '\n'; +} + +interface ParsedColumns { + columns: Column[]; + closeLine: number; } function parseColumns( @@ -244,15 +325,20 @@ function parseColumns( startLine: number, endLine: number, outerLength: number, -): Column[] | undefined { +): ParsedColumns | undefined { const columns: Column[] = []; let line = startLine; while (line < endLine) { - if (/^[ \t]*$/.test(rawLineAt(state, line))) { + const rawLine = rawLineAt(state, line); + if (/^[ \t]*$/.test(rawLine)) { line += 1; continue; } - const opening = columnOpen.exec(rawLineAt(state, line)); + const outerClose = colonClose.exec(rawLine); + if (outerClose && outerClose[1].length === outerLength) { + return columns.length >= 2 ? { columns, closeLine: line } : undefined; + } + const opening = columnOpen.exec(rawLine); if (!opening || opening[1].length >= outerLength) { return undefined; } @@ -266,12 +352,33 @@ function parseColumns( } const delimiter = opening[1]; let closeLine = -1; + let fenceCharacter: '`' | '~' | undefined; + let fenceLength = 0; for (let childLine = line + 1; childLine < endLine; childLine += 1) { const child = rawLineAt(state, childLine); + const fence = markdownFence(child.replace(/^[ \t]+/, '')); + if (fence) { + if (!fenceCharacter) { + fenceCharacter = fence.character; + fenceLength = fence.length; + } else if ( + fence.character === fenceCharacter && + fence.length >= fenceLength && + fence.remainder.trim() === '' + ) { + fenceCharacter = undefined; + fenceLength = 0; + } + continue; + } + if (fenceCharacter) { + continue; + } if (columnsOpen.test(child) || columnOpen.test(child)) { return undefined; } - if (child === delimiter) { + const childClose = colonClose.exec(child); + if (childClose && childClose[1].length === delimiter.length) { closeLine = childLine; break; } @@ -279,10 +386,31 @@ function parseColumns( if (closeLine < 0) { return undefined; } - columns.push({ contentStart: line + 1, contentEnd: closeLine, width }); + columns.push({ + openLine: line, + contentStart: line + 1, + contentEnd: closeLine, + width, + }); line = closeLine + 1; } - return columns; + return undefined; +} + +function markdownFence( + line: string, +): { character: '`' | '~'; length: number; remainder: string } | undefined { + const character = line[0]; + if (character !== '`' && character !== '~') { + return undefined; + } + let length = 0; + while (line[length] === character) { + length += 1; + } + return length >= 3 + ? { character, length, remainder: line.slice(length) } + : undefined; } function rawLineAt( @@ -305,7 +433,10 @@ function installFenceRenderer(md: MarkdownIt): void { md.renderer.rules.fence = (tokens, index, options, env, renderer) => { const token = tokens[index]; if (token.info === 'mermaid') { - return `
${md.utils.escapeHtml(token.content)}
\n`; + token.attrJoin('class', 'better-markdown-preview-mermaid'); + token.attrSet('data-bmp-mermaid-source', ''); + token.attrSet('data-bmp-mermaid-state', 'source'); + return `${md.utils.escapeHtml(token.content)}
\n`; } const metadata = parseFenceMetadata(token.info); const annotations = parseDiffAnnotations(token.content); diff --git a/src/preview/runtime.test.ts b/src/preview/runtime.test.ts index 94d0239..9ce0d7e 100644 --- a/src/preview/runtime.test.ts +++ b/src/preview/runtime.test.ts @@ -113,6 +113,50 @@ describe('preview runtime', () => { controller.dispose(); }); + test('selects the first visible TOC link above the first section after a leading title', async () => { + setDocument( + '

Title

One

Two

', + ); + const headings = document.querySelectorAll('h1, h2'); + vi.spyOn(headings[0], 'getBoundingClientRect').mockReturnValue({ + top: -10, + } as DOMRect); + vi.spyOn(headings[1], 'getBoundingClientRect').mockReturnValue({ + top: 300, + } as DOMRect); + vi.spyOn(headings[2], 'getBoundingClientRect').mockReturnValue({ + top: 600, + } as DOMRect); + const controller = enhancePreview(document); + await controller.ready; + const current = document + .querySelector('[data-bmp-heading-id="one"]') + ?.getAttribute('aria-current'); + controller.dispose(); + expect(current).toBe('location'); + }); + + test('selects the final TOC link at the document bottom', async () => { + setDocument('

One

Two

'); + for (const heading of document.querySelectorAll('h2')) { + vi.spyOn(heading, 'getBoundingClientRect').mockReturnValue({ + top: 300, + } as DOMRect); + } + vi.spyOn(window, 'scrollY', 'get').mockReturnValue(900); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(100); + vi.spyOn(document.documentElement, 'scrollHeight', 'get').mockReturnValue( + 1000, + ); + const controller = enhancePreview(document); + await controller.ready; + const current = document + .querySelector('[data-bmp-heading-id="two"]') + ?.getAttribute('aria-current'); + controller.dispose(); + expect(current).toBe('location'); + }); + test('re-enhances replaced body content without duplicate controls', async () => { setDocument('

One

Two

'); const controller = enhancePreview(document); @@ -139,6 +183,25 @@ describe('preview runtime', () => { controller.dispose(); }); + test('retargets when VS Code replaces the markdown body element', async () => { + setDocument('

One

Two

'); + const controller = enhancePreview(document); + await controller.ready; + const oldBody = document.querySelector('.markdown-body')!; + const newBody = document.createElement('div'); + newBody.className = 'markdown-body'; + newBody.innerHTML = '

Three

Four

'; + oldBody.replaceWith(newBody); + await vi.waitFor(() => + expect(document.querySelector('[data-bmp-toc]')?.textContent).toContain( + 'Four', + ), + ); + expect(document.querySelectorAll('[data-bmp-toc]')).toHaveLength(1); + expect(newBody.closest('[data-bmp-layout]')).not.toBeNull(); + controller.dispose(); + }); + test('computes floating TOC visibility during initial enhancement', async () => { setDocument('

One

Two

'); const geometry = vi @@ -217,6 +280,35 @@ describe('preview runtime', () => { controller.dispose(); }); + test('recaches Mermaid source when VS Code reuses a block for an edit', async () => { + setDocument( + '
graph TD\nA-->B
', + ); + const sources: string[] = []; + const render = vi.fn(async (element: HTMLElement, source: string) => { + sources.push(source); + element.innerHTML = ''; + }); + const controller = enhancePreview(document, { + loadMermaid: async () => ({ render }), + }); + await controller.ready; + const block = document.querySelector( + '[data-bmp-mermaid-source]', + )!; + block.dataset.bmpMermaidState = 'source'; + block.textContent = 'graph TD\nA-->C'; + await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(2)); + document.body.classList.add('vscode-dark'); + await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(3)); + expect(sources).toEqual([ + 'graph TD\nA-->B', + 'graph TD\nA-->C', + 'graph TD\nA-->C', + ]); + controller.dispose(); + }); + test('adds code line presentation without changing authored copy text or source maps', async () => { setDocument( '
first\nneedle
', diff --git a/src/preview/runtime.ts b/src/preview/runtime.ts index 8d9091a..7024c6f 100644 --- a/src/preview/runtime.ts +++ b/src/preview/runtime.ts @@ -98,7 +98,9 @@ function createController( } const theme = readTheme(document); for (const block of pending) { - if (!mermaidSources.has(block)) { + if (block.dataset.bmpMermaidState === 'source') { + mermaidSources.set(block, block.textContent ?? ''); + } else if (!mermaidSources.has(block)) { mermaidSources.set(block, block.textContent ?? ''); } const source = mermaidSources.get(block) ?? ''; @@ -148,12 +150,18 @@ function createController( }); }; + const containsMarkdownBody = (node: Node): boolean => + node.nodeType === Node.ELEMENT_NODE && + ((node as Element).matches('.markdown-body') || + Boolean((node as Element).querySelector('.markdown-body'))); const contentObserver = new MutationObserver((mutations) => { if ( mutations.some( (mutation) => mutation.type === 'childList' && - (mutation.target as Element).closest?.('.markdown-body'), + (Boolean((mutation.target as Element).closest?.('.markdown-body')) || + Array.from(mutation.addedNodes).some(containsMarkdownBody) || + Array.from(mutation.removedNodes).some(containsMarkdownBody)), ) ) { schedule(); @@ -171,15 +179,26 @@ function createController( const updateActiveHeading = (): void => { scrollFrame = 0; - const headings = Array.from( - document.querySelectorAll( - '.markdown-body h1[id], .markdown-body h2[id], .markdown-body h3[id]', + const trackedLinks = Array.from( + document.querySelectorAll( + '[data-bmp-toc] [data-bmp-heading-id]', ), ); - let active = headings[0]; - for (const heading of headings) { - if (heading.getBoundingClientRect().top <= 96) { - active = heading; + const headings = trackedLinks + .map((link) => document.getElementById(link.dataset.bmpHeadingId ?? '')) + .filter((heading): heading is HTMLElement => heading !== null); + let active: HTMLElement | undefined = headings[0]; + const scrollHeight = document.documentElement.scrollHeight; + const atBottom = + scrollHeight > 0 && + Math.ceil(window.scrollY + window.innerHeight) >= scrollHeight; + if (atBottom) { + active = headings.at(-1); + } else { + for (const heading of headings) { + if (heading.getBoundingClientRect().top <= 96) { + active = heading; + } } } for (const link of document.querySelectorAll( diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 0d513a2..7e2630f 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -21,9 +21,14 @@ suite('Better Markdown Preview extension', () => { const api = (await extension.activate()) as MarkdownExtensionApi; assert.strictEqual(extension.isActive, true); - const html = api - .extendMarkdownIt(new MarkdownIt({ html: true, linkify: false })) - .render('- [x] native preview extension\n'); + const markdown = api.extendMarkdownIt( + new MarkdownIt({ html: true, linkify: false }), + ); + assert.strictEqual(markdown.options.linkify, true); + const html = markdown.render( + '- [x] native preview extension\n\nhttps://example.com\n', + ); assert.match(html, /task-list-item/); + assert.match(html, /href="https:\/\/example\.com"/); }); }); diff --git a/test/presentation.test.mjs b/test/presentation.test.mjs index 5a89e89..f6636ba 100644 --- a/test/presentation.test.mjs +++ b/test/presentation.test.mjs @@ -31,3 +31,18 @@ test('task lists and rich code lines preserve the owned layout contract', () => ); assert.match(css, /@media \(max-width: 82rem\)/); }); + +test('explicit and implicit columns retain distinct Airplan flex semantics', () => { + assert.match(css, /\.better-markdown-preview-column \{[\s\S]*?flex: 1 1 0;/); + assert.match( + css, + /\.better-markdown-preview-column\[data-bmp-column-width\] \{[\s\S]*?flex: 0 1 var\(--bmp-column-width\);/, + ); +}); + +test('wide tables use the Airplan overflow contract', () => { + assert.match( + css, + /\.markdown-body table \{[\s\S]*?display: block;[\s\S]*?width: max-content;[\s\S]*?max-width: 100%;[\s\S]*?overflow-x: auto;/, + ); +}); From 79c31f634d47d6d44990764958cc57ea2e05461d Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Tue, 11 Aug 2026 03:28:35 +0000 Subject: [PATCH 4/5] fix: make GFM links independent of preview settings --- AGENTS.md | 5 ++-- docs/architecture.md | 10 +++---- docs/testing.md | 5 ++-- src/markdown/compose.test.ts | 32 +++++++++++++++++++++- src/markdown/compose.ts | 53 ++++++++++++++++++++++++++---------- src/test/extension.test.ts | 5 ++-- 6 files changed, 83 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aaf3620..9a25978 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,9 @@ Better Markdown Preview extends VS Code's built-in Markdown preview. Read container-looking lines inside backtick or tilde fences are code, not nested column syntax. - VS Code 1.125 configures its supplied Markdown-It's linkifier with - `fuzzyLink: false`; enabling the native linkify rule covers schemes and email, - but GFM `www.` literals need the extension's narrow linkify pass. + `fuzzyLink: false` and reapplies per-render options after contributed plugins. + The extension's narrow post-native pass must therefore fill missing GFM HTTP, + HTTPS, email, and `www.` literals without mutating `md.options.linkify`. - VS Code 1.125 collapses backslash-escaped punctuation into plain inline text before contributed core rules run. When the resulting token is identical to authored text (for example `www\.example.com` versus `www.example.com`), do diff --git a/docs/architecture.md b/docs/architecture.md index 7ece308..79bc272 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,11 +15,11 @@ the native renderer itself. ## Rendering Boundary `src/markdown/compose.ts` installs standard Markdown-It plugins for task lists, -definition lists, footnotes, and GitHub alerts. It enables Markdown-It's native -linkify rule even when the preview setting disabled it, preserving native -scheme and email handling. VS Code disables fuzzy links on the supplied -linkifier, so a narrow `linkify-it` pass adds only GFM `www.` literals while -retaining native normalization, validation, nesting, and HTML-anchor guards. +definition lists, footnotes, and GitHub alerts. Markdown-It's native linkify +rule runs first when enabled. A narrow post-native `linkify-it` pass fills +missing GFM HTTP, HTTPS, email, and `www.` literals independently of that +setting, while filtering bare domains and other schemes and retaining native +normalization, validation, nesting, and HTML-anchor guards. VS Code 1.125 collapses some backslash-escaped punctuation before contributed core rules run, so an escaped `www\.` is indistinguishable from authored `www.` at this boundary. Local rules cover GFM tag filtering, TOML diff --git a/docs/testing.md b/docs/testing.md index 2f6f91e..2d74ab4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -33,8 +33,9 @@ preview across light, dark, and high-contrast themes. Record manual or visual evidence when automated assertions would not meaningfully prove the result. The parser suite lives beside `src/markdown/compose.ts`; its GFM boundary cases -also reproduce VS Code's `fuzzyLink: false` configuration. The DOM suite uses a -controlled browser DOM and Mermaid adapter to cover conditional loading, +cover native linkification both enabled and disabled, including VS Code's +`fuzzyLink: false` configuration. The DOM suite uses a controlled browser DOM +and Mermaid adapter to cover conditional loading, failure fallback, theme rerender, reused Mermaid blocks, body replacement, TOC focus behavior, and source-preserving code presentation. These focused suites do not replace the real Extension Host activation test or runtime presentation diff --git a/src/markdown/compose.test.ts b/src/markdown/compose.test.ts index 61ec1ca..fd7d386 100644 --- a/src/markdown/compose.test.ts +++ b/src/markdown/compose.test.ts @@ -21,6 +21,35 @@ describe('Markdown composition', () => { expect(html).not.toContain('