Skip to content

feat: enhance the built-in Markdown preview - #2

Merged
jimeh merged 5 commits into
mainfrom
t3code/markdown-preview-features
Aug 11, 2026
Merged

jimeh merged 5 commits into
mainfrom
t3code/markdown-preview-features

Conversation

@jimeh

@jimeh jimeh commented Aug 11, 2026 •

Copy link
Copy Markdown
Owner

Why

VS Code’s native Markdown preview already provides exact editor-theme syntax highlighting, source maps, and scroll synchronization, but its Markdown and presentation feature set is deliberately narrow. Better Markdown Preview should retain those native behaviors while adding the richer authoring and reading experience used by Airplan and jimeh.me.

What changed

  • extend the built-in Markdown-it pipeline with task lists, setting-independent GFM autolinks, tag filtering, definition lists, footnotes, alerts, validated TOML frontmatter, and Airplan-compatible columns
  • add rich fence metadata for captions, line and word emphasis, line numbers, and diff annotations while preserving native Highlight.js output and source maps
  • render exact mermaid fences from a local, conditionally loaded bundle under the native preview CSP
  • add an idempotent responsive table of contents, accessible native dialog behavior, and clean Airplan-inspired presentation that follows VS Code light, dark, and high-contrast variables
  • build and package desktop, web, preview, Mermaid, and CSS assets with dedicated watch tasks
  • document the renderer boundary, feature contract, testing strategy, and a kitchen-sink fixture

Verification

  • mise run verify
  • 8 repository contract tests
  • 28 parser and DOM tests
  • VS Code 1.125 Extension Host activation test
  • exact 11-file VSIX inventory validation
  • real VS Code Electron preview checks in light, dark, high-contrast, wide, and narrow layouts
  • runtime checks for setting-independent GFM links, native source maps, live-edit conditional Mermaid, theme rerendering, modal focus, responsive columns/TOC, task markers, wide-table containment, and copied code text
  • independent Codex and Claude reviews accepted; CodeRabbit approved the final head with zero unresolved threads

Scope note

The browser extension bundle is Node-free and package-validated, but this repository does not yet have a stable automated vscode.dev Extension Host harness; web-host support is therefore structurally verified rather than claimed as an executed browser-host test. VS Code 1.125 also collapses some backslash-escaped punctuation before contributed core rules run, making escaped and authored autolink text indistinguishable at that hook boundary.

@jimeh

jimeh commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Enhanced Markdown preview with front matter, alerts, task lists, definition lists, footnotes, columns, autolinks, and safer HTML handling.
    • Added responsive layouts, table of contents navigation, theme-aware styling, Mermaid diagrams, and richer code blocks with line numbers and highlighting.
    • Added support for desktop and web extension environments.
  • Bug Fixes
    • Improved preview updates, navigation, theme changes, and Mermaid rendering reliability.
  • Documentation
    • Expanded guidance on supported Markdown features, preview behavior, architecture, development, and testing.

Walkthrough

The extension now provides Markdown-It composition, responsive preview enhancement, Mermaid rendering, native Markdown preview integration, separate preview bundles, expanded tests, and documentation for the new rendering and runtime behavior.

Changes

Markdown Preview

