diff --git a/third_party/muya/src/block/commonMark/codeBlock/index.ts b/third_party/muya/src/block/commonMark/codeBlock/index.ts index f647508..3c13075 100644 --- a/third_party/muya/src/block/commonMark/codeBlock/index.ts +++ b/third_party/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/third_party/muya/src/block/content/codeBlockContent/__tests__/infoStringHighlight.spec.ts b/third_party/muya/src/block/content/codeBlockContent/__tests__/infoStringHighlight.spec.ts new file mode 100644 index 0000000..745ce9e --- /dev/null +++ b/third_party/muya/src/block/content/codeBlockContent/__tests__/infoStringHighlight.spec.ts @@ -0,0 +1,31 @@ +// @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; +} + +describe('code fence info string through the live block', () => { + it('does not crash on a language plus 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/third_party/muya/src/block/content/codeBlockContent/index.ts b/third_party/muya/src/block/content/codeBlockContent/index.ts index 6c57fc4..e3643b2 100644 --- a/third_party/muya/src/block/content/codeBlockContent/index.ts +++ b/third_party/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'; @@ -100,7 +100,7 @@ class CodeBlockContent extends Content { private get _lang() { const { _codeContainer: codeContainer } = this; - return codeContainer ? codeContainer.lang : this._initialLang; + return firstWordOfInfo(codeContainer ? codeContainer.lang : this._initialLang); } /** diff --git a/third_party/muya/src/block/content/langInputContent/__tests__/editInfoString.spec.ts b/third_party/muya/src/block/content/langInputContent/__tests__/editInfoString.spec.ts new file mode 100644 index 0000000..74ada81 --- /dev/null +++ b/third_party/muya/src/block/content/langInputContent/__tests__/editInfoString.spec.ts @@ -0,0 +1,41 @@ +// @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', () => { + it('keeps a typed multi-word info string instead of truncating it', () => { + const muya = boot('```js\nx\n```\n'); + const li = firstLangInput(muya); + 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/third_party/muya/src/block/content/langInputContent/index.ts b/third_party/muya/src/block/content/langInputContent/index.ts index 821da3c..25ac1e0 100644 --- a/third_party/muya/src/block/content/langInputContent/index.ts +++ b/third_party/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/third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts b/third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts index 3967571..1f35bf7 100644 --- a/third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts +++ b/third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts @@ -33,18 +33,8 @@ describe('#4770: fenced code block info string round-trip', () => { expect(output).not.toContain('```undefined'); }); - it('uses the edited language when a stored info string is now stale', () => { - const states = [ - { - name: 'code-block' as const, - meta: { type: 'fenced', lang: 'python', info: '{example, listing1-name}' }, - text: 'x', - }, - ]; - - const output = new ExportMarkdown({ listIndentation: 1 }).generate(states); - - expect(output).toContain('```python\n'); - expect(output).not.toContain('example'); - }); + // The stale-`meta.info` guard test from the interim #4770 port is gone on + // purpose: since #4856 the model stores the whole info string on + // `meta.lang` itself, so an edited language can no longer disagree with a + // separately stored info string. }); diff --git a/third_party/muya/src/state/__tests__/infoStringModel.spec.ts b/third_party/muya/src/state/__tests__/infoStringModel.spec.ts new file mode 100644 index 0000000..6c455d0 --- /dev/null +++ b/third_party/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 plus 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/third_party/muya/src/state/markdownToState.ts b/third_party/muya/src/state/markdownToState.ts index e42878e..b1bc79c 100644 --- a/third_party/muya/src/state/markdownToState.ts +++ b/third_party/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'; @@ -419,10 +420,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. @@ -453,14 +454,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'; - 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/third_party/muya/src/state/stateToMarkdown.ts b/third_party/muya/src/state/stateToMarkdown.ts index e42feab..b21a6ac 100644 --- a/third_party/muya/src/state/stateToMarkdown.ts +++ b/third_party/muya/src/state/stateToMarkdown.ts @@ -353,12 +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; - 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/third_party/muya/src/state/types.ts b/third_party/muya/src/state/types.ts index cb9de49..1246e4b 100644 --- a/third_party/muya/src/state/types.ts +++ b/third_party/muya/src/state/types.ts @@ -29,9 +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; - info?: string; }; text: string; } diff --git a/third_party/muya/src/utils/__tests__/firstWordOfInfo.spec.ts b/third_party/muya/src/utils/__tests__/firstWordOfInfo.spec.ts new file mode 100644 index 0000000..08deb3a --- /dev/null +++ b/third_party/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 plus 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 or whitespace info', () => { + expect(firstWordOfInfo('')).toBe(''); + expect(firstWordOfInfo(' ')).toBe(''); + }); +}); diff --git a/third_party/muya/src/utils/index.ts b/third_party/muya/src/utils/index.ts index 7d49fbb..71c578c 100644 --- a/third_party/muya/src/utils/index.ts +++ b/third_party/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 */