Skip to content

Commit 2ec9a1d

Browse files
authored
Merge branch 'main' into fix-block-equation-markdown
2 parents ed65760 + 168f803 commit 2ec9a1d

23 files changed

Lines changed: 411 additions & 893 deletions

File tree

packages/lexical-code-core/flow/LexicalCodeCore.js.flow

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ declare export function $outdentLeadingSpaces(
7979
selection: RangeSelection,
8080
): boolean;
8181

82+
declare export function $plainifyCodeContent(text: string): LexicalNode[];
83+
8284
/**
8385
* CodeNode
8486
*/

packages/lexical-code-core/src/CodeNode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type SerializedCodeNode = Spread<
5656
>;
5757

5858
export const DEFAULT_CODE_LANGUAGE = 'javascript';
59+
/** @internal Configurable through the extensions. */
5960
export const getDefaultCodeLanguage = (): string => DEFAULT_CODE_LANGUAGE;
6061

6162
function hasChildDOMNodeTag(node: Node, tagName: string) {

packages/lexical-code-core/src/FlatStructureUtils.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import type {
1717
} from 'lexical';
1818

1919
import {
20+
$createLineBreakNode,
21+
$createTabNode,
2022
$getSiblingCaret,
2123
$isElementNode,
2224
$isLineBreakNode,
@@ -25,7 +27,10 @@ import {
2527
} from 'lexical';
2628
import invariant from 'shared/invariant';
2729

28-
import {$isCodeHighlightNode} from './CodeHighlightNode';
30+
import {
31+
$createCodeHighlightNode,
32+
$isCodeHighlightNode,
33+
} from './CodeHighlightNode';
2934

3035
function $getLastMatchingCodeNode<D extends CaretDirection>(
3136
anchor: CodeHighlightNode | TabNode | LineBreakNode,
@@ -222,6 +227,34 @@ export function $getEndOfCodeInLine(
222227
return lastNode;
223228
}
224229

230+
/**
231+
* Plain split of code text into CodeHighlightNodes (with no highlight
232+
* type) + LineBreakNodes + TabNodes. Used when the tokenizer opts out
233+
* of a default language so a previously highlighted block still
234+
* renders its `\n` / `\t` as real line breaks / tabs, while staying
235+
* compatible with the indent / shift-lines handlers that only accept
236+
* CodeHighlightNode + TabNode + LineBreakNode inside a CodeNode.
237+
*/
238+
export function $plainifyCodeContent(text: string): LexicalNode[] {
239+
const out: LexicalNode[] = [];
240+
const lines = text.split('\n');
241+
lines.forEach((line, lineIdx) => {
242+
if (lineIdx > 0) {
243+
out.push($createLineBreakNode());
244+
}
245+
const tabParts = line.split('\t');
246+
tabParts.forEach((part, partIdx) => {
247+
if (partIdx > 0) {
248+
out.push($createTabNode());
249+
}
250+
if (part.length > 0) {
251+
out.push($createCodeHighlightNode(part));
252+
}
253+
});
254+
});
255+
return out;
256+
}
257+
225258
/**
226259
* Strip up to `tabSize` leading spaces from a {@link CodeHighlightNode} that
227260
* starts a code line, to support outdenting space-indented code lines (e.g.

packages/lexical-code-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,5 @@ export {
3232
$getLastCodeNodeOfLine,
3333
$getStartOfCodeInLine,
3434
$outdentLeadingSpaces,
35+
$plainifyCodeContent,
3536
} from './FlatStructureUtils';

packages/lexical-code-prism/flow/LexicalCodePrism.js.flow

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export interface Token {
2020
content: TokenContent;
2121
}
2222
export interface Tokenizer {
23-
defaultLanguage: string;
23+
defaultLanguage: string | null;
2424
tokenize(code: string, language?: string): (string | Token)[];
2525
}
2626
declare export var PrismTokenizer: Tokenizer;

packages/lexical-code-prism/src/CodeHighlighterPrism.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {LexicalEditor, LexicalNode, NodeKey} from 'lexical';
1111
import {
1212
$isCodeHighlightNode,
1313
$isCodeNode,
14+
$plainifyCodeContent,
1415
CodeExtension,
1516
CodeHighlightNode,
1617
CodeIndentExtension,
@@ -50,20 +51,32 @@ export interface Token {
5051
}
5152

5253
export interface Tokenizer {
53-
defaultLanguage: string;
54+
/**
55+
* Language to fall back to when a {@link CodeNode} doesn't carry one.
56+
* Set to `null` to opt out of the implicit fallback — code blocks
57+
* without a language stay untouched (no `data-language` attribute, no
58+
* syntax highlighting) so a markdown round-trip can preserve ``` with
59+
* no info string.
60+
*/
61+
defaultLanguage: string | null;
5462
tokenize(code: string, language?: string): (string | Token)[];
5563
$tokenize(codeNode: CodeNode, language?: string): LexicalNode[];
5664
}
5765

5866
export const PrismTokenizer: Tokenizer = {
5967
$tokenize(codeNode: CodeNode, language?: string): LexicalNode[] {
60-
return $getHighlightNodes(codeNode, language || this.defaultLanguage);
68+
const lang = language || this.defaultLanguage;
69+
return lang === null
70+
? $plainifyCodeContent(codeNode.getTextContent())
71+
: $getHighlightNodes(codeNode, lang);
6172
},
6273
defaultLanguage: DEFAULT_CODE_LANGUAGE,
6374
tokenize(code: string, language?: string): (string | Token)[] {
75+
const fallback = this.defaultLanguage;
6476
return Prism.tokenize(
6577
code,
66-
Prism.languages[language || ''] || Prism.languages[this.defaultLanguage],
78+
Prism.languages[language || ''] ||
79+
(fallback === null ? undefined : Prism.languages[fallback]),
6780
);
6881
},
6982
};
@@ -119,22 +132,29 @@ function $codeNodeTransform(
119132
const {nodesCurrentlyHighlighting} = transformState;
120133
const nodeKey = node.getKey();
121134

122-
// When new code block inserted it might not have language selected
123-
if (node.getLanguage() === undefined) {
135+
// When new code block inserted it might not have language selected.
136+
// Tokenizers configured with `defaultLanguage: null` opt out of the
137+
// implicit fallback — leave the node unset and skip highlighting so
138+
// markdown round-trips ``` (no info string) without injecting one.
139+
if (node.getLanguage() === undefined && tokenizer.defaultLanguage !== null) {
124140
node.setLanguage(tokenizer.defaultLanguage);
125141
}
126142

127143
const language = node.getLanguage() || tokenizer.defaultLanguage;
128-
if (isCodeLanguageLoaded(language)) {
129-
if (!node.getIsSyntaxHighlightSupported()) {
130-
node.setIsSyntaxHighlightSupported(true);
131-
}
132-
} else {
133-
if (node.getIsSyntaxHighlightSupported()) {
134-
node.setIsSyntaxHighlightSupported(false);
144+
if (language) {
145+
if (isCodeLanguageLoaded(language)) {
146+
if (!node.getIsSyntaxHighlightSupported()) {
147+
node.setIsSyntaxHighlightSupported(true);
148+
}
149+
} else {
150+
if (node.getIsSyntaxHighlightSupported()) {
151+
node.setIsSyntaxHighlightSupported(false);
152+
}
153+
loadCodeLanguage(language, editor, nodeKey);
154+
return;
135155
}
136-
loadCodeLanguage(language, editor, nodeKey);
137-
return;
156+
} else if (node.getIsSyntaxHighlightSupported()) {
157+
node.setIsSyntaxHighlightSupported(false);
138158
}
139159

140160
if (nodesCurrentlyHighlighting.has(nodeKey)) {
@@ -161,7 +181,10 @@ function $codeNodeTransform(
161181
currentNode.getLanguage() || tokenizer.defaultLanguage;
162182
//const diffLanguageMatch = DIFF_LANGUAGE_REGEX.exec(currentLanguage);
163183

164-
const highlightNodes = tokenizer.$tokenize(currentNode, currentLanguage);
184+
const highlightNodes = tokenizer.$tokenize(
185+
currentNode,
186+
currentLanguage ?? undefined,
187+
);
165188

166189
const diffRange = getDiffRange(currentNode.getChildren(), highlightNodes);
167190
const {from, to, nodesForReplacement} = diffRange;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
*/
8+
9+
import type {CodeNode} from '@lexical/code';
10+
11+
import {$createCodeNode} from '@lexical/code';
12+
import {CodePrismExtension, PrismTokenizer} from '@lexical/code-prism';
13+
import {buildEditorFromExtensions, configExtension} from '@lexical/extension';
14+
import {RichTextExtension} from '@lexical/rich-text';
15+
import {$createTextNode, $getRoot, defineExtension} from 'lexical';
16+
import {describe, expect, test} from 'vitest';
17+
18+
function createEditor() {
19+
return buildEditorFromExtensions(
20+
defineExtension({
21+
dependencies: [
22+
RichTextExtension,
23+
configExtension(CodePrismExtension, {
24+
tokenizer: {...PrismTokenizer, defaultLanguage: null},
25+
}),
26+
],
27+
name: 'prism-default-null',
28+
}),
29+
);
30+
}
31+
32+
describe('Prism defaultLanguage: null (#7235)', () => {
33+
test('leaves `__language` unset and skips highlight mutation', () => {
34+
using editor = createEditor();
35+
36+
let codeNode!: CodeNode;
37+
editor.update(
38+
() => {
39+
codeNode = $createCodeNode();
40+
codeNode.append($createTextNode('hello'));
41+
$getRoot().append(codeNode);
42+
},
43+
{discrete: true},
44+
);
45+
46+
editor.read(() => {
47+
expect(codeNode.getLanguage()).toBe(undefined);
48+
});
49+
});
50+
51+
test('splits text into CodeHighlightNode + LineBreakNode + TabNode for `\\n` / `\\t` so indent + line-move handlers stay compatible', () => {
52+
using editor = createEditor();
53+
54+
let codeNode!: CodeNode;
55+
editor.update(
56+
() => {
57+
codeNode = $createCodeNode();
58+
codeNode.append($createTextNode('a\n\tb'));
59+
$getRoot().append(codeNode);
60+
},
61+
{discrete: true},
62+
);
63+
64+
editor.read(() => {
65+
expect(codeNode.getChildren().map(child => child.getType())).toEqual([
66+
'code-highlight',
67+
'linebreak',
68+
'tab',
69+
'code-highlight',
70+
]);
71+
});
72+
});
73+
});

packages/lexical-code-shiki/flow/LexicalCodeShiki.js.flow

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {CodeNode} from '@lexical/code';
1919
*/
2020

2121
export type Tokenizer = {
22-
defaultLanguage: string;
22+
defaultLanguage: string | null;
2323
defaultTheme: string;
2424
$tokenize: (codeNode: CodeNode, language?: string) => LexicalNode[];
2525
}

packages/lexical-code-shiki/src/CodeHighlighterShiki.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {LexicalEditor, LexicalNode, NodeKey} from 'lexical';
1111
import {
1212
$isCodeHighlightNode,
1313
$isCodeNode,
14+
$plainifyCodeContent,
1415
CodeExtension,
1516
CodeHighlightNode,
1617
CodeIndentExtension,
@@ -43,7 +44,14 @@ import {
4344
} from './FacadeShiki';
4445

4546
export interface Tokenizer {
46-
defaultLanguage: string;
47+
/**
48+
* Language to fall back to when a {@link CodeNode} doesn't carry one.
49+
* Set to `null` to opt out of the implicit fallback — code blocks
50+
* without a language stay untouched (no `data-language` attribute, no
51+
* syntax highlighting) so a markdown round-trip can preserve ``` with
52+
* no info string.
53+
*/
54+
defaultLanguage: string | null;
4755
defaultTheme: string;
4856
$tokenize: (
4957
this: Tokenizer,
@@ -60,7 +68,10 @@ export const ShikiTokenizer: Tokenizer = {
6068
codeNode: CodeNode,
6169
language?: string,
6270
): LexicalNode[] {
63-
return $getHighlightNodes(codeNode, language || this.defaultLanguage);
71+
const lang = language || this.defaultLanguage;
72+
return lang === null
73+
? $plainifyCodeContent(codeNode.getTextContent())
74+
: $getHighlightNodes(codeNode, lang);
6475
},
6576
defaultLanguage: DEFAULT_CODE_LANGUAGE,
6677
defaultTheme: DEFAULT_CODE_THEME,
@@ -126,9 +137,12 @@ function $codeNodeTransform(
126137
const nodeKey = node.getKey();
127138
const {nodesCurrentlyHighlighting} = transformState;
128139

129-
// When new code block inserted it might not have language selected
140+
// When new code block inserted it might not have language selected.
141+
// Tokenizers configured with `defaultLanguage: null` opt out of the
142+
// implicit fallback — leave the node unset and skip highlighting so
143+
// markdown round-trips ``` (no info string) without injecting one.
130144
let language = node.getLanguage();
131-
if (!language) {
145+
if (!language && tokenizer.defaultLanguage !== null) {
132146
language = tokenizer.defaultLanguage;
133147
node.setLanguage(language);
134148
}
@@ -147,16 +161,20 @@ function $codeNodeTransform(
147161
}
148162

149163
// dynamic import of languages
150-
if (isCodeLanguageLoaded(language)) {
151-
if (!node.getIsSyntaxHighlightSupported()) {
152-
node.setIsSyntaxHighlightSupported(true);
153-
}
154-
} else {
155-
if (node.getIsSyntaxHighlightSupported()) {
156-
node.setIsSyntaxHighlightSupported(false);
164+
if (language) {
165+
if (isCodeLanguageLoaded(language)) {
166+
if (!node.getIsSyntaxHighlightSupported()) {
167+
node.setIsSyntaxHighlightSupported(true);
168+
}
169+
} else {
170+
if (node.getIsSyntaxHighlightSupported()) {
171+
node.setIsSyntaxHighlightSupported(false);
172+
}
173+
loadCodeLanguage(language, editor, nodeKey);
174+
inFlight = true;
157175
}
158-
loadCodeLanguage(language, editor, nodeKey);
159-
inFlight = true;
176+
} else if (node.getIsSyntaxHighlightSupported()) {
177+
node.setIsSyntaxHighlightSupported(false);
160178
}
161179

162180
if (inFlight) {
@@ -184,7 +202,7 @@ function $codeNodeTransform(
184202
}
185203

186204
const lang = currentNode.getLanguage() || tokenizer.defaultLanguage;
187-
const highlightNodes = tokenizer.$tokenize(currentNode, lang);
205+
const highlightNodes = tokenizer.$tokenize(currentNode, lang ?? undefined);
188206
const diffRange = getDiffRange(currentNode.getChildren(), highlightNodes);
189207
const {from, to, nodesForReplacement} = diffRange;
190208

0 commit comments

Comments
 (0)