Layer / File(s) Summary
Markdown composition and extension contract
src/markdown/compose.ts, src/extension.ts, src/markdown/compose.test.ts, src/test/extension.test.ts, src/types/vendor.d.ts, docs/architecture.md, docs/plans/002-renderer-implementation.md, AGENTS.md
Adds the extendMarkdownIt API and Markdown-It processing for GFM syntax, frontmatter, columns, alerts, autolinks, Mermaid fences, source mapping, and rich code metadata.
Preview runtime and presentation
src/preview/*, media/preview.css, src/preview/runtime.test.ts, test/presentation.test.mjs
Adds document-scoped preview enhancement with TOC navigation, heading tracking, code-line rendering, responsive styling, Mermaid loading, theme handling, and lifecycle cleanup.
Build, manifest, and test wiring
esbuild.js, package.json, .vscode/tasks.json, tsconfig*.json, vitest.config.mjs, test/harness.test.mjs, test/manifest.test.mjs, test/package-content.test.mjs, .vscodeignore, mise.toml
Adds preview build targets, manifest contributions, watch tasks, test configuration, dependency declarations, and package-content validation.
Fixtures and implementation documentation
test/fixtures/kitchen-sink.md, README.md, docs/testing.md, CHANGELOG.md, .markdownlint-cli2.jsonc
Adds a comprehensive Markdown fixture and documents supported syntax, preview behavior, development watchers, and validation coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VSCodePreview
  participant PreviewRuntime
  participant MermaidRuntime
  participant PreviewDOM
  VSCodePreview->>PreviewRuntime: load preview script
  PreviewRuntime->>PreviewDOM: build layout, TOC, and code-line presentation
  PreviewRuntime->>MermaidRuntime: load Mermaid when diagrams exist
  MermaidRuntime->>PreviewDOM: insert themed SVG diagrams
  VSCodePreview->>PreviewRuntime: notify content or theme changes
  PreviewRuntime->>PreviewDOM: re-enhance or rerender affected content
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title uses valid Conventional Commits syntax and accurately describes the Markdown preview enhancements.
Description check ✅ Passed The description clearly explains the Markdown preview features, build changes, testing, and known web-host limitation.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (7)
src/markdown/compose.ts (2)

266-273: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the local columnOpen token variable to avoid shadowing the module regex.

Line 13 declares a module-level columnOpen regex. Line 266 declares a local columnOpen token with the same name. The shadowing is legal, but a later edit inside this block can reference the wrong binding. Rename the local variable.

♻️ Proposed rename
-				const columnOpen = state.push(
+				const columnOpenToken = state.push(
 					'better_markdown_preview_column_open',
 					'div',
 					1,
 				);
-				columnOpen.block = true;
-				columnOpen.map = [column.openLine, column.openLine + 1];
-				columnOpen.meta = { width: column.width };
+				columnOpenToken.block = true;
+				columnOpenToken.map = [column.openLine, column.openLine + 1];
+				columnOpenToken.meta = { width: column.width };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/markdown/compose.ts` around lines 266 - 273, Rename the local token
variable `columnOpen` in the column composition block to a distinct name, and
update its subsequent `block`, `map`, and `meta` assignments accordingly; leave
the module-level `columnOpen` regex unchanged.

503-522: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse markdown-it's escapeHtml instead of the local copy.

Lines 516-522 duplicate md.utils.escapeHtml, which this file already uses at lines 233, 462, 486, and 497. Two escaping implementations can drift. The local copy exists only because renderPrevious has no md reference. Pass the escaper in, or import it from markdown-it.

Note on the static analysis hints for this range: replacing this with DOMPurify or sanitize-html would be wrong. This escapes code text for display; it does not sanitize HTML.

♻️ Proposed refactor
 function renderPrevious(
 	previous: FenceRenderRule | undefined,
 	tokens: Token[],
 	index: number,
 	options: Parameters<FenceRenderRule>[2],
 	env: unknown,
 	renderer: Parameters<FenceRenderRule>[4],
+	escape: (value: string) => string,
 ): string {
 	return previous
 		? previous(tokens, index, options, env, renderer)
-		: `<pre><code>${escapeHtml(tokens[index].content)}</code></pre>\n`;
+		: `<pre><code>${escape(tokens[index].content)}</code></pre>\n`;
 }
-
-function escapeHtml(value: string): string {
-	return value
-		.replaceAll('&', '&amp;')
-		.replaceAll('<', '&lt;')
-		.replaceAll('>', '&gt;')
-		.replaceAll('"', '&quot;');
-}

Update both call sites at lines 473 and 499 to pass md.utils.escapeHtml.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/markdown/compose.ts` around lines 503 - 522, Remove the local escapeHtml
helper and update renderPrevious to accept an escaping function parameter. At
both renderPrevious call sites, pass md.utils.escapeHtml, and use that parameter
for fallback code-content escaping so markdown-it remains the single escaping
implementation.

Source: Linters/SAST tools

src/preview/mermaid-runtime.ts (1)

11-25: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

mermaid.initialize runs once per diagram, not once per theme.

render is called for each Mermaid block in a pass. src/preview/runtime.ts reads the theme once per pass at Line 99 and then loops the blocks. Therefore mermaid.initialize re-applies identical global configuration for every block in the document.

Cache the applied theme and call mermaid.initialize only when the theme changes.

♻️ Proposed fix to initialize only on theme change
 let diagramId = 0;
+let appliedTheme: string | undefined;
 
 export async function render(
 	element: HTMLElement,
 	source: string,
 	theme: MermaidTheme,
 ): Promise<void> {
-	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 signature = JSON.stringify(theme);
+	if (signature !== appliedTheme) {
+		appliedTheme = signature;
+		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++}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/preview/mermaid-runtime.ts` around lines 11 - 25, Cache the theme used by
the Mermaid runtime around the mermaid.initialize call, and only reinitialize
when the current theme differs from the cached theme. Preserve the existing
initialization options and update the cache after applying a changed theme so
repeated render calls for blocks in the same pass do not reapply identical
global configuration.
src/preview/runtime.test.ts (1)

11-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test isolation depends on every test reaching its own dispose call.

src/preview/runtime.ts stores controllers in a module-level WeakMap keyed by Document. happy-dom reuses one document for the whole file. If any test fails or throws before its dispose call, the shared controller survives into the next test. enhancePreview then returns the stale controller with an already-resolved ready, and the following tests fail for an unrelated reason.

Also, vi.restoreAllMocks() runs in beforeEach, so the spies of the last test are never restored. The explicit geometry.mockRestore() at Line 219 works around that gap.

Move teardown into afterEach and track the active controllers.

♻️ Proposed fix for deterministic teardown
-import { beforeEach, describe, expect, test, vi } from 'vitest';
-import { enhancePreview, parseLineSet, type MermaidAdapter } from './runtime';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import {
+	enhancePreview as createPreview,
+	parseLineSet,
+	type MermaidAdapter,
+	type PreviewController,
+	type PreviewOptions,
+} from './runtime';
+
+const active = new Set<PreviewController>();
+
+function enhancePreview(
+	target: Document,
+	options?: PreviewOptions,
+): PreviewController {
+	const controller = createPreview(target, options);
+	active.add(controller);
+	return controller;
+}
 
 function setDocument(html: string): void {
 	document.body.innerHTML = `<div class="markdown-body">${html}</div>`;
 }
 
 describe('preview runtime', () => {
 	beforeEach(() => {
 		setDocument('');
-		vi.restoreAllMocks();
 	});
+
+	afterEach(() => {
+		for (const controller of active) {
+			controller.dispose();
+		}
+		active.clear();
+		vi.restoreAllMocks();
+	});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/preview/runtime.test.ts` around lines 11 - 14, Move test cleanup from
beforeEach to afterEach in the runtime tests, and track controllers created by
each test so teardown disposes every active controller even when a test fails
before its own dispose call. Restore mocks in afterEach as well, including
geometry-related spies, while keeping document reset/setup in beforeEach and
ensuring the tracked controllers are cleared after cleanup.
test/presentation.test.mjs (1)

14-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the alert assertion so it proves the fallback order inside one declaration.

Line 15 only proves that the escaped form exists somewhere in the file. Line 16-21 only proves that the hyphen form appears somewhere before an --vscode-editor-foreground token, because [\s\S]*? crosses rule boundaries. The stylesheet could split the escaped form and the fallback chain into unrelated rules and still pass.

The guideline requires the escaped dot form first and the normalized hyphen form as the fallback. Assert both inside a single color: declaration.

As per coding guidelines: "For VS Code 1.125 Markdown alert custom properties, escape the dot in CSS and retain the normalized hyphen form as a compatibility fallback."

♻️ Proposed fix to bind the assertion to one declaration
 	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`,
-			),
-		);
+		const declaration = new RegExp(
+			`\\.better-markdown-preview-alert-${alert}\\s*\\{[^}]*?` +
+				`--vscode-markdownAlert-${alert}\\\\\\.foreground[^}]*?` +
+				`--vscode-markdownAlert-${alert}-foreground[^}]*?` +
+				`--vscode-editor-foreground[^}]*?\\}`,
+		);
+		assert.match(css, declaration, `${alert} alert fallback chain is out of order`);
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/presentation.test.mjs` around lines 14 - 22, Update the alert assertions
in the test loop to match one complete color declaration containing the
escaped-dot custom property first, followed by the normalized hyphen fallback
and --vscode-editor-foreground. Replace the separate broad css.includes and
cross-rule RegExp checks with a declaration-scoped assertion that verifies this
exact fallback order for every alert.

Source: Coding guidelines

src/preview/runtime.ts (2)

180-222: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

The animation frame handle is cleared by direct calls, so dispose cannot cancel a pending frame.

updateActiveHeading sets scrollFrame = 0 at Line 181. enhance calls updateActiveHeading directly at Line 138. If a scroll frame is already pending at that moment, the handle is discarded while the callback is still queued. dispose at Line 232 then finds scrollFrame === 0 and cannot cancel it. The queued callback runs after disposal and mutates the TOC links.

The effect is small because the callback is idempotent. Clear the handle only in the frame callback.

♻️ Proposed fix to keep the frame handle authoritative
 	const updateActiveHeading = (): void => {
-		scrollFrame = 0;
 		const trackedLinks = Array.from(
@@
 	const onScroll = (): void => {
 		if (!scrollFrame) {
-			scrollFrame = requestAnimationFrame(updateActiveHeading);
+			scrollFrame = requestAnimationFrame(() => {
+				scrollFrame = 0;
+				updateActiveHeading();
+			});
 		}
 	};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/preview/runtime.ts` around lines 180 - 222, Keep scrollFrame
authoritative until the scheduled animation-frame callback executes: remove its
reset from updateActiveHeading and clear it inside the requestAnimationFrame
callback before invoking updateActiveHeading. Ensure direct calls to
updateActiveHeading do not discard a pending frame, so dispose can still cancel
it.

9-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Configure TypeScript-aware unused-variable rules.

The **/*.ts block keeps the base no-unused-vars rule enabled and does not enable @typescript-eslint/no-unused-vars. Because pnpm run lint:code uses --max-warnings=0, this signature can block lint. Disable the base rule and enable the TypeScript rule, or set args: 'none' for declarations. Keep the descriptive parameter names.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/preview/runtime.ts` around lines 9 - 15, Update the TypeScript ESLint
configuration for the **/*.ts block to disable the base no-unused-vars rule and
enable `@typescript-eslint/no-unused-vars`, or configure the TypeScript rule with
args: 'none'. Preserve the descriptive parameter names in MermaidAdapter.render
and ensure pnpm run lint:code reports no warnings.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@media/preview.css`:
- Line 162: Update the border declaration near the currentColor value to use the
lowercase currentcolor keyword, resolving the Stylelint value-keyword-case
violation without changing the border styling.
- Around line 78-83: Update the TOC link rule covering
.better-markdown-preview-toc a:hover, .better-markdown-preview-toc-dialog
a:hover, and .better-markdown-preview-toc-active to remove both !important
declarations and preserve the intended styling through higher selector
specificity instead, allowing user markdown.styles overrides to retain
precedence.

