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/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/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/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/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']) + }) +}) 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() + }) }) 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/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/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/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/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`, 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 */ 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; }; } 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: