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) + }) +}) 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/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; } 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), 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/__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(//); + }); +}); 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..facfeca41a 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, })), })), ); @@ -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; }