---

Nitpick comments:
In `@src/markdown/compose.ts`:
- Around line 266-273: Rename the local token variable `columnOpen` in the
column composition block to a distinct name, and update its subsequent `block`,
`map`, and `meta` assignments accordingly; leave the module-level `columnOpen`
regex unchanged.
- Around line 503-522: Remove the local escapeHtml helper and update
renderPrevious to accept an escaping function parameter. At both renderPrevious
call sites, pass md.utils.escapeHtml, and use that parameter for fallback
code-content escaping so markdown-it remains the single escaping implementation.

In `@src/preview/mermaid-runtime.ts`:
- Around line 11-25: Cache the theme used by the Mermaid runtime around the
mermaid.initialize call, and only reinitialize when the current theme differs
from the cached theme. Preserve the existing initialization options and update
the cache after applying a changed theme so repeated render calls for blocks in
the same pass do not reapply identical global configuration.

In `@src/preview/runtime.test.ts`:
- Around line 11-14: Move test cleanup from beforeEach to afterEach in the
runtime tests, and track controllers created by each test so teardown disposes
every active controller even when a test fails before its own dispose call.
Restore mocks in afterEach as well, including geometry-related spies, while
keeping document reset/setup in beforeEach and ensuring the tracked controllers
are cleared after cleanup.

