diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f69d1a..0a26d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,14 @@ All notable changes to **Rich Markdown Diff** will be documented in this file. -## [NEXT RELEASE] +## [1.4.0] - 2026-07-19 -### Added in NEXT RELEASE +### Added in 1.4.0 - **MDX & Custom Components**: Native rendering and high-fidelity diffs for MDX, Docusaurus, and Astro Starlight components. Supports interactive ``, inline `` elements, timeline ``, premium `` layouts, Docusaurus triple-colon admonitions (`:::note` etc.), and graceful visual fallbacks for custom JSX/TSX tags. +- **Interactive Wikilinks**: Click to open local target files directly from `[[Wiki-Style Links]]` in the diff view. -### Fixed in NEXT RELEASE +### Fixed in 1.4.0 - **Quick Edit**: Fixed a bug where the Quick Edit overlay loaded incorrect source text when editing documents that contain frontmatter metadata. - **Obsidian Tags**: Expanded the tag parser to support Japanese, Chinese, and other multi-byte characters. @@ -17,9 +18,10 @@ All notable changes to **Rich Markdown Diff** will be documented in this file. - Fixed horizontal scroll clipping of line highlights on code blocks and tables. - Resolved visual alignment and active highlight issues for embedded code blocks and task list checkboxes. - Fixed potential memory leaks in clipboard comparisons and child process hangs in Git Blame. + - Fixed duplicate containers in GitHub Alerts and incorrect numbering in Starlight ``. + - Added outline and background highlights for active cells in frontmatter diffs. - **Compare Commands**: Fixed a bug where comparing a file with itself opened a blank diff panel. - ## [1.3.1] - 2026-05-24 ### Improved in 1.3.1 diff --git a/fixtures/comprehensive_v1.md b/fixtures/comprehensive_v1.md index 158e2ac..d538288 100644 --- a/fixtures/comprehensive_v1.md +++ b/fixtures/comprehensive_v1.md @@ -4,8 +4,8 @@ This document tests all supported markdown extensions. ## Text Formatting -This is **bold text** and this is _italic text_. -You can also use ~~strikethrough~~ and `inline code`. +This is **Bold text** and this is _Italic text_. +You can also use ~~Strikethrough~~ and `inline code`. Use ==highlighted text== for emphasis. ## Lists @@ -13,13 +13,13 @@ Use ==highlighted text== for emphasis. ### Unordered List - Item 1 -- Item 2 + - Item 3 ### Ordered List 1. First item -2. Second item +2. Second item (older) 3. Third item ### Task List @@ -32,6 +32,7 @@ Use ==highlighted text== for emphasis. ```javascript function greet(name) { console.log(`Hello, ${name}!`); + console.log(`Good night!`); } ``` @@ -40,8 +41,9 @@ function greet(name) { | Feature | Status | | ----------------- | ------ | | Text diff | ✅ | -| Code highlighting | ✅ | +| Code highlighting | ❌ | | Emoji support | ✅ | +| Obsidian support | ✅ | ## Links and Images @@ -58,7 +60,7 @@ function greet(name) { ## Math (KaTeX) -Inline math: $E = mc^2$ +Inline math: $E = mc^2$ and $F = ma$ Block math: @@ -85,7 +87,7 @@ graph TD; # Obsidian -#mermaid +#mermaid #日本語 ![[Page]] @@ -160,13 +162,15 @@ SOFTWARE. Let's test inline badges: and . And here is a Starlight steps component: + 1. Download the tool 2. Configure settings 3. Start running - + Here is a Starlight Card: + Welcome to the premium card view. @@ -177,6 +181,7 @@ This is standard admonition text in v1. ::: And a custom unknown fallback element: + ## Summary diff --git a/fixtures/comprehensive_v2.md b/fixtures/comprehensive_v2.md index c2fff64..09073d1 100644 --- a/fixtures/comprehensive_v2.md +++ b/fixtures/comprehensive_v2.md @@ -191,12 +191,14 @@ Let's test inline badges: and + 1. Download the tool (new version) 2. Configure settings securely 3. Start running the process - + Here is a Starlight Card: + Welcome to the premium card view, now with updated content. @@ -211,6 +213,7 @@ Critical warning! ::: And a custom unknown fallback element: + ## Summary diff --git a/fixtures/expected/comprehensive.html b/fixtures/expected/comprehensive.html index fd681be..40207e5 100644 --- a/fixtures/expected/comprehensive.html +++ b/fixtures/expected/comprehensive.html @@ -1,22 +1,23 @@

