@fuzdev/fuz_code 0.46.0 → 0.46.1
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/dist/CodeHighlight.svelte +14 -50
- package/dist/CodeHighlight.svelte.d.ts +1 -1
- package/dist/CodeHighlight.svelte.d.ts.map +1 -1
- package/dist/CodeTextarea.svelte +149 -0
- package/dist/CodeTextarea.svelte.d.ts +43 -0
- package/dist/CodeTextarea.svelte.d.ts.map +1 -0
- package/dist/highlight_manager.d.ts +20 -6
- package/dist/highlight_manager.d.ts.map +1 -1
- package/dist/highlight_manager.js +128 -72
- package/dist/range_highlighting.svelte.d.ts +39 -0
- package/dist/range_highlighting.svelte.d.ts.map +1 -0
- package/dist/range_highlighting.svelte.js +57 -0
- package/dist/syntax_styler.d.ts +12 -0
- package/dist/syntax_styler.d.ts.map +1 -1
- package/dist/syntax_styler.js +49 -13
- package/package.json +6 -5
- package/src/lib/highlight_manager.ts +152 -82
- package/src/lib/range_highlighting.svelte.ts +100 -0
- package/src/lib/syntax_styler.ts +63 -14
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SyntaxStyler, SyntaxGrammar } from './syntax_styler.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reactive inputs for `create_range_highlighting`. All values are getters so the
|
|
4
|
+
* helper can track the consuming component's reactive state across the call
|
|
5
|
+
* boundary (the Svelte 5 getter-injection pattern).
|
|
6
|
+
*/
|
|
7
|
+
export interface RangeHighlightingOptions {
|
|
8
|
+
/** The element whose first text node receives the highlight ranges. */
|
|
9
|
+
element: () => Element | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* The text to tokenize. Must match the element's text node exactly (e.g. a
|
|
12
|
+
* textarea backdrop includes its trailing newline here too).
|
|
13
|
+
*/
|
|
14
|
+
text: () => string;
|
|
15
|
+
/** Language id; `null` disables highlighting. */
|
|
16
|
+
lang: () => string | null;
|
|
17
|
+
/** Optional custom grammar; takes precedence over `lang` for tokenization. */
|
|
18
|
+
grammar: () => SyntaxGrammar | undefined;
|
|
19
|
+
/** The syntax styler whose registered grammars back `lang` lookups. */
|
|
20
|
+
syntax_styler: () => SyntaxStyler;
|
|
21
|
+
/** Extra gate — ranges are only applied when this returns true. Defaults to always-on. */
|
|
22
|
+
enabled?: () => boolean;
|
|
23
|
+
/** Component name used in DEV warnings. */
|
|
24
|
+
dev_label: string;
|
|
25
|
+
}
|
|
26
|
+
/** Reactive outputs from `create_range_highlighting`. */
|
|
27
|
+
export interface RangeHighlighting {
|
|
28
|
+
readonly highlighting_disabled: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Wires up CSS Custom Highlight API range highlighting for a single element's
|
|
32
|
+
* text node, shared by `CodeHighlight` and `CodeTextarea`. Creates a
|
|
33
|
+
* `HighlightManager`, memoizes tokenization, applies/clears ranges in an effect,
|
|
34
|
+
* emits DEV warnings for unsupported languages, and tears down on destroy.
|
|
35
|
+
*
|
|
36
|
+
* Must be called during component initialization (it uses `$effect`/`onDestroy`).
|
|
37
|
+
*/
|
|
38
|
+
export declare const create_range_highlighting: (options: RangeHighlightingOptions) => RangeHighlighting;
|
|
39
|
+
//# sourceMappingURL=range_highlighting.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"range_highlighting.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/range_highlighting.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAGpE;;;;GAIG;AACH,MAAM,WAAW,wBAAwB;IACxC,uEAAuE;IACvE,OAAO,EAAE,MAAM,OAAO,GAAG,SAAS,CAAC;IACnC;;;OAGG;IACH,IAAI,EAAE,MAAM,MAAM,CAAC;IACnB,iDAAiD;IACjD,IAAI,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAC1B,8EAA8E;IAC9E,OAAO,EAAE,MAAM,aAAa,GAAG,SAAS,CAAC;IACzC,uEAAuE;IACvE,aAAa,EAAE,MAAM,YAAY,CAAC;IAClC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC;IACxB,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,yDAAyD;AACzD,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,qBAAqB,EAAE,OAAO,CAAC;CACxC;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,wBAAwB,KAAG,iBAuD7E,CAAC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { onDestroy } from 'svelte';
|
|
2
|
+
import { DEV } from 'esm-env';
|
|
3
|
+
import { HighlightManager, supports_css_highlight_api } from './highlight_manager.js';
|
|
4
|
+
/**
|
|
5
|
+
* Wires up CSS Custom Highlight API range highlighting for a single element's
|
|
6
|
+
* text node, shared by `CodeHighlight` and `CodeTextarea`. Creates a
|
|
7
|
+
* `HighlightManager`, memoizes tokenization, applies/clears ranges in an effect,
|
|
8
|
+
* emits DEV warnings for unsupported languages, and tears down on destroy.
|
|
9
|
+
*
|
|
10
|
+
* Must be called during component initialization (it uses `$effect`/`onDestroy`).
|
|
11
|
+
*/
|
|
12
|
+
export const create_range_highlighting = (options) => {
|
|
13
|
+
const manager = supports_css_highlight_api() ? new HighlightManager() : null;
|
|
14
|
+
const is_enabled = options.enabled ?? (() => true);
|
|
15
|
+
const language_supported = $derived(options.lang() !== null && !!options.syntax_styler().langs[options.lang()]);
|
|
16
|
+
const highlighting_disabled = $derived(options.lang() === null || (!language_supported && !options.grammar()));
|
|
17
|
+
// tokenize once per (text, grammar, lang) change -- memoized so unrelated
|
|
18
|
+
// reactivity doesn't trigger a full re-tokenization
|
|
19
|
+
const range_tokens = $derived.by(() => {
|
|
20
|
+
if (!manager || !is_enabled() || highlighting_disabled)
|
|
21
|
+
return null;
|
|
22
|
+
const text = options.text();
|
|
23
|
+
if (!text)
|
|
24
|
+
return null;
|
|
25
|
+
// route through the styler so `before_tokenize`/`after_tokenize` hooks run,
|
|
26
|
+
// matching `stylize`'s HTML path; `grammar` undefined falls back to the
|
|
27
|
+
// registered lang grammar (`! safe bc of `highlighting_disabled`)
|
|
28
|
+
return options.syntax_styler().tokenize(text, options.lang(), options.grammar());
|
|
29
|
+
});
|
|
30
|
+
if (manager) {
|
|
31
|
+
$effect(() => {
|
|
32
|
+
const element = options.element();
|
|
33
|
+
if (!element || !range_tokens) {
|
|
34
|
+
manager.clear_element_ranges();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
manager.highlight_from_syntax_tokens(element, range_tokens);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (DEV) {
|
|
41
|
+
$effect(() => {
|
|
42
|
+
// a lang was requested but we can't highlight it (unknown id, no grammar)
|
|
43
|
+
if (options.lang() && highlighting_disabled) {
|
|
44
|
+
const langs = Object.keys(options.syntax_styler().langs).join(', ');
|
|
45
|
+
// eslint-disable-next-line no-console
|
|
46
|
+
console.error(`[${options.dev_label}] Language "${options.lang()}" is not supported and no custom grammar provided. ` +
|
|
47
|
+
`Highlighting disabled. Supported: ${langs}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
onDestroy(() => manager?.destroy());
|
|
52
|
+
return {
|
|
53
|
+
get highlighting_disabled() {
|
|
54
|
+
return highlighting_disabled;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
package/dist/syntax_styler.d.ts
CHANGED
|
@@ -65,6 +65,18 @@ export declare class SyntaxStyler {
|
|
|
65
65
|
* ```
|
|
66
66
|
*/
|
|
67
67
|
stylize(text: string, lang: string, grammar?: SyntaxGrammar | undefined): string;
|
|
68
|
+
/**
|
|
69
|
+
* Tokenizes `text` into a `SyntaxTokenStream`, running the `before_tokenize`
|
|
70
|
+
* and `after_tokenize` hooks. This is the tokenization half of `stylize` — use
|
|
71
|
+
* it when you need the token stream itself (e.g. CSS Custom Highlight API range
|
|
72
|
+
* highlighting) rather than HTML.
|
|
73
|
+
*
|
|
74
|
+
* @param text - source to tokenize
|
|
75
|
+
* @param lang - language identifier; passed to the tokenize hooks
|
|
76
|
+
* @param grammar - grammar to tokenize with; defaults to `this.get_lang(lang)`
|
|
77
|
+
* @returns the resulting token stream
|
|
78
|
+
*/
|
|
79
|
+
tokenize(text: string, lang: string, grammar?: SyntaxGrammar | undefined): SyntaxTokenStream;
|
|
68
80
|
/**
|
|
69
81
|
* Inserts tokens _before_ another token in a language definition or any other grammar.
|
|
70
82
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"syntax_styler.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/syntax_styler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,WAAW,EAAE,KAAK,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAGtE,MAAM,MAAM,gBAAgB,GAAG,CAAC,aAAa,EAAE,YAAY,KAAK,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"syntax_styler.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/syntax_styler.ts"],"names":[],"mappings":"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"}
|
package/dist/syntax_styler.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { SyntaxToken } from './syntax_token.js';
|
|
2
2
|
import { tokenize_syntax } from './tokenize_syntax.js';
|
|
3
|
+
/**
|
|
4
|
+
* Maps a matched `&`, `<`, or non-breaking space in text content to its
|
|
5
|
+
* HTML-safe form. Used as the replacer in `stringify_token` for leaf strings
|
|
6
|
+
* (non-breaking spaces are normalized to a regular space).
|
|
7
|
+
*/
|
|
8
|
+
const escape_text_char = (ch) => (ch === '&' ? '&' : ch === '<' ? '<' : ' ');
|
|
3
9
|
/**
|
|
4
10
|
* Based on Prism (https://github.com/PrismJS/prism)
|
|
5
11
|
* by Lea Verou (https://lea.verou.me/)
|
|
@@ -110,7 +116,32 @@ export class SyntaxStyler {
|
|
|
110
116
|
* ```
|
|
111
117
|
*/
|
|
112
118
|
stylize(text, lang, grammar = this.get_lang(lang)) {
|
|
113
|
-
|
|
119
|
+
// stringify with the post-hook `lang`, which a `before_tokenize` hook may
|
|
120
|
+
// have rewritten (it flows into each token's `wrap` hook context)
|
|
121
|
+
const c = this.#tokenize_hooked(text, lang, grammar);
|
|
122
|
+
return this.stringify_token(c.tokens, c.lang);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Tokenizes `text` into a `SyntaxTokenStream`, running the `before_tokenize`
|
|
126
|
+
* and `after_tokenize` hooks. This is the tokenization half of `stylize` — use
|
|
127
|
+
* it when you need the token stream itself (e.g. CSS Custom Highlight API range
|
|
128
|
+
* highlighting) rather than HTML.
|
|
129
|
+
*
|
|
130
|
+
* @param text - source to tokenize
|
|
131
|
+
* @param lang - language identifier; passed to the tokenize hooks
|
|
132
|
+
* @param grammar - grammar to tokenize with; defaults to `this.get_lang(lang)`
|
|
133
|
+
* @returns the resulting token stream
|
|
134
|
+
*/
|
|
135
|
+
tokenize(text, lang, grammar = this.get_lang(lang)) {
|
|
136
|
+
return this.#tokenize_hooked(text, lang, grammar).tokens;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Runs `before_tokenize` → `tokenize_syntax` → `after_tokenize`, returning the
|
|
140
|
+
* resolved context. Shared by `stylize` (which also needs the post-hook `lang`)
|
|
141
|
+
* and `tokenize` (which only needs `tokens`).
|
|
142
|
+
*/
|
|
143
|
+
#tokenize_hooked(text, lang, grammar) {
|
|
144
|
+
const ctx = {
|
|
114
145
|
code: text,
|
|
115
146
|
grammar,
|
|
116
147
|
lang,
|
|
@@ -120,7 +151,7 @@ export class SyntaxStyler {
|
|
|
120
151
|
const c = ctx;
|
|
121
152
|
c.tokens = tokenize_syntax(c.code, c.grammar);
|
|
122
153
|
this.run_hook_after_tokenize(c);
|
|
123
|
-
return
|
|
154
|
+
return c;
|
|
124
155
|
}
|
|
125
156
|
/**
|
|
126
157
|
* Inserts tokens _before_ another token in a language definition or any other grammar.
|
|
@@ -233,10 +264,9 @@ export class SyntaxStyler {
|
|
|
233
264
|
*/
|
|
234
265
|
stringify_token(o, lang) {
|
|
235
266
|
if (typeof o === 'string') {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
.replace(/\u00a0/g, ' ');
|
|
267
|
+
// single pass over the leaf text (only `&` and `<` need escaping in text
|
|
268
|
+
// content; `\u00a0` is normalized to a regular space)
|
|
269
|
+
return o.replace(/[&<\u00a0]/g, escape_text_char);
|
|
240
270
|
}
|
|
241
271
|
if (Array.isArray(o)) {
|
|
242
272
|
var s = '';
|
|
@@ -245,19 +275,25 @@ export class SyntaxStyler {
|
|
|
245
275
|
}
|
|
246
276
|
return s;
|
|
247
277
|
}
|
|
278
|
+
var content = this.stringify_token(o.content, lang);
|
|
279
|
+
// build the class list once; aliases are always an array after normalization
|
|
280
|
+
var classes = `token_${o.type}`;
|
|
281
|
+
for (const a of o.alias) {
|
|
282
|
+
classes += ` token_${a}`;
|
|
283
|
+
}
|
|
284
|
+
// fast path: with no `wrap` hooks the tag is always a plain <span> with no
|
|
285
|
+
// attributes, so skip the per-token context object and hook dispatch
|
|
286
|
+
if (this.hooks_wrap.length === 0) {
|
|
287
|
+
return '<span class="' + classes + '">' + content + '</span>';
|
|
288
|
+
}
|
|
248
289
|
var ctx = {
|
|
249
290
|
type: o.type,
|
|
250
|
-
content
|
|
291
|
+
content,
|
|
251
292
|
tag: 'span',
|
|
252
|
-
classes:
|
|
293
|
+
classes: classes.split(' '),
|
|
253
294
|
attributes: {},
|
|
254
295
|
lang,
|
|
255
296
|
};
|
|
256
|
-
var aliases = o.alias;
|
|
257
|
-
// alias is always an array after normalization
|
|
258
|
-
for (const a of aliases) {
|
|
259
|
-
ctx.classes.push(`token_${a}`);
|
|
260
|
-
}
|
|
261
297
|
this.run_hook_wrap(ctx);
|
|
262
298
|
var attributes = '';
|
|
263
299
|
for (var name in ctx.attributes) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzdev/fuz_code",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.1",
|
|
4
4
|
"description": "syntax styling utilities and components for TypeScript, Svelte, Markdown, and more",
|
|
5
5
|
"glyph": "🎨",
|
|
6
6
|
"logo": "logo.svg",
|
|
@@ -55,10 +55,11 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@changesets/changelog-git": "^0.2.1",
|
|
57
57
|
"@fuzdev/blake3_wasm": "^0.1.1",
|
|
58
|
-
"@fuzdev/fuz_css": "^0.
|
|
59
|
-
"@fuzdev/fuz_ui": "^0.
|
|
58
|
+
"@fuzdev/fuz_css": "^0.63.2",
|
|
59
|
+
"@fuzdev/fuz_ui": "^0.205.0",
|
|
60
60
|
"@fuzdev/fuz_util": "^0.65.1",
|
|
61
|
-
"@fuzdev/gro": "^0.
|
|
61
|
+
"@fuzdev/gro": "^0.204.0",
|
|
62
|
+
"@fuzdev/mdz": "^0.1.0",
|
|
62
63
|
"@ryanatkn/eslint-config": "^0.12.1",
|
|
63
64
|
"@sveltejs/acorn-typescript": "^1.0.9",
|
|
64
65
|
"@sveltejs/adapter-static": "^3.0.10",
|
|
@@ -75,7 +76,7 @@
|
|
|
75
76
|
"prettier-plugin-svelte": "^3.4.1",
|
|
76
77
|
"svelte": "^5.56.2",
|
|
77
78
|
"svelte-check": "^4.6.0",
|
|
78
|
-
"svelte-docinfo": "^0.
|
|
79
|
+
"svelte-docinfo": "^0.5.1",
|
|
79
80
|
"svelte2tsx": "^0.7.55",
|
|
80
81
|
"tslib": "^2.8.1",
|
|
81
82
|
"typescript": "^5.9.3",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {DEV} from 'esm-env';
|
|
2
|
+
|
|
1
3
|
import type {SyntaxTokenStream} from './syntax_token.js';
|
|
2
4
|
import {highlight_priorities} from './highlight_priorities.js';
|
|
3
5
|
|
|
@@ -10,10 +12,55 @@ export const supports_css_highlight_api = (): boolean =>
|
|
|
10
12
|
!!(globalThis.CSS?.highlights && globalThis.Highlight);
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
*
|
|
14
|
-
* Tracks ranges per element and only removes its own ranges when clearing.
|
|
15
|
+
* How a manager builds ranges for the Highlight API.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
+
* `StaticRange` is preferred: it's an immutable snapshot the browser does *not*
|
|
18
|
+
* track across DOM mutations. Since highlights are rebuilt wholesale whenever
|
|
19
|
+
* content changes, liveness buys nothing and only costs per-mutation boundary
|
|
20
|
+
* bookkeeping and paint work — the main efficiency (and Safari-stability) lever.
|
|
21
|
+
* Falls back to a live `Range` where `StaticRange` is unavailable.
|
|
22
|
+
*/
|
|
23
|
+
type RangeKind = 'static' | 'live';
|
|
24
|
+
|
|
25
|
+
const detect_range_kind = (): RangeKind =>
|
|
26
|
+
typeof globalThis.StaticRange === 'function' ? 'static' : 'live';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Finds the first text node child of `element`, or `null` if there is none.
|
|
30
|
+
*
|
|
31
|
+
* The text node might not be `firstChild` because frameworks (e.g. Svelte) can
|
|
32
|
+
* insert comment/anchor nodes around it.
|
|
33
|
+
*/
|
|
34
|
+
const find_text_node = (element: Element): Node | null => {
|
|
35
|
+
for (const node of element.childNodes) {
|
|
36
|
+
if (node.nodeType === Node.TEXT_NODE) return node;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const has_tokens = (tokens: SyntaxTokenStream): boolean =>
|
|
42
|
+
tokens.some((t) => typeof t !== 'string');
|
|
43
|
+
|
|
44
|
+
const push_range = (
|
|
45
|
+
ranges_by_name: Map<string, Array<AbstractRange>>,
|
|
46
|
+
name: string,
|
|
47
|
+
range: AbstractRange,
|
|
48
|
+
): void => {
|
|
49
|
+
const existing = ranges_by_name.get(name);
|
|
50
|
+
if (existing) {
|
|
51
|
+
existing.push(range);
|
|
52
|
+
} else {
|
|
53
|
+
ranges_by_name.set(name, [range]);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Manages CSS Custom Highlight API ranges for a single element's text node.
|
|
59
|
+
* Tracks ranges per element and only removes its own ranges when clearing,
|
|
60
|
+
* cooperating with other managers that share the global `CSS.highlights` registry.
|
|
61
|
+
*
|
|
62
|
+
* **Experimental** — limited browser support. Use `Code.svelte` for production
|
|
63
|
+
* block code; this powers the experimental `CodeHighlight` and `CodeTextarea`.
|
|
17
64
|
*
|
|
18
65
|
* @example
|
|
19
66
|
* ```ts
|
|
@@ -22,62 +69,94 @@ export const supports_css_highlight_api = (): boolean =>
|
|
|
22
69
|
* ```
|
|
23
70
|
*/
|
|
24
71
|
export class HighlightManager {
|
|
25
|
-
|
|
72
|
+
/**
|
|
73
|
+
* This manager's ranges, keyed by prefixed highlight name (e.g. `token_keyword`).
|
|
74
|
+
* A single range object may be shared across several names (a token type plus
|
|
75
|
+
* its aliases), since one range can belong to multiple `Highlight` sets.
|
|
76
|
+
*/
|
|
77
|
+
element_ranges: Map<string, Array<AbstractRange>>;
|
|
78
|
+
|
|
79
|
+
#range_kind: RangeKind;
|
|
26
80
|
|
|
27
81
|
constructor() {
|
|
28
82
|
if (!supports_css_highlight_api()) {
|
|
29
83
|
throw Error('CSS Highlights API not supported');
|
|
30
84
|
}
|
|
31
85
|
this.element_ranges = new Map();
|
|
86
|
+
this.#range_kind = detect_range_kind();
|
|
32
87
|
}
|
|
33
88
|
|
|
34
89
|
/**
|
|
35
|
-
* Highlights from a `SyntaxTokenStream` produced by
|
|
90
|
+
* Highlights `element`'s text node from a `SyntaxTokenStream` produced by
|
|
91
|
+
* `tokenize_syntax`. Clears this manager's previous ranges first.
|
|
92
|
+
*
|
|
93
|
+
* In production this never throws on a tokenizer/DOM mismatch: out-of-bounds
|
|
94
|
+
* tokens are clamped and a missing text node is a no-op. In DEV the same
|
|
95
|
+
* conditions throw loudly to surface grammar bugs.
|
|
36
96
|
*/
|
|
37
97
|
highlight_from_syntax_tokens(element: Element, tokens: SyntaxTokenStream): void {
|
|
38
|
-
|
|
39
|
-
let text_node: Node | null = null;
|
|
40
|
-
for (const node of element.childNodes) {
|
|
41
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
42
|
-
text_node = node;
|
|
43
|
-
break;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
98
|
+
this.clear_element_ranges();
|
|
46
99
|
|
|
100
|
+
const text_node = find_text_node(element);
|
|
47
101
|
if (!text_node) {
|
|
48
|
-
|
|
102
|
+
if (has_tokens(tokens)) {
|
|
103
|
+
if (DEV) {
|
|
104
|
+
throw new Error('no text node to highlight');
|
|
105
|
+
} else {
|
|
106
|
+
// eslint-disable-next-line no-console
|
|
107
|
+
console.error('[HighlightManager] tokens present but no text node to highlight');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
49
111
|
}
|
|
50
112
|
|
|
51
|
-
|
|
113
|
+
try {
|
|
114
|
+
this.#apply(text_node, tokens);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// some engines may reject `StaticRange` in `Highlight.add` -- fall back to
|
|
117
|
+
// live `Range` once rather than letting the throw escape into the effect
|
|
118
|
+
if (this.#range_kind === 'static') {
|
|
119
|
+
this.#range_kind = 'live';
|
|
120
|
+
this.clear_element_ranges(); // undo any partial application
|
|
121
|
+
this.#apply(text_node, tokens);
|
|
122
|
+
} else {
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
52
127
|
|
|
53
|
-
|
|
54
|
-
const
|
|
128
|
+
#apply(text_node: Node, tokens: SyntaxTokenStream): void {
|
|
129
|
+
const ranges_by_name: Map<string, Array<AbstractRange>> = new Map();
|
|
130
|
+
const final_pos = this.#collect_ranges(tokens, text_node, ranges_by_name, 0);
|
|
55
131
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
132
|
+
if (DEV) {
|
|
133
|
+
const text_length = text_node.textContent?.length ?? 0;
|
|
134
|
+
if (final_pos !== text_length) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Token stream length mismatch: tokens covered ${final_pos} chars but text node has ${text_length} chars`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
62
139
|
}
|
|
63
140
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
141
|
+
// TODO: cross-instance coupling -- all managers share one global `Highlight`
|
|
142
|
+
// per token type, so re-highlighting one element (e.g. a textarea on every
|
|
143
|
+
// keystroke) mutates highlights that also hold ranges from every other code
|
|
144
|
+
// block on the page, forcing the browser to re-evaluate the shared set.
|
|
145
|
+
// Isolating per-instance needs unique highlight names + runtime-injected
|
|
146
|
+
// `::highlight()` CSS (≈50 token types × N instances), trading the static
|
|
147
|
+
// theme file for generated CSS. Only worth it with many concurrently-updating
|
|
148
|
+
// instances; revisit if profiling shows it.
|
|
149
|
+
for (const [name, ranges] of ranges_by_name) {
|
|
150
|
+
this.element_ranges.set(name, ranges);
|
|
69
151
|
|
|
70
|
-
|
|
71
|
-
let highlight = CSS.highlights.get(prefixed_type);
|
|
152
|
+
let highlight = CSS.highlights.get(name);
|
|
72
153
|
if (!highlight) {
|
|
73
154
|
highlight = new Highlight();
|
|
74
|
-
//
|
|
75
|
-
highlight.priority =
|
|
76
|
-
|
|
77
|
-
CSS.highlights.set(prefixed_type, highlight);
|
|
155
|
+
// priority follows CSS cascade order (higher = later in CSS = wins)
|
|
156
|
+
highlight.priority = highlight_priorities[name as keyof typeof highlight_priorities] ?? 0;
|
|
157
|
+
CSS.highlights.set(name, highlight);
|
|
78
158
|
}
|
|
79
159
|
|
|
80
|
-
// Add all ranges to the highlight
|
|
81
160
|
for (const range of ranges) {
|
|
82
161
|
highlight.add(range);
|
|
83
162
|
}
|
|
@@ -85,14 +164,14 @@ export class HighlightManager {
|
|
|
85
164
|
}
|
|
86
165
|
|
|
87
166
|
/**
|
|
88
|
-
* Clears only this
|
|
167
|
+
* Clears only this manager's ranges from the shared highlights. Defensive:
|
|
168
|
+
* a highlight may already be gone (e.g. another manager removed the last
|
|
169
|
+
* range, or HMR reset the registry), which is a valid state, not an error.
|
|
89
170
|
*/
|
|
90
171
|
clear_element_ranges(): void {
|
|
91
172
|
for (const [name, ranges] of this.element_ranges) {
|
|
92
173
|
const highlight = CSS.highlights.get(name);
|
|
93
|
-
if (!highlight)
|
|
94
|
-
throw new Error('Expected to find CSS highlight: ' + name);
|
|
95
|
-
}
|
|
174
|
+
if (!highlight) continue;
|
|
96
175
|
|
|
97
176
|
for (const range of ranges) {
|
|
98
177
|
highlight.delete(range);
|
|
@@ -110,13 +189,29 @@ export class HighlightManager {
|
|
|
110
189
|
this.clear_element_ranges();
|
|
111
190
|
}
|
|
112
191
|
|
|
192
|
+
#make_range(text_node: Node, start: number, end: number): AbstractRange {
|
|
193
|
+
if (this.#range_kind === 'static') {
|
|
194
|
+
return new StaticRange({
|
|
195
|
+
startContainer: text_node,
|
|
196
|
+
startOffset: start,
|
|
197
|
+
endContainer: text_node,
|
|
198
|
+
endOffset: end,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const range = new Range();
|
|
202
|
+
range.setStart(text_node, start);
|
|
203
|
+
range.setEnd(text_node, end);
|
|
204
|
+
return range;
|
|
205
|
+
}
|
|
206
|
+
|
|
113
207
|
/**
|
|
114
|
-
*
|
|
208
|
+
* Walks the token tree, collecting one range per non-empty token (shared
|
|
209
|
+
* across its type and aliases). Returns the end position covered.
|
|
115
210
|
*/
|
|
116
|
-
#
|
|
211
|
+
#collect_ranges(
|
|
117
212
|
tokens: SyntaxTokenStream,
|
|
118
213
|
text_node: Node,
|
|
119
|
-
|
|
214
|
+
ranges_by_name: Map<string, Array<AbstractRange>>,
|
|
120
215
|
offset: number,
|
|
121
216
|
): number {
|
|
122
217
|
const text_length = text_node.textContent?.length ?? 0;
|
|
@@ -128,61 +223,36 @@ export class HighlightManager {
|
|
|
128
223
|
continue;
|
|
129
224
|
}
|
|
130
225
|
|
|
131
|
-
const
|
|
132
|
-
const end_pos = pos + length;
|
|
226
|
+
const end_pos = pos + token.length;
|
|
133
227
|
|
|
134
|
-
|
|
135
|
-
if (end_pos > text_length) {
|
|
228
|
+
if (DEV && end_pos > text_length) {
|
|
136
229
|
throw new Error(
|
|
137
230
|
`Token ${token.type} extends beyond text node: position ${end_pos} > length ${text_length}`,
|
|
138
231
|
);
|
|
139
232
|
}
|
|
140
233
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
range
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!ranges_by_type.has(type)) {
|
|
149
|
-
ranges_by_type.set(type, []);
|
|
150
|
-
}
|
|
151
|
-
ranges_by_type.get(type)!.push(range);
|
|
152
|
-
|
|
153
|
-
// Also add range for any aliases (alias is always an array)
|
|
234
|
+
// production-safe: clamp rather than throw on a tokenizer edge case
|
|
235
|
+
const safe_end = end_pos > text_length ? text_length : end_pos;
|
|
236
|
+
if (safe_end > pos) {
|
|
237
|
+
// one range shared across the token type and all its aliases --
|
|
238
|
+
// the same range object can belong to multiple `Highlight` sets
|
|
239
|
+
const range = this.#make_range(text_node, pos, safe_end);
|
|
240
|
+
push_range(ranges_by_name, `token_${token.type}`, range);
|
|
154
241
|
for (const alias of token.alias) {
|
|
155
|
-
|
|
156
|
-
ranges_by_type.set(alias, []);
|
|
157
|
-
}
|
|
158
|
-
// Create a new range for each alias (ranges can't be reused)
|
|
159
|
-
const alias_range = new Range();
|
|
160
|
-
alias_range.setStart(text_node, pos);
|
|
161
|
-
alias_range.setEnd(text_node, end_pos);
|
|
162
|
-
ranges_by_type.get(alias)!.push(alias_range);
|
|
242
|
+
push_range(ranges_by_name, `token_${alias}`, range);
|
|
163
243
|
}
|
|
164
|
-
} catch (e) {
|
|
165
|
-
throw new Error(`Failed to create range for ${token.type}: ${e}`);
|
|
166
244
|
}
|
|
167
245
|
|
|
168
|
-
// Process nested tokens
|
|
169
246
|
if (Array.isArray(token.content)) {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
text_node,
|
|
173
|
-
ranges_by_type,
|
|
174
|
-
pos,
|
|
175
|
-
);
|
|
176
|
-
// Validate that nested tokens match the parent token's claimed length
|
|
177
|
-
if (actual_end_pos !== end_pos) {
|
|
247
|
+
const nested_end = this.#collect_ranges(token.content, text_node, ranges_by_name, pos);
|
|
248
|
+
if (DEV && nested_end !== end_pos) {
|
|
178
249
|
throw new Error(
|
|
179
|
-
`Token ${token.type} length mismatch: claimed ${length} chars (${pos}-${end_pos}) but nested content covered ${
|
|
250
|
+
`Token ${token.type} length mismatch: claimed ${token.length} chars (${pos}-${end_pos}) but nested content covered ${nested_end - pos} chars (${pos}-${nested_end})`,
|
|
180
251
|
);
|
|
181
252
|
}
|
|
182
|
-
pos = actual_end_pos;
|
|
183
|
-
} else {
|
|
184
|
-
pos = end_pos;
|
|
185
253
|
}
|
|
254
|
+
|
|
255
|
+
pos = end_pos;
|
|
186
256
|
}
|
|
187
257
|
|
|
188
258
|
return pos;
|