From a374e1bc7e2861aaace3e0ed9556131e7a821a7c Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Fri, 5 Jun 2026 22:49:51 +0800 Subject: [PATCH 1/7] fix: heading ID generation and @import with # in path --- CHANGELOG.md | 7 ++ .../curly-bracket-attributes.ts | 46 ++++++++++ src/markdown-engine/heading-id-generator.ts | 4 +- src/markdown-engine/transformer.ts | 37 ++++++-- test/hash-in-path.test.ts | 90 +++++++++++++++++++ test/header-id-generator.test.ts | 16 ++++ 6 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 test/hash-in-path.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a24d0e6e3..c828e9eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/releases for the more changelog +## [Unreleased] + +### Bug fixes + +- **Fix heading auto-ID generation for underscore-based italic/bold at string boundaries** — When a heading used underscore-based emphasis at the beginning or end (e.g., `_Toy Story_` or `__Bold Title__`), the generated heading ID would retain the underscores (e.g., `_toy-story_`), which markdown-it would interpret as emphasis markers, splitting the `{#id data-source-line="N"}` attribute block across multiple tokens and leaving it visible in the rendered output. Heading IDs now properly strip underscore emphasis markers at boundaries, restoring correct heading rendering. Fixes [vscode-mpe#2319](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319). +- **Fix `@import` / `![[wikilink]]` file resolution when the file path contains `#`** — When a project directory name contains `#` (e.g., `[#11111111]`), the `@import` and wikilink-based file imports would fail because the `#` in the directory name was incorrectly treated as a heading anchor fragment separator during post-resolution path splitting. The `#fragment` is now extracted from the original import syntax before path resolution, so literal `#` characters in directory paths are preserved. Fixes [vscode-mpe#2317](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2317). + ## [0.9.29] - 2026-06-05 ### Bug fixes diff --git a/src/custom-markdown-it-features/curly-bracket-attributes.ts b/src/custom-markdown-it-features/curly-bracket-attributes.ts index 686c1a9a9..38c3dbde4 100644 --- a/src/custom-markdown-it-features/curly-bracket-attributes.ts +++ b/src/custom-markdown-it-features/curly-bracket-attributes.ts @@ -19,6 +19,7 @@ export default (md: MarkdownIt) => { if (!headingInline.children || headingInline.children.length === 0) { continue; } + let matched = false; const lastChild = headingInline.children[headingInline.children.length - 1]; if (lastChild.type === 'text') { @@ -29,6 +30,51 @@ export default (md: MarkdownIt) => { for (const key in attributes) { tokens[i].attrJoin(key, attributes[key]); } + matched = true; + } + } + + // Defense-in-depth: if the {#...} block was split across tokens (e.g., + // underscores inside the block were interpreted as emphasis markers by + // markdown-it), join all text children and find the pattern that way. + if (!matched && headingInline.children) { + let combinedText = ''; + for (let j = 0; j < headingInline.children.length; j++) { + if (headingInline.children[j].type === 'text') { + combinedText += headingInline.children[j].content; + } + } + const combinedMatch = combinedText.match(/\{([^}]+)\}\s*$/); + if (combinedMatch) { + const patternStart = combinedText.length - combinedMatch[0].length; + let offset = 0; + let foundIdx = -1; + let posInToken = -1; + for (let j = 0; j < headingInline.children.length; j++) { + const child = headingInline.children[j]; + if (child.type === 'text') { + if (patternStart >= offset && patternStart < offset + child.content.length) { + foundIdx = j; + posInToken = patternStart - offset; + break; + } + offset += child.content.length; + } + } + if (foundIdx >= 0) { + headingInline.children[foundIdx].content = + headingInline.children[foundIdx].content + .slice(0, posInToken) + .trimEnd(); + headingInline.children.length = foundIdx + 1; + headingInline.children = headingInline.children.filter( + (c) => c.type !== 'text' || c.content.length > 0, + ); + const attributes = parseBlockAttributes(combinedMatch[1]); + for (const key in attributes) { + tokens[i].attrJoin(key, attributes[key]); + } + } } } } diff --git a/src/markdown-engine/heading-id-generator.ts b/src/markdown-engine/heading-id-generator.ts index c297450eb..0d363bb05 100644 --- a/src/markdown-engine/heading-id-generator.ts +++ b/src/markdown-engine/heading-id-generator.ts @@ -22,8 +22,8 @@ export default class HeadingIdGenerator { .replace(/~|。/g, '') // sanitize .replace(/``(.+?)``\s?/g, replacement) .replace(/`(.*?)`\s?/g, replacement) - .replace(/\s__([^_]+?)__\s/g, `-$1-`) - .replace(/\s_([^_]+?)_\s/g, `-$1-`); + .replace(/(^|\s)__([^_]+?)__(\s|$)/g, `$1$2$3`) + .replace(/(^|\s)_([^_]+?)_(\s|$)/g, `$1$2$3`); let slug = uslug(heading.replace(/\s/g, '~')).replace(/~/g, '-'); if (this.table[slug] >= 0) { this.table[slug] = this.table[slug] + 1; diff --git a/src/markdown-engine/transformer.ts b/src/markdown-engine/transformer.ts index 1dd1dfa70..07941875a 100644 --- a/src/markdown-engine/transformer.ts +++ b/src/markdown-engine/transformer.ts @@ -732,17 +732,44 @@ export async function transformMarkdown( ); if (importMatch || imageImportMatch || wikilinkImportMatch) { let filePath = ''; + + // Extract #fragment from the original import path before + // decodeURIComponent and path resolution. If we extract it + // later from the resolved absolute path, a literal `#` in a + // directory name (e.g. `/notes/[#111]/file.md`) would be + // incorrectly treated as a heading anchor fragment, breaking + // file resolution. + let fileHash = ''; if (importMatch) { outputString += importMatch[1]; filePath = importMatch[3].trim(); + const hashIdx = filePath.lastIndexOf('#'); + if (hashIdx > 0) { + fileHash = filePath.substring(hashIdx); + filePath = filePath.substring(0, hashIdx); + } } else if (imageImportMatch) { outputString += imageImportMatch[1]; filePath = imageImportMatch[3].trim().replace(/\s"[^"]*"\s*$/, ''); + const hashIdx = filePath.lastIndexOf('#'); + if (hashIdx > 0) { + fileHash = filePath.substring(hashIdx); + filePath = filePath.substring(0, hashIdx); + } } else if (wikilinkImportMatch) { outputString += wikilinkImportMatch[1]; - const { link } = notebook.processWikilink(wikilinkImportMatch[2]); - filePath = link; + const result = notebook.processWikilink(wikilinkImportMatch[2]); + // processWikilink re-appends hash and blockRef to its `link` + // return value. Strip them so path resolution only sees the + // clean file portion — otherwise a literal `#` might end up + // in the resolved path and confuse file loading. + fileHash = (result.hash || '') + (result.blockRef || ''); + const suffix = fileHash; + filePath = suffix + ? result.link.slice(0, result.link.length - suffix.length) + : result.link; } + // URL-decode so `my%20origin.md` resolves to `my origin.md` — // standard for markdown links and Obsidian's URI form for // wikilinks / @import targets with spaces in the path. @@ -806,12 +833,6 @@ export async function transformMarkdown( ); absoluteFilePath = path.resolve(projectDirectoryPath, resolved); } - let fileHash = ''; - const hashIndex = absoluteFilePath.lastIndexOf('#'); - if (hashIndex > 0) { - fileHash = absoluteFilePath.substring(hashIndex); - absoluteFilePath = absoluteFilePath.substring(0, hashIndex); - } const extname = path.extname(absoluteFilePath).toLocaleLowerCase(); let output = ''; diff --git a/test/hash-in-path.test.ts b/test/hash-in-path.test.ts new file mode 100644 index 000000000..ae871be69 --- /dev/null +++ b/test/hash-in-path.test.ts @@ -0,0 +1,90 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { Notebook } from '../src/notebook/index'; + +describe('@import when directory path contains #', () => { + let tmp: string; + let nb: Notebook; + let engine: ReturnType; + + beforeAll(async () => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hash-in-path-')); + const dirWithHash = path.join(tmp, '[#11111111]'); + fs.mkdirSync(dirWithHash); + fs.writeFileSync( + path.join(dirWithHash, '1.md'), + '@import "test.csv"\n', + ); + fs.writeFileSync( + path.join(dirWithHash, 'test.csv'), + 'Name,Year,House\nAlice,2020,Red\nBob,2021,Blue\n', + ); + nb = await Notebook.init({ + notebookPath: tmp, + config: { markdownParser: 'markdown-it' }, + }); + engine = nb.getNoteMarkdownEngine(path.join(dirWithHash, '1.md')); + }); + + afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + test('@import resolves file when directory contains #', async () => { + const { html } = await engine.parseMD( + fs.readFileSync(path.join(tmp, '[#11111111]', '1.md'), 'utf-8'), + { + useRelativeFilePath: false, + isForPreview: true, + hideFrontMatter: false, + fileDirectoryPath: path.join(tmp, '[#11111111]'), + }, + ); + expect(html).toContain('Alice'); + expect(html).toContain('Bob'); + expect(html).toContain('Blue'); + }); + + test('@import resolves file with #fragment when directory contains #', async () => { + const mdContent = + '@import "test.md#section"\n'; + fs.writeFileSync( + path.join(tmp, '[#11111111]', '2.md'), + mdContent, + ); + fs.writeFileSync( + path.join(tmp, '[#11111111]', 'test.md'), + '## Section\n\ntest content.\n\n## Other\n\nshould not appear.\n', + ); + const engine2 = nb.getNoteMarkdownEngine( + path.join(tmp, '[#11111111]', '2.md'), + ); + const { html } = await engine2.parseMD(mdContent, { + useRelativeFilePath: false, + isForPreview: true, + hideFrontMatter: false, + fileDirectoryPath: path.join(tmp, '[#11111111]'), + }); + expect(html).toContain('test content'); + expect(html).not.toContain('should not appear'); + }); + + test('![[wikilink]] resolves file when directory contains #', async () => { + const mdContent = '![[test.md]]\n'; + fs.writeFileSync( + path.join(tmp, '[#11111111]', '3.md'), + mdContent, + ); + const engine3 = nb.getNoteMarkdownEngine( + path.join(tmp, '[#11111111]', '3.md'), + ); + const { html } = await engine3.parseMD(mdContent, { + useRelativeFilePath: false, + isForPreview: true, + hideFrontMatter: false, + fileDirectoryPath: path.join(tmp, '[#11111111]'), + }); + expect(html).toContain('test content'); + }); +}); diff --git a/test/header-id-generator.test.ts b/test/header-id-generator.test.ts index 0a8a86afb..e5aea3efa 100644 --- a/test/header-id-generator.test.ts +++ b/test/header-id-generator.test.ts @@ -92,6 +92,22 @@ const testCasesForHeaderIdGenerator: { input: 'test __test__ test', expected: 'test-test-test-1', }, + { + input: '_Toy Story_', + expected: 'toy-story', + }, + { + input: '__Bold Title__', + expected: 'bold-title', + }, + { + input: "Pixar's _Toy Story_", + expected: 'pixars-toy-story', + }, + { + input: '_Toy Story_ (1995)', + expected: 'toy-story-1995', + }, ]; describe('header-id-generator', () => { From 210ecb9a39fb7308785b6f81a11323525839e20d Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Sat, 6 Jun 2026 13:49:43 +0800 Subject: [PATCH 2/7] fix: GitHub-parity heading IDs, escaped {#id} blocks, bare ^block-id embeds - Strip underscore emphasis in heading IDs per CommonMark rules (punctuation boundaries, adjacent runs, em+strong) so generated anchors match GitHub's; intraword underscores are kept. - Backslash-escape `_`/`*` in the internal `{#id ...}` attribute block (markdown-it / markdown_yo) so IDs containing emphasis markers survive inline parsing intact and rendered heading IDs always match TOC anchors. Pandoc is excluded as it parses attributes natively. - Fix line-level `![[note^block-id]]` embeds: prepend the `#` that downstream block-transclusion handling expects. - Dedupe @import/image hash extraction; minor cleanups. - Tests: GitHub-parity ID cases, end-to-end heading-emphasis rendering (no attr-block leak, TOC/anchor consistency, markdown_yo), and a line-level bare block-ref embed test. Refs: - https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319 - https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2317 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- .../curly-bracket-attributes.ts | 10 +- src/markdown-engine/heading-id-generator.ts | 23 +++- src/markdown-engine/transformer.ts | 49 +++++--- test/header-id-generator.test.ts | 35 ++++++ test/heading-emphasis.test.ts | 115 ++++++++++++++++++ test/wikilink-embed.test.ts | 19 +++ 7 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 test/heading-emphasis.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c828e9eb7..325ff4726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/relea ### Bug fixes -- **Fix heading auto-ID generation for underscore-based italic/bold at string boundaries** — When a heading used underscore-based emphasis at the beginning or end (e.g., `_Toy Story_` or `__Bold Title__`), the generated heading ID would retain the underscores (e.g., `_toy-story_`), which markdown-it would interpret as emphasis markers, splitting the `{#id data-source-line="N"}` attribute block across multiple tokens and leaving it visible in the rendered output. Heading IDs now properly strip underscore emphasis markers at boundaries, restoring correct heading rendering. Fixes [vscode-mpe#2319](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319). -- **Fix `@import` / `![[wikilink]]` file resolution when the file path contains `#`** — When a project directory name contains `#` (e.g., `[#11111111]`), the `@import` and wikilink-based file imports would fail because the `#` in the directory name was incorrectly treated as a heading anchor fragment separator during post-resolution path splitting. The `#fragment` is now extracted from the original import syntax before path resolution, so literal `#` characters in directory paths are preserved. Fixes [vscode-mpe#2317](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2317). +- **Fix heading auto-ID generation for underscore-based italic/bold** — When a heading used underscore-based emphasis at the beginning or end (e.g., `_Toy Story_` or `__Bold Title__`), the generated heading ID would retain the underscores (e.g., `_toy-story_`), which markdown-it would interpret as emphasis markers, splitting the `{#id data-source-line="N"}` attribute block across multiple tokens and leaving it visible in the rendered output. Heading IDs now strip underscore emphasis markers following CommonMark rules — boundaries include punctuation (`# x _foo bar_! end` → `x-foo-bar-end`), adjacent runs both match (`_a_ _b_` → `a-b`), and intraword underscores are kept (`foo_bar_` → `foo_bar_`) — matching the anchors GitHub generates for the same headings. Additionally, ids embedded in the internal `{#id}` attribute block are now backslash-escaped so that any id still containing `_`/`*` survives inline parsing intact and rendered heading ids always match TOC anchors. Fixes [vscode-mpe#2319](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319). +- **Fix `@import` / `![[wikilink]]` file resolution when the file path contains `#`** — When a project directory name contains `#` (e.g., `[#11111111]`), the `@import` and wikilink-based file imports would fail because the `#` in the directory name was incorrectly treated as a heading anchor fragment separator during post-resolution path splitting. The `#fragment` is now extracted from the original import syntax before path resolution, so literal `#` characters in directory paths are preserved (and `%23` can be used to write a literal `#` in import paths). Also fixed line-level `![[note^block-id]]` embeds (bare block reference without `#`), which previously failed to resolve the target block. Fixes [vscode-mpe#2317](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2317). ## [0.9.29] - 2026-06-05 diff --git a/src/custom-markdown-it-features/curly-bracket-attributes.ts b/src/custom-markdown-it-features/curly-bracket-attributes.ts index 38c3dbde4..fd0c66662 100644 --- a/src/custom-markdown-it-features/curly-bracket-attributes.ts +++ b/src/custom-markdown-it-features/curly-bracket-attributes.ts @@ -37,7 +37,7 @@ export default (md: MarkdownIt) => { // Defense-in-depth: if the {#...} block was split across tokens (e.g., // underscores inside the block were interpreted as emphasis markers by // markdown-it), join all text children and find the pattern that way. - if (!matched && headingInline.children) { + if (!matched) { let combinedText = ''; for (let j = 0; j < headingInline.children.length; j++) { if (headingInline.children[j].type === 'text') { @@ -46,14 +46,18 @@ export default (md: MarkdownIt) => { } const combinedMatch = combinedText.match(/\{([^}]+)\}\s*$/); if (combinedMatch) { - const patternStart = combinedText.length - combinedMatch[0].length; + const patternStart = + combinedText.length - combinedMatch[0].length; let offset = 0; let foundIdx = -1; let posInToken = -1; for (let j = 0; j < headingInline.children.length; j++) { const child = headingInline.children[j]; if (child.type === 'text') { - if (patternStart >= offset && patternStart < offset + child.content.length) { + if ( + patternStart >= offset && + patternStart < offset + child.content.length + ) { foundIdx = j; posInToken = patternStart - offset; break; diff --git a/src/markdown-engine/heading-id-generator.ts b/src/markdown-engine/heading-id-generator.ts index 0d363bb05..254b3c81b 100644 --- a/src/markdown-engine/heading-id-generator.ts +++ b/src/markdown-engine/heading-id-generator.ts @@ -22,8 +22,27 @@ export default class HeadingIdGenerator { .replace(/~|。/g, '') // sanitize .replace(/``(.+?)``\s?/g, replacement) .replace(/`(.*?)`\s?/g, replacement) - .replace(/(^|\s)__([^_]+?)__(\s|$)/g, `$1$2$3`) - .replace(/(^|\s)_([^_]+?)_(\s|$)/g, `$1$2$3`); + // Strip underscore emphasis markers the way markdown renders them, + // so the generated id matches GitHub's anchors (which are derived + // from the *rendered* heading text). Per CommonMark, `_` emphasis + // opens after start-of-line/whitespace/punctuation and closes + // before end-of-line/whitespace/punctuation (no intraword `_` + // emphasis, so `foo_bar_` keeps its underscores). The boundary + // classes exclude `_` itself so that an intraword `__` run is not + // half-consumed as a boundary, and the trailing boundary is a + // lookahead so adjacent runs like `_a_ _b_` both match. + .replace( + /(^|\s|(?!_)[\p{P}\p{S}])___([^\s_](?:[^_]*[^\s_])?)___(?=$|\s|(?!_)[\p{P}\p{S}])/gu, + `$1$2`, + ) + .replace( + /(^|\s|(?!_)[\p{P}\p{S}])__([^\s_](?:[^_]*[^\s_])?)__(?=$|\s|(?!_)[\p{P}\p{S}])/gu, + `$1$2`, + ) + .replace( + /(^|\s|(?!_)[\p{P}\p{S}])_([^\s_](?:[^_]*[^\s_])?)_(?=$|\s|(?!_)[\p{P}\p{S}])/gu, + `$1$2`, + ); let slug = uslug(heading.replace(/\s/g, '~')).replace(/~/g, '-'); if (this.table[slug] >= 0) { this.table[slug] = this.table[slug] + 1; diff --git a/src/markdown-engine/transformer.ts b/src/markdown-engine/transformer.ts index 07941875a..6f20ebfd8 100644 --- a/src/markdown-engine/transformer.ts +++ b/src/markdown-engine/transformer.ts @@ -635,7 +635,20 @@ export async function transformMarkdown( // Add attributes let optionsStr = '{'; if (id) { - optionsStr += `#${id} `; + // Backslash-escape emphasis markers so an id containing `_` + // or `*` (e.g. from a heading like `# x _foo_bar_ y`, where + // the underscores are kept because they are not stripped as + // emphasis) survives inline parsing intact. Without this, + // markdown-it would tokenize `{#x-_foo_bar_-y ...}` into + // emphasis tokens, splitting the attribute block and leaving + // it visible in the output. The escapes are unescaped during + // inline parsing, so the applied id matches the one recorded + // in `headings` (used by TOC links). Pandoc parses the + // `{#id}` attribute syntax natively, so no escaping there. + const escapedId = usePandocParser + ? id + : id.replace(/([_*])/g, '\\$1'); + optionsStr += `#${escapedId} `; } if (classes) { optionsStr += '.' + classes.replace(/\s+/g, ' .') + ' '; @@ -743,31 +756,33 @@ export async function transformMarkdown( if (importMatch) { outputString += importMatch[1]; filePath = importMatch[3].trim(); - const hashIdx = filePath.lastIndexOf('#'); - if (hashIdx > 0) { - fileHash = filePath.substring(hashIdx); - filePath = filePath.substring(0, hashIdx); - } } else if (imageImportMatch) { outputString += imageImportMatch[1]; filePath = imageImportMatch[3].trim().replace(/\s"[^"]*"\s*$/, ''); - const hashIdx = filePath.lastIndexOf('#'); - if (hashIdx > 0) { - fileHash = filePath.substring(hashIdx); - filePath = filePath.substring(0, hashIdx); - } } else if (wikilinkImportMatch) { outputString += wikilinkImportMatch[1]; const result = notebook.processWikilink(wikilinkImportMatch[2]); - // processWikilink re-appends hash and blockRef to its `link` - // return value. Strip them so path resolution only sees the - // clean file portion — otherwise a literal `#` might end up - // in the resolved path and confuse file loading. - fileHash = (result.hash || '') + (result.blockRef || ''); - const suffix = fileHash; + // processWikilink re-appends `hash` and `blockRef` (in that + // order) to its `link` return value. Strip them so path + // resolution only sees the clean file portion — otherwise a + // literal `#` might end up in the resolved path and confuse + // file loading. + const suffix = (result.hash || '') + (result.blockRef || ''); filePath = suffix ? result.link.slice(0, result.link.length - suffix.length) : result.link; + // Downstream fragment handling expects a leading `#` + // (`fileHash.slice(1)` must yield `^block-id` for block + // transclusion), so for the bare `[[note^block]]` form + // (blockRef without `#`) prepend it. + fileHash = !suffix || suffix.startsWith('#') ? suffix : '#' + suffix; + } + if (importMatch || imageImportMatch) { + const hashIdx = filePath.lastIndexOf('#'); + if (hashIdx > 0) { + fileHash = filePath.substring(hashIdx); + filePath = filePath.substring(0, hashIdx); + } } // URL-decode so `my%20origin.md` resolves to `my origin.md` — diff --git a/test/header-id-generator.test.ts b/test/header-id-generator.test.ts index e5aea3efa..fdea6cb3d 100644 --- a/test/header-id-generator.test.ts +++ b/test/header-id-generator.test.ts @@ -108,6 +108,41 @@ const testCasesForHeaderIdGenerator: { input: '_Toy Story_ (1995)', expected: 'toy-story-1995', }, + // The cases below mirror GitHub's heading anchors, which are derived + // from the *rendered* heading text (emphasis markers stripped). + { + // Adjacent emphasis runs sharing one space. + input: '_a_ _b_', + expected: 'a-b', + }, + { + // Punctuation can close emphasis (`_foo bar_!` renders as ). + input: 'x _foo bar_! end', + expected: 'x-foo-bar-end', + }, + { + // Punctuation can open emphasis too. + input: '(_foo_)', + expected: 'foo', + }, + { + // em + strong. + input: '___x___', + expected: 'x', + }, + { + input: '__bold__ and _italic_', + expected: 'bold-and-italic', + }, + { + // Intraword underscores are NOT emphasis — kept, as on GitHub. + input: 'foo_bar_', + expected: 'foo_bar_', + }, + { + input: '_foo_bar', + expected: '_foo_bar', + }, ]; describe('header-id-generator', () => { diff --git a/test/heading-emphasis.test.ts b/test/heading-emphasis.test.ts new file mode 100644 index 000000000..9c5aa95d9 --- /dev/null +++ b/test/heading-emphasis.test.ts @@ -0,0 +1,115 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Notebook } from '../src/notebook/index'; + +// Integration tests for +// https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319 — +// headings using underscore-based emphasis must render without leaking the +// internal `{#id data-source-line="N"}` attribute block, and the rendered +// heading id must match the id recorded for TOC links. + +describe('headings with underscore emphasis', () => { + let tmp: string; + let notebook: Notebook; + + const parse = async (markdown: string) => { + const filePath = path.join(tmp, 'note.md'); + fs.writeFileSync(filePath, markdown); + const engine = notebook.getNoteMarkdownEngine(filePath); + return engine.parseMD(markdown, { + useRelativeFilePath: false, + isForPreview: true, + hideFrontMatter: false, + }); + }; + + beforeAll(async () => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'heading-emphasis-')); + notebook = await Notebook.init({ + notebookPath: tmp, + config: { markdownParser: 'markdown-it' }, + }); + }); + + afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('renders emphasis-only heading without leaking the attribute block', async () => { + const { html } = await parse('# _Toy Story_\n'); + expect(html).toContain('Toy Story'); + expect(html).toMatch(/]*id="toy-story"/); + // No `{#...}` text may leak into the visible output. + expect(html.replace(/<[^>]*>/g, '')).not.toContain('{#'); + }); + + it('renders bold-only heading without leaking the attribute block', async () => { + const { html } = await parse('## __Bold Title__\n'); + expect(html).toContain('Bold Title'); + expect(html).toMatch(/]*id="bold-title"/); + expect(html.replace(/<[^>]*>/g, '')).not.toContain('{#'); + }); + + it('generates GitHub-style ids for emphasis closed by punctuation', async () => { + const { html } = await parse('# x _foo bar_! end\n'); + expect(html).toMatch(/]*id="x-foo-bar-end"/); + expect(html.replace(/<[^>]*>/g, '')).not.toContain('{#'); + }); + + it('keeps rendered heading ids consistent with TOC anchors', async () => { + // `x _foo_bar_ y` is pathological: markdown-it renders + // `foo_bar` but the id generator keeps the underscores + // (`x-_foo_bar_-y`). The id is escaped when embedded as a + // `{#id}` attribute block, so the rendered id must still match + // the id used for TOC links — and nothing may leak. + const markdown = [ + '# _Toy Story_', + '', + '## x _foo bar_! end', + '', + '### x _foo_bar_ y', + '', + 'content', + '', + ].join('\n'); + const { html, tocHTML } = await parse(markdown); + + expect(html.replace(/<[^>]*>/g, '')).not.toContain('{#'); + + const renderedIds = [...html.matchAll(/]*\sid="([^"]*)"/g)].map( + (m) => m[1], + ); + const tocAnchors = [...tocHTML.matchAll(/href="#([^"]*)"/g)].map( + (m) => m[1], + ); + expect(renderedIds.length).toBe(3); + expect(tocAnchors).toEqual(renderedIds); + }); + + it('respects user-provided heading ids containing underscores', async () => { + const { html } = await parse('# Title {#my_custom_id}\n'); + expect(html).toMatch(/]*id="my_custom_id"/); + expect(html.replace(/<[^>]*>/g, '')).not.toContain('{#'); + }); + + it('renders emphasis heading correctly with markdown_yo parser too', async () => { + const notebookYo = await Notebook.init({ + notebookPath: tmp, + config: { markdownParser: 'markdown_yo' }, + }); + const markdown = '# _Toy Story_\n\n## x _foo_bar_ y\n'; + const filePath = path.join(tmp, 'note-yo.md'); + fs.writeFileSync(filePath, markdown); + const engine = notebookYo.getNoteMarkdownEngine(filePath); + const { html } = await engine.parseMD(markdown, { + useRelativeFilePath: false, + isForPreview: true, + hideFrontMatter: false, + }); + expect(html).toMatch(/]*id="toy-story"/); + // The escaped id must round-trip: no backslashes or `{#` may leak. + expect(html.replace(/<[^>]*>/g, '')).not.toContain('{#'); + expect(html).not.toContain('\\_'); + }); +}); diff --git a/test/wikilink-embed.test.ts b/test/wikilink-embed.test.ts index c35ce4ba0..f179b0279 100644 --- a/test/wikilink-embed.test.ts +++ b/test/wikilink-embed.test.ts @@ -207,6 +207,25 @@ describe('Wikilink embed integration', () => { expect(html).toContain('First list item'); }); + it('renders block-level ![[note^block-id]] (alone on a line) extracting just the referenced block', async () => { + // A `![[...]]` embed alone on a line goes through the transformer's + // line-level import path (not the inline wikilink feature), which + // must also handle the bare `^block-id` form without a `#`. + const markdown = '![[block-ref-note^first-paragraph]]\n'; + const engine = notebook.getNoteMarkdownEngine( + path.resolve(__dirname, './markdown/test-files/test-block-embed-line.md'), + ); + const { html } = await engine.parseMD(markdown, { + useRelativeFilePath: false, + isForPreview: true, + hideFrontMatter: false, + }); + + expect(html).toContain('A paragraph with some content'); + expect(html).not.toContain('Second list item'); + expect(html).not.toContain('Another paragraph here'); + }); + it('renders ![[note#^block-id]] embed extracting just the referenced block', async () => { const markdown = 'Before ![[block-ref-note#^first-paragraph]] after.'; const engine = notebook.getNoteMarkdownEngine( From a1815a1986a939f8a174b2ec4c45966b28c71e66 Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Sat, 6 Jun 2026 14:31:34 +0800 Subject: [PATCH 3/7] fix: sandbox config.js/parser.js in QuickJS WASM to close RCE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.crossnote/config.js` and `.crossnote/parser.js` were evaluated with `vm.runInNewContext()` (desktop) and `sval` (web), neither of which is a security boundary: both share the host realm's object prototypes, so untrusted workspace code could climb `({}).constructor.constructor` to the host Function constructor and reach `process` / `child_process`, achieving RCE just by opening a markdown file in a malicious repo (GHSA-427h-jhpr-8jch). - Evaluate both files inside a QuickJS engine compiled to WebAssembly (src/lib/js-sandbox.ts). The guest has its own realm/intrinsics and heap in WASM memory; the host process/Function do not exist there, so the prototype-chain escape has nothing to reach. Only plain data (config.js) and strings (parser.js hooks) cross the boundary. A memory limit and an execution-time deadline guard against DoS. Same sandbox runs in Node and the browser/VS Code web extension (singlefile variant, WASM embedded as base64). - Strip security-sensitive keys (enableScriptExecution, chromePath, pandocPath, imageMagickPath, markdownYoBinaryPath) from an untrusted config.js result so it cannot grant itself trust or point an executable path at an arbitrary binary — these are only honoured from trusted editor settings. - Remove the vm/sval-based interpretJS and the obsolete sanitizeParserConfig; swap the `sval` dependency for quickjs-emscripten-core + singlefile variant. - Add regression tests: RCE blocked at eval and in hooks, no host globals, DoS timeout, sensitive-key stripping, and that normal hooks/config still work. Thanks to @ritikchaddha for reporting the issue. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + package.json | 3 +- pnpm-lock.yaml | 42 +++-- src/lib/js-sandbox.ts | 237 +++++++++++++++++++++++++ src/notebook/config-helper.ts | 103 ++++++----- src/utility.ts | 21 --- test/parser-config-security.test.ts | 264 +++++++++++++++++++--------- 7 files changed, 494 insertions(+), 180 deletions(-) create mode 100644 src/lib/js-sandbox.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 325ff4726..90c061c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/relea ## [Unreleased] +### Security + +- **Fix a remote code execution vulnerability in `.crossnote/config.js` and `.crossnote/parser.js` evaluation**. These workspace files are now evaluated inside a [QuickJS](https://github.com/justjake/quickjs-emscripten) WebAssembly sandbox so untrusted code from a repository can no longer reach the host environment. Thanks to @ritikchaddha for reporting the issue. + ### Bug fixes - **Fix heading auto-ID generation for underscore-based italic/bold** — When a heading used underscore-based emphasis at the beginning or end (e.g., `_Toy Story_` or `__Bold Title__`), the generated heading ID would retain the underscores (e.g., `_toy-story_`), which markdown-it would interpret as emphasis markers, splitting the `{#id data-source-line="N"}` attribute block across multiple tokens and leaving it visible in the rendered output. Heading IDs now strip underscore emphasis markers following CommonMark rules — boundaries include punctuation (`# x _foo bar_! end` → `x-foo-bar-end`), adjacent runs both match (`_a_ _b_` → `a-b`), and intraword underscores are kept (`foo_bar_` → `foo_bar_`) — matching the anchors GitHub generates for the same headings. Additionally, ids embedded in the internal `{#id}` attribute block are now backslash-escaped so that any id still containing `_`/`*` survives inline parsing intact and rendered heading ids always match TOC anchors. Fixes [vscode-mpe#2319](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319). diff --git a/package.json b/package.json index 8aec7421f..3d8385201 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "dependencies": { "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.0.18", + "@jitl/quickjs-singlefile-cjs-release-sync": "^0.32.0", "@mdi/js": "^7.2.96", "@mdi/react": "^1.6.1", "@monaco-editor/react": "^4.5.2", @@ -130,6 +131,7 @@ "plantuml-encoder": "^1.4.0", "puppeteer-core": "^24.16.2", "qiniu": "^7.9.0", + "quickjs-emscripten-core": "^0.32.0", "react": "^18.2.0", "react-contexify": "^6.0.0", "react-dom": "^18.2.0", @@ -137,7 +139,6 @@ "sharp": "^0.33.5", "simple-icons": "^9.13.0", "slash": "^5.1.0", - "sval": "^0.6.9", "twemoji": "^13.1.0", "type-fest": "^4.3.1", "unstated-next": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7cbf5975..e362107b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@heroicons/react': specifier: ^2.0.18 version: 2.2.0(react@18.3.1) + '@jitl/quickjs-singlefile-cjs-release-sync': + specifier: ^0.32.0 + version: 0.32.0 '@mdi/js': specifier: ^7.2.96 version: 7.4.47 @@ -200,6 +203,9 @@ importers: qiniu: specifier: ^7.9.0 version: 7.14.0 + quickjs-emscripten-core: + specifier: ^0.32.0 + version: 0.32.0 react: specifier: ^18.2.0 version: 18.3.1 @@ -221,9 +227,6 @@ importers: slash: specifier: ^5.1.0 version: 5.1.0 - sval: - specifier: ^0.6.9 - version: 0.6.9 twemoji: specifier: ^13.1.0 version: 13.1.1 @@ -1609,6 +1612,12 @@ packages: resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-singlefile-cjs-release-sync@0.32.0': + resolution: {integrity: sha512-NjUUcw26PoeJHND6nmflAH8nIvAJvxJ2qkSPi95wfiBqPim80GtcdWommroiWb8hh1/7fVettEwodAsGt2Mrsg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2165,11 +2174,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -4954,6 +4958,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + react-contexify@6.0.0: resolution: {integrity: sha512-jMhz6yZI81Jv3UDj7TXqCkhdkCFEEmvwGCPXsQuA2ZUC8EbCuVQ6Cy8FzKMXa0y454XTDClBN2YFvvmoFlrFkg==} peerDependencies: @@ -5395,9 +5402,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - sval@0.6.9: - resolution: {integrity: sha512-IZszs15QLaH+N79oRhczeucVLK4J8iUFRn3jgVwwHJylc5v0cxZDCX3fA/4uj8xxINpGpVgEojSi3DTNzyd8jA==} - sver@1.8.4: resolution: {integrity: sha512-71o1zfzyawLfIWBOmw8brleKyvnbn73oVHNCsu51uPMz/HWiKkkXsI31JjHW5zqXEqnPYkIiHd8ZmL7FCimLEA==} @@ -7276,6 +7280,12 @@ snapshots: '@types/yargs': 17.0.34 chalk: 4.1.2 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-singlefile-cjs-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7914,8 +7924,6 @@ snapshots: dependencies: acorn: 8.16.0 - acorn@8.15.0: {} - acorn@8.16.0: {} agent-base@7.1.4: {} @@ -11262,6 +11270,10 @@ snapshots: queue-microtask@1.2.3: {} + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + react-contexify@6.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: clsx: 1.2.1 @@ -11772,10 +11784,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - sval@0.6.9: - dependencies: - acorn: 8.15.0 - sver@1.8.4: optionalDependencies: semver: 6.3.1 diff --git a/src/lib/js-sandbox.ts b/src/lib/js-sandbox.ts new file mode 100644 index 000000000..9642278d1 --- /dev/null +++ b/src/lib/js-sandbox.ts @@ -0,0 +1,237 @@ +import releaseSyncVariant from '@jitl/quickjs-singlefile-cjs-release-sync'; +import { + newQuickJSWASMModuleFromVariant, + QuickJSContext, + QuickJSHandle, + QuickJSRuntime, + QuickJSWASMModule, +} from 'quickjs-emscripten-core'; +import { ParserConfig } from '../notebook/types'; + +/** + * Securely evaluate the untrusted JavaScript found in `.crossnote/config.js` + * and `.crossnote/parser.js`. + * + * Background: these files used to be evaluated with `vm.runInNewContext()` + * (desktop) and `sval` (web). Neither is a security boundary — both share the + * host realm's object prototypes, so untrusted code could climb + * `({}).constructor.constructor` to reach the host `Function` constructor and, + * from there, `process` / `child_process`, achieving arbitrary code execution + * just by opening a markdown file in a malicious repository + * (GHSA-427h-jhpr-8jch). + * + * QuickJS runs the guest code inside a complete JavaScript engine compiled to + * WebAssembly. The guest has its own heap and its own intrinsics living in the + * WASM linear memory; the host's `process`, `Object`, and `Function` simply do + * not exist in that realm, so there is no prototype chain to traverse back to + * the host. Values cross the boundary only as explicitly-marshaled data + * (strings here). This same module works in Node and in the browser/VS Code web + * extension because WebAssembly is available in both. + */ + +// Guard rails against a malicious file hanging or OOMing the extension host. +const MEMORY_LIMIT_BYTES = 64 * 1024 * 1024; // 64 MB +const EVAL_TIMEOUT_MS = 5000; +const HOOK_TIMEOUT_MS = 5000; + +let quickJSModulePromise: Promise | null = null; +function loadQuickJS(): Promise { + if (!quickJSModulePromise) { + // The "singlefile" variant embeds the WebAssembly module as base64 and + // instantiates it synchronously — no dynamic `import()` or asset fetch. + // That keeps loading portable across Node, jest, and the bundled VS Code + // web extension without extra build configuration. + quickJSModulePromise = newQuickJSWASMModuleFromVariant(releaseSyncVariant); + } + return quickJSModulePromise; +} + +/** + * Strip a trailing `;` / `,` and wrap the snippet in parentheses so a bare + * object literal (`{ ... }`) is parsed as an expression rather than a block — + * matching the historical `interpretJS` behaviour. + */ +function toExpression(code: string): string { + return `(${code.trim().replace(/[;,]+$/, '')})`; +} + +/** + * A runtime + context pair with a mutable interrupt deadline. The interrupt + * handler is polled by QuickJS during execution; bumping `deadline` before each + * operation gives every evaluation/ hook-call its own time budget. + */ +interface Sandbox { + runtime: QuickJSRuntime; + context: QuickJSContext; + setDeadline: (ms: number) => void; +} + +function createSandbox(module: QuickJSWASMModule): Sandbox { + const runtime = module.newRuntime(); + runtime.setMemoryLimit(MEMORY_LIMIT_BYTES); + let deadline = 0; + // Returning `true` from the interrupt handler aborts the running guest code. + runtime.setInterruptHandler(() => Date.now() > deadline); + const context = runtime.newContext(); + return { + runtime, + context, + setDeadline: (ms: number) => { + deadline = Date.now() + ms; + }, + }; +} + +function disposeSandbox(sandbox: Sandbox): void { + sandbox.context.dispose(); + sandbox.runtime.dispose(); +} + +/** Read a QuickJS error handle into a host Error, then dispose it. */ +function consumeError( + context: QuickJSContext, + errorHandle: QuickJSHandle, +): Error { + const dumped = context.dump(errorHandle) as + | { name?: string; message?: string } + | string + | undefined; + errorHandle.dispose(); + if (dumped && typeof dumped === 'object') { + return new Error(`${dumped.name ?? 'Error'}: ${dumped.message ?? ''}`); + } + return new Error(String(dumped)); +} + +/** + * Evaluate untrusted JavaScript that is expected to return plain *data* + * (used for `.crossnote/config.js`). The result is deep-copied out of the + * sandbox as ordinary host values; any functions it contains are dropped, + * which is intentional — config files should be declarative data. + * + * @throws if the code throws, times out, or exceeds the memory limit. + */ +export async function evalConfigJS(code: string): Promise { + const module = await loadQuickJS(); + const sandbox = createSandbox(module); + try { + sandbox.setDeadline(EVAL_TIMEOUT_MS); + const result = sandbox.context.evalCode(toExpression(code)); + if (result.error) { + throw consumeError(sandbox.context, result.error); + } + const data = sandbox.context.dump(result.value); + result.value.dispose(); + return data; + } finally { + disposeSandbox(sandbox); + } +} + +/** Parser hooks plus an explicit disposer for the backing QuickJS context. */ +export type SandboxedParserConfig = ParserConfig & { dispose: () => void }; + +/** + * Evaluate untrusted JavaScript that returns parser hooks + * (`.crossnote/parser.js`) and wrap them so the host can call them with a + * string and receive a string back, all marshaled across the WASM boundary. + * + * The QuickJS context is kept alive for the lifetime of the returned config so + * the hooks (and any module-level state they close over) persist between calls. + * Call `dispose()` to free it. + * + * @throws if the setup code throws, times out, or exceeds the memory limit. + */ +export async function createSandboxedParserConfig( + code: string, + defaults: ParserConfig, +): Promise { + const module = await loadQuickJS(); + const sandbox = createSandbox(module); + const { context, runtime } = sandbox; + + // Install the user module and a host-callable dispatcher. Wrapping the hook + // call in `Promise.resolve(...).then(String)` normalizes both sync and async + // hooks to a promise of a string, so the host side is uniform. + const setupSource = ` + var __module = ${toExpression(code)}; + globalThis.__crossnoteCallHook = function (hookName, input) { + var hook = + __module && typeof __module[hookName] === 'function' + ? __module[hookName] + : null; + if (!hook) { + return Promise.resolve(input); + } + return Promise.resolve(hook(input)).then(function (result) { + return String(result); + }); + }; + `; + + let dispatcher: QuickJSHandle; + try { + sandbox.setDeadline(EVAL_TIMEOUT_MS); + const setupResult = context.evalCode(setupSource); + if (setupResult.error) { + throw consumeError(context, setupResult.error); + } + setupResult.value.dispose(); + dispatcher = context.getProp(context.global, '__crossnoteCallHook'); + } catch (e) { + disposeSandbox(sandbox); + throw e; + } + + let disposed = false; + const invoke = async (hookName: string, input: string): Promise => { + if (disposed) { + return input; + } + sandbox.setDeadline(HOOK_TIMEOUT_MS); + const hookNameHandle = context.newString(hookName); + const inputHandle = context.newString(input); + try { + const callResult = context.callFunction(dispatcher, context.undefined, [ + hookNameHandle, + inputHandle, + ]); + if (callResult.error) { + throw consumeError(context, callResult.error); + } + // The dispatcher always returns a promise; resolve it via the job queue. + const resolved = context.resolvePromise(callResult.value); + callResult.value.dispose(); + runtime.executePendingJobs(); + const settled = await resolved; + if (settled.error) { + throw consumeError(context, settled.error); + } + const output = context.getString(settled.value); + settled.value.dispose(); + return output; + } finally { + hookNameHandle.dispose(); + inputHandle.dispose(); + } + }; + + return { + onWillParseMarkdown: (markdown: string) => + invoke('onWillParseMarkdown', markdown).catch(() => + defaults.onWillParseMarkdown(markdown), + ), + onDidParseMarkdown: (html: string) => + invoke('onDidParseMarkdown', html).catch(() => + defaults.onDidParseMarkdown(html), + ), + dispose: () => { + if (disposed) { + return; + } + disposed = true; + dispatcher.dispose(); + disposeSandbox(sandbox); + }, + }; +} diff --git a/src/notebook/config-helper.ts b/src/notebook/config-helper.ts index 5bb55c946..460e0dea6 100644 --- a/src/notebook/config-helper.ts +++ b/src/notebook/config-helper.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as less from 'less'; import * as path from 'path'; -import { interpretJS } from '../utility'; +import { createSandboxedParserConfig, evalConfigJS } from '../lib/js-sandbox'; import { FileSystemApi, NotebookConfig, @@ -13,6 +13,43 @@ import { getDefaultParserConfig, } from './types'; +/** + * Config keys that a workspace-provided `.crossnote/config.js` must NOT be able + * to set, because they control code execution or which executables are spawned. + * Letting an untrusted repository set these would let it grant itself trust + * (`enableScriptExecution`) or point an executable path at an arbitrary binary, + * re-introducing the very RCE that sandboxing `config.js` is meant to close. + * These settings are only honoured when they come from trusted VS Code + * settings, not from the workspace file. + */ +const SECURITY_SENSITIVE_CONFIG_KEYS: readonly (keyof NotebookConfig)[] = [ + 'enableScriptExecution', + 'chromePath', + 'pandocPath', + 'imageMagickPath', + 'markdownYoBinaryPath', +]; + +/** + * Remove security-sensitive keys from the result of an untrusted + * `.crossnote/config.js` and warn if any were present. + */ +function stripSensitiveConfigKeys( + config: Partial, +): Partial { + const sanitized = { ...config }; + for (const key of SECURITY_SENSITIVE_CONFIG_KEYS) { + if (key in sanitized) { + delete sanitized[key]; + console.warn( + `crossnote: ignoring "${key}" set in .crossnote/config.js — this ` + + `setting can only be configured via trusted editor settings.`, + ); + } + } + return sanitized; +} + /** * Load the configs from the given directory path. * If the directory does not exist and `createDirectoryIfNotExists` is `true`, create it and return the default configs. @@ -143,23 +180,17 @@ async function getConfigs( if (await fs.exists(configScriptPath)) { try { - // HACK: Dyamic import here doesn't work for the VSCode packaged extension. - /* - const result = isVSCodeWebExtension() - ? await import(configScriptPath + `?version=${Date.now()}`) - : (() => { - delete require.cache[require.resolve(configScriptPath)]; - return require(configScriptPath); - })(); - */ - // NOTE: Never mind, the above code doesn't work in VSCode Web extension - + // `config.js` is untrusted code from the workspace. Evaluate it inside the + // QuickJS WASM sandbox (see ../lib/js-sandbox) so it cannot reach the host + // realm. Only plain data crosses back out. const script = await fs.readFile(configScriptPath); - const result = interpretJS(script); + const result = (await evalConfigJS(script)) as + | Partial + | undefined; if (Object.keys(result ?? {}).length === 0) { return await setupDefaultConfigScript(); } - return result; + return stripSensitiveConfigKeys(result ?? {}); } catch (e) { console.error(e); return {}; @@ -169,34 +200,6 @@ async function getConfigs( } } -/** - * Wrap user-provided parser hooks so they are called with a null-prototype - * `this`, preventing prototype-chain escapes (e.g. `this.constructor.constructor` - * reaching the host Function/process). - */ -function sanitizeParserConfig( - defaultParserConfig: ParserConfig, - result: Record | undefined, -): ParserConfig { - const safeThis = Object.create(null); - return { - onWillParseMarkdown: - typeof result?.onWillParseMarkdown === 'function' - ? (md: string) => - ( - result.onWillParseMarkdown as ParserConfig['onWillParseMarkdown'] - ).call(safeThis, md) - : defaultParserConfig.onWillParseMarkdown, - onDidParseMarkdown: - typeof result?.onDidParseMarkdown === 'function' - ? (html: string) => - ( - result.onDidParseMarkdown as ParserConfig['onDidParseMarkdown'] - ).call(safeThis, html) - : defaultParserConfig.onDidParseMarkdown, - }; -} - async function getParserConfig( configPath: string, fs: FileSystemApi, @@ -205,19 +208,11 @@ async function getParserConfig( const parserConfigPath = path.join(configPath, './parser.js'); if (await fs.exists(parserConfigPath)) { try { - // HACK: Dyamic import here doesn't work for the VSCode packaged extension. - /* - const result = isVSCodeWebExtension() - ? await import(parserConfigPath) - : (() => { - delete require.cache[require.resolve(parserConfigPath)]; - return require(parserConfigPath); - })(); - */ - // NOTE: Never mind, the above code doesn't work in VSCode Web extension + // `parser.js` is untrusted code from the workspace. Its hooks run inside + // the QuickJS WASM sandbox (see ../lib/js-sandbox); only strings cross the + // boundary, so the hooks cannot reach the host realm / Node APIs. const script = await fs.readFile(parserConfigPath); - const result = interpretJS(script); - return sanitizeParserConfig(defaultParserConfig, result); + return await createSandboxedParserConfig(script, defaultParserConfig); } catch (e) { console.error(e); return defaultParserConfig; diff --git a/src/utility.ts b/src/utility.ts index d1854bae0..db0e0137a 100644 --- a/src/utility.ts +++ b/src/utility.ts @@ -1,7 +1,6 @@ import structuredClone from '@ungap/structured-clone'; import * as child_process from 'child_process'; import * as path from 'path'; -import Sval from 'sval'; import * as temp from './lib/temp'; import { JsonObject } from 'type-fest'; import { fileURLToPath } from 'url'; @@ -356,26 +355,6 @@ export function isVSCodeWebExtension() { return process.env.IS_VSCODE_WEB_EXTENSION === 'true'; } -/** - * This function is used to evaluate the config.js and parser.js - * @param code - */ -export function interpretJS(code: string) { - code = code.trim().replace(/[;,]+$/, ''); - if (isVSCodeWebExtension()) { - const interpreter = new Sval({ - sandBox: true, - ecmaVer: 2019, - }); - interpreter.run(`exports.result = (${code})`); - return interpreter.exports.result; - } else { - const context: Record = {}; - vm.runInNewContext(`result = (${code})`, context); - return context['result']; - } -} - export function findClosingTagIndex( inputString: string, tagName: string, diff --git a/test/parser-config-security.test.ts b/test/parser-config-security.test.ts index ee9b61d2b..e7f364e88 100644 --- a/test/parser-config-security.test.ts +++ b/test/parser-config-security.test.ts @@ -1,7 +1,13 @@ -import * as vm from 'vm'; +import { + createSandboxedParserConfig, + evalConfigJS, +} from '../src/lib/js-sandbox'; import { loadConfigsInDirectory } from '../src/notebook/config-helper'; -import { FileSystemApi, ParserConfig } from '../src/notebook/types'; -import { interpretJS } from '../src/utility'; +import { + FileSystemApi, + ParserConfig, + getDefaultParserConfig, +} from '../src/notebook/types'; // Mock `less` since it's not available in the test environment jest.mock('less', () => ({ @@ -14,45 +20,128 @@ jest.mock('less', () => ({ }, })); -describe('parser.js prototype-chain RCE prevention', () => { - const maliciousCode = `({ - onWillParseMarkdown: async function(markdown) { - try { - const p = this.constructor.constructor("return process")(); - return "ESCAPED:" + p.version; - } catch(e) { - return "BLOCKED:" + e.message; - } - }, - onDidParseMarkdown: async function(html) { - try { - const p = this.constructor.constructor("return process")(); - return "ESCAPED:" + p.version; - } catch(e) { - return "BLOCKED:" + e.message; - } +/** + * Regression tests for GHSA-427h-jhpr-8jch: `.crossnote/config.js` and + * `.crossnote/parser.js` are untrusted code from the workspace and must never + * be able to reach the host realm (and from there `process` / `child_process`). + * + * They are now evaluated inside the QuickJS WASM sandbox, which has its own + * realm/intrinsics, so the prototype-chain escape + * (`({}).constructor.constructor('return process')()`) that defeated both + * `vm.runInNewContext` and `sval` cannot reach a host `process`. + */ + +// The advisory's proof-of-concept escape, used verbatim where possible. +const RCE_EXPRESSION = `(function () { + try { + var F = this.constructor.constructor; + var p = F('return process')(); + return 'ESCAPED:' + (p && p.version); + } catch (e) { + return 'BLOCKED:' + e.message; + } +})()`; + +describe('js-sandbox: config.js evaluation', () => { + it('returns plain data from a config object', async () => { + const result = (await evalConfigJS( + `({ a: 1, nested: { b: [2, 3] }, katexConfig: { macros: {} } })`, + )) as Record; + expect(result).toEqual({ + a: 1, + nested: { b: [2, 3] }, + katexConfig: { macros: {} }, + }); + }); + + it('does not expose host globals (process / require) to config.js', async () => { + const result = (await evalConfigJS(`({ + hasProcess: typeof globalThis.process, + hasRequire: typeof globalThis.require, + escapeAttempt: ${RCE_EXPRESSION}, + })`)) as Record; + expect(result.hasProcess).toBe('undefined'); + expect(result.hasRequire).toBe('undefined'); + expect(result.escapeAttempt).toMatch(/^BLOCKED:/); + }); + + it('rejects the advisory RCE payload instead of executing it', async () => { + // `process` does not exist in the sandbox realm, so the payload throws + // at evaluation time rather than achieving code execution. + await expect( + evalConfigJS(`(() => { + var F = this.constructor.constructor; + var p = F('return process')(); + p.getBuiltinModule('child_process').execSync('echo pwned'); + return { enableScriptExecution: true }; + })()`), + ).rejects.toThrow(/process'? is not defined/); + }); + + it('aborts an infinite loop via the interrupt deadline (DoS guard)', async () => { + await expect(evalConfigJS(`(() => { while (true) {} })()`)).rejects.toThrow( + /interrupted|InternalError/i, + ); + }, 15000); +}); + +describe('js-sandbox: parser.js hooks', () => { + it('runs safe sync and async hooks with string-in/string-out', async () => { + const parser = await createSandboxedParserConfig( + `({ + onWillParseMarkdown: async function (md) { return md.replace(/foo/g, 'bar'); }, + onDidParseMarkdown: function (html) { return html.toUpperCase(); } + })`, + getDefaultParserConfig(), + ); + try { + expect(await parser.onWillParseMarkdown('foo baz')).toBe('bar baz'); + expect(await parser.onDidParseMarkdown('hi')).toBe('HI'); + } finally { + parser.dispose(); + } + }); + + it('prevents prototype-chain escape from inside a hook', async () => { + const parser = await createSandboxedParserConfig( + `({ + onWillParseMarkdown: async function (md) { return ${RCE_EXPRESSION}; }, + onDidParseMarkdown: function (html) { return html; } + })`, + getDefaultParserConfig(), + ); + try { + expect(await parser.onWillParseMarkdown('x')).toMatch(/^BLOCKED:/); + } finally { + parser.dispose(); } - })`; + }); - const safeCode = `({ - onWillParseMarkdown: async function(markdown) { - return markdown.replace(/foo/g, "bar"); - }, - onDidParseMarkdown: async function(html) { - return html.replace(/foo/g, "bar"); + it('falls back to identity when a hook throws uncaught', async () => { + const parser = await createSandboxedParserConfig( + `({ + onWillParseMarkdown: async function (md) { + // Uncaught reference to a host global -> rejects inside the sandbox. + return process.version; + }, + onDidParseMarkdown: function (html) { return html; } + })`, + getDefaultParserConfig(), + ); + try { + // The default parser config is identity, so the input passes through. + expect(await parser.onWillParseMarkdown('untouched')).toBe('untouched'); + } finally { + parser.dispose(); } - })`; + }); +}); - /** - * Create a mock FileSystemApi that serves the given parser.js content. - */ - function mockFs(parserJsContent: string): FileSystemApi { - const files: Record = { - 'test-dir/parser.js': parserJsContent, - }; +describe('loadConfigsInDirectory (production code path)', () => { + function mockFs(files: Record): FileSystemApi { return { readFile: async (filePath: string) => { - if (files[filePath]) return files[filePath]; + if (filePath in files) return files[filePath]; throw new Error(`File not found: ${filePath}`); }, writeFile: async (filePath: string, content: string) => { @@ -69,65 +158,66 @@ describe('parser.js prototype-chain RCE prevention', () => { }; } - describe('loadConfigsInDirectory (production code path)', () => { - it('blocks prototype-chain escape via onWillParseMarkdown', async () => { - const fs = mockFs(maliciousCode); - const config = await loadConfigsInDirectory('test-dir', fs); - const parserConfig = config.parserConfig as ParserConfig; - const result = await parserConfig.onWillParseMarkdown('test'); - expect(result).toMatch(/^BLOCKED:/); - }); - - it('blocks prototype-chain escape via onDidParseMarkdown', async () => { - const fs = mockFs(maliciousCode); - const config = await loadConfigsInDirectory('test-dir', fs); - const parserConfig = config.parserConfig as ParserConfig; - const result = await parserConfig.onDidParseMarkdown('test'); - expect(result).toMatch(/^BLOCKED:/); - }); - - it('still allows normal parser hooks to function', async () => { - const fs = mockFs(safeCode); - const config = await loadConfigsInDirectory('test-dir', fs); - const parserConfig = config.parserConfig as ParserConfig; - expect(await parserConfig.onWillParseMarkdown('foo baz')).toBe('bar baz'); - expect(await parserConfig.onDidParseMarkdown('foo baz')).toBe('bar baz'); + it('blocks the prototype-chain escape via parser.js hooks', async () => { + const fs = mockFs({ + 'test-dir/parser.js': `({ + onWillParseMarkdown: async function (md) { return ${RCE_EXPRESSION}; }, + onDidParseMarkdown: async function (html) { return ${RCE_EXPRESSION}; } + })`, }); + const config = await loadConfigsInDirectory('test-dir', fs); + const parserConfig = config.parserConfig as ParserConfig; + expect(await parserConfig.onWillParseMarkdown('test')).toMatch(/^BLOCKED:/); + expect(await parserConfig.onDidParseMarkdown('test')).toMatch(/^BLOCKED:/); }); - describe('vulnerable pattern (spread into host object)', () => { - function simulateVulnerableFlow(code: string) { - const result = interpretJS(code); - return { - onWillParseMarkdown: async (md: string) => md, - onDidParseMarkdown: async (html: string) => html, - ...(result ?? {}), - }; - } - - it('allows prototype-chain escape via onWillParseMarkdown', async () => { - const config = simulateVulnerableFlow(maliciousCode); - const result = await config.onWillParseMarkdown('test'); - expect(result).toMatch(/^ESCAPED:/); + it('still allows normal parser hooks to function', async () => { + const fs = mockFs({ + 'test-dir/parser.js': `({ + onWillParseMarkdown: async function (md) { return md.replace(/foo/g, 'bar'); }, + onDidParseMarkdown: async function (html) { return html.replace(/foo/g, 'bar'); } + })`, }); + const config = await loadConfigsInDirectory('test-dir', fs); + const parserConfig = config.parserConfig as ParserConfig; + expect(await parserConfig.onWillParseMarkdown('foo baz')).toBe('bar baz'); + expect(await parserConfig.onDidParseMarkdown('foo baz')).toBe('bar baz'); + }); - it('allows prototype-chain escape via onDidParseMarkdown', async () => { - const config = simulateVulnerableFlow(maliciousCode); - const result = await config.onDidParseMarkdown('test'); - expect(result).toMatch(/^ESCAPED:/); + it('does not execute the config.js RCE payload', async () => { + const fs = mockFs({ + 'test-dir/config.js': `(() => { + var F = this.constructor.constructor; + F('return process')().getBuiltinModule('child_process').execSync('echo pwned'); + return { enableScriptExecution: true }; + })()`, }); + // The payload throws inside the sandbox; loadConfigsInDirectory swallows the + // error and the malicious config is simply not applied. + const config = await loadConfigsInDirectory('test-dir', fs); + expect(config.enableScriptExecution).not.toBe(true); }); - describe('vm.runInNewContext isolation (without spread)', () => { - it('blocks escape when this stays in vm context', async () => { - const context: Record = {}; - vm.runInNewContext(`result = (${maliciousCode.trim()})`, context); - const result = await ( - context.result as { - onWillParseMarkdown: (s: string) => Promise; - } - ).onWillParseMarkdown('test'); - expect(result).toMatch(/^BLOCKED:/); + it('strips security-sensitive keys a config.js tries to set', async () => { + // Even without any escape attempt, an untrusted config.js must not be able + // to grant itself trust or point executable paths at arbitrary binaries. + const fs = mockFs({ + 'test-dir/config.js': `({ + enableScriptExecution: true, + pandocPath: '/tmp/evil', + chromePath: '/tmp/evil-chrome', + imageMagickPath: '/tmp/evil-magick', + markdownYoBinaryPath: '/tmp/evil-yo', + previewTheme: 'github-light.css' + })`, }); + const config = await loadConfigsInDirectory('test-dir', fs); + expect(config.enableScriptExecution).toBeUndefined(); + expect(config.pandocPath).toBeUndefined(); + expect(config.chromePath).toBeUndefined(); + expect(config.imageMagickPath).toBeUndefined(); + expect(config.markdownYoBinaryPath).toBeUndefined(); + // Non-sensitive keys are still honoured. + expect(config.previewTheme).toBe('github-light.css'); }); }); From 5a7b7ab445bf3d35b71a33afc5bd962a0bc71ef9 Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Sat, 6 Jun 2026 14:32:48 +0800 Subject: [PATCH 4/7] 0.9.30 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c061c0d..985f03b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/relea ## [Unreleased] +## [0.9.30] - 2026-06-06 + ### Security - **Fix a remote code execution vulnerability in `.crossnote/config.js` and `.crossnote/parser.js` evaluation**. These workspace files are now evaluated inside a [QuickJS](https://github.com/justjake/quickjs-emscripten) WebAssembly sandbox so untrusted code from a repository can no longer reach the host environment. Thanks to @ritikchaddha for reporting the issue. diff --git a/package.json b/package.json index 3d8385201..2ab2c744a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crossnote", - "version": "0.9.29", + "version": "0.9.30", "description": "A powerful markdown notebook tool", "keywords": [ "markdown" From 4cc07da9680d67c69d1de7bafd5bb8e05b497bd1 Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Sat, 6 Jun 2026 14:44:24 +0800 Subject: [PATCH 5/7] style: prettier formatting for hash-in-path test Co-Authored-By: Claude Opus 4.8 (1M context) --- test/hash-in-path.test.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/test/hash-in-path.test.ts b/test/hash-in-path.test.ts index ae871be69..544cc25e8 100644 --- a/test/hash-in-path.test.ts +++ b/test/hash-in-path.test.ts @@ -12,10 +12,7 @@ describe('@import when directory path contains #', () => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hash-in-path-')); const dirWithHash = path.join(tmp, '[#11111111]'); fs.mkdirSync(dirWithHash); - fs.writeFileSync( - path.join(dirWithHash, '1.md'), - '@import "test.csv"\n', - ); + fs.writeFileSync(path.join(dirWithHash, '1.md'), '@import "test.csv"\n'); fs.writeFileSync( path.join(dirWithHash, 'test.csv'), 'Name,Year,House\nAlice,2020,Red\nBob,2021,Blue\n', @@ -47,12 +44,8 @@ describe('@import when directory path contains #', () => { }); test('@import resolves file with #fragment when directory contains #', async () => { - const mdContent = - '@import "test.md#section"\n'; - fs.writeFileSync( - path.join(tmp, '[#11111111]', '2.md'), - mdContent, - ); + const mdContent = '@import "test.md#section"\n'; + fs.writeFileSync(path.join(tmp, '[#11111111]', '2.md'), mdContent); fs.writeFileSync( path.join(tmp, '[#11111111]', 'test.md'), '## Section\n\ntest content.\n\n## Other\n\nshould not appear.\n', @@ -72,10 +65,7 @@ describe('@import when directory path contains #', () => { test('![[wikilink]] resolves file when directory contains #', async () => { const mdContent = '![[test.md]]\n'; - fs.writeFileSync( - path.join(tmp, '[#11111111]', '3.md'), - mdContent, - ); + fs.writeFileSync(path.join(tmp, '[#11111111]', '3.md'), mdContent); const engine3 = nb.getNoteMarkdownEngine( path.join(tmp, '[#11111111]', '3.md'), ); From b30dc50ddc1187d408f7e48ad8f0e989e7ea81e2 Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Sat, 6 Jun 2026 14:55:49 +0800 Subject: [PATCH 6/7] docs: credit issue reporters for #2319 and #2317 in CHANGELOG Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 985f03b80..e3a1cd84e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/relea ### Bug fixes -- **Fix heading auto-ID generation for underscore-based italic/bold** — When a heading used underscore-based emphasis at the beginning or end (e.g., `_Toy Story_` or `__Bold Title__`), the generated heading ID would retain the underscores (e.g., `_toy-story_`), which markdown-it would interpret as emphasis markers, splitting the `{#id data-source-line="N"}` attribute block across multiple tokens and leaving it visible in the rendered output. Heading IDs now strip underscore emphasis markers following CommonMark rules — boundaries include punctuation (`# x _foo bar_! end` → `x-foo-bar-end`), adjacent runs both match (`_a_ _b_` → `a-b`), and intraword underscores are kept (`foo_bar_` → `foo_bar_`) — matching the anchors GitHub generates for the same headings. Additionally, ids embedded in the internal `{#id}` attribute block are now backslash-escaped so that any id still containing `_`/`*` survives inline parsing intact and rendered heading ids always match TOC anchors. Fixes [vscode-mpe#2319](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319). -- **Fix `@import` / `![[wikilink]]` file resolution when the file path contains `#`** — When a project directory name contains `#` (e.g., `[#11111111]`), the `@import` and wikilink-based file imports would fail because the `#` in the directory name was incorrectly treated as a heading anchor fragment separator during post-resolution path splitting. The `#fragment` is now extracted from the original import syntax before path resolution, so literal `#` characters in directory paths are preserved (and `%23` can be used to write a literal `#` in import paths). Also fixed line-level `![[note^block-id]]` embeds (bare block reference without `#`), which previously failed to resolve the target block. Fixes [vscode-mpe#2317](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2317). +- **Fix heading auto-ID generation for underscore-based italic/bold** — When a heading used underscore-based emphasis at the beginning or end (e.g., `_Toy Story_` or `__Bold Title__`), the generated heading ID would retain the underscores (e.g., `_toy-story_`), which markdown-it would interpret as emphasis markers, splitting the `{#id data-source-line="N"}` attribute block across multiple tokens and leaving it visible in the rendered output. Heading IDs now strip underscore emphasis markers following CommonMark rules — boundaries include punctuation (`# x _foo bar_! end` → `x-foo-bar-end`), adjacent runs both match (`_a_ _b_` → `a-b`), and intraword underscores are kept (`foo_bar_` → `foo_bar_`) — matching the anchors GitHub generates for the same headings. Additionally, ids embedded in the internal `{#id}` attribute block are now backslash-escaped so that any id still containing `_`/`*` survives inline parsing intact and rendered heading ids always match TOC anchors. Fixes [vscode-mpe#2319](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2319). Reported by @skycommand. +- **Fix `@import` / `![[wikilink]]` file resolution when the file path contains `#`** — When a project directory name contains `#` (e.g., `[#11111111]`), the `@import` and wikilink-based file imports would fail because the `#` in the directory name was incorrectly treated as a heading anchor fragment separator during post-resolution path splitting. The `#fragment` is now extracted from the original import syntax before path resolution, so literal `#` characters in directory paths are preserved (and `%23` can be used to write a literal `#` in import paths). Also fixed line-level `![[note^block-id]]` embeds (bare block reference without `#`), which previously failed to resolve the target block. Fixes [vscode-mpe#2317](https://github.com/shd101wyy/vscode-markdown-preview-enhanced/issues/2317). Reported by @LY1806620741. ## [0.9.29] - 2026-06-05 From 8b4e60685865bb7219e0081eed7ae4b27733da93 Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Sat, 6 Jun 2026 15:00:20 +0800 Subject: [PATCH 7/7] ci: bump pnpm/action-setup v4 -> v6 (Node 24 runtime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm/action-setup@v4 runs on the deprecated Node.js 20 actions runtime; v6 runs on Node.js 24. The action is invoked with no `version` input and resolves pnpm from the package.json `packageManager` field, which v6 supports identically — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 4 ++-- .github/workflows/typedoc.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54c912e42..7e8469722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v5 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Install nodejs uses: actions/setup-node@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d20e50e1..e63c40945 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 0 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Install nodejs uses: actions/setup-node@v6 @@ -61,7 +61,7 @@ jobs: fetch-depth: 0 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Install nodejs uses: actions/setup-node@v6 diff --git a/.github/workflows/typedoc.yml b/.github/workflows/typedoc.yml index fb2f61ac3..73fb05697 100644 --- a/.github/workflows/typedoc.yml +++ b/.github/workflows/typedoc.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Install nodejs uses: actions/setup-node@v6