diff --git a/third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts b/third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts new file mode 100644 index 0000000..3967571 --- /dev/null +++ b/third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { MarkdownToState } from '../markdownToState'; +import ExportMarkdown from '../stateToMarkdown'; + +function roundTrip(markdown: string): string { + const states = new MarkdownToState().generate(markdown); + return new ExportMarkdown({ listIndentation: 1 }).generate(states); +} + +describe('#4770: fenced code block info string round-trip', () => { + it('preserves a Pandoc/RMarkdown-style attribute info string', () => { + const markdown = '```{example, listing1-name}\nlabel for code listing 1\n```\n'; + + expect(roundTrip(markdown)).toContain('```{example, listing1-name}'); + }); + + it('preserves a language followed by attributes', () => { + const markdown = '```js title="app.js"\nconst a = 1\n```\n'; + + expect(roundTrip(markdown)).toContain('```js title="app.js"'); + }); + + it('leaves a plain single-word language unchanged', () => { + const output = roundTrip('```js\nconst a = 1\n```\n'); + + expect(output).toContain('```js\n'); + }); + + it('leaves a language-less fence unchanged', () => { + const output = roundTrip('```\nplain\n```\n'); + + expect(output).toContain('```\n'); + 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'); + }); +}); diff --git a/third_party/muya/src/state/markdownToState.ts b/third_party/muya/src/state/markdownToState.ts index b029023..a049bc5 100644 --- a/third_party/muya/src/state/markdownToState.ts +++ b/third_party/muya/src/state/markdownToState.ts @@ -458,12 +458,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, ...(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 acf7e53..e42feab 100644 --- a/third_party/muya/src/state/stateToMarkdown.ts +++ b/third_party/muya/src/state/stateToMarkdown.ts @@ -354,10 +354,11 @@ export default class ExportMarkdown { const { text, meta } = state; const textList = text.split('\n'); 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}${lang ? `${fence}${lang}\n` : `${fence}\n`}`); + result.push(`${indent}${info ? `${fence}${info}\n` : `${fence}\n`}`); textList.forEach((text) => { result.push(`${indent}${text}\n`); }); diff --git a/third_party/muya/src/state/types.ts b/third_party/muya/src/state/types.ts index 051c2bd..cb9de49 100644 --- a/third_party/muya/src/state/types.ts +++ b/third_party/muya/src/state/types.ts @@ -31,6 +31,7 @@ export interface ICodeBlockState { type: string; // "indented" | "fenced"; lang: string; fenceLength?: number; + info?: string; }; text: string; }