diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a26d69..fa3b885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to **Rich Markdown Diff** will be documented in this file. +## [1.5.0] - 2026-08-DD + +### Added in 1.5.0 + +- **Visual Conflict Resolver**: Interactive 3-way merge editor for resolving Git merge conflicts (`<<<<<<<`, `=======`, `>>>>>>>`) directly in rendered Markdown. +- **Semantic Mermaid Diff**: Element-level diff highlighting for Mermaid flowcharts with dynamic color-coding and ghost outlines. +- **HTML Export**: Export rendered side-by-side or inline diffs to standalone HTML files via the `Export Rendered Diff to HTML` command. +- **Custom Diff Colors & View Preferences**: Added settings for custom insertion/deletion highlight colors and default view/folding modes. +- **Committed Revision Diffs**: Render visual diffs for committed revisions from Git history and the editor context menu. + +### Fixed in 1.5.0 + +- **Stability & Edge Cases**: + - Fixed frontmatter parser error when metadata keys include `hasOwnProperty`. + - Fixed MDX `` tag counting inside `` blocks. + - Resolved table diff `TypeError` on rows with fewer columns than the header. + - Added support for 64-character SHA-256 commit hashes in Git Blame. + - Fixed line number data-attribute injection when HTML tags contain `>`. + - Hardened validation for webview edit message payloads. + ## [1.4.0] - 2026-07-19 ### Added in 1.4.0 diff --git a/README.md b/README.md index 195ab81..2e12ece 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere ## Key Features - **Visual Diff**: Compare rendered HTML side-by-side or inline instead of raw source code. +- ⚔️ **Visual Conflict Resolver**: Resolve Git merge conflicts (`<<<<<<<`, `|||||||`, `=======`, `>>>>>>>`) in rendered Markdown with an interactive 3-Way Merge View (`Accept Mine`, `Accept Theirs`, `Accept Both`). +- 📊 **Semantic Mermaid Diff**: Element-level diff highlighting for Mermaid flowcharts. Added, deleted, and modified nodes and edges are dynamically color-coded with ghost outlines for deleted elements. - **Git Integration**: Compare changes directly from the VS Code Source Control view (Workspace, Staged, or HEAD). - **Git Blame**: Hover over any line in the diff view to instantly see its commit author and history. - **Interactive Image Diff**: Compare visual changes in images using "Swipe" and "Onion Skin" comparison modes. @@ -21,7 +23,7 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere | **MDX / Custom** | Native diffing for MDX, Docusaurus, and Astro Starlight components (Tabs, Steps, Badges, Cards, and Admonitions). | | **Marp** | Render and diff slide decks with full theme support. | | **Math** | High-quality KaTeX rendering for formulas. | -| **Mermaid** | Flowcharts, sequence diagrams, and Gantt charts with VS Code theme-aware rendering. | +| **Mermaid** | Flowcharts with element-level diff highlighting, plus sequence diagrams and Gantt charts with VS Code theme-aware rendering. | | **Obsidian** | Native support for Tags (`#tag`) and Transclusions (`![[link]]`). | | **Alerts** | GitHub-style `[!NOTE]`, `[!WARNING]`, etc. | | **Structure** | Robust diffing for Tables, Nested Lists, Footnotes, and block-level math/code changes. | @@ -29,6 +31,7 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere ## Use Cases +- ⚔️ **Merge Conflicts**: Resolve Git merge conflicts in Markdown docs visually rather than inspecting raw syntax markers. - 🖼️ **Marp Presentations**: Verify slide layout and theme changes visually. - 📚 **Knowledge Bases**: Review changes in foam/wiki notes with wikilinks and footnotes. - 🌐 **Modern Tech Docs**: Catch rendering issues in MDX, Docusaurus, and Astro Starlight components. @@ -42,12 +45,30 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere Select two `.md` files in the Explorer, right-click, and select **Show Markdown Diff**. ### 2. Git & SCM -Click the **Show Markdown Diff** icon next to any modified Markdown file in the Source Control view. +- **Working Tree & Staged**: Click the **Show Markdown Diff** icon next to any modified Markdown file in the Source Control view. +- **Committed Revisions**: When viewing committed file diffs from Git history or the Git Graph, click the **Show Markdown Diff** icon in the editor title bar or right-click inside the diff editor and choose **Show Rich Markdown Diff**. ![SCM Diff](https://raw.githubusercontent.com/phine-apps/rich-markdown-diff/main/images/scm-diff.gif) ### 3. Clipboard Comparison -Right-click in any Markdown editor and choose **Compare with Clipboard**. +Right-click in any Markdown editor and choose **Compare with Clipboard** (or press `Cmd+Shift+C` / `Ctrl+Shift+C`). + +### 4. Visual Conflict Resolver +Right-click any Markdown file containing Git conflict markers, choose **Open With...**, and select **Markdown Visual Conflict Resolver**. Click **Accept Mine**, **Accept Theirs**, or **Accept Both** on any conflict block to resolve it directly. + +### 5. Export to HTML +Click the **Export to HTML** icon in the diff view toolbar, or run **Export Rendered Diff to HTML** from the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) to generate a standalone HTML document. + +## Keyboard Shortcuts + +The following default shortcuts are available when the visual diff panel is active: + +| Shortcut (Mac) | Shortcut (Windows/Linux) | Command | Description | +| --- | --- | --- | --- | +| `F7` / `Shift+F7` | `F7` / `Shift+F7` | Next / Previous Change | Jump to the next or previous diff highlight. | +| `Alt+F7` | `Alt+F7` | Toggle Inline View | Toggle between Side-by-Side and Inline diff layouts. | +| `Alt+F8` | `Alt+F8` | Toggle Fold Unchanged | Fold or unfold unchanged Markdown blocks. | +| `Cmd+Shift+C` | `Ctrl+Shift+C` | Compare with Clipboard | Compare active Markdown document with clipboard content. | ## Technical Details diff --git a/fixtures/expected/comprehensive.html b/fixtures/expected/comprehensive.html index 40207e5..e960560 100644 --- a/fixtures/expected/comprehensive.html +++ b/fixtures/expected/comprehensive.html @@ -86,17 +86,7 @@

Math (KaTeX)

M834 80h400000v40h-400000z">

New equation added:

i=1ni=n(n+1)2\sum_{i=1}^{n} i = \frac{n(n+1)}{2}

Mermaid Diagrams

-
-graph TD; - A-->B; - B-->C; - C-->D; - -
Math (KaTeX) E --> H[End] F --> H G --> H -"> -graph TD +">graph TD A[Start] --> B{Decision} B -- Yes --> C[Process One] B -- No --> D[Process Two] @@ -118,7 +107,32 @@

Math (KaTeX)

F --> H G --> H -

GitHub Alerts

+%% Ghost definitions for deleted elements + A--["A--"] + B--["B--"] + C--["C--"] + C -.-> D + +%% Dynamic Diff Styles + style A fill:#e6ffec,stroke:#22863a,stroke-width:2px; + style E fill:#e6ffec,stroke:#22863a,stroke-width:2px; + style F fill:#e6ffec,stroke:#22863a,stroke-width:2px; + style G fill:#e6ffec,stroke:#22863a,stroke-width:2px; + style H fill:#e6ffec,stroke:#22863a,stroke-width:2px; + style A-- fill:#ffeef0,stroke:#d73a49,stroke-width:1px,stroke-dasharray:5 5,opacity:0.75; + style B-- fill:#ffeef0,stroke:#d73a49,stroke-width:1px,stroke-dasharray:5 5,opacity:0.75; + style C-- fill:#ffeef0,stroke:#d73a49,stroke-width:1px,stroke-dasharray:5 5,opacity:0.75; + style B fill:#fffdef,stroke:#b08800,stroke-width:2px; + style C fill:#fffdef,stroke:#b08800,stroke-width:2px; + style D fill:#fffdef,stroke:#b08800,stroke-width:2px; + linkStyle 2 stroke:#22863a,stroke-width:2px; + linkStyle 3 stroke:#22863a,stroke-width:2px; + linkStyle 4 stroke:#22863a,stroke-width:2px; + linkStyle 5 stroke:#22863a,stroke-width:2px; + linkStyle 6 stroke:#22863a,stroke-width:2px; + linkStyle 7 stroke:#22863a,stroke-width:2px; + linkStyle 8 stroke:#22863a,stroke-width:2px; + linkStyle 9 stroke:#d73a49,stroke-width:1px,stroke-dasharray:3 3,opacity:0.75;

GitHub Alerts

Note

This is a note alert with updated content.

Warning

This is a warning alert.

diff --git a/package.json b/package.json index ba3cd70..5ff59b0 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "publisher": "phine-apps", "displayName": "Rich Markdown Diff", "description": "Professional visual Markdown diff with Math, Mermaid, and Git integration for better document reviews.", - "version": "1.4.0", + "version": "1.5.0", "icon": "images/icon.png", "author": "phine-apps", "license": "MIT", @@ -313,6 +313,16 @@ } ], "priority": "option" + }, + { + "viewType": "rich-markdown-diff.conflictResolver", + "displayName": "Markdown Visual Conflict Resolver", + "selector": [ + { + "filenamePattern": "*.md" + } + ], + "priority": "option" } ] }, @@ -348,7 +358,7 @@ "@typescript-eslint/eslint-plugin": "^8.63.0", "@typescript-eslint/parser": "^8.63.0", "@vscode/l10n-dev": "^0.0.35", - "@vscode/test-electron": "^3.0.0", + "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", "esbuild": "^0.28.1", "eslint": "^10.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f7012d..afc8ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,8 +88,8 @@ importers: specifier: ^0.0.35 version: 0.0.35 '@vscode/test-electron': - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^3.1.0 + version: 3.1.0 '@vscode/vsce': specifier: ^3.9.2 version: 3.9.2 @@ -795,8 +795,8 @@ packages: '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} - '@vscode/test-electron@3.0.0': - resolution: {integrity: sha512-TY5mC7aAjxSLDXsyjhrG8cJHgc/HLdiE5lvtW7hABYQrY24Qwozzr5UoO3HiuAM4Hzz4b7K/eZlwrCILj94CcA==} + '@vscode/test-electron@3.1.0': + resolution: {integrity: sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==} engines: {node: '>=22'} '@vscode/vsce-sign-alpine-arm64@2.0.6': @@ -3289,7 +3289,7 @@ snapshots: '@vscode/l10n@0.0.18': {} - '@vscode/test-electron@3.0.0': + '@vscode/test-electron@3.1.0': dependencies: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 diff --git a/src/conflictEditorProvider.ts b/src/conflictEditorProvider.ts new file mode 100644 index 0000000..cbb4ffb --- /dev/null +++ b/src/conflictEditorProvider.ts @@ -0,0 +1,295 @@ +/* + * MIT License + * + * Copyright (c) 2026 Rich Markdown Diff Authors + */ + +import * as crypto from "crypto"; +import * as vscode from "vscode"; +import { parseConflictBlocks, reconstructDocument, DocBlock } from "./markdown/conflictParser"; +import { MarkdownDiffProvider } from "./markdownDiff"; +import { escapeHtml } from "./markdown/sanitizer"; + +export class ConflictEditorProvider implements vscode.CustomTextEditorProvider { + public static readonly viewType = "rich-markdown-diff.conflictResolver"; + + constructor(private readonly context: vscode.ExtensionContext) {} + + public static register(context: vscode.ExtensionContext): vscode.Disposable { + const provider = new ConflictEditorProvider(context); + return vscode.window.registerCustomEditorProvider( + ConflictEditorProvider.viewType, + provider, + { + webviewOptions: { + retainContextWhenHidden: true, + }, + supportsMultipleEditorsPerDocument: false, + } + ); + } + + public async resolveCustomTextEditor( + document: vscode.TextDocument, + webviewPanel: vscode.WebviewPanel, + _token: vscode.CancellationToken + ): Promise { + webviewPanel.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, "media"), + ], + }; + + // [SEC-01] Generate a per-session nonce for CSP (same approach as webviewTemplate.ts) + const nonce = crypto.randomBytes(16).toString("hex"); + + // [UX-01] Set the shell HTML only once. Subsequent updates are sent via postMessage + // to avoid a full Webview reload, which previously caused scroll-position race conditions. + webviewPanel.webview.html = this.getShellHtml(nonce); + + const sendUpdate = () => { + const text = document.getText(); + const blocks = parseConflictBlocks(text); + const content = this.renderBlocksContent(blocks); + webviewPanel.webview.postMessage({ command: "update", content }); + }; + + const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() === document.uri.toString()) { + sendUpdate(); + } + }); + + webviewPanel.onDidDispose(() => { + changeDocumentSubscription.dispose(); + }); + + // [SEC-02] Allowlist for valid conflict resolution choices + const VALID_CHOICES = ["mine", "theirs", "both"] as const; + type ValidChoice = typeof VALID_CHOICES[number]; + + webviewPanel.webview.onDidReceiveMessage(async (message) => { + if (message.command === "resolveConflict") { + const { blockId, choice } = message; + + // Validate choice against allowlist before applying + if (typeof choice !== "string" || !VALID_CHOICES.includes(choice as ValidChoice)) { + console.warn(`[ConflictEditorProvider] Rejected invalid choice: ${String(choice)}`); + return; + } + + const text = document.getText(); + const blocks = parseConflictBlocks(text); + + const targetBlock = blocks.find( + (b) => b.type === "conflict" && b.id === blockId + ); + if (targetBlock && targetBlock.type === "conflict") { + targetBlock.choice = choice as ValidChoice; + const newText = reconstructDocument(blocks); + + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(text.length) + ); + edit.replace(document.uri, fullRange, newText); + await vscode.workspace.applyEdit(edit); + } + } else if (message.command === "ready") { + // [UX-01] Webview signals that it is ready to receive content. + // Send the initial content only after the Webview JS is fully initialised, + // so the scroll-restore logic runs AFTER the DOM is populated. + sendUpdate(); + } + }); + + // Do NOT call sendUpdate() here. The Webview JS fires "ready" once mounted, + // which triggers sendUpdate() at the correct time. + } + + /** + * Returns the static shell HTML: styles + script only, no content. + * This is set on the webview exactly once and never replaced afterwards. + */ + private getShellHtml(nonce: string): string { + return ` + + + + + + + Visual Conflict Resolver + + + +
+ + + + `; + } + + /** + * Renders blocks to an HTML string for injection into #content via postMessage. + * Does NOT include // wrappers. + */ + private renderBlocksContent(blocks: DocBlock[]): string { + const diffProvider = new MarkdownDiffProvider(); + let bodyContent = ""; + + for (const block of blocks) { + if (block.type === "common") { + const { html } = diffProvider.computeDiff(block.text, block.text); + bodyContent += `
${html}
`; + } else { + const safeMineLabel = escapeHtml(block.mineLabel || "Current (Mine)"); + const safeTheirsLabel = escapeHtml(block.theirsLabel || "Incoming (Theirs)"); + const safeBlockId = escapeHtml(block.id); + + // Compute Mine vs Theirs diff to show highlighted changes + const { html: mineVsTheirsHtml } = diffProvider.computeDiff(block.mine, block.theirs); + const { html: mineHtml } = diffProvider.computeDiff(block.mine, block.mine); + const { html: theirsHtml } = diffProvider.computeDiff(block.theirs, block.theirs); + + bodyContent += ` +
+
+ Conflict (${safeMineLabel} vs ${safeTheirsLabel}) +
+ + + +
+
+
+
Diff Preview (Mine → Theirs)
+
${mineVsTheirsHtml}
+
+
+
+
Mine (${safeMineLabel})
+
${mineHtml}
+
+
+
Theirs (${safeTheirsLabel})
+
${theirsHtml}
+
+
+
+ `; + } + } + + return bodyContent; + } +} diff --git a/src/extension.ts b/src/extension.ts index eaae151..3bc4f5a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,6 +23,7 @@ */ import * as vscode from "vscode"; +import { ConflictEditorProvider } from "./conflictEditorProvider"; import { MarkdownDiffProvider } from "./markdownDiff"; import { describeComparisonSide, @@ -1054,6 +1055,7 @@ export function activate(context: vscode.ExtensionContext) { ); context.subscriptions.push( + ConflictEditorProvider.register(context), vscode.window.onDidChangeActiveTextEditor((editor) => { void updateRenderedDiffContext(editor); }), diff --git a/src/markdown/conflictParser.ts b/src/markdown/conflictParser.ts new file mode 100644 index 0000000..c436dfb --- /dev/null +++ b/src/markdown/conflictParser.ts @@ -0,0 +1,168 @@ +/* + * MIT License + * + * Copyright (c) 2026 Rich Markdown Diff Authors + */ + +export interface CommonBlock { + type: "common"; + text: string; +} + +export interface ConflictBlock { + type: "conflict"; + id: string; + mine: string; + theirs: string; + base?: string; + mineLabel?: string; + theirsLabel?: string; + startLine: number; + endLine: number; + choice?: "mine" | "theirs" | "both"; +} + +export type DocBlock = CommonBlock | ConflictBlock; + +/** + * Parses markdown text containing Git conflict markers (<<<<<<<, |||||||, =======, >>>>>>>) + * into a sequence of Common and Conflict blocks. + */ +export function parseConflictBlocks(content: string): DocBlock[] { + const lines = content.split(/\r?\n/); + const blocks: DocBlock[] = []; + + let currentCommonLines: string[] = []; + let inConflict = false; + + let conflictIdCounter = 1; + let conflictMineLines: string[] = []; + let conflictBaseLines: string[] = []; + let conflictTheirsLines: string[] = []; + let mineLabel = ""; + let conflictStartLine = 0; + let section: "mine" | "base" | "theirs" = "mine"; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Git conflict markers start with <<<<<<<, |||||||, =======, >>>>>>> + if (line.startsWith("<<<<<<<")) { + if (currentCommonLines.length > 0) { + blocks.push({ + type: "common", + text: currentCommonLines.join("\n"), + }); + currentCommonLines = []; + } + inConflict = true; + section = "mine"; + conflictStartLine = i; + mineLabel = line.substring(7).trim() || "Current (Mine)"; + conflictMineLines = []; + conflictBaseLines = []; + conflictTheirsLines = []; + continue; + } + + if (inConflict && line.startsWith("|||||||")) { + section = "base"; + continue; + } + + if (inConflict && line.startsWith("=======")) { + section = "theirs"; + continue; + } + + if (inConflict && line.startsWith(">>>>>>>")) { + const theirsLabel = line.substring(7).trim() || "Incoming (Theirs)"; + blocks.push({ + type: "conflict", + id: `conflict-${conflictIdCounter++}`, + mine: conflictMineLines.join("\n"), + theirs: conflictTheirsLines.join("\n"), + base: conflictBaseLines.length > 0 ? conflictBaseLines.join("\n") : undefined, + mineLabel, + theirsLabel, + startLine: conflictStartLine, + endLine: i, + }); + inConflict = false; + continue; + } + + if (inConflict) { + if (section === "base") { + conflictBaseLines.push(line); + } else if (section === "theirs") { + conflictTheirsLines.push(line); + } else { + conflictMineLines.push(line); + } + } else { + currentCommonLines.push(line); + } + } + + // Safely rescue unclosed conflict marker + if (inConflict) { + const unclosedLines: string[] = [ + `<<<<<<< ${mineLabel}`, + ...conflictMineLines, + ]; + if (conflictBaseLines.length > 0) { + unclosedLines.push("|||||||"); + unclosedLines.push(...conflictBaseLines); + } + if (section === "theirs") { + unclosedLines.push("======="); + unclosedLines.push(...conflictTheirsLines); + } + currentCommonLines.push(...unclosedLines); + } + + if (currentCommonLines.length > 0) { + blocks.push({ + type: "common", + text: currentCommonLines.join("\n"), + }); + } + + return blocks; +} + +/** + * Reconstructs document text based on conflict choices made by the user. + */ +export function reconstructDocument(blocks: DocBlock[]): string { + const resultLines: string[] = []; + + for (const block of blocks) { + if (block.type === "common") { + resultLines.push(block.text); + } else { + if (block.choice === "mine") { + resultLines.push(block.mine); + } else if (block.choice === "theirs") { + resultLines.push(block.theirs); + } else if (block.choice === "both") { + resultLines.push(block.mine); + resultLines.push(block.theirs); + } else { + // Unresolved: keep conflict markers intact + resultLines.push(`<<<<<<< ${block.mineLabel || "HEAD"}`); + resultLines.push(block.mine); + if (block.base !== undefined) { + resultLines.push("|||||||"); + resultLines.push(block.base); + } + resultLines.push("======="); + resultLines.push(block.theirs); + resultLines.push(`>>>>>>> ${block.theirsLabel || "Incoming"}`); + } + } + } + + return resultLines.join("\n"); +} diff --git a/src/markdown/mermaidDiff.ts b/src/markdown/mermaidDiff.ts new file mode 100644 index 0000000..193894e --- /dev/null +++ b/src/markdown/mermaidDiff.ts @@ -0,0 +1,323 @@ +/* + * MIT License + * + * Copyright (c) 2026 Rich Markdown Diff Authors + */ + +export interface MermaidDiffOptions { + insertedColor?: string; + deletedColor?: string; +} + +export interface MermaidNode { + id: string; + label?: string; + raw: string; +} + +export interface MermaidEdge { + from: string; + to: string; + label?: string; + raw: string; +} + +/** + * Checks if the given Mermaid code is a Flowchart / Graph diagram. + */ +export function isFlowchartMermaid(code: string): boolean { + const lines = code.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("%%")) { + continue; + } + return /^(?:graph|flowchart)\b/i.test(trimmed); + } + return false; +} + +/** + * Parses edge connections from a Flowchart / Graph Mermaid code block. + */ +export function parseMermaidEdges(code: string): MermaidEdge[] { + const edges: MermaidEdge[] = []; + const lines = code.split(/\r?\n/); + + // Match arrows: + // 1. A -->|label| B + // 2. A -- label --> B or A -- "label" --> B + // 3. A --> B or A --- B or A ==> B or A -.- B + // The optional shape group (?:[(\[{](?:[^)\]}>]|"[^"]*")*[)\]}>])? after the node ID + // allows shaped nodes like A["Start"] --> B to be captured correctly. + const SHAPE_GROUP = `(?:(?:\\(\\(|\\[\\[|[([{>])(?:[^)\\]}>]|"[^"]*")*(?:\\)\\)|\\]\\]|[)\\]}>]))?`; + const edgeRegex = new RegExp( + `\\b([a-zA-Z0-9_-]+)${SHAPE_GROUP}\\s*` + + `(?:(?:---|-->|==>|-\\.-)\\s*(?:\\|([^|]+)\\|)?\\s*` + + `|--\\s*(?:["']([^"']+)["']|([a-zA-Z0-9_\\s]+))\\s*-->\\s*)` + + `([a-zA-Z0-9_-]+)`, + "g" + ); + + for (const line of lines) { + const trimmed = line.trim(); + // [MERMAID-01] Skip directive lines and subgraph/end keywords to avoid false edge matches + if ( + !trimmed || + trimmed.startsWith("%%") || + trimmed.startsWith("style") || + trimmed.startsWith("linkStyle") || + trimmed.startsWith("subgraph") || + trimmed === "end" + ) { + continue; + } + + // Reset lastIndex explicitly at the start of each line. + // The chain-notation rewind (below) adjusts lastIndex mid-line intentionally, + // but it must not bleed into the next line's processing. + edgeRegex.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = edgeRegex.exec(trimmed)) !== null) { + const from = match[1]; + const label = match[2] || match[3] || match[4]; + const to = match[5]; + if (from && to && !["graph", "flowchart", "subgraph", "end"].includes(from)) { + edges.push({ from, to, label: label ? label.trim() : undefined, raw: match[0] }); + // [MERMAID-01] Rewind lastIndex to allow the 'to' node to serve as the 'from' of the + // next edge in chain notation (e.g. A --> B --> C produces both A→B and B→C). + // This is safe because the rewound position is always ahead of match.index. + edgeRegex.lastIndex = match.index + match[0].length - to.length; + } + } + } + + return edges; +} + +/** + * Parses node IDs, labels, and raw node strings from a Flowchart / Graph Mermaid code block. + */ +export function parseMermaidNodes(code: string): Map { + const nodes = new Map(); + const lines = code.split(/\r?\n/); + + const edges = parseMermaidEdges(code); + const edgeLabelWords = new Set(); + for (const edge of edges) { + if (edge.label) { + edge.label.split(/\s+/).forEach((w) => edgeLabelWords.add(w)); + } + } + + for (const line of lines) { + const trimmed = line.trim(); + // [MERMAID-01] Skip directive lines. Crucially, skip `subgraph myGroup[Label]` lines + // to prevent the group name from being mistaken for a node definition. + if ( + !trimmed || + trimmed.startsWith("%%") || + trimmed.startsWith("graph") || + trimmed.startsWith("flowchart") || + trimmed.startsWith("style") || + trimmed.startsWith("linkStyle") || + trimmed.startsWith("classDef") || + trimmed.startsWith("class ") || + trimmed.startsWith("subgraph") || + trimmed === "end" + ) { + continue; + } + + const nodeRegex = /\b([a-zA-Z0-9_-]+)(?:\(\((?:["']([^"']+)["']|([^\)]+))\)\)|\[\[(?:["']([^"']+)["']|([^\]]+))\]\]|\[(?:["']([^"']+)["']|([^\]]+))\]|\((?:["']([^"']+)["']|([^\)]+))\)|\{(?:["']([^"']+)["']|([^\}]+))\}|>([^\\]]+)\])?/g; + + let match: RegExpExecArray | null; + while ((match = nodeRegex.exec(trimmed)) !== null) { + const id = match[1]; + if (["graph", "flowchart", "subgraph", "end", "style", "linkStyle", "classDef", "class", "TD", "LR", "BT", "RL", "TB"].includes(id)) { + continue; + } + + const hasExplicitShape = Boolean(match[2] || match[3] || match[4] || match[5] || match[6] || match[7] || match[8] || match[9] || match[10] || match[11] || match[12]); + + if (!hasExplicitShape && edgeLabelWords.has(id)) { + continue; + } + + const label = match[2] || match[3] || match[4] || match[5] || match[6] || match[7] || match[8] || match[9] || match[10] || match[11] || match[12] || id; + if (!nodes.has(id)) { + nodes.set(id, { id, label, raw: match[0] }); + } + } + } + + return nodes; +} + +/** + * Computes semantic diff between two Mermaid diagrams (flowcharts) and injects dynamic diff styles. + */ +export function computeMermaidDiff( + oldCode: string, + newCode: string, + options: MermaidDiffOptions = {} +): string { + // Base colors — defined early so empty-side paths can reuse them + const insFill = options.insertedColor || "#e6ffec"; + const insStroke = "#22863a"; + const delFill = options.deletedColor || "#ffeef0"; + const delStroke = "#d73a49"; + const modFill = "#fffdef"; + const modStroke = "#b08800"; + + // [MERMAID-02] When one side is empty, style ALL elements on the non-empty side + // (all-inserted green or all-deleted red) rather than returning the code unstyled. + if (!oldCode.trim()) { + if (!isFlowchartMermaid(newCode)) { + return newCode; + } + const newNodes = parseMermaidNodes(newCode); + const newEdges = parseMermaidEdges(newCode); + let result = newCode.trim(); + const styleLines = ["\n%% Dynamic Diff Styles (all inserted)"]; + for (const id of newNodes.keys()) { + styleLines.push(` style ${id} fill:${insFill},stroke:${insStroke},stroke-width:2px;`); + } + newEdges.forEach((_edge, idx) => { + styleLines.push(` linkStyle ${idx} stroke:${insStroke},stroke-width:2px;`); + }); + if (styleLines.length > 1) { result += "\n" + styleLines.join("\n"); } + return result; + } + if (!newCode.trim()) { + if (!isFlowchartMermaid(oldCode)) { + return oldCode; + } + const oldNodes = parseMermaidNodes(oldCode); + const oldEdges = parseMermaidEdges(oldCode); + let result = oldCode.trim(); + const ghostLines = ["\n%% Ghost definitions for deleted elements (all deleted)"]; + const styleLines = ["\n%% Dynamic Diff Styles (all deleted)"]; + for (const [id, node] of oldNodes) { + const labelStr = node.label ? `["${node.label}"]` : ""; + ghostLines.push(` ${id}${labelStr}`); + styleLines.push( + ` style ${id} fill:${delFill},stroke:${delStroke},stroke-width:1px,stroke-dasharray:5 5,opacity:0.75;` + ); + } + oldEdges.forEach((_edge, idx) => { + styleLines.push( + ` linkStyle ${idx} stroke:${delStroke},stroke-width:1px,stroke-dasharray:3 3,opacity:0.75;` + ); + }); + if (ghostLines.length > 1) { result += "\n" + ghostLines.join("\n"); } + if (styleLines.length > 1) { result += "\n" + styleLines.join("\n"); } + return result; + } + + // Guard: Only process flowcharts / graph diagrams + if (!isFlowchartMermaid(newCode) && !isFlowchartMermaid(oldCode)) { + return newCode; + } + + const oldNodes = parseMermaidNodes(oldCode); + const newNodes = parseMermaidNodes(newCode); + + const oldEdges = parseMermaidEdges(oldCode); + const newEdges = parseMermaidEdges(newCode); + + const addedNodeIds: string[] = []; + const removedNodeIds: string[] = []; + const modifiedNodeIds: string[] = []; + + for (const [id, newNode] of newNodes) { + if (!oldNodes.has(id)) { + addedNodeIds.push(id); + } else { + const oldNode = oldNodes.get(id)!; + if (oldNode.label !== newNode.label) { + modifiedNodeIds.push(id); + } + } + } + + for (const id of oldNodes.keys()) { + if (!newNodes.has(id)) { + removedNodeIds.push(id); + } + } + + // (Colors already defined above) + + let resultMermaid = newCode.trim(); + + // 1. Append ghost definitions for deleted nodes & edges + const ghostLines: string[] = []; + if (removedNodeIds.length > 0) { + ghostLines.push("\n%% Ghost definitions for deleted elements"); + for (const id of removedNodeIds) { + const oldNode = oldNodes.get(id)!; + const labelStr = oldNode.label ? `["${oldNode.label}"]` : ""; + ghostLines.push(` ${id}${labelStr}`); + } + } + + // Check deleted edges + const removedEdges: MermaidEdge[] = []; + for (const oldEdge of oldEdges) { + const isPresentInNew = newEdges.some( + (e) => e.from === oldEdge.from && e.to === oldEdge.to + ); + if (!isPresentInNew) { + removedEdges.push(oldEdge); + ghostLines.push(` ${oldEdge.from} -.-> ${oldEdge.to}`); + } + } + + if (ghostLines.length > 0) { + resultMermaid += "\n" + ghostLines.join("\n"); + } + + // 2. Inject Node Styles + const styleLines: string[] = ["\n%% Dynamic Diff Styles"]; + + for (const id of addedNodeIds) { + styleLines.push( + ` style ${id} fill:${insFill},stroke:${insStroke},stroke-width:2px;` + ); + } + + for (const id of removedNodeIds) { + styleLines.push( + ` style ${id} fill:${delFill},stroke:${delStroke},stroke-width:1px,stroke-dasharray:5 5,opacity:0.75;` + ); + } + + for (const id of modifiedNodeIds) { + styleLines.push( + ` style ${id} fill:${modFill},stroke:${modStroke},stroke-width:2px;` + ); + } + + // 3. Inject Link Styles (Edges) + newEdges.forEach((edge, idx) => { + const isAdded = !oldEdges.some((e) => e.from === edge.from && e.to === edge.to); + if (isAdded) { + styleLines.push(` linkStyle ${idx} stroke:${insStroke},stroke-width:2px;`); + } + }); + + removedEdges.forEach((edge, offsetIdx) => { + const totalIdx = newEdges.length + offsetIdx; + styleLines.push( + ` linkStyle ${totalIdx} stroke:${delStroke},stroke-width:1px,stroke-dasharray:3 3,opacity:0.75;` + ); + }); + + if (styleLines.length > 1) { + resultMermaid += "\n" + styleLines.join("\n"); + } + + return resultMermaid; +} diff --git a/src/markdown/structuralDiff.ts b/src/markdown/structuralDiff.ts index c97a07e..13338a4 100644 --- a/src/markdown/structuralDiff.ts +++ b/src/markdown/structuralDiff.ts @@ -25,6 +25,8 @@ import * as crypto from "crypto"; import { diffTables } from "./tableDiff"; import { findClosing } from "./domUtils"; +import { computeMermaidDiff } from "./mermaidDiff"; +import { escapeHtml } from "./sanitizer"; /** * Main entrance for computing granular HTML diffs. @@ -1567,6 +1569,21 @@ export function refineBlockDiffs( attributes, ) ) { + if (/class=["'][^"']*mermaid[^"']*["']/i.test(attributes)) { + const unescapeHtml = (str: string) => + str + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); + + const oldCode = unescapeHtml(delInner.replace(/<[^>]+>/g, "").trim()); + const newCode = unescapeHtml(insInner.replace(/<[^>]+>/g, "").trim()); + const diffMermaid = computeMermaidDiff(oldCode, newCode); + const escapedDiff = escapeHtml(diffMermaid); + return `<${insTag}${attributes}>${escapedDiff}`; + } return match; } diff --git a/src/test/unit/conflictParser.test.ts b/src/test/unit/conflictParser.test.ts new file mode 100644 index 0000000..fec9aba --- /dev/null +++ b/src/test/unit/conflictParser.test.ts @@ -0,0 +1,115 @@ +import * as assert from "assert"; +import { + parseConflictBlocks, + reconstructDocument, +} from "../../markdown/conflictParser"; + +describe("Conflict Parser", () => { + it("should parse normal document without conflict markers as single common block", () => { + const text = "Hello World\nThis is normal text."; + const blocks = parseConflictBlocks(text); + assert.strictEqual(blocks.length, 1); + assert.strictEqual(blocks[0].type, "common"); + if (blocks[0].type === "common") { + assert.strictEqual(blocks[0].text, text); + } + }); + + it("should parse document with single conflict marker into common and conflict blocks", () => { + const text = `Line 1 +<<<<<<< HEAD +Mine content +======= +Theirs content +>>>>>>> feature +Line 2`; + + const blocks = parseConflictBlocks(text); + assert.strictEqual(blocks.length, 3); + assert.strictEqual(blocks[0].type, "common"); + assert.strictEqual(blocks[1].type, "conflict"); + assert.strictEqual(blocks[2].type, "common"); + + if (blocks[1].type === "conflict") { + assert.strictEqual(blocks[1].mine, "Mine content"); + assert.strictEqual(blocks[1].theirs, "Theirs content"); + assert.strictEqual(blocks[1].mineLabel, "HEAD"); + assert.strictEqual(blocks[1].theirsLabel, "feature"); + } + }); + + it("should reconstruct document correctly when choices are made", () => { + const text = `Line 1 +<<<<<<< HEAD +Mine content +======= +Theirs content +>>>>>>> feature +Line 2`; + + const blocks = parseConflictBlocks(text); + if (blocks[1].type === "conflict") { + blocks[1].choice = "mine"; + } + + const reconstructedMine = reconstructDocument(blocks); + assert.strictEqual(reconstructedMine, "Line 1\nMine content\nLine 2"); + + if (blocks[1].type === "conflict") { + blocks[1].choice = "theirs"; + } + const reconstructedTheirs = reconstructDocument(blocks); + assert.strictEqual(reconstructedTheirs, "Line 1\nTheirs content\nLine 2"); + }); + + it("should parse conflict markers inside code blocks", () => { + const text = `\`\`\`typescript +<<<<<<< HEAD +const x = 1; +======= +const x = 2; +>>>>>>> main +\`\`\``; + + const blocks = parseConflictBlocks(text); + assert.strictEqual(blocks.length, 3); + assert.strictEqual(blocks[0].type, "common"); + assert.strictEqual(blocks[1].type, "conflict"); + assert.strictEqual(blocks[2].type, "common"); + if (blocks[1].type === "conflict") { + assert.strictEqual(blocks[1].mine, "const x = 1;"); + assert.strictEqual(blocks[1].theirs, "const x = 2;"); + } + }); + + it("should support diff3 conflict marker format with base section", () => { + const text = `<<<<<<< HEAD +Mine content +||||||| merged common ancestors +Base content +======= +Theirs content +>>>>>>> branch`; + + const blocks = parseConflictBlocks(text); + assert.strictEqual(blocks.length, 1); + assert.strictEqual(blocks[0].type, "conflict"); + if (blocks[0].type === "conflict") { + assert.strictEqual(blocks[0].mine, "Mine content"); + assert.strictEqual(blocks[0].base, "Base content"); + assert.strictEqual(blocks[0].theirs, "Theirs content"); + } + }); + + it("should safely rescue unclosed conflict marker at EOF", () => { + const text = `Start +<<<<<<< HEAD +Unclosed mine content`; + + const blocks = parseConflictBlocks(text); + assert.strictEqual(blocks.length, 2); + assert.strictEqual(blocks[0].type, "common"); + assert.strictEqual(blocks[1].type, "common"); + assert.ok(blocks[1].text.includes("Unclosed mine content")); + }); +}); diff --git a/src/test/unit/mermaidDiff.test.ts b/src/test/unit/mermaidDiff.test.ts new file mode 100644 index 0000000..c2ae1f4 --- /dev/null +++ b/src/test/unit/mermaidDiff.test.ts @@ -0,0 +1,181 @@ +import * as assert from "assert"; +import { + computeMermaidDiff, + parseMermaidNodes, + parseMermaidEdges, +} from "../../markdown/mermaidDiff"; + +describe("Mermaid Semantic Diff", () => { + it("should parse nodes correctly", () => { + const code = ` + graph TD + A["Start"] --> B(Process) + C((End)) + `; + const nodes = parseMermaidNodes(code); + assert.strictEqual(nodes.size, 3); + assert.strictEqual(nodes.get("A")?.label, "Start"); + assert.strictEqual(nodes.get("B")?.label, "Process"); + assert.strictEqual(nodes.get("C")?.label, "End"); + }); + + it("should parse edges correctly", () => { + const code = ` + graph TD + A --> B + B --> C + `; + const edges = parseMermaidEdges(code); + assert.strictEqual(edges.length, 2); + assert.strictEqual(edges[0].from, "A"); + assert.strictEqual(edges[0].to, "B"); + assert.strictEqual(edges[1].from, "B"); + assert.strictEqual(edges[1].to, "C"); + }); + + it("should inject inserted node styles when node is added", () => { + const oldCode = ` + graph TD + A --> B + `; + const newCode = ` + graph TD + A --> B + B --> C["New Node"] + `; + const diff = computeMermaidDiff(oldCode, newCode); + assert.ok(diff.includes("style C fill:#e6ffec"), "Should style node C as inserted"); + assert.ok(diff.includes("linkStyle 1 stroke:#22863a"), "Should style edge 1 as inserted link"); + }); + + it("should inject ghost definition and deleted node style when node is removed", () => { + const oldCode = ` + graph TD + A --> B + B --> C["Old Node"] + `; + const newCode = ` + graph TD + A --> B + `; + const diff = computeMermaidDiff(oldCode, newCode); + assert.ok(diff.includes("C[\"Old Node\"]"), "Should include ghost node definition for C"); + assert.ok(diff.includes("style C fill:#ffeef0"), "Should style node C as deleted"); + assert.ok(diff.includes("linkStyle 1 stroke:#d73a49"), "Should style deleted link"); + }); + + it("should highlight modified node when node label changes", () => { + const oldCode = ` + graph TD + A["Original Step"] --> B + `; + const newCode = ` + graph TD + A["Updated Step"] --> B + `; + const diff = computeMermaidDiff(oldCode, newCode); + assert.ok(diff.includes("style A fill:#fffdef"), "Should style node A as modified"); + }); + + it("should not treat edge labels as node identifiers", () => { + const code = ` + graph TD + A --> B + B -- Yes --> C + B -- No --> D + `; + const nodes = parseMermaidNodes(code); + assert.strictEqual(nodes.size, 4, "Should only detect A, B, C, D as nodes"); + assert.ok(nodes.has("A")); + assert.ok(nodes.has("B")); + assert.ok(nodes.has("C")); + assert.ok(nodes.has("D")); + assert.ok(!nodes.has("Yes"), "Yes should not be treated as a node"); + assert.ok(!nodes.has("No"), "No should not be treated as a node"); + }); + + it("should skip style injection for non-flowchart diagrams (e.g. sequenceDiagram)", () => { + const oldCode = ` + sequenceDiagram + Alice->>Bob: Hello Bob + `; + const newCode = ` + sequenceDiagram + Alice->>Bob: Hello Bob + Bob-->>Alice: Hi Alice + `; + const diff = computeMermaidDiff(oldCode, newCode); + assert.strictEqual(diff.trim(), newCode.trim(), "Should return unmodified new code for sequenceDiagram"); + }); + + // --- MERMAID-01 regression tests --- + + it("[MERMAID-01] should NOT register subgraph group name as a node", () => { + // 'myGroup' appears on the `subgraph myGroup[...]` line and must not be + // treated as a diagram node, even though it has an explicit bracket shape. + const code = ` + graph TD + subgraph myGroup[My Group] + A --> B + end + `; + const nodes = parseMermaidNodes(code); + assert.ok(!nodes.has("myGroup"), "subgraph group ID must not be registered as a node"); + assert.ok(nodes.has("A"), "A should be registered as a node"); + assert.ok(nodes.has("B"), "B should be registered as a node"); + }); + + it("[MERMAID-01] should parse all edges in chain notation (A --> B --> C)", () => { + // A single line `A --> B --> C` encodes two edges: A→B and B→C. + // The edge parser must rewind its index so the shared node B is captured + // as both the 'to' of the first edge and the 'from' of the second. + const code = ` + graph LR + A --> B --> C + `; + const edges = parseMermaidEdges(code); + assert.strictEqual(edges.length, 2, "Chain A-->B-->C should produce 2 edges"); + assert.ok( + edges.some((e) => e.from === "A" && e.to === "B"), + "Edge A→B must be present" + ); + assert.ok( + edges.some((e) => e.from === "B" && e.to === "C"), + "Edge B→C must be present" + ); + }); + + // --- MERMAID-02 regression tests --- + + it("[MERMAID-02] should style all nodes/edges as inserted when oldCode is empty", () => { + // When the Mermaid block is brand new (no previous version), all elements + // should be highlighted green rather than returned unstyled. + const newCode = ` + graph TD + A["Start"] --> B + B --> C["End"] + `; + const diff = computeMermaidDiff("", newCode); + assert.ok(diff.includes("style A fill:#e6ffec"), "Node A should be styled as inserted"); + assert.ok(diff.includes("style B fill:#e6ffec"), "Node B should be styled as inserted"); + assert.ok(diff.includes("style C fill:#e6ffec"), "Node C should be styled as inserted"); + assert.ok(diff.includes("linkStyle 0 stroke:#22863a"), "Edge 0 should be styled as inserted"); + assert.ok(diff.includes("linkStyle 1 stroke:#22863a"), "Edge 1 should be styled as inserted"); + }); + + it("[MERMAID-02] should style all nodes/edges as deleted when newCode is empty", () => { + // When the Mermaid block is entirely removed, all elements from the old + // diagram should be highlighted red with ghost definitions. + const oldCode = ` + graph TD + A["Start"] --> B + B --> C["End"] + `; + const diff = computeMermaidDiff(oldCode, ""); + assert.ok(diff.includes("style A fill:#ffeef0"), "Node A should be styled as deleted"); + assert.ok(diff.includes("style B fill:#ffeef0"), "Node B should be styled as deleted"); + assert.ok(diff.includes("style C fill:#ffeef0"), "Node C should be styled as deleted"); + assert.ok(diff.includes("linkStyle 0 stroke:#d73a49"), "Edge 0 should be styled as deleted"); + assert.ok(diff.includes("linkStyle 1 stroke:#d73a49"), "Edge 1 should be styled as deleted"); + }); +}); diff --git a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png index 3113e1c..2e53fea 100644 Binary files a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png and b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-v2-split-light-chromium-linux.png differ