From 404c8c8648950e61820e40f6ab0ebe47cc8c652e Mon Sep 17 00:00:00 2001
From: Ran Luo
Date: Sat, 4 Jul 2026 19:33:01 +0800
Subject: [PATCH 1/6] fix(muya): stop double percent-encoding autolink hrefs
(#4841)
A CommonMark autolink's href is the literal text between `< >`, which the
author has already percent-encoded (e.g. `%20`). The renderer ran it through
`encodeURI`, re-encoding the `%` and turning `%20` into `%2520`, so the
followed link pointed at the wrong URL. Standard `[text](url)` links never
encode the href, so autolinks now match by using it verbatim.
Fixes #3548
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../__tests__/autoLinkEncoding.spec.ts | 56 +++++++++++++++++++
.../src/inlineRenderer/renderer/autoLink.ts | 2 +-
2 files changed, 57 insertions(+), 1 deletion(-)
create mode 100644 packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts
diff --git a/packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts b/packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts
new file mode 100644
index 0000000000..38c81d8864
--- /dev/null
+++ b/packages/muya/src/inlineRenderer/__tests__/autoLinkEncoding.spec.ts
@@ -0,0 +1,56 @@
+// @vitest-environment happy-dom
+
+import type { Muya as MuyaType } from '../../muya';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { Muya } from '../../muya';
+
+// #3548 — a CommonMark autolink's href is the literal source between `< >`,
+// which the author already percent-encoded (`%20` etc). The renderer used
+// `encodeURI(href)`, re-encoding the `%` to `%25` and turning `%20` into
+// `%2520`, so the followed link pointed at the wrong URL. Standard
+// `[text](url)` links never encode the href, so autolinks must match.
+
+const bootedHosts: HTMLElement[] = [];
+let originalVersion: string | undefined;
+let hadVersion = false;
+
+beforeEach(() => {
+ hadVersion = 'MUYA_VERSION' in window;
+ originalVersion = window.MUYA_VERSION;
+ window.MUYA_VERSION = 'test';
+});
+
+afterEach(() => {
+ while (bootedHosts.length)
+ bootedHosts.pop()!.remove();
+ document.getSelection()?.removeAllRanges();
+ if (hadVersion)
+ window.MUYA_VERSION = originalVersion as string;
+ else
+ delete (window as Partial).MUYA_VERSION;
+});
+
+function bootMuya(markdown: string): MuyaType {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const muya = new Muya(host, { markdown } as ConstructorParameters[1]);
+ muya.init();
+ bootedHosts.push(muya.domNode);
+ return muya;
+}
+
+describe('#3548 — autolink hrefs are not double percent-encoded', () => {
+ it('keeps an already-encoded `%20` intact instead of turning it into `%2520`', () => {
+ const muya = bootMuya('\n');
+ const anchor = muya.domNode.querySelector('a.mu-auto-link')!;
+ const href = anchor.getAttribute('href')!;
+ expect(href).toContain('%20');
+ expect(href).not.toContain('%2520');
+ });
+
+ it('does not re-encode reserved characters in a query string', () => {
+ const muya = bootMuya('\n');
+ const anchor = muya.domNode.querySelector('a.mu-auto-link')!;
+ expect(anchor.getAttribute('href')).toBe('https://example.com/a?b=c&d=e');
+ });
+});
diff --git a/packages/muya/src/inlineRenderer/renderer/autoLink.ts b/packages/muya/src/inlineRenderer/renderer/autoLink.ts
index cabaf87b3b..dd8dfbc48f 100644
--- a/packages/muya/src/inlineRenderer/renderer/autoLink.ts
+++ b/packages/muya/src/inlineRenderer/renderer/autoLink.ts
@@ -28,7 +28,7 @@ export default function autoLink(
token,
);
- const hyperlink = isLink ? encodeURI(href) : `mailto:${email}`;
+ const hyperlink = isLink ? href : `mailto:${email}`;
return [
h(`span.${className}`, startMarker),
From 1328c758b7dc11ca5250ba1ef3a763b63a053679 Mon Sep 17 00:00:00 2001
From: Ran Luo
Date: Sat, 4 Jul 2026 19:43:01 +0800
Subject: [PATCH 2/6] fix(muya): keep pasted list items in order when merging
mid-list (#4842)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pasting a same-kind list into a non-last item scrambled order: both merge
paths pushed the pasted items onto the END of the enclosing list's children
instead of inserting them right after the anchor item. Pasting `- 1/- 2/- 3`
onto the empty middle item of `- A/- /- B` produced `- A/- 1/- B/- 2/- 3`.
Splice the pasted items in after the anchor item, and — since they are no
longer the list's last descendant — seat the caret on the last pasted item
explicitly. Extracted the post-rebuild caret placement into
`seatListMergeCursor` to keep `tryMergeListPaste` within the complexity budget.
Fixes #3549
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../__tests__/pasteListMerge.spec.ts | 18 ++++++
packages/muya/src/clipboard/paste.ts | 56 +++++++++++++++----
2 files changed, 63 insertions(+), 11 deletions(-)
diff --git a/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts b/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts
index 3eb8d1ab14..fb8d40a135 100644
--- a/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts
+++ b/packages/muya/src/clipboard/__tests__/pasteListMerge.spec.ts
@@ -133,4 +133,22 @@ describe('paste — same-type list merges into the enclosing list (A5, muyajs pa
expect(selection.anchor?.offset).toBe(4);
expect(selection.focus?.offset).toBe(4);
});
+
+ // #3549 — pasting into a non-last item must keep the pasted items in order
+ // right after the anchor item, not append them to the end of the list.
+ it('inserts pasted items after the anchor item, not at the list end', async () => {
+ const muya = bootMuya('- Item A\n- \n- Item B\n');
+ const empty = contentBlocks(muya).find(b => b.text === '')!;
+ expect(await paste(muya, empty, 0, 0, '- Item 1\n- Item 2\n- Item 3')).toBe(
+ '- Item A\n- Item 1\n- Item 2\n- Item 3\n- Item B\n',
+ );
+ });
+
+ it('keeps order for an ordered list pasted into the middle', async () => {
+ const muya = bootMuya('1. A\n2. \n3. B\n');
+ const empty = contentBlocks(muya).find(b => b.text === '')!;
+ expect(await paste(muya, empty, 0, 0, '1. one\n2. two')).toBe(
+ '1. A\n2. one\n3. two\n4. B\n',
+ );
+ });
});
diff --git a/packages/muya/src/clipboard/paste.ts b/packages/muya/src/clipboard/paste.ts
index 7d00b85ed2..fcd497bbbd 100644
--- a/packages/muya/src/clipboard/paste.ts
+++ b/packages/muya/src/clipboard/paste.ts
@@ -239,6 +239,39 @@ function itemParaContent(list: Parent, itemIndex: number, paraIndex: number): Nu
return para?.firstContentInDescendant() ?? null;
}
+interface IListMergeSeam {
+ foldedOnly: boolean;
+ itemIndex: number;
+ paraIndex: number;
+ head: string;
+ sewOffset: number;
+ canFold: boolean;
+ pastedCount: number;
+ trailingStates: TState[];
+}
+
+// Seat the caret after a list-merge rebuilds the list block: inside the folded
+// paragraph, on trailing non-list content, or on the last pasted item (which is
+// spliced in after the anchor, so it is no longer the list's last descendant).
+function seatListMergeCursor(muya: Muya, newList: Parent, seam: IListMergeSeam): void {
+ const { foldedOnly, itemIndex, paraIndex, head, sewOffset, canFold, pastedCount, trailingStates } = seam;
+ if (foldedOnly) {
+ const cursor = itemParaContent(newList, itemIndex, paraIndex);
+ const offset = head.length + sewOffset;
+ cursor?.setCursor(offset, offset, true);
+
+ return;
+ }
+ if (trailingStates.length > 0) {
+ const last = insertStatesAfter(muya, newList, trailingStates);
+ seatCursorAtSeam(last, sewOffset);
+
+ return;
+ }
+ const lastPastedIndex = itemIndex + pastedCount - (canFold ? 1 : 0);
+ seatCursorAtSeam(newList.find(lastPastedIndex) as Nullable, sewOffset);
+}
+
// Same list kind + same bullet marker / order delimiter.
function listMarkersMatch(a: TState, b: TState): boolean {
if (a.name === 'order-list' && b.name === 'order-list')
@@ -301,7 +334,7 @@ function tryMergeListPaste(
if (canFold) {
anchorPara.text = head + pastedFirst.text;
currentItem.children = [...currentItem.children, ...pastedItems[0].children.slice(1)];
- mergedChildren.push(...pastedItems.slice(1));
+ mergedChildren.splice(itemIndex + 1, 0, ...pastedItems.slice(1));
// The whole paste folded into `anchorPara` (no extra blocks/items): the
// caret stays in that paragraph at the seam.
foldedOnly
@@ -311,7 +344,7 @@ function tryMergeListPaste(
}
else {
anchorPara.text = head;
- mergedChildren.push(...pastedItems);
+ mergedChildren.splice(itemIndex + 1, 0, ...pastedItems);
}
const loose = listState.meta.loose || firstState.meta.loose;
@@ -327,15 +360,16 @@ function tryMergeListPaste(
);
listBlock.replaceWith(newList);
- if (foldedOnly) {
- const cursor = itemParaContent(newList, itemIndex, paraIndex);
- const offset = head.length + sewOffset;
- cursor?.setCursor(offset, offset, true);
- }
- else {
- const last = insertStatesAfter(clipboard.muya, newList, states.slice(1));
- seatCursorAtSeam(last, sewOffset);
- }
+ seatListMergeCursor(clipboard.muya, newList as Parent, {
+ foldedOnly,
+ itemIndex,
+ paraIndex,
+ head,
+ sewOffset,
+ canFold,
+ pastedCount: pastedItems.length,
+ trailingStates: states.slice(1),
+ });
return true;
}
From abd81c1a816e4053ee00c9ce7624ab727164464e Mon Sep 17 00:00:00 2001
From: Ran Luo
Date: Sat, 4 Jul 2026 20:07:22 +0800
Subject: [PATCH 3/6] fix(desktop): confirm before opening executable link
targets (code execution) (#4843)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(common): add isDangerousExecutableFile guard
Add a pure extension-based check (and the backing extension list) for files
the OS shell would execute as code — Windows Script Host scripts (js, vbs,
wsf, hta…), native executables/installers, batch, PowerShell, and shortcut/
registry/JVM launchers. Case-insensitive; no filesystem access so it can gate
a decision before the file is opened.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(desktop): confirm before opening executable link targets
Clicking a markdown link to a co-located local file called shell.openPath
with no safety check. On Windows a link like `[open](./update.js)` next to an
untrusted document ran the script through wscript.exe with no prompt, giving
silent code execution.
Guard the non-markdown local-file branch of mt::format-link-click with
isDangerousExecutableFile and show a warning dialog (default Cancel) before
opening; the file is only handed to the OS shell if the user confirms.
Fixes #3575
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(common): harden isDangerousExecutableFile (review #4843)
Two gaps from review:
- Trailing dot/space bypass: `update.js.` and `<./update.js >` (an angle-
bracket link skips the space rejection) reach the guard with an extension of
`.` / `js ` that isn't listed, yet Windows strips trailing dots/spaces during
ShellExecute and still runs `update.js`. Trim trailing `[ .]+` before reading
the extension.
- Windows-only coverage: the vulnerable shell.openPath path is cross-platform,
so add macOS (`command`, `app`) and Linux (`desktop`, `appimage`, `run`)
launchers — `[x](./run.command)` / `./launch.desktop` reproduced #3575 off
Windows.
Co-Authored-By: Claude Opus 4.8 (1M context)
* i18n: add unsafe-file dialog strings to all locales
Add dialog.unsafeFileTitle / unsafeFileMessage / unsafeFileDetail ({name})
and dialog.openAnyway to the ten locale files so the #3575 confirmation
dialog is localized (getTranslation returns the raw key when one is missing).
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(desktop): localize the unsafe-file confirmation dialog (review #4843)
Route the dialog title/message/detail and buttons through t('dialog.…')
instead of hardcoded English, matching the other dialogs in this file.
Co-Authored-By: Claude Opus 4.8 (1M context)
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../desktop/src/common/filesystem/paths.ts | 68 +++++++++++++++++++
.../desktop/src/main/menu/actions/file.ts | 21 +++++-
packages/desktop/static/locales/de.json | 6 +-
packages/desktop/static/locales/en.json | 6 +-
packages/desktop/static/locales/es.json | 6 +-
packages/desktop/static/locales/fr.json | 6 +-
packages/desktop/static/locales/ja.json | 6 +-
packages/desktop/static/locales/ko.json | 6 +-
packages/desktop/static/locales/pt.json | 6 +-
packages/desktop/static/locales/tr.json | 6 +-
packages/desktop/static/locales/zh-CN.json | 6 +-
packages/desktop/static/locales/zh-TW.json | 6 +-
.../specs/dangerous-executable-file.spec.ts | 62 +++++++++++++++++
13 files changed, 199 insertions(+), 12 deletions(-)
create mode 100644 packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts
diff --git a/packages/desktop/src/common/filesystem/paths.ts b/packages/desktop/src/common/filesystem/paths.ts
index 57e1704f76..fc465f64b4 100644
--- a/packages/desktop/src/common/filesystem/paths.ts
+++ b/packages/desktop/src/common/filesystem/paths.ts
@@ -32,6 +32,74 @@ export const IMAGE_EXTENSIONS: readonly string[] = Object.freeze([
'webp'
])
+// Extensions the OS shell will execute rather than open in an application.
+// Opening one of these via shell.openPath runs code, so a markdown link
+// pointing at a co-located script/executable must be confirmed first (#3575).
+// The vulnerable path is cross-platform, so the list covers Windows, macOS and
+// Linux launchers — not just Windows.
+export const DANGEROUS_EXECUTABLE_EXTENSIONS: readonly string[] = Object.freeze([
+ // Windows — native executables, installers and control-panel items
+ 'exe',
+ 'com',
+ 'scr',
+ 'pif',
+ 'cpl',
+ 'msi',
+ 'msp',
+ 'msc',
+ 'gadget',
+ 'application',
+ // Windows — shell / batch
+ 'bat',
+ 'cmd',
+ // Windows Script Host
+ 'js',
+ 'jse',
+ 'vbs',
+ 'vbe',
+ 'wsf',
+ 'wsh',
+ 'ws',
+ 'wsc',
+ 'hta',
+ // PowerShell
+ 'ps1',
+ 'ps1xml',
+ 'ps2',
+ 'ps2xml',
+ 'psc1',
+ 'psc2',
+ 'psd1',
+ 'psm1',
+ // Windows — shortcuts, registry and JVM launchers
+ 'lnk',
+ 'inf',
+ 'reg',
+ 'scf',
+ 'jar',
+ 'jnlp',
+ // macOS — Terminal scripts and app bundles
+ 'command',
+ 'app',
+ // Linux — desktop entries and self-contained executables
+ 'desktop',
+ 'appimage',
+ 'run'
+])
+
+/**
+ * Returns true if the path's extension is one the OS will execute as code
+ * (script or binary), so opening it warrants a confirmation prompt.
+ */
+export const isDangerousExecutableFile = (filepath: string): boolean => {
+ if (!filepath || typeof filepath !== 'string') return false
+ // Windows strips trailing dots/spaces during ShellExecute canonicalization,
+ // so `update.js.` / `<./update.js >` still run `update.js` — strip them
+ // before reading the extension or the guard is trivially bypassed.
+ const ext = path.extname(filepath.replace(/[ .]+$/, '')).slice(1).toLowerCase()
+ return !!ext && DANGEROUS_EXECUTABLE_EXTENSIONS.includes(ext)
+}
+
/**
* Returns true if the filename matches one of the markdown extensions.
*/
diff --git a/packages/desktop/src/main/menu/actions/file.ts b/packages/desktop/src/main/menu/actions/file.ts
index e91b9b97d5..4ff54302d7 100644
--- a/packages/desktop/src/main/menu/actions/file.ts
+++ b/packages/desktop/src/main/menu/actions/file.ts
@@ -11,7 +11,7 @@ import {
} from 'electron'
import log from 'electron-log'
import { isDirectory, isFile, exists } from 'common/filesystem'
-import { MARKDOWN_EXTENSIONS, isMarkdownFile } from 'common/filesystem/paths'
+import { MARKDOWN_EXTENSIONS, isDangerousExecutableFile, isMarkdownFile } from 'common/filesystem/paths'
import { checkUpdates, userSetting } from './marktext'
import { showTabBar } from './view'
import { COMMANDS } from '../../commands'
@@ -583,7 +583,7 @@ interface FormatLinkPayload {
dirname?: string
}
-ipcMain.on('mt::format-link-click', (e, { data, dirname }: FormatLinkPayload) => {
+ipcMain.on('mt::format-link-click', async(e, { data, dirname }: FormatLinkPayload) => {
if (!data || (!data.href && !data.text)) {
return
}
@@ -629,6 +629,23 @@ ipcMain.on('mt::format-link-click', (e, { data, dirname }: FormatLinkPayload) =>
openFileOrFolder(innerWin, pathname)
}
} else {
+ // A link in an untrusted document could point at a co-located script or
+ // executable; opening it via the OS shell would run code silently (#3575).
+ if (isDangerousExecutableFile(pathname)) {
+ const { response } = await dialog.showMessageBox(win, {
+ type: 'warning',
+ buttons: [t('dialog.cancel'), t('dialog.openAnyway')],
+ defaultId: 0,
+ cancelId: 0,
+ noLink: true,
+ title: t('dialog.unsafeFileTitle'),
+ message: t('dialog.unsafeFileMessage'),
+ detail: t('dialog.unsafeFileDetail', { name: path.basename(pathname) })
+ })
+ if (response !== 1) {
+ return
+ }
+ }
shell.openPath(pathname)
}
}
diff --git a/packages/desktop/static/locales/de.json b/packages/desktop/static/locales/de.json
index e75059cc83..bbd1acb248 100644
--- a/packages/desktop/static/locales/de.json
+++ b/packages/desktop/static/locales/de.json
@@ -889,7 +889,11 @@
"replace": "Ersetzen",
"save": "Speichern",
"saveChanges": "Änderungen speichern",
- "saveFailure": "Speichern fehlgeschlagen"
+ "saveFailure": "Speichern fehlgeschlagen",
+ "unsafeFileTitle": "Möglicherweise unsichere Datei",
+ "unsafeFileMessage": "Dieser Link öffnet eine Datei, die Code ausführen kann",
+ "unsafeFileDetail": "„{name}“ ist eine ausführbare Datei oder ein Skript. Das Öffnen kann Programme auf Ihrem Computer ausführen. Fahren Sie nur fort, wenn Sie diesem Dokument vertrauen.",
+ "openAnyway": "Trotzdem öffnen"
},
"error": {
"configSchemaViolation": "Konfigurationsschema-Verletzung",
diff --git a/packages/desktop/static/locales/en.json b/packages/desktop/static/locales/en.json
index 8fcefb828b..e56f616389 100644
--- a/packages/desktop/static/locales/en.json
+++ b/packages/desktop/static/locales/en.json
@@ -889,7 +889,11 @@
"replace": "Replace",
"save": "Save",
"saveChanges": "Save changes",
- "saveFailure": "Save failure"
+ "saveFailure": "Save failure",
+ "unsafeFileTitle": "Potentially unsafe file",
+ "unsafeFileMessage": "This link opens a file that can run code",
+ "unsafeFileDetail": "\"{name}\" is an executable or script file. Opening it may run programs on your computer. Only continue if you trust this document.",
+ "openAnyway": "Open Anyway"
},
"error": {
"configSchemaViolation": "Configuration schema violation",
diff --git a/packages/desktop/static/locales/es.json b/packages/desktop/static/locales/es.json
index 114edb0517..9db59943c6 100644
--- a/packages/desktop/static/locales/es.json
+++ b/packages/desktop/static/locales/es.json
@@ -889,7 +889,11 @@
"replace": "Reemplazar",
"save": "Guardar",
"saveChanges": "Guardar cambios",
- "saveFailure": "Error al guardar"
+ "saveFailure": "Error al guardar",
+ "unsafeFileTitle": "Archivo potencialmente peligroso",
+ "unsafeFileMessage": "Este enlace abre un archivo que puede ejecutar código",
+ "unsafeFileDetail": "«{name}» es un archivo ejecutable o de script. Abrirlo podría ejecutar programas en tu equipo. Continúa solo si confías en este documento.",
+ "openAnyway": "Abrir de todos modos"
},
"error": {
"configSchemaViolation": "Violación del esquema de configuración",
diff --git a/packages/desktop/static/locales/fr.json b/packages/desktop/static/locales/fr.json
index 5ba30b45bb..216696e3ea 100644
--- a/packages/desktop/static/locales/fr.json
+++ b/packages/desktop/static/locales/fr.json
@@ -889,7 +889,11 @@
"replace": "Remplacer",
"save": "Enregistrer",
"saveChanges": "Enregistrer les modifications",
- "saveFailure": "Échec de l'enregistrement"
+ "saveFailure": "Échec de l'enregistrement",
+ "unsafeFileTitle": "Fichier potentiellement dangereux",
+ "unsafeFileMessage": "Ce lien ouvre un fichier pouvant exécuter du code",
+ "unsafeFileDetail": "« {name} » est un fichier exécutable ou un script. L’ouvrir pourrait exécuter des programmes sur votre ordinateur. Ne continuez que si vous faites confiance à ce document.",
+ "openAnyway": "Ouvrir quand même"
},
"error": {
"configSchemaViolation": "Violation du schéma de configuration",
diff --git a/packages/desktop/static/locales/ja.json b/packages/desktop/static/locales/ja.json
index 4c43940c99..f48c1ad419 100644
--- a/packages/desktop/static/locales/ja.json
+++ b/packages/desktop/static/locales/ja.json
@@ -889,7 +889,11 @@
"replace": "置換",
"save": "保存",
"saveChanges": "変更を保存",
- "saveFailure": "保存に失敗"
+ "saveFailure": "保存に失敗",
+ "unsafeFileTitle": "安全でない可能性のあるファイル",
+ "unsafeFileMessage": "このリンクはコードを実行できるファイルを開きます",
+ "unsafeFileDetail": "「{name}」は実行可能ファイルまたはスクリプトファイルです。開くとコンピューター上でプログラムが実行される可能性があります。この文書を信頼できる場合のみ続行してください。",
+ "openAnyway": "それでも開く"
},
"error": {
"configSchemaViolation": "設定スキーマ違反",
diff --git a/packages/desktop/static/locales/ko.json b/packages/desktop/static/locales/ko.json
index 9663a4ae84..84a9ea2bba 100644
--- a/packages/desktop/static/locales/ko.json
+++ b/packages/desktop/static/locales/ko.json
@@ -889,7 +889,11 @@
"replace": "바꾸기",
"save": "저장",
"saveChanges": "변경 사항 저장",
- "saveFailure": "저장 실패"
+ "saveFailure": "저장 실패",
+ "unsafeFileTitle": "안전하지 않을 수 있는 파일",
+ "unsafeFileMessage": "이 링크는 코드를 실행할 수 있는 파일을 엽니다",
+ "unsafeFileDetail": "\"{name}\"은(는) 실행 파일 또는 스크립트 파일입니다. 열면 컴퓨터에서 프로그램이 실행될 수 있습니다. 이 문서를 신뢰하는 경우에만 계속하세요.",
+ "openAnyway": "그래도 열기"
},
"error": {
"configSchemaViolation": "구성 스키마 위반",
diff --git a/packages/desktop/static/locales/pt.json b/packages/desktop/static/locales/pt.json
index 58973e6533..c8f4aceb1c 100644
--- a/packages/desktop/static/locales/pt.json
+++ b/packages/desktop/static/locales/pt.json
@@ -889,7 +889,11 @@
"replace": "Substituir",
"save": "Salvar",
"saveChanges": "Salvar alterações",
- "saveFailure": "Falha ao salvar"
+ "saveFailure": "Falha ao salvar",
+ "unsafeFileTitle": "Arquivo potencialmente inseguro",
+ "unsafeFileMessage": "Este link abre um arquivo que pode executar código",
+ "unsafeFileDetail": "\"{name}\" é um arquivo executável ou de script. Abri-lo pode executar programas no seu computador. Continue apenas se confiar neste documento.",
+ "openAnyway": "Abrir mesmo assim"
},
"error": {
"configSchemaViolation": "Violação do esquema de configuração",
diff --git a/packages/desktop/static/locales/tr.json b/packages/desktop/static/locales/tr.json
index 4f07425aad..27f46a2a69 100644
--- a/packages/desktop/static/locales/tr.json
+++ b/packages/desktop/static/locales/tr.json
@@ -889,7 +889,11 @@
"replace": "Değiştir",
"save": "Kaydet",
"saveChanges": "Değişiklikleri kaydet",
- "saveFailure": "Kaydetme hatası"
+ "saveFailure": "Kaydetme hatası",
+ "unsafeFileTitle": "Güvenli olmayabilecek dosya",
+ "unsafeFileMessage": "Bu bağlantı, kod çalıştırabilen bir dosyayı açar",
+ "unsafeFileDetail": "\"{name}\" bir yürütülebilir veya betik dosyasıdır. Açmak, bilgisayarınızda program çalıştırabilir. Yalnızca bu belgeye güveniyorsanız devam edin.",
+ "openAnyway": "Yine de aç"
},
"error": {
"configSchemaViolation": "Yapılandırma şeması ihlali",
diff --git a/packages/desktop/static/locales/zh-CN.json b/packages/desktop/static/locales/zh-CN.json
index 3b65d7ca66..d32ecab6fa 100644
--- a/packages/desktop/static/locales/zh-CN.json
+++ b/packages/desktop/static/locales/zh-CN.json
@@ -889,7 +889,11 @@
"replace": "替换",
"save": "保存",
"saveChanges": "保存修改",
- "saveFailure": "保存失败"
+ "saveFailure": "保存失败",
+ "unsafeFileTitle": "潜在不安全的文件",
+ "unsafeFileMessage": "此链接将打开一个可执行代码的文件",
+ "unsafeFileDetail": "“{name}”是可执行文件或脚本文件。打开它可能会在你的计算机上运行程序。请仅在信任此文档时继续。",
+ "openAnyway": "仍然打开"
},
"error": {
"configSchemaViolation": "配置架构违规",
diff --git a/packages/desktop/static/locales/zh-TW.json b/packages/desktop/static/locales/zh-TW.json
index 15c66f5e40..17621c18c0 100644
--- a/packages/desktop/static/locales/zh-TW.json
+++ b/packages/desktop/static/locales/zh-TW.json
@@ -889,7 +889,11 @@
"replace": "取代",
"save": "儲存",
"saveChanges": "儲存變更",
- "saveFailure": "儲存失敗"
+ "saveFailure": "儲存失敗",
+ "unsafeFileTitle": "潛在不安全的檔案",
+ "unsafeFileMessage": "此連結將開啟一個可執行程式碼的檔案",
+ "unsafeFileDetail": "「{name}」是可執行檔或指令碼檔案。開啟它可能會在你的電腦上執行程式。請僅在信任此文件時繼續。",
+ "openAnyway": "仍要開啟"
},
"error": {
"configSchemaViolation": "設定結構違規",
diff --git a/packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts b/packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts
new file mode 100644
index 0000000000..138885d238
--- /dev/null
+++ b/packages/desktop/test/unit/specs/dangerous-executable-file.spec.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from 'vitest'
+import { isDangerousExecutableFile } from 'common/filesystem/paths'
+
+// #3575 — clicking a markdown link to a co-located script/executable used to
+// call shell.openPath() with no check, so a `.js`/`.vbs`/`.bat` next to an
+// untrusted document ran code (WSH JScript) on Windows without confirmation.
+// This guard flags those extensions so the handler can confirm before opening.
+
+describe('#3575 — isDangerousExecutableFile', () => {
+ it('flags Windows Script Host script files', () => {
+ for (const ext of ['js', 'jse', 'vbs', 'vbe', 'wsf', 'wsh', 'ws', 'wsc', 'hta']) {
+ expect(isDangerousExecutableFile(`payload.${ext}`)).toBe(true)
+ }
+ })
+
+ it('flags native executables, installers and batch files', () => {
+ for (const ext of ['exe', 'com', 'scr', 'pif', 'cpl', 'msi', 'msp', 'bat', 'cmd']) {
+ expect(isDangerousExecutableFile(`payload.${ext}`)).toBe(true)
+ }
+ })
+
+ it('flags PowerShell and shortcut/registry files', () => {
+ for (const ext of ['ps1', 'psm1', 'lnk', 'reg', 'inf', 'scf', 'jar']) {
+ expect(isDangerousExecutableFile(`payload.${ext}`)).toBe(true)
+ }
+ })
+
+ it('is case-insensitive and tolerates an absolute path', () => {
+ expect(isDangerousExecutableFile('C:\\Users\\a\\Update.JS')).toBe(true)
+ expect(isDangerousExecutableFile('/tmp/run.VBS')).toBe(true)
+ })
+
+ it('flags macOS and Linux launchers, not just Windows', () => {
+ for (const name of ['run.command', 'Foo.app', 'launch.desktop', 'App.AppImage', 'installer.run']) {
+ expect(isDangerousExecutableFile(name)).toBe(true)
+ }
+ })
+
+ it('still flags when a trailing dot or space would slip past ShellExecute (#4843 review)', () => {
+ // Windows strips trailing dots/spaces, so these still run update.js.
+ expect(isDangerousExecutableFile('update.js.')).toBe(true)
+ expect(isDangerousExecutableFile('update.js ')).toBe(true)
+ expect(isDangerousExecutableFile('payload.exe...')).toBe(true)
+ expect(isDangerousExecutableFile('payload.bat ')).toBe(true)
+ })
+
+ it('does not misflag a safe file that merely ends in a dot/space', () => {
+ expect(isDangerousExecutableFile('note.md.')).toBe(false)
+ expect(isDangerousExecutableFile('photo.png ')).toBe(false)
+ })
+
+ it('does not flag documents, images or markdown', () => {
+ for (const name of ['note.md', 'photo.png', 'data.json', 'readme.txt', 'archive.zip', 'index.html']) {
+ expect(isDangerousExecutableFile(name)).toBe(false)
+ }
+ })
+
+ it('does not flag a file with no extension or an empty input', () => {
+ expect(isDangerousExecutableFile('Makefile')).toBe(false)
+ expect(isDangerousExecutableFile('')).toBe(false)
+ })
+})
From 3bc6447fa956bb076d85b968fc25584a58c22e1c Mon Sep 17 00:00:00 2001
From: Ran Luo
Date: Sat, 4 Jul 2026 20:48:40 +0800
Subject: [PATCH 4/6] fix(muya): keep soft line breaks on export via CSS, not
(#4844)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A soft line break (Shift+Enter, serialized as a bare `\n` inside a block)
shows as a line break in the editor (`.mu-content` is pre-wrap) but was lost
on export: marked renders a soft break as a space, so the two lines ran
together in the exported HTML/PDF.
Instead of forcing marked to emit `
` (which CommonMark reserves for hard
breaks — it would make the exported HTML non-conformant), keep the conformant
`\n` and render it the way the editor does, with `white-space: pre-wrap` in
exportStyle.css:
- `.markdown-body p` covers paragraphs, blockquotes and loose list items
(whose content is wrapped in ``).
- `.markdown-body li:not(:has(> p))` covers tight list items, whose soft break
is a bare `\n` directly inside the `
`. Loose items are excluded so
marked's pretty-printing newline between `
` and `` is not exposed
as a stray blank line — no DOM post-processing needed.
The exported HTML now matches the editor while staying CommonMark-conformant
(soft break = line ending; hard breaks still `
`). Verified in real Chromium
that the paragraph and tight-item breaks render and the loose item has no
stray blank line.
Fixes #3676
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../muya/src/assets/styles/exportStyle.css | 16 +++++++++
.../__tests__/softBreakExportHtml.spec.ts | 36 +++++++++++++++++++
2 files changed, 52 insertions(+)
create mode 100644 packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts
diff --git a/packages/muya/src/assets/styles/exportStyle.css b/packages/muya/src/assets/styles/exportStyle.css
index e362724954..3e7b5aad52 100644
--- a/packages/muya/src/assets/styles/exportStyle.css
+++ b/packages/muya/src/assets/styles/exportStyle.css
@@ -89,6 +89,22 @@
white-space: pre-wrap;
}
+/* Render soft line breaks (Shift+Enter → a bare `\n` inside a block) the way
+ the editor does (`.mu-content` is pre-wrap) instead of emitting a
+ non-standard `
`, so the exported HTML stays CommonMark-conformant (the
+ `\n` is a plain line ending) yet still shows the break (#3676).
+
+ `li:not(:has(> p))` targets only tight list items, whose soft break is a
+ bare `\n` directly inside the ``. Loose items wrap their content in
+ `` (handled by the `p` rule); giving them `li` pre-wrap would expose
+ marked's pretty-printing newline between `
` and `` as a stray blank
+ line, so they are deliberately excluded. Specificity stays level with the
+ earlier `.toc-container ul li` rule (no-descending-specificity). */
+.markdown-body p,
+.markdown-body li:not(:has(> p)) {
+ white-space: pre-wrap;
+}
+
.markdown-body table {
display: table;
}
diff --git a/packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts b/packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts
new file mode 100644
index 0000000000..2e09cf778d
--- /dev/null
+++ b/packages/muya/src/state/__tests__/softBreakExportHtml.spec.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { getHighlightHtml } from '../../utils/marked';
+
+// #3676 — a soft line break (Shift+Enter, serialized as a bare `\n` inside a
+// block) shows as a line break in the editor (`.mu-content` is pre-wrap) but
+// was lost on export because marked renders a soft break as a space. Rather
+// than emit a non-standard `
` (which CommonMark reserves for hard breaks),
+// the export keeps the conformant `\n` and renders it with `white-space:
+// pre-wrap` on `.markdown-body p` and `li:not(:has(> p))` (tight items only).
+//
+// These assert the HTML stays conformant — the soft break is a preserved
+// newline, never a `
`, and hard breaks are untouched. The pre-wrap
+// rendering itself (and the `:has()` exclusion of loose items) is a CSS
+// concern verified in a real browser against the export stylesheet.
+
+const OPTS = { math: false, superSubScript: false, footnote: false, frontMatter: false };
+
+describe('#3676 — soft line breaks survive export as a conformant newline', () => {
+ it('keeps a paragraph soft break as a newline, never a
', () => {
+ const html = getHighlightHtml('line one\nline two', OPTS);
+ expect(html).toContain('line one\nline two
');
+ expect(html).not.toMatch(/
/);
+ });
+
+ it('keeps a soft break inside a tight list item, never a
', () => {
+ const html = getHighlightHtml('- line A\n line B', OPTS);
+ expect(html).toMatch(/line A\nline B<\/li>/);
+ expect(html).not.toMatch(/
/);
+ });
+
+ it('leaves a real hard break (two trailing spaces) as
', () => {
+ // Sanity: the change only touches soft breaks; hard breaks are untouched.
+ const html = getHighlightHtml('line one \nline two', OPTS);
+ expect(html).toMatch(/
/);
+ });
+});
From b62ed22e9d0227aea962b095a48f3914bb2dc212 Mon Sep 17 00:00:00 2001
From: Ran Luo
Date: Sat, 4 Jul 2026 21:36:54 +0800
Subject: [PATCH 5/6] fix(muya): show escaped pipe in table cell code as | not
\| (#4849) (#4850)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An escaped pipe inside a table cell's inline code (`` `\|` ``) was
displayed with the backslash — `\|` — instead of the intended `|`. GFM
escapes a literal `|` in a table cell as `\|`; once the table is parsed
that escape is a literal pipe, so `` `\|` `` must render as `|`
(which the HTML/PDF export already does).
On import, `restoreTableEscapeCharacters` re-added the `\|` escape into the
stored cell text. Outside code the backslash rule hides it, but inside
inline code (where escapes don't apply) the backslash leaked into the
display. That re-escaping was also redundant: `stateToMarkdown.escapeText`
already re-escapes unescaped `|` in a cell on serialization.
Store the cell text as marked emits it (escape already resolved to `|`) and
let `escapeText` re-add the escape on the way out. The editor now shows the
pipe correctly and the markdown still round-trips to `` `\|` ``.
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../state/__tests__/tableEscapedPipe.spec.ts | 63 +++++++++++++++++++
packages/muya/src/state/markdownToState.ts | 14 ++---
2 files changed, 70 insertions(+), 7 deletions(-)
create mode 100644 packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts
diff --git a/packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts b/packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts
new file mode 100644
index 0000000000..d1c0f1a799
--- /dev/null
+++ b/packages/muya/src/state/__tests__/tableEscapedPipe.spec.ts
@@ -0,0 +1,63 @@
+// @vitest-environment happy-dom
+
+// #4849: an escaped pipe inside a table cell's inline code (`` `\|` ``) was
+// displayed with the backslash (`\|`) instead of the intended `|`. GFM escapes
+// `|` in table cells with a backslash; after the table is parsed that escape is
+// a literal `|`, so `` `\|` `` must render as | (as the HTML/PDF
+// export already does). The stored cell text re-added the escape, which leaks
+// into the editor's inline-code display (the backslash rule doesn't apply
+// inside code). Serialization re-escapes on its own, so the round-trip is kept.
+
+import { describe, expect, it } from 'vitest';
+import { Muya } from '../../muya';
+import { MarkdownToState } from '../markdownToState';
+import ExportMarkdown from '../stateToMarkdown';
+
+function boot(markdown: string): Muya {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const muya = new Muya(host, { markdown } as ConstructorParameters[1]);
+ muya.init();
+ return muya;
+}
+
+function codeTexts(muya: Muya): string[] {
+ return [...muya.domNode!.querySelectorAll('td code')].map(c => c.textContent ?? '');
+}
+
+function roundTrip(md: string): string {
+ const states = new MarkdownToState().generate(md);
+ return new ExportMarkdown({ listIndentation: 1 }).generate(states);
+}
+
+const TABLE = [
+ '| a | b |',
+ '| --- | --- |',
+ '| `\\|` | x |',
+ '| `\\|\\|` | y |',
+ '',
+].join('\n');
+
+describe('#4849: escaped pipe in a table cell', () => {
+ it('renders `\\|` inside code as | (no backslash) in the editor', () => {
+ const muya = boot(TABLE);
+ expect(codeTexts(muya)).toEqual(['|', '||']);
+ });
+
+ it('keeps the table structure (2 columns, 3 rows)', () => {
+ const muya = boot(TABLE);
+ expect(muya.domNode!.querySelectorAll('tr').length).toBe(3);
+ expect(muya.domNode!.querySelectorAll('tr')[2].querySelectorAll('td').length).toBe(2);
+ });
+
+ it('round-trips the escaped pipes back to `\\|` / `\\|\\|`', () => {
+ const md = roundTrip(TABLE);
+ expect(md).toContain('`\\|`');
+ expect(md).toContain('`\\|\\|`');
+ });
+
+ it('round-trips an escaped pipe in plain cell text', () => {
+ const md = roundTrip('| a | b |\n| --- | --- |\n| x \\| y | z |\n');
+ expect(md).toContain('x \\| y');
+ });
+});
diff --git a/packages/muya/src/state/markdownToState.ts b/packages/muya/src/state/markdownToState.ts
index 2b3f5efdec..32b6ea92b1 100644
--- a/packages/muya/src/state/markdownToState.ts
+++ b/packages/muya/src/state/markdownToState.ts
@@ -14,11 +14,6 @@ import logger from '../utils/logger';
import { lexBlock } from '../utils/marked';
const debug = logger('import markdown: ');
-function restoreTableEscapeCharacters(text: string) {
- // NOTE: markedjs replaces all escaped "|" ("\|") characters inside a cell with "|".
- // We have to re-escape the character to not break the table.
- return text.replace(/\|/g, '\\|');
-}
interface IMarkdownToStateOptions {
footnote: boolean;
@@ -299,12 +294,17 @@ export class MarkdownToState {
children: [],
};
+ // Store the cell text as marked emits it (with the table `\|`
+ // escape already resolved to a literal `|`), so the editor shows
+ // `` `|` `` rather than the escaped `` `\|` `` inside inline code
+ // (#4849). `escapeText` re-adds the `\|` escape on serialization,
+ // keeping the markdown round-trip intact.
tableState.children.push({
name: 'table.row',
children: header.map((h, i) => ({
name: 'table.cell' as const,
meta: { align: align[i] || 'none' },
- text: restoreTableEscapeCharacters(h.text),
+ text: h.text,
})),
});
@@ -314,7 +314,7 @@ export class MarkdownToState {
children: row.map((c, i) => ({
name: 'table.cell' as const,
meta: { align: align[i] || 'none' },
- text: restoreTableEscapeCharacters(c.text),
+ text: c.text,
})),
})),
);
From 5f54c09c5b7fafc08cb32ba964e25b6ff4bb8c6f Mon Sep 17 00:00:00 2001
From: Ran Luo
Date: Sat, 4 Jul 2026 21:54:56 +0800
Subject: [PATCH 6/6] fix(muya): preserve fenced code block info string on
round-trip (#4770) (#4846)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A fenced code block's info string was reduced to its first word on save:
only `meta.lang` (used for syntax highlighting) was kept, and the
serializer emitted just that word. So ```` ```{example, listing1-name} ````
was rewritten to ```` ```{example, ```` — and ```` ```js title="app.js" ````
lost its attributes — the moment the document was saved.
`meta.lang` must stay a single word: the code-block content adds a
`language-${lang}` class and a lang with spaces would break
`classList.add`. So preserve the full info string additively in a new
optional `meta.info`, set at parse time when the info string carries more
than the language word, and emit it on serialize. The language word still
drives highlighting; the fence now round-trips losslessly.
Serialization only trusts `meta.info` while `lang` is still its first
word, so editing the language (which rewrites `lang`) correctly drops the
stale attributes instead of re-emitting them.
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../__tests__/codeFenceInfoString.spec.ts | 53 +++++++++++++++++++
packages/muya/src/state/markdownToState.ts | 5 ++
packages/muya/src/state/stateToMarkdown.ts | 8 ++-
packages/muya/src/state/types.ts | 5 ++
4 files changed, 70 insertions(+), 1 deletion(-)
create mode 100644 packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts
diff --git a/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts b/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts
new file mode 100644
index 0000000000..b27a950e59
--- /dev/null
+++ b/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts
@@ -0,0 +1,53 @@
+// Regression for #4770: a fenced code block's info string must survive a
+// markdown -> state -> markdown round-trip. MarkText used only the first word
+// of the info string as the language (for highlighting) and serialized just
+// that word back, so `` ```{example, listing1-name} `` was rewritten to
+// `` ```{example, `` on save — dropping everything after the first space.
+
+import { describe, expect, it } from 'vitest';
+import { MarkdownToState } from '../markdownToState';
+import ExportMarkdown from '../stateToMarkdown';
+
+function roundTrip(md: string): string {
+ const states = new MarkdownToState().generate(md);
+ return new ExportMarkdown({ listIndentation: 1 }).generate(states);
+}
+
+describe('#4770: fenced code block info string round-trip', () => {
+ it('preserves a Pandoc/RMarkdown-style attribute info string', () => {
+ const md = '```{example, listing1-name}\nlabel for code listing 1\n```\n';
+ expect(roundTrip(md)).toContain('```{example, listing1-name}');
+ });
+
+ it('preserves a language followed by attributes', () => {
+ const md = '```js title="app.js"\nconst a = 1\n```\n';
+ expect(roundTrip(md)).toContain('```js title="app.js"');
+ });
+
+ it('leaves a plain single-word language unchanged (no regression)', () => {
+ const out = roundTrip('```js\nconst a = 1\n```\n');
+ expect(out).toContain('```js\n');
+ });
+
+ it('leaves a language-less fence unchanged (no regression)', () => {
+ const out = roundTrip('```\nplain\n```\n');
+ expect(out).toContain('```\n');
+ expect(out).not.toContain('```undefined');
+ });
+
+ it('uses the edited language when a stored info string is now stale', () => {
+ // Emulates the user opening ```{example, listing1-name} then changing the
+ // language to `python` via the language input: `lang` no longer matches the
+ // stored info's first word, so serialization must emit the edited language.
+ const states = [
+ {
+ name: 'code-block' as const,
+ meta: { type: 'fenced', lang: 'python', info: '{example, listing1-name}' },
+ text: 'x',
+ },
+ ];
+ const out = new ExportMarkdown({ listIndentation: 1 }).generate(states);
+ expect(out).toContain('```python\n');
+ expect(out).not.toContain('example');
+ });
+});
diff --git a/packages/muya/src/state/markdownToState.ts b/packages/muya/src/state/markdownToState.ts
index 32b6ea92b1..facfeca41a 100644
--- a/packages/muya/src/state/markdownToState.ts
+++ b/packages/muya/src/state/markdownToState.ts
@@ -449,12 +449,17 @@ export class MarkdownToState {
// but `'fenced'` reaches us at runtime via the
// walkTokens assignment — hence the cast.
const isFenced = (codeBlockStyle as 'indented' | 'fenced' | undefined) === 'fenced';
+ // Preserve the full info string when it carries more than the language
+ // word (attributes like `title="x"`, or a Pandoc/RMarkdown `{…}` block),
+ // so the fence round-trips instead of collapsing to its first word (#4770).
+ const info = infoString || '';
return {
name: 'code-block' as const,
meta: {
type: isFenced ? 'fenced' : 'indented',
lang,
...(isFenced && fenceLength && fenceLength > 3 ? { fenceLength } : {}),
+ ...(isFenced && info !== lang ? { info } : {}),
},
text: value,
};
diff --git a/packages/muya/src/state/stateToMarkdown.ts b/packages/muya/src/state/stateToMarkdown.ts
index 1945ccf9a0..4064479bc4 100644
--- a/packages/muya/src/state/stateToMarkdown.ts
+++ b/packages/muya/src/state/stateToMarkdown.ts
@@ -354,10 +354,16 @@ export default class ExportMarkdown {
const { text, meta } = state;
const textList = text.split('\n');
const { type, lang } = meta;
+ // Emit the preserved full info string (`js title="x"`, Pandoc `{…}`
+ // attributes), but only while `lang` is still its first word: once the
+ // language is edited the stored info is stale, so the edited language
+ // wins (#4770).
+ const info
+ = meta.info && meta.info.match(/\S*/)?.[0] === lang ? meta.info : lang;
if (type === 'fenced') {
const fence = '`'.repeat(this._codeFenceLength(text, meta.fenceLength));
- result.push(`${indent}${lang ? `${fence}${lang}\n` : `${fence}\n`}`);
+ result.push(`${indent}${info ? `${fence}${info}\n` : `${fence}\n`}`);
textList.forEach((text) => {
result.push(`${indent}${text}\n`);
});
diff --git a/packages/muya/src/state/types.ts b/packages/muya/src/state/types.ts
index ef0c7c6f63..3500d3fa76 100644
--- a/packages/muya/src/state/types.ts
+++ b/packages/muya/src/state/types.ts
@@ -31,6 +31,11 @@ export interface ICodeBlockState {
type: string; // "indented" | "fenced";
lang: string;
fenceLength?: number;
+ // Full fenced info string when it carries more than the language word
+ // (e.g. `js title="x"` or a Pandoc/RMarkdown `{…}` attribute block).
+ // `lang` keeps just the first word for highlighting; `info` preserves
+ // the whole string so the fence round-trips losslessly (#4770).
+ info?: string;
};
text: string;
}