@motionscript/code 0.1.0-alpha.3 → 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +201 -201
- package/NOTICE +48 -0
- package/README.md +176 -108
- package/dist/browser/THIRD_PARTY_LICENSES.txt +431 -0
- package/dist/browser/index.js +10 -6
- package/dist/browser/index.js.map +3 -3
- package/dist/browser/manifest.json +84 -2
- package/dist/code-range.d.ts +6 -0
- package/dist/code-range.d.ts.map +1 -1
- package/dist/code-range.js +8 -0
- package/dist/code-range.js.map +1 -1
- package/dist/diff.d.ts +14 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +134 -2
- package/dist/diff.js.map +1 -1
- package/dist/engine.d.ts +2 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -1
- package/dist/engine.js.map +1 -1
- package/dist/highlight.d.ts +10 -2
- package/dist/highlight.d.ts.map +1 -1
- package/dist/highlight.js +40 -6
- package/dist/highlight.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.js.map +1 -1
- package/dist/node.d.ts +187 -301
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +633 -754
- package/dist/node.js.map +1 -1
- package/dist/nodes.d.ts +6 -9
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +7 -9
- package/dist/nodes.js.map +1 -1
- package/dist/render.d.ts +9 -2
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +59 -0
- package/dist/render.js.map +1 -1
- package/dist/style.d.ts +3 -3
- package/dist/style.d.ts.map +1 -1
- package/dist/style.js +10 -6
- package/dist/style.js.map +1 -1
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +5 -1
- package/dist/tokens.js.map +1 -1
- package/dist/transitions.d.ts +19 -1
- package/dist/transitions.d.ts.map +1 -1
- package/dist/transitions.js.map +1 -1
- package/package.json +7 -5
- package/registry.json +6 -6
- package/src/code-range.ts +165 -156
- package/src/diff.ts +351 -216
- package/src/engine.ts +21 -21
- package/src/highlight.ts +340 -291
- package/src/index.ts +3 -3
- package/src/layout.ts +157 -157
- package/src/node.ts +854 -904
- package/src/nodes.ts +16 -18
- package/src/render.ts +315 -247
- package/src/style.ts +255 -251
- package/src/tokens.ts +79 -75
- package/src/transitions.ts +204 -184
package/src/highlight.ts
CHANGED
|
@@ -1,291 +1,340 @@
|
|
|
1
|
-
import { Parser, Tree } from "@lezer/common";
|
|
2
|
-
import { highlightTree } from "@lezer/highlight";
|
|
3
|
-
import {
|
|
4
|
-
BUILTIN_THEMES,
|
|
5
|
-
CodeHighlightStyle,
|
|
6
|
-
CodeTheme,
|
|
7
|
-
DefaultHighlightStyle,
|
|
8
|
-
compileStyle,
|
|
9
|
-
} from "./style";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Lezer-backed syntax highlighting for the Code component, replacing the former
|
|
13
|
-
* Shiki wiring. This mirrors Motion Canvas's `LezerHighlighter`:
|
|
14
|
-
*
|
|
15
|
-
* - {@link prepareHighlight} parses code once and builds a `from:to` → color map
|
|
16
|
-
* by walking {@link highlightTree}.
|
|
17
|
-
* - {@link tokenizeCode} splits code at parse-tree leaf boundaries.
|
|
18
|
-
* - {@link highlightAt} resolves the color (and run length) for a character index.
|
|
19
|
-
*
|
|
20
|
-
* Parsers load lazily and asynchronously (dynamic import) so the bundle doesn't
|
|
21
|
-
* pull in every grammar. The node's asset loader awaits {@link ensureHighlighter}
|
|
22
|
-
* before tokenizing; until a parser resolves, callers fall back to plain text and
|
|
23
|
-
* re-tokenize once {@link canHighlight} returns true.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
/** Dynamic-import factories for each supported language → a Lezer {@link Parser}. */
|
|
27
|
-
const LANGUAGE_LOADERS: Record<string, () => Promise<Parser>> = {
|
|
28
|
-
typescript: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "ts" }),
|
|
29
|
-
tsx: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "ts jsx" }),
|
|
30
|
-
javascript: async () => (await import("@lezer/javascript")).parser,
|
|
31
|
-
jsx: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "jsx" }),
|
|
32
|
-
python: async () => (await import("@lezer/python")).parser,
|
|
33
|
-
json: async () => (await import("@lezer/json")).parser,
|
|
34
|
-
html: async () => (await import("@lezer/html")).parser,
|
|
35
|
-
css: async () => (await import("@lezer/css")).parser,
|
|
36
|
-
rust: async () => (await import("@lezer/rust")).parser,
|
|
37
|
-
go: async () => (await import("@lezer/go")).parser,
|
|
38
|
-
java: async () => (await import("@lezer/java")).parser,
|
|
39
|
-
cpp: async () => (await import("@lezer/cpp")).parser,
|
|
40
|
-
php: async () => (await import("@lezer/php")).parser,
|
|
41
|
-
markdown: async () => (await import("@lezer/markdown")).parser,
|
|
42
|
-
xml: async () => (await import("@lezer/xml")).parser,
|
|
43
|
-
yaml: async () => (await import("@lezer/yaml")).parser,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
// Aliases so common language strings resolve to a loader above.
|
|
47
|
-
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
48
|
-
ts: "typescript",
|
|
49
|
-
js: "javascript",
|
|
50
|
-
py: "python",
|
|
51
|
-
"c++": "cpp",
|
|
52
|
-
md: "markdown",
|
|
53
|
-
yml: "yaml",
|
|
54
|
-
shell: "javascript", // no shell grammar bundled; fall back rather than throw
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
*/
|
|
198
|
-
function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*/
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
1
|
+
import { Parser, Tree } from "@lezer/common";
|
|
2
|
+
import { highlightTree } from "@lezer/highlight";
|
|
3
|
+
import {
|
|
4
|
+
BUILTIN_THEMES,
|
|
5
|
+
CodeHighlightStyle,
|
|
6
|
+
CodeTheme,
|
|
7
|
+
DefaultHighlightStyle,
|
|
8
|
+
compileStyle,
|
|
9
|
+
} from "./style";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lezer-backed syntax highlighting for the Code component, replacing the former
|
|
13
|
+
* Shiki wiring. This mirrors Motion Canvas's `LezerHighlighter`:
|
|
14
|
+
*
|
|
15
|
+
* - {@link prepareHighlight} parses code once and builds a `from:to` → color map
|
|
16
|
+
* by walking {@link highlightTree}.
|
|
17
|
+
* - {@link tokenizeCode} splits code at parse-tree leaf boundaries.
|
|
18
|
+
* - {@link highlightAt} resolves the color (and run length) for a character index.
|
|
19
|
+
*
|
|
20
|
+
* Parsers load lazily and asynchronously (dynamic import) so the bundle doesn't
|
|
21
|
+
* pull in every grammar. The node's asset loader awaits {@link ensureHighlighter}
|
|
22
|
+
* before tokenizing; until a parser resolves, callers fall back to plain text and
|
|
23
|
+
* re-tokenize once {@link canHighlight} returns true.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Dynamic-import factories for each supported language → a Lezer {@link Parser}. */
|
|
27
|
+
const LANGUAGE_LOADERS: Record<string, () => Promise<Parser>> = {
|
|
28
|
+
typescript: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "ts" }),
|
|
29
|
+
tsx: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "ts jsx" }),
|
|
30
|
+
javascript: async () => (await import("@lezer/javascript")).parser,
|
|
31
|
+
jsx: async () => (await import("@lezer/javascript")).parser.configure({ dialect: "jsx" }),
|
|
32
|
+
python: async () => (await import("@lezer/python")).parser,
|
|
33
|
+
json: async () => (await import("@lezer/json")).parser,
|
|
34
|
+
html: async () => (await import("@lezer/html")).parser,
|
|
35
|
+
css: async () => (await import("@lezer/css")).parser,
|
|
36
|
+
rust: async () => (await import("@lezer/rust")).parser,
|
|
37
|
+
go: async () => (await import("@lezer/go")).parser,
|
|
38
|
+
java: async () => (await import("@lezer/java")).parser,
|
|
39
|
+
cpp: async () => (await import("@lezer/cpp")).parser,
|
|
40
|
+
php: async () => (await import("@lezer/php")).parser,
|
|
41
|
+
markdown: async () => (await import("@lezer/markdown")).parser,
|
|
42
|
+
xml: async () => (await import("@lezer/xml")).parser,
|
|
43
|
+
yaml: async () => (await import("@lezer/yaml")).parser,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Aliases so common language strings resolve to a loader above.
|
|
47
|
+
const LANGUAGE_ALIASES: Record<string, string> = {
|
|
48
|
+
ts: "typescript",
|
|
49
|
+
js: "javascript",
|
|
50
|
+
py: "python",
|
|
51
|
+
"c++": "cpp",
|
|
52
|
+
md: "markdown",
|
|
53
|
+
yml: "yaml",
|
|
54
|
+
shell: "javascript", // no shell grammar bundled; fall back rather than throw
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Names that mean "no grammar, on purpose": drawn as plain text without a warning. */
|
|
58
|
+
const PLAIN_LANGUAGES = new Set(["", "text", "plaintext"]);
|
|
59
|
+
|
|
60
|
+
const warned = new Set<string>();
|
|
61
|
+
|
|
62
|
+
/** One `console.warn` per `kind:name` for the life of the process, so a listing redrawn every frame says it once. */
|
|
63
|
+
function warnOnce(kind: string, name: string, message: string): void {
|
|
64
|
+
const key = `${kind}:${name}`;
|
|
65
|
+
if (warned.has(key)) return;
|
|
66
|
+
warned.add(key);
|
|
67
|
+
console.warn(`[Code] ${message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The source as the highlighter and the commands see it: lines joined by `\n`.
|
|
72
|
+
* A `\r\n` (or a lone `\r`) from a file or a clipboard would otherwise survive
|
|
73
|
+
* as a character at the end of a line, measured and drawn as a token.
|
|
74
|
+
*/
|
|
75
|
+
export function normalizeSource(code: string): string {
|
|
76
|
+
return code.indexOf("\r") === -1 ? code : code.replace(/\r\n?/g, "\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveLanguageKey(language: string): string {
|
|
80
|
+
const key = language.toLowerCase();
|
|
81
|
+
return LANGUAGE_ALIASES[key] ?? key;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Loaded parsers and in-flight loads, keyed by resolved language key, so
|
|
85
|
+
// concurrent Code nodes asking for the same language share one dynamic import.
|
|
86
|
+
const loadedParsers = new Map<string, Parser>();
|
|
87
|
+
const inFlightLangs = new Map<string, Promise<Parser | null>>();
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_THEMES = [DefaultHighlightStyle.name];
|
|
90
|
+
export const DEFAULT_LANGS = ["typescript", "javascript", "json", "python"];
|
|
91
|
+
|
|
92
|
+
// Theme registry, seeded with the built-ins (Motion Canvas default + GitHub
|
|
93
|
+
// dark/light). The `theme` prop is a string name or a CodeHighlightStyle object;
|
|
94
|
+
// apps can register additional styles by name and reference them by that name.
|
|
95
|
+
const themesByName = new Map<string, CodeHighlightStyle>(Object.entries(BUILTIN_THEMES));
|
|
96
|
+
|
|
97
|
+
/** Register a custom {@link CodeHighlightStyle} so Code nodes can reference it by name. */
|
|
98
|
+
export function registerCodeTheme(style: CodeHighlightStyle): void {
|
|
99
|
+
themesByName.set(style.name, style);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a theme reference (a registered name, or a CodeHighlightStyle object)
|
|
104
|
+
* to a concrete style. Unknown names fall back to the Motion Canvas default so a
|
|
105
|
+
* stray theme string never throws, with one warning per name.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveTheme(theme: CodeTheme): CodeHighlightStyle {
|
|
108
|
+
if (typeof theme !== "string") return theme;
|
|
109
|
+
const style = themesByName.get(theme);
|
|
110
|
+
if (style) return style;
|
|
111
|
+
warnOnce(
|
|
112
|
+
"theme",
|
|
113
|
+
theme,
|
|
114
|
+
`unknown theme "${theme}"; using "${DefaultHighlightStyle.name}". Built-in themes: ${Object.keys(BUILTIN_THEMES).join(", ")}; register others with registerCodeTheme.`,
|
|
115
|
+
);
|
|
116
|
+
return DefaultHighlightStyle;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Has `language` finished loading its parser? */
|
|
120
|
+
function isLanguageLoaded(language: string): boolean {
|
|
121
|
+
return loadedParsers.has(resolveLanguageKey(language));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load the parsers for `langs` (themes are synchronous, so they're a no-op here
|
|
126
|
+
* but kept in the signature for API parity with the former Shiki implementation).
|
|
127
|
+
* Resolves once every requested language's parser is available. Unknown languages
|
|
128
|
+
* resolve without loading anything — {@link canHighlight} stays false for them and
|
|
129
|
+
* the node renders them as plain text, with one warning per language. A grammar
|
|
130
|
+
* whose import is rejected is reported the same way and is tried again by the
|
|
131
|
+
* next call.
|
|
132
|
+
*/
|
|
133
|
+
export function ensureHighlighter(
|
|
134
|
+
_themes: string[] = DEFAULT_THEMES,
|
|
135
|
+
langs: string[] = DEFAULT_LANGS,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const jobs: Promise<unknown>[] = [];
|
|
138
|
+
|
|
139
|
+
for (const lang of langs) {
|
|
140
|
+
const key = resolveLanguageKey(lang);
|
|
141
|
+
if (loadedParsers.has(key)) continue;
|
|
142
|
+
|
|
143
|
+
const loader = LANGUAGE_LOADERS[key];
|
|
144
|
+
if (!loader) {
|
|
145
|
+
// Unsupported language; leave it unhighlighted, and say so.
|
|
146
|
+
if (!PLAIN_LANGUAGES.has(key)) {
|
|
147
|
+
warnOnce(
|
|
148
|
+
"language",
|
|
149
|
+
key,
|
|
150
|
+
`no grammar for language "${lang}"; drawing it as plain text. Supported: ${Object.keys(LANGUAGE_LOADERS).join(", ")} (aliases: ${Object.keys(LANGUAGE_ALIASES).join(", ")}).`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let job = inFlightLangs.get(key);
|
|
157
|
+
if (!job) {
|
|
158
|
+
job = loader()
|
|
159
|
+
.then(parser => {
|
|
160
|
+
loadedParsers.set(key, parser);
|
|
161
|
+
return parser;
|
|
162
|
+
})
|
|
163
|
+
.catch((error: unknown) => {
|
|
164
|
+
warnOnce(
|
|
165
|
+
"load",
|
|
166
|
+
key,
|
|
167
|
+
`could not load the "${lang}" grammar; drawing it as plain text: ${error instanceof Error ? error.message : String(error)}`,
|
|
168
|
+
);
|
|
169
|
+
return null;
|
|
170
|
+
})
|
|
171
|
+
.finally(() => inFlightLangs.delete(key));
|
|
172
|
+
inFlightLangs.set(key, job);
|
|
173
|
+
}
|
|
174
|
+
jobs.push(job);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return jobs.length === 0 ? Promise.resolve() : Promise.all(jobs).then(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Load a syntax-highlight language into the shared highlighter. Exposed so a Code
|
|
182
|
+
* node's asset loader can run it on the timeline, and so apps can preload
|
|
183
|
+
* languages explicitly. Returns once the parser is ready (or immediately for an
|
|
184
|
+
* unsupported language). The `theme` arg is accepted for backwards compatibility.
|
|
185
|
+
*/
|
|
186
|
+
export async function loadCodeLanguage(language: string, _theme?: string): Promise<void> {
|
|
187
|
+
await ensureHighlighter(DEFAULT_THEMES, [language]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function initSyntaxHighlighter(
|
|
191
|
+
themes: string[] = DEFAULT_THEMES,
|
|
192
|
+
langs: string[] = DEFAULT_LANGS,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
await ensureHighlighter(themes, langs);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True once `language` can be tokenized (its parser has loaded). */
|
|
198
|
+
export function canHighlight(language: string, _theme?: CodeTheme): boolean {
|
|
199
|
+
return isLanguageLoaded(language);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A parsed-and-prepared highlight cache for one snapshot of code. */
|
|
203
|
+
interface HighlightCache {
|
|
204
|
+
tree: Tree;
|
|
205
|
+
code: string;
|
|
206
|
+
// `${from}:${to}` node id → color, exactly as Motion Canvas keys it.
|
|
207
|
+
colorLookup: Map<string, string>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function nodeId(from: number, to: number): string {
|
|
211
|
+
return `${from}:${to}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Parse `code` and build the per-node color map. Mirrors Motion Canvas's
|
|
216
|
+
* `LezerHighlighter.prepare`: for each highlighted range, walk the subtree and
|
|
217
|
+
* record the color against every contained node's `from:to` id, so a later
|
|
218
|
+
* {@link highlightAt} lookup by `resolveInner` index finds it.
|
|
219
|
+
*/
|
|
220
|
+
function prepareHighlight(code: string, language: string, style: CodeHighlightStyle): HighlightCache | null {
|
|
221
|
+
const parser = loadedParsers.get(resolveLanguageKey(language));
|
|
222
|
+
if (!parser) return null;
|
|
223
|
+
|
|
224
|
+
const tree = parser.parse(code);
|
|
225
|
+
const highlighter = compileStyle(style);
|
|
226
|
+
const colorLookup = new Map<string, string>();
|
|
227
|
+
|
|
228
|
+
highlightTree(tree, highlighter, (from, to, classes) => {
|
|
229
|
+
// tagHighlighter emits our color string as the "class".
|
|
230
|
+
const color = classes;
|
|
231
|
+
if (!color) return;
|
|
232
|
+
const cursor = tree.cursorAt(from, 1);
|
|
233
|
+
do {
|
|
234
|
+
colorLookup.set(nodeId(cursor.from, cursor.to), color);
|
|
235
|
+
} while (cursor.next() && cursor.to <= to);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return { tree, code, colorLookup };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolve the color and run length for the token starting at `index`. Mirrors
|
|
243
|
+
* Motion Canvas's `LezerHighlighter.highlight`: resolve the innermost node, look
|
|
244
|
+
* up its color, and report how far the run extends (`skipAhead`).
|
|
245
|
+
*/
|
|
246
|
+
function highlightAt(index: number, cache: HighlightCache): { color: string | null; skipAhead: number } {
|
|
247
|
+
const node = cache.tree.resolveInner(index, 1);
|
|
248
|
+
const color = cache.colorLookup.get(nodeId(node.from, node.to));
|
|
249
|
+
if (color) {
|
|
250
|
+
return { color, skipAhead: node.to - index };
|
|
251
|
+
}
|
|
252
|
+
// No color: skip past a childless (leaf) node so we don't re-resolve every char.
|
|
253
|
+
const skipAhead = node.firstChild ? 0 : node.to - index;
|
|
254
|
+
return { color: null, skipAhead };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Split `code` into structural tokens at parse-tree leaf boundaries, including the
|
|
259
|
+
* gaps between leaves (whitespace/punctuation). Mirrors Motion Canvas's
|
|
260
|
+
* `LezerHighlighter.tokenize`; this segmentation is what the token-level
|
|
261
|
+
* append/insert/erase/replace animations diff against, so matching it keeps the
|
|
262
|
+
* animation behavior identical to Motion Canvas.
|
|
263
|
+
*/
|
|
264
|
+
function tokenizeCode(code: string, language: string): string[] {
|
|
265
|
+
const parser = loadedParsers.get(resolveLanguageKey(language));
|
|
266
|
+
if (!parser) return [code];
|
|
267
|
+
|
|
268
|
+
const tree = parser.parse(code);
|
|
269
|
+
const cursor = tree.cursor();
|
|
270
|
+
const tokens: string[] = [];
|
|
271
|
+
let current = 0;
|
|
272
|
+
|
|
273
|
+
do {
|
|
274
|
+
if (!cursor.node.firstChild) {
|
|
275
|
+
if (cursor.from > current) tokens.push(code.slice(current, cursor.from));
|
|
276
|
+
if (cursor.from < cursor.to) tokens.push(code.slice(cursor.from, cursor.to));
|
|
277
|
+
current = cursor.to;
|
|
278
|
+
}
|
|
279
|
+
} while (cursor.next());
|
|
280
|
+
|
|
281
|
+
if (current < code.length) tokens.push(code.slice(current));
|
|
282
|
+
return tokens;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Tokenize `code` into per-line, colored tokens — the contract the rest of the
|
|
287
|
+
* Code component consumes. Combines {@link tokenizeCode} (structural split) with
|
|
288
|
+
* {@link highlightAt} (per-run color), then breaks tokens at newlines so each
|
|
289
|
+
* output line is a row of single-line, single-color tokens.
|
|
290
|
+
*
|
|
291
|
+
* Callers must have checked {@link canHighlight} first; this returns a single
|
|
292
|
+
* uncolored line per source line if the parser isn't loaded.
|
|
293
|
+
*/
|
|
294
|
+
export function highlightToTokens(
|
|
295
|
+
code: string,
|
|
296
|
+
language: string,
|
|
297
|
+
theme: CodeTheme = DefaultHighlightStyle,
|
|
298
|
+
): { content: string; color?: string }[][] {
|
|
299
|
+
code = normalizeSource(code);
|
|
300
|
+
const style = resolveTheme(theme);
|
|
301
|
+
const cache = prepareHighlight(code, language, style);
|
|
302
|
+
|
|
303
|
+
const lines: { content: string; color?: string }[][] = [[]];
|
|
304
|
+
const pushLine = () => lines.push([]);
|
|
305
|
+
|
|
306
|
+
if (!cache) {
|
|
307
|
+
// Parser unavailable: one uncolored token per source line.
|
|
308
|
+
return code.split("\n").map(line => [{ content: line, color: style.defaultColor }]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Walk the structural tokens, coloring each by its starting index and
|
|
312
|
+
// splitting on newlines so no token spans rows.
|
|
313
|
+
let index = 0;
|
|
314
|
+
for (const token of tokenizeCode(code, language)) {
|
|
315
|
+
let offset = 0;
|
|
316
|
+
while (offset < token.length) {
|
|
317
|
+
const { color, skipAhead } = highlightAt(index + offset, cache);
|
|
318
|
+
// Extend the run as far as the highlight says, but never past this
|
|
319
|
+
// structural token and never across a newline (lines are drawn rows).
|
|
320
|
+
let runEnd = offset + Math.max(skipAhead, 1);
|
|
321
|
+
if (runEnd > token.length) runEnd = token.length;
|
|
322
|
+
const newlineRel = token.indexOf("\n", offset);
|
|
323
|
+
if (newlineRel !== -1 && newlineRel < runEnd) runEnd = newlineRel;
|
|
324
|
+
|
|
325
|
+
const slice = token.slice(offset, runEnd);
|
|
326
|
+
if (slice.length > 0) {
|
|
327
|
+
lines[lines.length - 1].push({ content: slice, color: color ?? style.defaultColor });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (newlineRel === runEnd) {
|
|
331
|
+
pushLine();
|
|
332
|
+
runEnd += 1; // consume the newline itself
|
|
333
|
+
}
|
|
334
|
+
offset = runEnd;
|
|
335
|
+
}
|
|
336
|
+
index += token.length;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return lines;
|
|
340
|
+
}
|