@fuzdev/fuz_code 0.45.1 → 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.
Files changed (39) hide show
  1. package/README.md +1 -0
  2. package/dist/Code.svelte +2 -2
  3. package/dist/Code.svelte.d.ts +2 -2
  4. package/dist/CodeHighlight.svelte +18 -54
  5. package/dist/CodeHighlight.svelte.d.ts +4 -4
  6. package/dist/CodeHighlight.svelte.d.ts.map +1 -1
  7. package/dist/CodeTextarea.svelte +149 -0
  8. package/dist/CodeTextarea.svelte.d.ts +43 -0
  9. package/dist/CodeTextarea.svelte.d.ts.map +1 -0
  10. package/dist/grammar_markdown.js +3 -3
  11. package/dist/grammar_markup.d.ts +8 -7
  12. package/dist/grammar_markup.d.ts.map +1 -1
  13. package/dist/grammar_markup.js +8 -7
  14. package/dist/highlight_manager.d.ts +21 -7
  15. package/dist/highlight_manager.d.ts.map +1 -1
  16. package/dist/highlight_manager.js +130 -74
  17. package/dist/range_highlighting.svelte.d.ts +39 -0
  18. package/dist/range_highlighting.svelte.d.ts.map +1 -0
  19. package/dist/range_highlighting.svelte.js +57 -0
  20. package/dist/svelte_preprocess_fuz_code.d.ts +4 -4
  21. package/dist/svelte_preprocess_fuz_code.d.ts.map +1 -1
  22. package/dist/svelte_preprocess_fuz_code.js +3 -3
  23. package/dist/syntax_styler.d.ts +40 -32
  24. package/dist/syntax_styler.d.ts.map +1 -1
  25. package/dist/syntax_styler.js +81 -49
  26. package/dist/syntax_token.d.ts +4 -4
  27. package/dist/syntax_token.js +2 -2
  28. package/dist/tokenize_syntax.d.ts +2 -4
  29. package/dist/tokenize_syntax.d.ts.map +1 -1
  30. package/dist/tokenize_syntax.js +2 -4
  31. package/package.json +27 -29
  32. package/src/lib/grammar_markdown.ts +3 -3
  33. package/src/lib/grammar_markup.ts +8 -7
  34. package/src/lib/highlight_manager.ts +154 -84
  35. package/src/lib/range_highlighting.svelte.ts +100 -0
  36. package/src/lib/svelte_preprocess_fuz_code.ts +6 -6
  37. package/src/lib/syntax_styler.ts +98 -53
  38. package/src/lib/syntax_token.ts +4 -4
  39. package/src/lib/tokenize_syntax.ts +2 -4
@@ -1,19 +1,66 @@
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
 
4
6
  export type HighlightMode = 'auto' | 'ranges' | 'html';
5
7
 
6
8
  /**
7
- * Check for CSS Highlights API support.
9
+ * Checks for CSS Highlights API support.
8
10
  */
9
11
  export const supports_css_highlight_api = (): boolean =>
10
- !!(globalThis.CSS?.highlights && globalThis.Highlight); // eslint-disable-line @typescript-eslint/no-unnecessary-condition
12
+ !!(globalThis.CSS?.highlights && globalThis.Highlight);
11
13
 
