From d85420a34617797930d67a8d0e52ab435e66a4c4 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sun, 5 Jul 2026 08:04:11 +0800 Subject: [PATCH 1/6] fix(muya): load Prism component dependencies in order (fixes flaky c++ load) (#4861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadLanguage` invoked Prism's `getLoader().load()` without a Promise chainer, so the loader fired a dependent component's import without awaiting its dependency. A component whose grammar extends another (`cpp` extends `c`) could therefore evaluate before the dependency was registered: `Prism.languages.extend('c', …)` ran on `undefined` and threw "Cannot set properties of undefined (setting 'class-name')". That thrown error also left the load promise unresolved, hanging the caller — which surfaced as an intermittent 5s timeout in languageAlias.spec.ts on CI. Pass Prism's `series`/`parallel` chainer (its documented `Promise#then` / `Promise.all` hooks) so a dependency is imported and registered before its dependent. Await the composed loader promise and collect statuses directly instead of the ad-hoc deferred array (which only worked because the no-chainer loader happened to invoke callbacks synchronously). Reproduced the race locally (2/15 runs) and confirmed the fix eliminates it (0/30); added loadLanguageDependencyOrder.spec.ts pinning the dependency order (3/15 failing before the fix, deterministic after). Co-authored-by: Claude Opus 4.8 (1M context) --- .../loadLanguageDependencyOrder.spec.ts | 49 +++++++++++++++++ packages/muya/src/utils/prism/loadLanguage.ts | 55 ++++++++++--------- 2 files changed, 78 insertions(+), 26 deletions(-) create mode 100644 packages/muya/src/utils/prism/__tests__/loadLanguageDependencyOrder.spec.ts diff --git a/packages/muya/src/utils/prism/__tests__/loadLanguageDependencyOrder.spec.ts b/packages/muya/src/utils/prism/__tests__/loadLanguageDependencyOrder.spec.ts new file mode 100644 index 0000000000..4e5016b86b --- /dev/null +++ b/packages/muya/src/utils/prism/__tests__/loadLanguageDependencyOrder.spec.ts @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom + +// Regression for the flaky `c++`/`cpp` load: Prism components load their +// dependencies via `getLoader().load()`. Without a Promise `chainer`, the +// loader fires a dependent component's import without awaiting its dependency, +// so `cpp` (whose grammar `extend`s `c`) could evaluate before `c` was +// registered — `Prism.languages.extend('c', …)` then ran on `undefined` and +// threw "Cannot set properties of undefined (setting 'class-name')", which also +// left the load promise unresolved (a hang, surfacing as a CI test timeout). +// The fix loads in dependency order; this pins that contract. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import prism, { loadLanguage } from '../index'; +import { loadedLanguages } from '../loadLanguage'; + +function resetCppState() { + for (const lang of ['c', 'cpp']) { + delete (prism.languages as Record)[lang]; + loadedLanguages.delete(lang); + } +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('loadLanguage — dependency load order (flaky c++ fix)', () => { + it('loads a dependency (`c`) before the dependent (`cpp`) that extends it', async () => { + resetCppState(); + + const addOrder: string[] = []; + const realAdd = loadedLanguages.add.bind(loadedLanguages); + vi.spyOn(loadedLanguages, 'add').mockImplementation((lang: string) => { + addOrder.push(lang); + return realAdd(lang); + }); + + await expect(loadLanguage('c++')).resolves.toBeDefined(); + + // `cpp` requires `c`; the fix guarantees `c` is registered first. + expect(addOrder).toContain('c'); + expect(addOrder).toContain('cpp'); + expect(addOrder.indexOf('c')).toBeLessThan(addOrder.indexOf('cpp')); + + // And the resulting grammar is the valid extend-of-c, not `undefined`. + expect(prism.languages.cpp).toBeTruthy(); + expect(() => prism.tokenize('int main(){}', prism.languages.cpp)).not.toThrow(); + }); +}); diff --git a/packages/muya/src/utils/prism/loadLanguage.ts b/packages/muya/src/utils/prism/loadLanguage.ts index 77d6dbc531..488a46d96d 100644 --- a/packages/muya/src/utils/prism/loadLanguage.ts +++ b/packages/muya/src/utils/prism/loadLanguage.ts @@ -1,6 +1,5 @@ import components from 'prismjs/components.js'; import getLoader from 'prismjs/dependencies'; -import { getDefer } from '../index'; interface ILangLoadStatus { lang: string; @@ -77,39 +76,43 @@ function initLoadLanguage(Prism: IPrismLike) { if (!Array.isArray(langs)) langs = [langs]; - const promises: Promise[] = []; + const statuses: ILangLoadStatus[] = []; // The user might have loaded languages via some other way or used `prism.js` which already includes some // We don't need to validate the ids because `getLoader` will ignore invalid ones const loaded = [...loadedLanguages, ...Object.keys(Prism.languages)]; - getLoader(components, langs, loaded).load(async (lang: string) => { - const defer = getDefer(); - promises.push(defer.promise); + + const loadComponent = async (lang: string): Promise => { if (!(lang in components.languages)) { - defer.resolve({ - lang, - status: 'noexist', - }); - } - else if (loadedLanguages.has(lang)) { - defer.resolve({ - lang, - status: 'cached', - }); + statuses.push({ lang, status: 'noexist' }); + return; } - else { - delete Prism.languages[lang]; - await import( - `../../../node_modules/prismjs/components/prism-${lang}.js`, - ); - defer.resolve({ - lang, - status: 'loaded', - }); - loadedLanguages.add(lang); + if (loadedLanguages.has(lang)) { + statuses.push({ lang, status: 'cached' }); + return; } + delete Prism.languages[lang]; + await import( + `../../../node_modules/prismjs/components/prism-${lang}.js`, + ); + loadedLanguages.add(lang); + statuses.push({ lang, status: 'loaded' }); + }; + + // Load in dependency order: a component whose grammar `extend`s another + // (e.g. `cpp` extends `c`) must be imported only AFTER its dependency has + // registered. The `chainer`'s `series`/`parallel` are Prism's async hooks + // (`Promise#then` / `Promise.all`); without it the loader fires the + // dependent's import without awaiting the dependency, racing them — if the + // dependent evaluates first, `Prism.languages.extend('c', …)` runs on + // `undefined` and throws "Cannot set properties of undefined (setting + // 'class-name')", which also left the load promise unresolved (a hang). + await getLoader(components, langs, loaded).load(loadComponent, { + series: (before: Promise, after: () => Promise) => + before.then(after), + parallel: (values: Promise[]) => Promise.all(values), }); - return Promise.all(promises); + return statuses; }; } From 42c87f0b118e4a82ba9269a9b2bd11b56bc0b2e6 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sun, 5 Jul 2026 08:04:28 +0800 Subject: [PATCH 2/6] refactor(muya): treat the code fence info string as the source of truth (CommonMark) (#4856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(muya): add firstWordOfInfo helper for code fence info strings Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(muya): make meta.lang hold the full code fence info string Store the whole info string verbatim on meta.lang and serialize it as-is; the language is derived as its first word. Removes the redundant meta.info field and its staleness guard (the single field is now the source of truth). Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(muya): derive code highlight language from the info string's first word Co-Authored-By: Claude Opus 4.8 (1M context) * feat(muya): let the code block language input edit the full info string Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(muya): centralize code fence language derivation (review cleanup) Applies /simplify review feedback to the info-string refactor: - Derive the highlight/tokenize language once in `CodeBlockContent._lang` (returns the info string's first word) instead of at each call site. This fixes two consumers the scattered approach missed — the tabHandler markup test and the Firefox-compat backspaceHandler both read the raw field and silently mis-derived the language for a multi-word info string. - Move `firstWordOfInfo` from block/commonMark/codeBlock into utils, so the state layer no longer reaches into the block tree for it (removes a reverse state -> block runtime import). - Simplify `lang: isFenced ? info : lang` to `lang: info` in `_buildCodeState` — indented blocks have no info string, so both arms are equal. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/block/commonMark/codeBlock/index.ts | 8 ++-- .../__tests__/infoStringHighlight.spec.ts | 40 +++++++++++++++++ .../block/content/codeBlockContent/index.ts | 9 +++- .../__tests__/editInfoString.spec.ts | 43 +++++++++++++++++++ .../block/content/langInputContent/index.ts | 6 ++- .../__tests__/codeFenceInfoString.spec.ts | 16 ------- .../state/__tests__/infoStringModel.spec.ts | 25 +++++++++++ packages/muya/src/state/markdownToState.ts | 18 ++++---- packages/muya/src/state/stateToMarkdown.ts | 9 +--- packages/muya/src/state/types.ts | 8 ++-- .../utils/__tests__/firstWordOfInfo.spec.ts | 21 +++++++++ packages/muya/src/utils/index.ts | 8 ++++ 12 files changed, 166 insertions(+), 45 deletions(-) create mode 100644 packages/muya/src/block/content/codeBlockContent/__tests__/infoStringHighlight.spec.ts create mode 100644 packages/muya/src/block/content/langInputContent/__tests__/editInfoString.spec.ts create mode 100644 packages/muya/src/state/__tests__/infoStringModel.spec.ts create mode 100644 packages/muya/src/utils/__tests__/firstWordOfInfo.spec.ts diff --git a/packages/muya/src/block/commonMark/codeBlock/index.ts b/packages/muya/src/block/commonMark/codeBlock/index.ts index f647508435..3c130750f2 100644 --- a/packages/muya/src/block/commonMark/codeBlock/index.ts +++ b/packages/muya/src/block/commonMark/codeBlock/index.ts @@ -2,7 +2,7 @@ import type { Muya } from '../../../muya'; import type { ICodeBlockState } from '../../../state/types'; import type { TBlockPath } from '../../types'; import diff from 'fast-diff'; -import { diffToTextOp } from '../../../utils'; +import { diffToTextOp, firstWordOfInfo } from '../../../utils'; import { operateClassName } from '../../../utils/dom'; import logger from '../../../utils/logger'; import { loadLanguage } from '../../../utils/prism'; @@ -72,8 +72,10 @@ class CodeBlock extends Parent { operateClassName(this.domNode!, 'add', 'mu-fenced-code'); } - !!value - && loadLanguage(value) + // `value` is the full info string; load Prism for its first word only. + const language = firstWordOfInfo(value); + !!language + && loadLanguage(language) .then((infoList) => { if (!Array.isArray(infoList)) return; diff --git a/packages/muya/src/block/content/codeBlockContent/__tests__/infoStringHighlight.spec.ts b/packages/muya/src/block/content/codeBlockContent/__tests__/infoStringHighlight.spec.ts new file mode 100644 index 0000000000..6aa947c227 --- /dev/null +++ b/packages/muya/src/block/content/codeBlockContent/__tests__/infoStringHighlight.spec.ts @@ -0,0 +1,40 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest'; +import { Muya } from '../../../../muya'; + +// The `meta.lang` field now holds the whole fenced info string; the language +// used for the `language-*` class / Prism is derived as its first word +// (`firstWordOfInfo`). A multi-word info string must therefore neither crash +// the renderer (a space in `classList.add(`language-${lang}`)` would throw) nor +// leak the attributes into the class. The async Prism highlight itself is +// rAF-driven and does not run under happy-dom, so the highlighted class is +// checked in the real app (see the plan's manual verification); here we lock +// the crash-safety and the live-block round-trip. + +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; +} + +describe('code fence info string through the live block', () => { + it('does not crash on a language + attributes info string', () => { + expect(() => boot('```js title="app.js"\nconst a = 1\n```\n')).not.toThrow(); + }); + + it('does not crash on a Pandoc attribute info string', () => { + expect(() => boot('```{example, listing1-name}\nx\n```\n')).not.toThrow(); + }); + + it('never builds a language class token containing a space', () => { + const muya = boot('```js title="app.js"\nconst a = 1\n```\n'); + expect(muya.domNode!.innerHTML).not.toContain('language-js title'); + }); + + it('round-trips the full info string through the live block', () => { + const muya = boot('```js title="app.js"\nconst a = 1\n```\n'); + expect(muya.getMarkdown()).toContain('```js title="app.js"'); + }); +}); diff --git a/packages/muya/src/block/content/codeBlockContent/index.ts b/packages/muya/src/block/content/codeBlockContent/index.ts index 6c57fc4200..a789f71fab 100644 --- a/packages/muya/src/block/content/codeBlockContent/index.ts +++ b/packages/muya/src/block/content/codeBlockContent/index.ts @@ -9,7 +9,7 @@ import type { import type Code from '../../commonMark/codeBlock/code'; import type HTMLPreview from '../../commonMark/html/htmlPreview'; import { HTML_TAGS, VOID_HTML_TAGS } from '../../../config'; -import { adjustOffset, escapeHTML } from '../../../utils'; +import { adjustOffset, escapeHTML, firstWordOfInfo } from '../../../utils'; import { computeLineCount, repositionLineNumberSpans, syncLineNumbersSpans } from '../../../utils/codeBlockLineNumbers'; import { getHighlightHtml, MARKER_HASH } from '../../../utils/highlightHTML'; import prism, { loadedLanguages, transformAliasToOrigin, walkTokens } from '../../../utils/prism/index'; @@ -97,10 +97,15 @@ class CodeBlockContent extends Content { return content; } + // The language word for highlighting / tokenizing — the first word of the + // code container's info string (which may carry attributes, e.g. + // `js title="x"`). Every consumer of `_lang` wants the language, never the + // full info string (that is read from `meta.lang` directly by the language + // input), so derive it once here. private get _lang() { const { _codeContainer: codeContainer } = this; - return codeContainer ? codeContainer.lang : this._initialLang; + return firstWordOfInfo(codeContainer ? codeContainer.lang : this._initialLang); } /** diff --git a/packages/muya/src/block/content/langInputContent/__tests__/editInfoString.spec.ts b/packages/muya/src/block/content/langInputContent/__tests__/editInfoString.spec.ts new file mode 100644 index 0000000000..5644c0a84f --- /dev/null +++ b/packages/muya/src/block/content/langInputContent/__tests__/editInfoString.spec.ts @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest'; +import { Muya } from '../../../../muya'; + +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; +} + +interface ILangInput { + domNode: HTMLElement; + inputHandler: () => void; +} + +function firstLangInput(muya: Muya): ILangInput { + const codeBlock = muya.editor.scrollPage!.firstChild as unknown as { + firstContentInDescendant: () => ILangInput; + }; + return codeBlock.firstContentInDescendant(); +} + +describe('language input edits the whole info string (#4770 follow-up)', () => { + it('keeps a typed multi-word info string instead of truncating it', () => { + const muya = boot('```js\nx\n```\n'); + const li = firstLangInput(muya); + // Emulate typing the full info string into the language input, with the + // caret inside it (inputHandler reads the live selection). + li.domNode.textContent = 'js title="app.js"'; + const range = document.createRange(); + range.selectNodeContents(li.domNode); + range.collapse(false); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + li.inputHandler(); + muya.editor.jsonState.flush(); + expect(muya.getMarkdown().split('\n')[0]).toBe('```js title="app.js"'); + }); +}); diff --git a/packages/muya/src/block/content/langInputContent/index.ts b/packages/muya/src/block/content/langInputContent/index.ts index 821da3cdb3..25ac1e0fd0 100644 --- a/packages/muya/src/block/content/langInputContent/index.ts +++ b/packages/muya/src/block/content/langInputContent/index.ts @@ -55,8 +55,10 @@ class LangInputContent extends Content { override inputHandler() { const textContent = this.domNode!.textContent ?? ''; - const lang = textContent.split(/\s+/)[0]; - this._updateLanguage(lang); + // Store the whole info string; the language is derived as its first word + // elsewhere (`firstWordOfInfo`). Previously this truncated at the first + // whitespace, which dropped `title="x"` / Pandoc attributes on edit. + this._updateLanguage(textContent); } override enterHandler(event: Event) { diff --git a/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts b/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts index b27a950e59..f32d7be57e 100644 --- a/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts +++ b/packages/muya/src/state/__tests__/codeFenceInfoString.spec.ts @@ -34,20 +34,4 @@ describe('#4770: fenced code block info string round-trip', () => { 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__/infoStringModel.spec.ts b/packages/muya/src/state/__tests__/infoStringModel.spec.ts new file mode 100644 index 0000000000..e907ee2068 --- /dev/null +++ b/packages/muya/src/state/__tests__/infoStringModel.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { MarkdownToState } from '../markdownToState'; + +function codeMeta(md: string): { type: string; lang: string } { + const states = new MarkdownToState().generate(md) as Array<{ + name: string; + meta?: { type: string; lang: string }; + }>; + const block = states.find(s => s.name === 'code-block')!; + return block.meta!; +} + +describe('info string is stored whole on meta.lang', () => { + it('keeps a language + attributes verbatim', () => { + expect(codeMeta('```js title="app.js"\nx\n```\n').lang).toBe('js title="app.js"'); + }); + + it('keeps a Pandoc attribute block verbatim', () => { + expect(codeMeta('```{example, listing1-name}\nx\n```\n').lang).toBe('{example, listing1-name}'); + }); + + it('stores a plain language as-is', () => { + expect(codeMeta('```js\nx\n```\n').lang).toBe('js'); + }); +}); diff --git a/packages/muya/src/state/markdownToState.ts b/packages/muya/src/state/markdownToState.ts index facfeca41a..1c44532f7d 100644 --- a/packages/muya/src/state/markdownToState.ts +++ b/packages/muya/src/state/markdownToState.ts @@ -10,6 +10,7 @@ import type { ITaskListState, TState, } from './types'; +import { firstWordOfInfo } from '../utils'; import logger from '../utils/logger'; import { lexBlock } from '../utils/marked'; @@ -415,10 +416,10 @@ export class MarkdownToState { trimUnnecessaryCodeBlockEmptyLines: boolean, fenceLength?: number, ): TState { - // GH#697, markedjs#1387 — strip everything past the first - // whitespace; `\S*` matches the empty string so this is - // always non-null even for `infoString === ''`. - const lang = (infoString || '').match(/\S*/)?.[0] ?? ''; + // Keep the whole info string; the language for highlighting / diagram + // detection is its first word (CommonMark §4.5). + const info = (infoString || '').trim(); + const lang = firstWordOfInfo(info); let value = text; // Fix: #1265. @@ -449,17 +450,14 @@ 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, + // The full info string verbatim (empty for indented blocks); the + // language is its first word — see `firstWordOfInfo`. + lang: info, ...(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 4064479bc4..bb05294123 100644 --- a/packages/muya/src/state/stateToMarkdown.ts +++ b/packages/muya/src/state/stateToMarkdown.ts @@ -353,17 +353,12 @@ export default class ExportMarkdown { const result = []; const { text, meta } = state; const textList = text.split('\n'); + // `meta.lang` holds the full info string verbatim, so emit it as-is. 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}${info ? `${fence}${info}\n` : `${fence}\n`}`); + result.push(`${indent}${lang ? `${fence}${lang}\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 3500d3fa76..8f9f03a2bc 100644 --- a/packages/muya/src/state/types.ts +++ b/packages/muya/src/state/types.ts @@ -29,13 +29,11 @@ export interface ICodeBlockState { name: 'code-block'; meta: { type: string; // "indented" | "fenced"; + // The full fenced info string, verbatim (e.g. `js`, `js title="x"`, or a + // Pandoc/RMarkdown `{…}` block). The language for highlighting is its + // first word — derive via `firstWordOfInfo()`, never assume a single word. 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; } diff --git a/packages/muya/src/utils/__tests__/firstWordOfInfo.spec.ts b/packages/muya/src/utils/__tests__/firstWordOfInfo.spec.ts new file mode 100644 index 0000000000..fc46906940 --- /dev/null +++ b/packages/muya/src/utils/__tests__/firstWordOfInfo.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { firstWordOfInfo } from '../index'; + +describe('firstWordOfInfo', () => { + it('returns the whole word for a plain language', () => { + expect(firstWordOfInfo('js')).toBe('js'); + }); + + it('returns the first word for a language + attributes', () => { + expect(firstWordOfInfo('js title="app.js"')).toBe('js'); + }); + + it('returns the first token for a Pandoc-style attribute block', () => { + expect(firstWordOfInfo('{example, listing1-name}')).toBe('{example,'); + }); + + it('returns empty string for empty / whitespace info', () => { + expect(firstWordOfInfo('')).toBe(''); + expect(firstWordOfInfo(' ')).toBe(''); + }); +}); diff --git a/packages/muya/src/utils/index.ts b/packages/muya/src/utils/index.ts index 7d49fbb368..71c578c790 100644 --- a/packages/muya/src/utils/index.ts +++ b/packages/muya/src/utils/index.ts @@ -50,6 +50,14 @@ export const isLengthEven = (str = '') => str.length % 2 === 0; export function snakeToCamel(name: string) { return name.replace(/_([a-z])/g, (_p0, p1) => p1.toUpperCase()); } + +// The fenced code block info string's first non-whitespace run is the +// "language" used for syntax highlighting and the `language-*` class +// (CommonMark §4.5). The rest of the info string is preserved as-is on the +// block so the fence round-trips. +export function firstWordOfInfo(info: string): string { + return info.match(/\S*/)?.[0] ?? ''; +} /** * Are two arrays have intersection */ From c016c42fa05dc263d8655379055f97aea9f4409f Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sun, 5 Jul 2026 09:38:43 +0800 Subject: [PATCH 3/6] fix(muya): call h1-h6 "headings" not "headers" in the insert menu (#3811) (#4854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "/" quick-insert menu and the block "turn into" front menu labelled their h1-h6 section "HEADERS" with items "Header 1".."Header 6". In CommonMark/HTML terms those are headings; "header" means a page header or front matter, so the wording was incorrect (only in English — the other shipped locales already translated these as heading/title). Rename the section (headers -> headings) and item titles (Header N -> Heading N) in the menu config, and rename the matching i18n keys across all locale files so every translation keeps resolving (their translated values are unchanged). The internal block label ('atx-heading N') and icon paths are untouched. Co-authored-by: Claude Opus 4.8 (1M context) --- packages/muya/src/locales/de.ts | 14 +++++++------- packages/muya/src/locales/en.ts | 14 +++++++------- packages/muya/src/locales/es.ts | 14 +++++++------- packages/muya/src/locales/fr.ts | 14 +++++++------- packages/muya/src/locales/ja.ts | 14 +++++++------- packages/muya/src/locales/ko.ts | 14 +++++++------- packages/muya/src/locales/pt.ts | 14 +++++++------- packages/muya/src/locales/tr.ts | 14 +++++++------- packages/muya/src/locales/zh-CN.ts | 14 +++++++------- packages/muya/src/locales/zh-TW.ts | 14 +++++++------- .../__tests__/search.spec.ts | 13 ++++++++++++- .../muya/src/ui/paragraphQuickInsertMenu/config.ts | 14 +++++++------- 12 files changed, 89 insertions(+), 78 deletions(-) diff --git a/packages/muya/src/locales/de.ts b/packages/muya/src/locales/de.ts index 1fc782910c..49caad7d1f 100644 --- a/packages/muya/src/locales/de.ts +++ b/packages/muya/src/locales/de.ts @@ -16,12 +16,12 @@ export const de = { 'Paragraph': 'Absatz', 'Horizontal Line': 'Horizontale Linie', 'Front Matter': 'Front Matter', - 'Header 1': 'Überschrift 1', - 'Header 2': 'Überschrift 2', - 'Header 3': 'Überschrift 3', - 'Header 4': 'Überschrift 4', - 'Header 5': 'Überschrift 5', - 'Header 6': 'Überschrift 6', + 'Heading 1': 'Überschrift 1', + 'Heading 2': 'Überschrift 2', + 'Heading 3': 'Überschrift 3', + 'Heading 4': 'Überschrift 4', + 'Heading 5': 'Überschrift 5', + 'Heading 6': 'Überschrift 6', 'Table Block': 'Tabelle', 'Display Math': 'Mathematische Formel', 'HTML Block': 'HTML-Block', @@ -34,7 +34,7 @@ export const de = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': 'Grundblöcke', - 'headers': 'Überschriften', + 'headings': 'Überschriften', 'advanced blocks': 'Erweiterte Blöcke', 'list blocks': 'Listen', 'diagrams': 'Diagramme', diff --git a/packages/muya/src/locales/en.ts b/packages/muya/src/locales/en.ts index ed911ee141..da15c707dd 100644 --- a/packages/muya/src/locales/en.ts +++ b/packages/muya/src/locales/en.ts @@ -16,12 +16,12 @@ export const en = { 'Paragraph': 'Paragraph', 'Horizontal Line': 'Horizontal Line', 'Front Matter': 'Front Matter', - 'Header 1': 'Header 1', - 'Header 2': 'Header 2', - 'Header 3': 'Header 3', - 'Header 4': 'Header 4', - 'Header 5': 'Header 5', - 'Header 6': 'Header 6', + 'Heading 1': 'Heading 1', + 'Heading 2': 'Heading 2', + 'Heading 3': 'Heading 3', + 'Heading 4': 'Heading 4', + 'Heading 5': 'Heading 5', + 'Heading 6': 'Heading 6', 'Table Block': 'Table Block', 'Display Math': 'Display Math', 'HTML Block': 'HTML Block', @@ -34,7 +34,7 @@ export const en = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': 'basic blocks', - 'headers': 'headers', + 'headings': 'headings', 'advanced blocks': 'advanced blocks', 'list blocks': 'list blocks', 'diagrams': 'diagrams', diff --git a/packages/muya/src/locales/es.ts b/packages/muya/src/locales/es.ts index ea83bfcd95..1d1dd251a1 100644 --- a/packages/muya/src/locales/es.ts +++ b/packages/muya/src/locales/es.ts @@ -16,12 +16,12 @@ export const es = { 'Paragraph': 'Párrafo', 'Horizontal Line': 'Línea horizontal', 'Front Matter': 'Front Matter', - 'Header 1': 'Encabezado 1', - 'Header 2': 'Encabezado 2', - 'Header 3': 'Encabezado 3', - 'Header 4': 'Encabezado 4', - 'Header 5': 'Encabezado 5', - 'Header 6': 'Encabezado 6', + 'Heading 1': 'Encabezado 1', + 'Heading 2': 'Encabezado 2', + 'Heading 3': 'Encabezado 3', + 'Heading 4': 'Encabezado 4', + 'Heading 5': 'Encabezado 5', + 'Heading 6': 'Encabezado 6', 'Table Block': 'Tabla', 'Display Math': 'Fórmula matemática', 'HTML Block': 'Bloque HTML', @@ -34,7 +34,7 @@ export const es = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': 'bloques básicos', - 'headers': 'encabezados', + 'headings': 'encabezados', 'advanced blocks': 'bloques avanzados', 'list blocks': 'listas', 'diagrams': 'diagramas', diff --git a/packages/muya/src/locales/fr.ts b/packages/muya/src/locales/fr.ts index ae217566c3..622b4cd20e 100644 --- a/packages/muya/src/locales/fr.ts +++ b/packages/muya/src/locales/fr.ts @@ -16,12 +16,12 @@ export const fr = { 'Paragraph': 'Paragraphe', 'Horizontal Line': 'Ligne horizontale', 'Front Matter': 'Front Matter', - 'Header 1': 'Titre 1', - 'Header 2': 'Titre 2', - 'Header 3': 'Titre 3', - 'Header 4': 'Titre 4', - 'Header 5': 'Titre 5', - 'Header 6': 'Titre 6', + 'Heading 1': 'Titre 1', + 'Heading 2': 'Titre 2', + 'Heading 3': 'Titre 3', + 'Heading 4': 'Titre 4', + 'Heading 5': 'Titre 5', + 'Heading 6': 'Titre 6', 'Table Block': 'Tableau', 'Display Math': 'Formule mathématique', 'HTML Block': 'Bloc HTML', @@ -34,7 +34,7 @@ export const fr = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': 'blocs de base', - 'headers': 'titres', + 'headings': 'titres', 'advanced blocks': 'blocs avancés', 'list blocks': 'listes', 'diagrams': 'diagrammes', diff --git a/packages/muya/src/locales/ja.ts b/packages/muya/src/locales/ja.ts index b54a05e2eb..3ece39198e 100644 --- a/packages/muya/src/locales/ja.ts +++ b/packages/muya/src/locales/ja.ts @@ -16,12 +16,12 @@ export const ja = { 'Paragraph': '一般段落', 'Horizontal Line': '水平分割線', 'Front Matter': '上部情報ブロック', - 'Header 1': 'タイトル 1', - 'Header 2': 'タイトル 2', - 'Header 3': 'タイトル 3', - 'Header 4': 'タイトル 4', - 'Header 5': 'タイトル 5', - 'Header 6': 'タイトル 6', + 'Heading 1': 'タイトル 1', + 'Heading 2': 'タイトル 2', + 'Heading 3': 'タイトル 3', + 'Heading 4': 'タイトル 4', + 'Heading 5': 'タイトル 5', + 'Heading 6': 'タイトル 6', 'Table Block': '表', 'Display Math': '数式', 'HTML Block': 'HTMLブロック', @@ -34,7 +34,7 @@ export const ja = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': '基礎ブロック', - 'headers': 'タイトル', + 'headings': 'タイトル', 'advanced blocks': '高級ブロック', 'list blocks': 'リスト', 'diagrams': '図表', diff --git a/packages/muya/src/locales/ko.ts b/packages/muya/src/locales/ko.ts index 1d001234e3..51125fdc25 100644 --- a/packages/muya/src/locales/ko.ts +++ b/packages/muya/src/locales/ko.ts @@ -16,12 +16,12 @@ export const ko = { 'Paragraph': '단락', 'Horizontal Line': '수평선', 'Front Matter': '머리말 블록', - 'Header 1': '제목 1', - 'Header 2': '제목 2', - 'Header 3': '제목 3', - 'Header 4': '제목 4', - 'Header 5': '제목 5', - 'Header 6': '제목 6', + 'Heading 1': '제목 1', + 'Heading 2': '제목 2', + 'Heading 3': '제목 3', + 'Heading 4': '제목 4', + 'Heading 5': '제목 5', + 'Heading 6': '제목 6', 'Table Block': '표', 'Display Math': '수식', 'HTML Block': 'HTML 블록', @@ -34,7 +34,7 @@ export const ko = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': '기본 블록', - 'headers': '제목', + 'headings': '제목', 'advanced blocks': '고급 블록', 'list blocks': '목록', 'diagrams': '다이어그램', diff --git a/packages/muya/src/locales/pt.ts b/packages/muya/src/locales/pt.ts index 9c2728bdab..bd54634d90 100644 --- a/packages/muya/src/locales/pt.ts +++ b/packages/muya/src/locales/pt.ts @@ -16,12 +16,12 @@ export const pt = { 'Paragraph': 'Parágrafo', 'Horizontal Line': 'Linha horizontal', 'Front Matter': 'Front Matter', - 'Header 1': 'Título 1', - 'Header 2': 'Título 2', - 'Header 3': 'Título 3', - 'Header 4': 'Título 4', - 'Header 5': 'Título 5', - 'Header 6': 'Título 6', + 'Heading 1': 'Título 1', + 'Heading 2': 'Título 2', + 'Heading 3': 'Título 3', + 'Heading 4': 'Título 4', + 'Heading 5': 'Título 5', + 'Heading 6': 'Título 6', 'Table Block': 'Tabela', 'Display Math': 'Fórmula matemática', 'HTML Block': 'Bloco HTML', @@ -34,7 +34,7 @@ export const pt = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': 'blocos básicos', - 'headers': 'títulos', + 'headings': 'títulos', 'advanced blocks': 'blocos avançados', 'list blocks': 'listas', 'diagrams': 'diagramas', diff --git a/packages/muya/src/locales/tr.ts b/packages/muya/src/locales/tr.ts index 75ef60bd51..2772e6d9c3 100644 --- a/packages/muya/src/locales/tr.ts +++ b/packages/muya/src/locales/tr.ts @@ -16,12 +16,12 @@ export const tr = { 'Paragraph': 'Paragraf', 'Horizontal Line': 'Yatay Çizgi', 'Front Matter': 'Ön Bilgi', - 'Header 1': 'Başlık 1', - 'Header 2': 'Başlık 2', - 'Header 3': 'Başlık 3', - 'Header 4': 'Başlık 4', - 'Header 5': 'Başlık 5', - 'Header 6': 'Başlık 6', + 'Heading 1': 'Başlık 1', + 'Heading 2': 'Başlık 2', + 'Heading 3': 'Başlık 3', + 'Heading 4': 'Başlık 4', + 'Heading 5': 'Başlık 5', + 'Heading 6': 'Başlık 6', 'Table Block': 'Tablo Bloğu', 'Display Math': 'Matematik Bloğu', 'HTML Block': 'HTML Bloğu', @@ -34,7 +34,7 @@ export const tr = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': 'temel bloklar', - 'headers': 'başlıklar', + 'headings': 'başlıklar', 'advanced blocks': 'gelişmiş bloklar', 'list blocks': 'liste blokları', 'diagrams': 'diyagramlar', diff --git a/packages/muya/src/locales/zh-CN.ts b/packages/muya/src/locales/zh-CN.ts index d05b0fd135..01115928bf 100644 --- a/packages/muya/src/locales/zh-CN.ts +++ b/packages/muya/src/locales/zh-CN.ts @@ -16,12 +16,12 @@ export const zhCN = { 'Paragraph': '普通段落', 'Horizontal Line': '水平分割线', 'Front Matter': '顶部信息块', - 'Header 1': '标题 1', - 'Header 2': '标题 2', - 'Header 3': '标题 3', - 'Header 4': '标题 4', - 'Header 5': '标题 5', - 'Header 6': '标题 6', + 'Heading 1': '标题 1', + 'Heading 2': '标题 2', + 'Heading 3': '标题 3', + 'Heading 4': '标题 4', + 'Heading 5': '标题 5', + 'Heading 6': '标题 6', 'Table Block': '表格', 'Display Math': '数学公式', 'HTML Block': 'HTML块', @@ -34,7 +34,7 @@ export const zhCN = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': '基础块', - 'headers': '标题', + 'headings': '标题', 'advanced blocks': '高级块', 'list blocks': '列表', 'diagrams': '图表', diff --git a/packages/muya/src/locales/zh-TW.ts b/packages/muya/src/locales/zh-TW.ts index a2d5260cca..ea07c45e7c 100644 --- a/packages/muya/src/locales/zh-TW.ts +++ b/packages/muya/src/locales/zh-TW.ts @@ -16,12 +16,12 @@ export const zhTW = { 'Paragraph': '一般段落', 'Horizontal Line': '水平分隔線', 'Front Matter': '頂部資訊區塊', - 'Header 1': '標題 1', - 'Header 2': '標題 2', - 'Header 3': '標題 3', - 'Header 4': '標題 4', - 'Header 5': '標題 5', - 'Header 6': '標題 6', + 'Heading 1': '標題 1', + 'Heading 2': '標題 2', + 'Heading 3': '標題 3', + 'Heading 4': '標題 4', + 'Heading 5': '標題 5', + 'Heading 6': '標題 6', 'Table Block': '表格', 'Display Math': '數學公式', 'HTML Block': 'HTML 區塊', @@ -34,7 +34,7 @@ export const zhTW = { 'Mermaid': 'Mermaid', 'Plantuml': 'Plantuml', 'basic blocks': '基礎區塊', - 'headers': '標題', + 'headings': '標題', 'advanced blocks': '進階區塊', 'list blocks': '清單', 'diagrams': '圖表', diff --git a/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts b/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts index 7b5c25840c..fcd73884cf 100644 --- a/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts +++ b/packages/muya/src/ui/paragraphQuickInsertMenu/__tests__/search.spec.ts @@ -85,7 +85,7 @@ describe('paragraphQuickInsertMenu search() — zh-CN localized matching', () => const sectionNames = menu.renderData.map(d => d.name); expect(sectionNames).toEqual([ 'basic blocks', - 'headers', + 'headings', 'advanced blocks', 'list blocks', 'diagrams', @@ -99,6 +99,17 @@ describe('paragraphQuickInsertMenu search() — zh-CN localized matching', () => 'thematic-break', 'frontmatter', ]); + + // h1-h6 are "headings" in CommonMark, not "headers" (#3811). + const headings = menu.renderData.find(d => d.name === 'headings')!; + expect(headings.children.map(c => c.title)).toEqual([ + 'Heading 1', + 'Heading 2', + 'Heading 3', + 'Heading 4', + 'Heading 5', + 'Heading 6', + ]); }); it('search(localized fragment) keeps only sections whose i18nTitle matched', () => { diff --git a/packages/muya/src/ui/paragraphQuickInsertMenu/config.ts b/packages/muya/src/ui/paragraphQuickInsertMenu/config.ts index 41751f435a..7d4d20234e 100644 --- a/packages/muya/src/ui/paragraphQuickInsertMenu/config.ts +++ b/packages/muya/src/ui/paragraphQuickInsertMenu/config.ts @@ -100,10 +100,10 @@ export const MENU_CONFIG: IQuickInsertMenuItem[] = [ ], }, { - name: 'headers', + name: 'headings', children: [ { - title: 'Header 1', + title: 'Heading 1', subTitle: '# Lorem Ipsum...', label: 'atx-heading 1', shortCut: `${COMMAND_KEY}+1`, @@ -116,7 +116,7 @@ export const MENU_CONFIG: IQuickInsertMenuItem[] = [ icon: header1Icon, }, { - title: 'Header 2', + title: 'Heading 2', subTitle: '## Lorem Ipsum...', label: 'atx-heading 2', shortCut: `${COMMAND_KEY}+2`, @@ -129,7 +129,7 @@ export const MENU_CONFIG: IQuickInsertMenuItem[] = [ icon: header2Icon, }, { - title: 'Header 3', + title: 'Heading 3', subTitle: '### Lorem Ipsum...', label: 'atx-heading 3', shortCut: `${COMMAND_KEY}+3`, @@ -142,7 +142,7 @@ export const MENU_CONFIG: IQuickInsertMenuItem[] = [ icon: header3Icon, }, { - title: 'Header 4', + title: 'Heading 4', subTitle: '#### Lorem Ipsum...', label: 'atx-heading 4', shortCut: `${COMMAND_KEY}+4`, @@ -155,7 +155,7 @@ export const MENU_CONFIG: IQuickInsertMenuItem[] = [ icon: header4Icon, }, { - title: 'Header 5', + title: 'Heading 5', subTitle: '##### Lorem Ipsum...', label: 'atx-heading 5', shortCut: `${COMMAND_KEY}+5`, @@ -168,7 +168,7 @@ export const MENU_CONFIG: IQuickInsertMenuItem[] = [ icon: header5Icon, }, { - title: 'Header 6', + title: 'Heading 6', subTitle: '###### Lorem Ipsum...', label: 'atx-heading 6', shortCut: `${COMMAND_KEY}+6`, From 642a0ea525ff72b1cc0d9b90ef34546c08e2b81b Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sun, 5 Jul 2026 09:56:09 +0800 Subject: [PATCH 4/6] fix(desktop): don't crash on unwatchable image dirs (UNC/WSL) (#3779) (#4853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image-path auto-complete helper calls fs.watch() on the directory of the path being typed, with no error handling. fs.watch throws synchronously for directories the OS can't watch — notably UNC / \\wsl.localhost network paths on Windows (EISDIR). The throw happened inside the fs.readdir callback of searchFilesAndDir, so it escaped as an uncaught exception and surfaced to the user as the generic "Unexpected error occurred in the main process" dialog. Wrap fs.watch in try/catch and attach an 'error' listener to the watcher so auto-complete silently degrades to "not watching" that directory instead of crashing the main process. Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/main/utils/imagePathAutoComplement.ts | 28 +++++++++++++++---- .../specs/image-path-autocomplete.spec.ts | 24 +++++++++++++++- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src/main/utils/imagePathAutoComplement.ts b/packages/desktop/src/main/utils/imagePathAutoComplement.ts index 96933bb53a..38dc1c470b 100644 --- a/packages/desktop/src/main/utils/imagePathAutoComplement.ts +++ b/packages/desktop/src/main/utils/imagePathAutoComplement.ts @@ -64,12 +64,28 @@ const rebuild = (directory: string): void => { const watchDirectory = (directory: string): void => { if (watchers.has(directory)) return // Do not duplicate watch the same directory - const watcher = fs.watch(directory, (eventType, _filename) => { - if (eventType === 'rename') { - rebuild(directory) - } - }) - watchers.set(directory, watcher) + try { + const watcher = fs.watch(directory, (eventType, _filename) => { + if (eventType === 'rename') { + rebuild(directory) + } + }) + // Some directories become unwatchable after construction (network mounts + // dropping, permission changes); swallow the error and stop watching + // rather than leaking an uncaught exception into the main process. + watcher.on('error', (err) => { + log.error('imagePathAutoComplement::watchDirectory:', err) + watcher.close() + watchers.delete(directory) + }) + watchers.set(directory, watcher) + } catch (err) { + // `fs.watch` throws synchronously for directories the OS can't watch — + // e.g. UNC / \\wsl.localhost network paths on Windows (EISDIR). Image-path + // auto-complete must degrade to "not watching" instead of crashing the + // main process with an "Unexpected error" dialog (#3779). + log.error('imagePathAutoComplement::watchDirectory:', err) + } } export const searchFilesAndDir = (directory: string, key: string): Promise => { diff --git a/packages/desktop/test/unit/specs/image-path-autocomplete.spec.ts b/packages/desktop/test/unit/specs/image-path-autocomplete.spec.ts index de7a01f2be..423930fe8d 100644 --- a/packages/desktop/test/unit/specs/image-path-autocomplete.spec.ts +++ b/packages/desktop/test/unit/specs/image-path-autocomplete.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' @@ -103,4 +103,26 @@ describe('searchFilesAndDir', () => { await expect(searchFilesAndDir(missing, '')).rejects.toBeTruthy() }) + + it('still resolves when the directory cannot be watched (UNC/WSL paths, #3779)', async() => { + const dir = seedDir() + tmpDirs.push(dir) + + // fs.watch throws synchronously for unwatchable dirs (e.g. \\wsl.localhost + // UNC paths on Windows -> EISDIR). This used to escape as an uncaught + // exception -> "Unexpected error in the main process" dialog. + const spy = vi.spyOn(fs, 'watch').mockImplementation(() => { + throw Object.assign(new Error('EISDIR: illegal operation on a directory, watch'), { + code: 'EISDIR' + }) + }) + + const result = await searchFilesAndDir(dir, '') + + expect(result.some((e) => e.file === 'a.png')).toBe(true) + // The unwatchable directory is simply not tracked. + expect(watchers.has(dir)).toBe(false) + + spy.mockRestore() + }) }) From 6c23b1bafed5d8a2d3ca7779870a68bae2efb1f3 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sun, 5 Jul 2026 10:58:29 +0800 Subject: [PATCH 5/6] fix(desktop): save files atomically to prevent data loss on crash (#4852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): save files atomically via temp-file + rename (#3786, #3828) writeFile() wrote directly to the destination with fs-extra outputFile, which truncates the existing file before streaming the new content. A crash, BSOD, or power loss mid-write therefore left the user's document truncated to 0 bytes (one report attached a 44393-byte file that was entirely NUL). This is the data-loss the maintainers' own TODO in writeMarkdownFile flagged as "safeSaveDocuments". Write to a temp file in the same directory and rename it over the target instead. The rename is atomic on a single volume (guaranteed by keeping the temp file in the same directory), so an interrupted write can only corrupt the throwaway temp file, never the existing document. outputFile still recreates any missing parent directory first, preserving #3509. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(desktop): make the atomic save durable against power loss (#3786, #3828) Addressing review: the temp-file + rename change fixed the application-crash case but not the power-loss / OS-reboot case the issues actually describe. A bare rename is namespace-atomic but not data-durable: after a power loss the new directory entry can point at an inode whose data blocks were never flushed — a full-length, zero-filled file, exactly the 44393-byte all-0x00 artifact in #3786. It also dropped the in-place write's mode/owner preservation, symlink follow-through, and unique temp naming. Delegate to write-file-atomic (fsync-before-rename), which closes the power-loss window and restores those behaviors: it stat/chmod/chowns the temp to match the target, writes through a symlink via realpath, and uses a collision-free temp name. ensureDir keeps the #3509 missing-directory behavior. Tests now cover overwrite/buffer/missing-dir and permission-mode preservation. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(desktop): type write-file-atomic via @types instead of a local shim @types/write-file-atomic@4.0.3 declares the same public API write-file-atomic 7 exposes (its signature has been stable since v4), so drop the hand-rolled module shim for it. Every writeFile caller passes a BufferEncoding or nothing, so narrow the options parameter from WriteFileOptions to BufferEncoding — that also sidesteps the Node Mode (string | number) vs @types mode (number) mismatch cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- packages/desktop/package.json | 4 +- packages/desktop/src/main/filesystem/index.ts | 24 ++++-- .../desktop/src/main/filesystem/markdown.ts | 1 - .../test/unit/specs/write-file-atomic.spec.ts | 81 +++++++++++++++++++ pnpm-lock.yaml | 17 +++- 5 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 packages/desktop/test/unit/specs/write-file-atomic.spec.ts diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 35b982fd13..af7d1f7045 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -107,7 +107,8 @@ "vega-embed": "^7.1.0", "vue-i18n": "^11.4.6", "vue-router": "^4.6.4", - "webfontloader": "^1.6.28" + "webfontloader": "^1.6.28", + "write-file-atomic": "^7.0.1" }, "optionalDependencies": { "native-keymap": "^3.3.9" @@ -126,6 +127,7 @@ "@types/node": "^22.20.0", "@types/turndown": "^5.0.6", "@types/webfontloader": "^1.6.38", + "@types/write-file-atomic": "^4.0.3", "@vitejs/plugin-vue": "^6.0.7", "@vitest/coverage-v8": "^4.1.9", "cross-env": "^10.1.0", diff --git a/packages/desktop/src/main/filesystem/index.ts b/packages/desktop/src/main/filesystem/index.ts index ccf848bf51..2180b3f819 100644 --- a/packages/desktop/src/main/filesystem/index.ts +++ b/packages/desktop/src/main/filesystem/index.ts @@ -1,5 +1,6 @@ -import { readlinkSync, outputFile, type WriteFileOptions } from 'fs-extra' +import { readlinkSync, ensureDir } from 'fs-extra' import path from 'path' +import writeFileAtomic from 'write-file-atomic' import { isDirectory, isFile, isSymbolicLink } from 'common/filesystem' /** @@ -21,19 +22,28 @@ export const normalizeAndResolvePath = (pathname: string): string => { return path.resolve(pathname) } -export const writeFile = ( +export const writeFile = async( pathname: string, content: string | Buffer, extension?: string, - options: WriteFileOptions | undefined = 'utf-8' + options: BufferEncoding | undefined = 'utf-8' ): Promise => { if (!pathname) { return Promise.reject(new Error('[ERROR] Cannot save file without path.')) } pathname = !extension || pathname.endsWith(extension) ? pathname : `${pathname}${extension}` - // `outputFile` creates any missing parent directories before writing, so a - // save whose folder was moved/deleted recreates it and still succeeds — - // matching VS Code, and keeping (auto)save from ever silently failing (#3509). - return outputFile(pathname, content, options) + // write-file-atomic does not create parent directories; recreate a moved or + // deleted folder first so an (auto)save into it still succeeds (#3509). + await ensureDir(path.dirname(pathname)) + + // Durable atomic save: write to a temp file in the target's directory, fsync + // it, then rename it over the target. This survives an application crash AND + // a power loss / OS reboot — the fsync before the rename is what closes the + // power-loss window that otherwise leaves a full-length, zero-filled file + // (#3786, #3828); a bare rename is only namespace-atomic, not data-durable. + // write-file-atomic also preserves the target's mode/owner, writes through a + // symlink to its target, and uses a unique temp name — all of which a plain + // temp+rename dropped. + await writeFileAtomic(pathname, content, options) } diff --git a/packages/desktop/src/main/filesystem/markdown.ts b/packages/desktop/src/main/filesystem/markdown.ts index 52a4a8564c..e7f7069756 100644 --- a/packages/desktop/src/main/filesystem/markdown.ts +++ b/packages/desktop/src/main/filesystem/markdown.ts @@ -81,7 +81,6 @@ export const writeMarkdownFile = ( const buffer = iconv.encode(content, encoding, { addBOM: isBom }) - // TODO(@fxha): "safeSaveDocuments" using temporary file and rename syscall. return writeFile(pathname, buffer, extension, undefined) } diff --git a/packages/desktop/test/unit/specs/write-file-atomic.spec.ts b/packages/desktop/test/unit/specs/write-file-atomic.spec.ts new file mode 100644 index 0000000000..a712e7843a --- /dev/null +++ b/packages/desktop/test/unit/specs/write-file-atomic.spec.ts @@ -0,0 +1,81 @@ +import { + chmodSync, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'fs' +import { tmpdir } from 'os' +import path from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { writeFile } from 'main_renderer/filesystem' + +// #3786 / #3828: saves must survive an application crash AND a power loss. +// writeFile writes to a temp file, fsyncs it, then renames it over the target +// (via write-file-atomic), so an interrupted or unflushed write can never leave +// the document truncated or zero-filled — and, unlike a plain temp+rename, the +// target's permission mode is preserved across the save. + +const dirs: string[] = [] +function tempDir(): string { + const d = mkdtempSync(path.join(tmpdir(), 'mt-atomic-')) + dirs.push(d) + return d +} + +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +describe('writeFile — durable atomic save (#3786, #3828)', () => { + it('overwrites an existing file and leaves no temp file behind', async() => { + const dir = tempDir() + const target = path.join(dir, 'note.md') + writeFileSync(target, 'OLD') + + await writeFile(target, 'NEW', undefined) + + expect(readFileSync(target, 'utf-8')).toBe('NEW') + // The temp file was renamed over the target — nothing left in the dir. + expect(readdirSync(dir)).toEqual(['note.md']) + }) + + it('writes a Buffer payload (the markdown save path)', async() => { + const dir = tempDir() + const target = path.join(dir, 'note.md') + + await writeFile(target, Buffer.from('buffered', 'utf-8'), undefined) + + expect(readFileSync(target, 'utf-8')).toBe('buffered') + }) + + it('still recreates a missing parent directory (#3509)', async() => { + const base = tempDir() + const target = path.join(base, 'moved-away', 'note.md') + + await writeFile(target, 'hello', undefined) + + expect(existsSync(path.dirname(target))).toBe(true) + expect(readFileSync(target, 'utf-8')).toBe('hello') + }) + + it.skipIf(process.platform === 'win32')( + 'preserves the target file\'s permission mode across a save', + async() => { + const dir = tempDir() + const target = path.join(dir, 'secret.md') + writeFileSync(target, 'v1') + chmodSync(target, 0o600) + + await writeFile(target, 'v2', undefined) + + expect(readFileSync(target, 'utf-8')).toBe('v2') + // A plain temp+rename would install a fresh 0o644 inode; write-file-atomic + // restores the original mode. + expect(statSync(target).mode & 0o777).toBe(0o600) + } + ) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51b617fcde..61d01598eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: webfontloader: specifier: ^1.6.28 version: 1.6.28 + write-file-atomic: + specifier: ^7.0.1 + version: 7.0.1 devDependencies: '@electron/rebuild': specifier: ^4.0.4 @@ -259,6 +262,9 @@ importers: '@types/webfontloader': specifier: ^1.6.38 version: 1.6.38 + '@types/write-file-atomic': + specifier: ^4.0.3 + version: 4.0.3 '@vitejs/plugin-vue': specifier: ^6.0.7 version: 6.0.7(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) @@ -3561,6 +3567,9 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/write-file-atomic@4.0.3': + resolution: {integrity: sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -12504,6 +12513,10 @@ snapshots: '@types/whatwg-mimetype@3.0.2': {} + '@types/write-file-atomic@4.0.3': + dependencies: + '@types/node': 22.20.0 + '@types/ws@8.18.1': dependencies: '@types/node': 26.0.0 @@ -12906,7 +12919,7 @@ snapshots: magicast: 0.5.3 obug: 2.1.3 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.0.0)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.16.9)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -12922,7 +12935,7 @@ snapshots: obug: 2.1.3 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.16.9)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@26.0.0)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.6)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.16.9)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/eslint-plugin@1.6.20(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.9)': dependencies: From ac273f4653c1ab6bc425b4fcd633f4c813db3e45 Mon Sep 17 00:00:00 2001 From: Ran Luo Date: Sun, 5 Jul 2026 10:59:22 +0800 Subject: [PATCH 6/6] fix(desktop): make crash-recovery buffer writes durable against power loss (#4864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(desktop): add write-file-atomic dependency Adds write-file-atomic (+ @types) for durable, atomic file writes. Used by the crash-recovery buffer store; the document save path adopts it separately in #4852. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(desktop): make crash-recovery buffer writes durable against power loss writeBufferStoreFile persists unsaved tab content (the crash-recovery buffer) with a temp-file + rename. That is namespace-atomic — safe against an application crash — but omits fsync, so a power loss / OS reboot can still commit the new directory entry while the data blocks were never flushed, leaving a truncated or full-length zero-filled buffer. This is the same gap the document save path had (#3786). Write through write-file-atomic's sync API (fsync before the atomic rename), which also removes the hand-rolled temp/rename/cleanup and the writeSequence counter (its unique temp naming is handled internally). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/main/editorBufferStore/index.ts | 29 +++-------- .../unit/specs/buffer-store-durable.spec.ts | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+), 22 deletions(-) create mode 100644 packages/desktop/test/unit/specs/buffer-store-durable.spec.ts diff --git a/packages/desktop/src/main/editorBufferStore/index.ts b/packages/desktop/src/main/editorBufferStore/index.ts index e4f7a186b6..db24feb18d 100644 --- a/packages/desktop/src/main/editorBufferStore/index.ts +++ b/packages/desktop/src/main/editorBufferStore/index.ts @@ -1,5 +1,6 @@ import fs from 'fs' import path from 'path' +import writeFileAtomic from 'write-file-atomic' import { BrowserWindow, ipcMain, type IpcMainInvokeEvent } from 'electron' import { TypedEmitter } from '@shared/types/typedEmitter' import type BaseWindow from '../windows/base' @@ -32,7 +33,6 @@ class EditorBufferStore extends TypedEmitter { bufferStores: Record | null serviceName: string encryptKeys: string[] - writeSequence: number constructor(paths: EditorBufferStorePaths) { super() @@ -45,7 +45,6 @@ class EditorBufferStore extends TypedEmitter { this.bufferStores = null this.serviceName = 'marktext' this.encryptKeys = [] - this.writeSequence = 0 this.init() } @@ -177,26 +176,12 @@ class EditorBufferStore extends TypedEmitter { } writeBufferStoreFile(filePath: string, newState: unknown): void { - const tempPath = path.join( - path.dirname(filePath), - `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${++this.writeSequence}.tmp` - ) - - try { - // Write temp file first, then rename to the final file for atomicity - // and reduced risk of data corruption. - fs.writeFileSync(tempPath, JSON.stringify(newState), 'utf8') - fs.renameSync(tempPath, filePath) - } catch (err) { - try { - if (fs.existsSync(tempPath)) { - fs.unlinkSync(tempPath) - } - } catch (cleanupErr) { - console.error('Failed to clean up temporary buffer store file', cleanupErr) - } - throw err - } + // Durable atomic write: write-file-atomic writes to a temp file, fsyncs it, + // then renames it over the target. The previous temp-file + rename here was + // namespace-atomic (crash-safe) but omitted the fsync, so a power loss could + // still leave this crash-recovery buffer — which holds unsaved tab content — + // truncated or zero-filled, the same gap the document save path had (#3786). + writeFileAtomic.sync(filePath, JSON.stringify(newState), 'utf8') } updateBufferState(e: IpcMainInvokeEvent, newState: unknown): boolean { diff --git a/packages/desktop/test/unit/specs/buffer-store-durable.spec.ts b/packages/desktop/test/unit/specs/buffer-store-durable.spec.ts new file mode 100644 index 0000000000..880763ec4c --- /dev/null +++ b/packages/desktop/test/unit/specs/buffer-store-durable.spec.ts @@ -0,0 +1,52 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// The store registers ipcMain handlers in its constructor; stub electron so the +// module imports without a real main process. writeBufferStoreFile no longer +// touches `this`, so we exercise it via the prototype without booting the store. +vi.mock('electron', () => ({})) + +const { default: EditorBufferStore } = await import('main_renderer/editorBufferStore') + +// #4852 follow-up: the crash-recovery buffer holds unsaved tab content but used +// a temp+rename with no fsync — the same power-loss zero-fill gap the document +// save path had. writeBufferStoreFile now writes durably via write-file-atomic. +const writeBufferStoreFile = EditorBufferStore.prototype.writeBufferStoreFile + +const dirs: string[] = [] +function tempDir(): string { + const d = mkdtempSync(path.join(tmpdir(), 'mt-buf-')) + dirs.push(d) + return d +} + +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +describe('EditorBufferStore.writeBufferStoreFile — durable atomic write (#4852 follow-up)', () => { + it('writes the state as JSON and leaves no temp file behind', () => { + const dir = tempDir() + const target = path.join(dir, 'buffer.json') + const state = { tabs: [{ id: '1', markdown: 'hello' }] } + + writeBufferStoreFile(target, state) + + expect(JSON.parse(readFileSync(target, 'utf8'))).toEqual(state) + // The temp file was renamed over the target — nothing left in the dir. + expect(readdirSync(dir)).toEqual(['buffer.json']) + }) + + it('overwrites an existing buffer file', () => { + const dir = tempDir() + const target = path.join(dir, 'buffer.json') + + writeBufferStoreFile(target, { tabs: ['old'] }) + writeBufferStoreFile(target, { tabs: ['new'] }) + + expect(JSON.parse(readFileSync(target, 'utf8'))).toEqual({ tabs: ['new'] }) + expect(readdirSync(dir)).toEqual(['buffer.json']) + }) +})