Comprehensive Markdown Test Document (Updated)

-

This document tests all supported markdown extensions. -Updated with additional content in version 2.

+

This document tests all supported markdown extensions. +Updated with additional content in version 2.

Text Formatting

-

This is bold text and this is italic text. -You can also use strikethrough and inline code. +

This is Boldbold text and this is Italicitalic text. +You can also use Strikethroughstrikethrough and inline code. Use highlighted text for emphasis. Added new bold text in version 2.

Lists

Unordered List

    -
  • Item 1
  • -
  • Item 2 (modified)
  • -
  • Item 3
  • Item 4 (new)
  • +
  • Item 1

  • +
  • Item 2 (modified)
  • +

  • Item 3

  • +
  • Item 4 (new)

Ordered List

  1. First item
  2. -
  3. Second item (updated)
  4. +
  5. Second item (olderupdated)
  6. Third item
  7. Fourth item (added)

Task List

@@ -26,31 +27,35 @@

Task List

  • Task 3
  • Code Blocks

    function greet(name) {// Updated comment in v2
    -  console.log(`Hello, ${name}! Welcome!`);
    +  console.log(`Hello, ${name}! Welcome!`);
     }
     
    -function farewell(name) {
    -  console.log(`Goodbye, ${name}!`);
    +function farewell(name) {
    +  console.log(`GoodGoodbye, night${name}!`);
     }
    -

    Tables

    +

    Tables

    - - - + + + - - - - - + - - + + + + + + + + + +
    FeatureStatus
    FeatureStatus
    Text diff
    Code highlightingText diff
    Emoji supportCode highlighting
    Emoji support
    Obsidian support

    Links and Images

    @@ -64,7 +69,7 @@

    Blockquotes

    Emoji

    😄 🚀 👍 ⭐ ❤️


    Math (KaTeX)

    -

    Inline math: E=mc2E = mc^2 and F=maF = ma

    +

    Inline math: E=mc2E = mc^2 and F=maF = ma

    Block math:

    ex2dx=π\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}

    New equation added:

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

    Mermaid Diagrams

    -
    Math (KaTeX)

    GitHub Alerts

    Note

    -

    This is a note alert.

    -

    This is a note alert with updated content.

    -
    +

    This is a note alert with updated content.

    Warning

    This is a warning alert.

    -

    Obsidian

    #mermaid

    📄Page

    Tip

    This is a new tip alert added in v2.

    +

    Obsidian

    #mermaid #日本語

    📄Page

    Tip

    This is a new tip alert added in v2.

    Footnotes

    This is a sentence with a footnote[1]. And another sentence with a second footnote[2].

    Wikilinks

    -

    See Related Page for more information. -Also check New Page for recent updates.

    +

    See Related Page for more information. +Also check New Page for recent updates.

    Subscript and Superscript

    Water formula: H2O Sulfuric acid: H2SO4 @@ -161,7 +164,7 @@

    Common Block (Folding Test)

    Image Test

    -
    Icon V1
    +
    Icon V1
    Icon V2
    @@ -181,27 +184,35 @@

    MDX and Custom Components

    ```

    Let’s test inline badges: CautionWarning and DeprecatedRemoved.

    -

    And here is a Starlight steps component: -<Steps />

    -
      -
    1. Download the tool (new version)
    2. -
    3. Configure settings securely
    4. -
    5. Start running the process -
    6. +

      And here is a Starlight steps component:<Steps />

      +
        +
      1. Download the tool (new version)
      2. +
      3. Configure settings securely
      4. +
      5. Start running the process +
      -

      Here is a Starlight Card: -<Card title="Introduction" icon="documentnote" /> -Welcome to the premium card view, now with updated content. -

      -

      And a Docusaurus Admonition: +

      Here is a Starlight Card:

      +
      +
      + + Introduction +
      +

      Welcome to the premium card view, now with updated content.

      +

      And a Docusaurus Admonition: :::note Note Title This is standard admonition text with updates in v1v2. :::

      -
      Warning

      Critical warning!

      -

      And a custom unknown fallback element: -<CustomReactComponent user="alice" role="super-admin" theme="dark" />

      -

      Summary

      -

      This is version 12 of the comprehensive test document with modifications.

      +
      Warning

      Critical warning!

      +

      And a custom unknown fallback element:

      +
      +
      + Custom Component: <CustomReactComponent /> +
      +
      +
      • user: alice
      • role: super-admin
      • theme: dark
      +
      +

      Summary

      +

      This is version 12 of the comprehensive test document with modifications.


        diff --git a/package.json b/package.json index ed9ee5d..daa57b7 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.3.1", + "version": "1.4.0", "icon": "images/icon.png", "author": "phine-apps", "license": "MIT", diff --git a/src/extension.ts b/src/extension.ts index 4cfb93f..5ec0289 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -880,6 +880,28 @@ async function bindDiffPanel( return; } + if (message.page) { + const resolvedUri = await resolveWikilinkUri(message.page, uriToOpen); + if (resolvedUri) { + try { + const document = await vscode.workspace.openTextDocument(resolvedUri); + await vscode.window.showTextDocument(document, { + viewColumn: vscode.ViewColumn.One, + preserveFocus: false, + }); + } catch (error) { + vscode.window.showErrorMessage( + l10n.t("Could not open file: {0}", String(error)), + ); + } + } else { + vscode.window.showWarningMessage( + l10n.t("Wikilink target not found: {0}", message.page), + ); + } + return; + } + try { const document = await vscode.workspace.openTextDocument(uriToOpen); const editor = await vscode.window.showTextDocument(document, { @@ -1197,6 +1219,28 @@ export function activate(context: vscode.ExtensionContext) { } if (editor) { + if (message.page) { + resolveWikilinkUri(message.page, editor.document.uri).then((resolvedUri) => { + if (resolvedUri) { + vscode.workspace.openTextDocument(resolvedUri).then((doc) => { + vscode.window.showTextDocument(doc, { + viewColumn: vscode.ViewColumn.One, + preserveFocus: false, + }); + }, (err) => { + vscode.window.showErrorMessage( + l10n.t("Could not open file: {0}", String(err)), + ); + }); + } else { + vscode.window.showWarningMessage( + l10n.t("Wikilink target not found: {0}", message.page), + ); + } + }); + return; + } + vscode.window .showTextDocument(editor.document, vscode.ViewColumn.One) .then((e) => { @@ -1502,4 +1546,58 @@ class DiffEditorProvider implements vscode.CustomReadonlyEditorProvider { } } +async function resolveWikilinkUri( + page: string, + baseUri: vscode.Uri, +): Promise { + const cleanBase = toFileBackedUri(baseUri); + // 1. Try relative to the base file directory + let targetUri = vscode.Uri.joinPath(cleanBase, "..", page); + if (!path.extname(page)) { + targetUri = targetUri.with({ path: targetUri.path + ".md" }); + } + + try { + await vscode.workspace.fs.stat(targetUri); + return targetUri; + } catch { + // Ignore error, file does not exist at relative path + } + + // 2. Try relative to workspace folders + if (vscode.workspace.workspaceFolders) { + for (const folder of vscode.workspace.workspaceFolders) { + let rootTargetUri = vscode.Uri.joinPath(folder.uri, page); + if (!path.extname(page)) { + rootTargetUri = rootTargetUri.with({ path: rootTargetUri.path + ".md" }); + } + try { + await vscode.workspace.fs.stat(rootTargetUri); + return rootTargetUri; + } catch { + // Ignore error, file does not exist at workspace root + } + } + } + + // 3. Search globally in the workspace for shortest path matching + const basename = path.basename(page); + const ext = path.extname(page) ? "" : ".md"; + const globPattern = `**/${basename}${ext}`; + try { + const files = await vscode.workspace.findFiles( + globPattern, + "**/node_modules/**", + 5, + ); + if (files.length > 0) { + return files[0]; + } + } catch (err) { + console.error("findFiles failed:", err); + } + + return undefined; +} + export function deactivate() { } diff --git a/src/markdown/structuralDiff.ts b/src/markdown/structuralDiff.ts index 8cf8e09..c97a07e 100644 --- a/src/markdown/structuralDiff.ts +++ b/src/markdown/structuralDiff.ts @@ -845,7 +845,11 @@ export function restoreBlockAttributes( let res; const inDel = tagStack.includes("del"); const inIns = tagStack.includes("ins"); - if (inDel) { + + const isOnlyInOld = oldPools[key] && (!newPools[key] || newPools[key].length === 0); + const isOnlyInNew = newPools[key] && (!oldPools[key] || oldPools[key].length === 0); + + if (inDel || isOnlyInOld) { const idx = oldCounters[key] || 0; const pool = oldPools[key] || []; res = pool[idx] || pool[pool.length - 1] || ""; @@ -858,7 +862,7 @@ export function restoreBlockAttributes( newCounters[key] = idx + 1; // Also increment old counter if shared to keep them "aligned" where possible - if (!inIns) { + if (!inIns && !isOnlyInNew) { oldCounters[key] = (oldCounters[key] || 0) + 1; } } @@ -1220,32 +1224,36 @@ export function refineBlockDiffs( resultHtml = resultHtml.replace( alertRegex, (match, delBlock, oldInner, insBlock, newInner) => { - const alertCount = ( - newInner.match(/]*class="markdown-alert/g) || [] - ).length; - if (alertCount > 1) { - return match; - } - - const titleRegex = /

        ([\s\S]*?)<\/p>/; - const oldTitleMatch = oldInner.match(titleRegex); - const newTitleMatch = newInner.match(titleRegex); + const openTagRegex = /^

        ") && + newInner.endsWith("
        ") ) { - const titleHtml = oldTitleMatch[0]; - const oldBody = oldInner.replace(titleHtml, "").trim(); - const newBody = newInner.replace(titleHtml, "").trim(); - const diffBody = execute(oldBody, newBody); - const openTagRegex = /^
        '; - return `${openTag}${titleHtml}\n${diffBody}
        `; + const oldOpen = oldOpenMatch[0]; + const newOpen = newOpenMatch[0]; + const oldContent = oldInner.substring(oldOpen.length, oldInner.length - 6).trim(); + const newContent = newInner.substring(newOpen.length, newInner.length - 6).trim(); + + const titleRegex = /

        ([\s\S]*?)<\/p>/; + const oldTitleMatch = oldContent.match(titleRegex); + const newTitleMatch = newContent.match(titleRegex); + + if ( + oldTitleMatch && + newTitleMatch && + oldTitleMatch[0] === newTitleMatch[0] + ) { + const titleHtml = oldTitleMatch[0]; + const oldBody = oldContent.replace(titleHtml, "").trim(); + const newBody = newContent.replace(titleHtml, "").trim(); + const diffBody = execute(oldBody, newBody); + return `${newOpen}${titleHtml}\n${diffBody}`; + } } return replacer(match, delBlock, oldInner, insBlock, newInner); diff --git a/src/markdown/webviewTemplate.ts b/src/markdown/webviewTemplate.ts index c4b97f7..934b614 100644 --- a/src/markdown/webviewTemplate.ts +++ b/src/markdown/webviewTemplate.ts @@ -1229,6 +1229,26 @@ export function getWebviewContent( background-color: rgba(74, 222, 128, 0.2); color: var(--vscode-editor-foreground); } + .frontmatter-diff td.selected-change.selected-ins { + outline: 2px solid rgba(34, 197, 94, 0.8) !important; + outline-offset: -2px; + background-color: rgba(34, 197, 94, 0.35) !important; + } + .frontmatter-diff td.selected-change.selected-del { + outline: 2px solid rgba(239, 68, 68, 0.8) !important; + outline-offset: -2px; + background-color: rgba(248, 113, 113, 0.3) !important; + } + .frontmatter-diff td.selected-change.selected-mod { + outline: 2px solid rgba(59, 130, 246, 0.75) !important; + outline-offset: -2px; + } + .frontmatter-diff td.selected-change.selected-mod.fm-old { + background-color: rgba(248, 113, 113, 0.35) !important; + } + .frontmatter-diff td.selected-change.selected-mod.fm-new { + background-color: rgba(74, 222, 128, 0.35) !important; + } /* Split View Frontmatter Strategy */ /* Left Pane: Hide New, Show Old */ @@ -1968,12 +1988,14 @@ export function getWebviewContent( padding: 0; margin: 0; position: relative; + counter-reset: steps-counter; } .mdx-steps ol > li { position: relative; padding-left: 36px; margin-bottom: 24px; min-height: 28px; + counter-increment: steps-counter; } .mdx-steps ol > li::before { content: ""; @@ -1988,8 +2010,7 @@ export function getWebviewContent( display: none; } .mdx-steps ol > li::after { - content: counter(list-item); - counter-increment: list-item; + content: counter(steps-counter); position: absolute; left: 0; top: 2px; @@ -3301,7 +3322,16 @@ export function getWebviewContent( containers.forEach((container) => { if (container.querySelector('.mdx-tab-bar')) return; - const contents = Array.from(container.querySelectorAll(':scope > .mdx-tab-content')); + const isInlineMode = document.body.classList.contains('inline-mode'); + const contents = Array.from(container.querySelectorAll('.mdx-tab-content')) + .filter(el => { + if (el.closest('.mdx-tabs-container') !== container) return false; + if (!isInlineMode) { + if (container.closest('#left-pane') && el.closest('ins')) return false; + if (container.closest('#right-pane') && el.closest('del')) return false; + } + return true; + }); if (contents.length === 0) return; const tabBar = document.createElement('div'); @@ -3744,6 +3774,18 @@ export function getWebviewContent( return; } + // Wikilink Click + const wikilinkEl = e.target.closest('.wikilink'); + if (wikilinkEl) { + e.preventDefault(); + const page = wikilinkEl.getAttribute('data-page'); + if (page) { + const side = wikilinkEl.closest('#left-pane') ? 'original' : 'modified'; + vscode.postMessage({ command: 'openSource', side: side, page: page }); + return; + } + } + // Obsidian Tag Click const tagEl = e.target.closest('.obsidian-tag'); if (tagEl) { diff --git a/src/test/unit/alert.test.ts b/src/test/unit/alert.test.ts index 960a249..8b92a39 100644 --- a/src/test/unit/alert.test.ts +++ b/src/test/unit/alert.test.ts @@ -137,4 +137,19 @@ describe("GitHub Alert Tests", () => { // ALSO check if the dot actually EXISTS in the final output for V1 and V2 assert.ok(alertContent.includes("."), "The dot must be preserved"); }); + + it("should correctly render the updated content of an alert without duplicate nested divs and preserve the text", () => { + const oldMd = `\n> [!NOTE]\n> This is a note alert.\n`; + const newMd = `\n> [!NOTE]\n> This is a note alert with updated content.\n`; + + const { html: diffHtml } = provider.computeDiff(oldMd, newMd); + + // Ensure the updated text is present in the final HTML output + const cleanText = diffHtml.replace(/<[^>]+>/g, "").replace(/ /g, " "); + assert.ok(cleanText.includes("This is a note alert with updated content."), "HTML should include the updated text"); + + // Ensure there are no duplicate/nested alert divs + const alertDivCount = (diffHtml.match(/]*class="[^"]*markdown-alert/g) || []).length; + assert.strictEqual(alertDivCount, 1, "Should only have a single alert div container, no nesting"); + }); }); diff --git a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-inline-dark-chromium-linux.png b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-inline-dark-chromium-linux.png index b69142e..05a37a5 100644 Binary files a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-inline-dark-chromium-linux.png and b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-inline-dark-chromium-linux.png differ diff --git a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-split-light-chromium-linux.png b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-split-light-chromium-linux.png index 1b1570f..fa758cd 100644 Binary files a/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-split-light-chromium-linux.png and b/src/test/visual/__screenshots__/vrt.test.js-snapshots/comprehensive-v1-split-light-chromium-linux.png differ 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 fd09c34..3113e1c 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 diff --git a/src/test/visual/vrt.test.ts b/src/test/visual/vrt.test.ts index dd0e27f..fcb4ee9 100644 --- a/src/test/visual/vrt.test.ts +++ b/src/test/visual/vrt.test.ts @@ -52,6 +52,10 @@ test.describe("Visual Regression Tests", () => { for (const c of cases) { test(`Visual Diff: ${c.name} - ${c.suffix}`, async ({ page }) => { + if (c.name === "comprehensive-v1-v2") { + test.setTimeout(180000); + } + const v1Path = path.join(__dirname, "../../../fixtures", `${c.v1}.md`); const v2Path = path.join(__dirname, "../../../fixtures", `${c.v2}.md`); const md1 = fs.readFileSync(v1Path, "utf-8"); diff --git a/src/wikilinksPlugin.ts b/src/wikilinksPlugin.ts index a06270a..2d26cd8 100644 --- a/src/wikilinksPlugin.ts +++ b/src/wikilinksPlugin.ts @@ -64,6 +64,8 @@ function wikilinksPlugin(md: MarkdownIt, options: WikilinkOptions = {}) { if (!silent) { const linkOpen = state.push("link_open", "a", 1); linkOpen.attrSet("href", createHref(pageName, uriSuffix)); + linkOpen.attrSet("class", "wikilink"); + linkOpen.attrSet("data-page", pageName); const text = state.push("text", "", 0); text.content = label;