12
14
  /**
13
- * Manages CSS Custom Highlight API ranges for a single element.
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
- * **Experimental** limited browser support. Use `Code` for production.
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
- element_ranges: Map<string, Array<Range>>;
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
- * Highlight from syntax styler token stream.
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
- // Find the text node (it might not be firstChild due to Svelte comment nodes)
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
- throw new Error('no text node to highlight');
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
- this.clear_element_ranges();
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
- const ranges_by_type: Map<string, Array<Range>> = new Map();
54
- const final_pos = this.#create_all_ranges(tokens, text_node, ranges_by_type, 0);
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
- // Validate that token positions matched text node length
57
- const text_length = text_node.textContent?.length ?? 0;
58
- if (final_pos !== text_length) {
59
- throw new Error(
60
- `Token stream length mismatch: tokens covered ${final_pos} chars but text node has ${text_length} chars`,
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
- // Apply highlights
65
- for (const [type, ranges] of ranges_by_type) {
66
- const prefixed_type = `token_${type}`;
67
- // Track ranges for this element
68
- this.element_ranges.set(prefixed_type, ranges);
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
- // Get or create the shared highlight
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
- // Set priority based on CSS cascade order (higher = later in CSS = wins)
75
- highlight.priority =
76
- highlight_priorities[prefixed_type as keyof typeof highlight_priorities] ?? 0;
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
- * Clear only this element's ranges from highlights.
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
- * Create ranges for all tokens in the tree.
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
- #create_all_ranges(
211
+ #collect_ranges(
117
212
  tokens: SyntaxTokenStream,
118
213
  text_node: Node,
119
- ranges_by_type: Map<string, Array<Range>>,
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 length = token.length;
132
- const end_pos = pos + length;
226
+ const end_pos = pos + token.length;
133
227
 
134
- // Validate positions are within text node bounds before creating ranges
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
- try {
142
- const range = new Range();
143
- range.setStart(text_node, pos);
144
- range.setEnd(text_node, end_pos);
145
-
146
- // Add range for the token type
147
- const type = token.type;
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
- if (!ranges_by_type.has(alias)) {
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 actual_end_pos = this.#create_all_ranges(
171
- token.content,
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 ${actual_end_pos - pos} chars (${pos}-${actual_end_pos})`,
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;
@@ -0,0 +1,100 @@
1
+ import {onDestroy} from 'svelte';
2
+ import {DEV} from 'esm-env';
3
+
4
+ import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
5
+ import {HighlightManager, supports_css_highlight_api} from './highlight_manager.js';
6
+
7
+ /**
8
+ * Reactive inputs for `create_range_highlighting`. All values are getters so the
9
+ * helper can track the consuming component's reactive state across the call
10
+ * boundary (the Svelte 5 getter-injection pattern).
11
+ */
12
+ export interface RangeHighlightingOptions {
13
+ /** The element whose first text node receives the highlight ranges. */
14
+ element: () => Element | undefined;
15
+ /**
16
+ * The text to tokenize. Must match the element's text node exactly (e.g. a
17
+ * textarea backdrop includes its trailing newline here too).
18
+ */
19
+ text: () => string;
20
+ /** Language id; `null` disables highlighting. */
21
+ lang: () => string | null;
22
+ /** Optional custom grammar; takes precedence over `lang` for tokenization. */
23
+ grammar: () => SyntaxGrammar | undefined;
24
+ /** The syntax styler whose registered grammars back `lang` lookups. */
25
+ syntax_styler: () => SyntaxStyler;
26
+ /** Extra gate — ranges are only applied when this returns true. Defaults to always-on. */
27
+ enabled?: () => boolean;
28
+ /** Component name used in DEV warnings. */
29
+ dev_label: string;
30
+ }
31
+
32
+ /** Reactive outputs from `create_range_highlighting`. */
33
+ export interface RangeHighlighting {
34
+ readonly highlighting_disabled: boolean;
35
+ }
36
+
37
+ /**
38
+ * Wires up CSS Custom Highlight API range highlighting for a single element's
39
+ * text node, shared by `CodeHighlight` and `CodeTextarea`. Creates a
40
+ * `HighlightManager`, memoizes tokenization, applies/clears ranges in an effect,
41
+ * emits DEV warnings for unsupported languages, and tears down on destroy.
42
+ *
43
+ * Must be called during component initialization (it uses `$effect`/`onDestroy`).
44
+ */
45
+ export const create_range_highlighting = (options: RangeHighlightingOptions): RangeHighlighting => {
46
+ const manager = supports_css_highlight_api() ? new HighlightManager() : null;
47
+ const is_enabled = options.enabled ?? (() => true);
48
+
49
+ const language_supported = $derived(
50
+ options.lang() !== null && !!options.syntax_styler().langs[options.lang()!],
51
+ );
52
+ const highlighting_disabled = $derived(
53
+ options.lang() === null || (!language_supported && !options.grammar()),
54
+ );
55
+
56
+ // tokenize once per (text, grammar, lang) change -- memoized so unrelated
57
+ // reactivity doesn't trigger a full re-tokenization
58
+ const range_tokens = $derived.by(() => {
59
+ if (!manager || !is_enabled() || highlighting_disabled) return null;
60
+ const text = options.text();
61
+ if (!text) return null;
62
+ // route through the styler so `before_tokenize`/`after_tokenize` hooks run,
63
+ // matching `stylize`'s HTML path; `grammar` undefined falls back to the
64
+ // registered lang grammar (`! safe bc of `highlighting_disabled`)
65
+ return options.syntax_styler().tokenize(text, options.lang()!, options.grammar());
66
+ });
67
+
68
+ if (manager) {
69
+ $effect(() => {
70
+ const element = options.element();
71
+ if (!element || !range_tokens) {
72
+ manager.clear_element_ranges();
73
+ return;
74
+ }
75
+ manager.highlight_from_syntax_tokens(element, range_tokens);
76
+ });
77
+ }
78
+
79
+ if (DEV) {
80
+ $effect(() => {
81
+ // a lang was requested but we can't highlight it (unknown id, no grammar)
82
+ if (options.lang() && highlighting_disabled) {
83
+ const langs = Object.keys(options.syntax_styler().langs).join(', ');
84
+ // eslint-disable-next-line no-console
85
+ console.error(
86
+ `[${options.dev_label}] Language "${options.lang()}" is not supported and no custom grammar provided. ` +
87
+ `Highlighting disabled. Supported: ${langs}`,
88
+ );
89
+ }
90
+ });
91
+ }
92
+
93
+ onDestroy(() => manager?.destroy());
94
+
95
+ return {
96
+ get highlighting_disabled() {
97
+ return highlighting_disabled;
98
+ },
99
+ };
100
+ };
@@ -23,15 +23,15 @@ export interface PreprocessFuzCodeOptions {
23
23
  /** File patterns to exclude. */
24
24
  exclude?: Array<string | RegExp>;
25
25
 
26
- /** Custom syntax styler. @default syntax_styler_global */
26
+ /** Custom `SyntaxStyler` instance. @default syntax_styler_global */
27
27
  syntax_styler?: SyntaxStyler;
28
28
 
29
29
  /** Enable in-memory caching. @default true */
30
30
  cache?: boolean;
31
31
 
32
32
  /**
33
- * Import sources that resolve to the Code component.
34
- * Used to verify that `<Code>` in templates actually refers to fuz_code's Code.svelte.
33
+ * Import sources that resolve to the `Code` component.
34
+ * Used to verify that `<Code>` in templates actually refers to `Code.svelte`.
35
35
  *
36
36
  * @default ['@fuzdev/fuz_code/Code.svelte']
37
37
  */
@@ -48,7 +48,7 @@ export interface PreprocessFuzCodeOptions {
48
48
  * Svelte preprocessor that compiles static `Code` component content at build time,
49
49
  * replacing runtime syntax highlighting with pre-rendered HTML.
50
50
  *
51
- * @param options preprocessor configuration
51
+ * @param options - `PreprocessFuzCodeOptions` configuration
52
52
  * @returns a Svelte preprocessor group
53
53
  *
54
54
  * @example
@@ -141,7 +141,7 @@ interface FindCodeUsagesOptions {
141
141
  }
142
142
 
143
143
  /**
144
- * Attempt to highlight content, using cache if available.
144
+ * Attempts to highlight content, using cache if available.
145
145
  * Returns the highlighted HTML, or `null` on error.
146
146
  */
147
147
  const try_highlight = (
@@ -165,7 +165,7 @@ const try_highlight = (
165
165
  };
166
166
 
167
167
  /**
168
- * Walks the AST to find Code component usages with static `content` props
168
+ * Walks the AST to find `Code` component usages with static `content` props
169
169
  * and generates transformations to replace them with `dangerous_raw_html`.
170
170
  */
171
171
  const find_code_usages = (