In `@src/preview/runtime.ts`:
- Around line 180-222: Keep scrollFrame authoritative until the scheduled
animation-frame callback executes: remove its reset from updateActiveHeading and
clear it inside the requestAnimationFrame callback before invoking
updateActiveHeading. Ensure direct calls to updateActiveHeading do not discard a
pending frame, so dispose can still cancel it.
- Around line 9-15: Update the TypeScript ESLint configuration for the **/*.ts
block to disable the base no-unused-vars rule and enable
`@typescript-eslint/no-unused-vars`, or configure the TypeScript rule with args:
'none'. Preserve the descriptive parameter names in MermaidAdapter.render and
ensure pnpm run lint:code reports no warnings.

In `@test/presentation.test.mjs`:
- Around line 14-22: Update the alert assertions in the test loop to match one
complete color declaration containing the escaped-dot custom property first,
followed by the normalized hyphen fallback and --vscode-editor-foreground.
Replace the separate broad css.includes and cross-rule RegExp checks with a
declaration-scoped assertion that verifies this exact fallback order for every
alert.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 22896aea-c9db-4d56-a843-1bd76a0eca54

📥 Commits

Reviewing files that changed from the base of the PR and between 9f88163 and 79c31f6.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (30)
  • .markdownlint-cli2.jsonc
  • .vscode/tasks.json
  • .vscodeignore
  • AGENTS.md
  • CHANGELOG.md
  • README.md
  • docs/architecture.md
  • docs/plans/002-renderer-implementation.md
  • docs/testing.md
  • esbuild.js
  • media/preview.css
  • mise.toml
  • package.json
  • src/extension.ts
  • src/markdown/compose.test.ts
  • src/markdown/compose.ts
  • src/preview/index.ts
  • src/preview/mermaid-runtime.ts
  • src/preview/runtime.test.ts
  • src/preview/runtime.ts
  • src/test/extension.test.ts
  • src/types/vendor.d.ts
  • test/fixtures/kitchen-sink.md
  • test/harness.test.mjs
  • test/manifest.test.mjs
  • test/package-content.test.mjs
  • test/presentation.test.mjs
  • tsconfig.extension-tests.json
  • tsconfig.json
  • vitest.config.mjs

Comment thread media/preview.css
Comment thread media/preview.css Outdated
@jimeh

jimeh commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@jimeh
jimeh marked this pull request as ready for review August 11, 2026 03:46
@jimeh

jimeh commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Visual evidence

Captured from VS Code 1.125.0 with the extension running from PR head d900e277be1a.

Dark Modern — rich fences, native highlighting, Mermaid, and wide content

Dark Modern preview

Light Modern — alerts, definitions, footnotes, responsive columns, and code presentation

Light Modern preview

Dark High Contrast — narrow responsive layout and floating TOC trigger

Dark High Contrast narrow preview

Open the complete Airplan collection

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant