Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions third_party/muya/src/block/commonMark/codeBlock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof Muya>[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"');
});
});
4 changes: 2 additions & 2 deletions third_party/muya/src/block/content/codeBlockContent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof Muya>[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"');
});
});
6 changes: 4 additions & 2 deletions third_party/muya/src/block/content/langInputContent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 4 additions & 14 deletions third_party/muya/src/state/__tests__/codeFenceInfoString.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});
25 changes: 25 additions & 0 deletions third_party/muya/src/state/__tests__/infoStringModel.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
15 changes: 8 additions & 7 deletions third_party/muya/src/state/markdownToState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ITaskListState,
TState,
} from './types';
import { firstWordOfInfo } from '../utils';
import logger from '../utils/logger';
import { lexBlock } from '../utils/marked';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
Expand Down
4 changes: 2 additions & 2 deletions third_party/muya/src/state/stateToMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
});
Expand Down
4 changes: 3 additions & 1 deletion third_party/muya/src/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
21 changes: 21 additions & 0 deletions third_party/muya/src/utils/__tests__/firstWordOfInfo.spec.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
8 changes: 8 additions & 0 deletions third_party/muya/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading