@fuzdev/fuz_code 0.46.0 → 0.46.2

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.
@@ -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
+ };
@@ -3,6 +3,13 @@ import {tokenize_syntax} from './tokenize_syntax.js';
3
3
 
4
4
  export type AddSyntaxGrammar = (syntax_styler: SyntaxStyler) => void;
5
5
 
6
+ /**
7
+ * Maps a matched `&`, `<`, or non-breaking space in text content to its
8
+ * HTML-safe form. Used as the replacer in `stringify_token` for leaf strings
9
+ * (non-breaking spaces are normalized to a regular space).
10
+ */
11
+ const escape_text_char = (ch: string): string => (ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : ' ');
12
+
6
13
  /**
7
14
  * Based on Prism (https://github.com/PrismJS/prism)
8
15
  * by Lea Verou (https://lea.verou.me/)
@@ -127,7 +134,42 @@ export class SyntaxStyler {
127
134
  lang: string,
128
135
  grammar: SyntaxGrammar | undefined = this.get_lang(lang),
129
136
  ): string {
130
- var ctx: HookBeforeTokenizeCallbackContext = {
137
+ // stringify with the post-hook `lang`, which a `before_tokenize` hook may
138
+ // have rewritten (it flows into each token's `wrap` hook context)
139
+ const c = this.#tokenize_hooked(text, lang, grammar);
140
+ return this.stringify_token(c.tokens, c.lang);
141
+ }
142
+
143
+ /**
144
+ * Tokenizes `text` into a `SyntaxTokenStream`, running the `before_tokenize`
145
+ * and `after_tokenize` hooks. This is the tokenization half of `stylize` — use
146
+ * it when you need the token stream itself (e.g. CSS Custom Highlight API range
147
+ * highlighting) rather than HTML.
148
+ *
149
+ * @param text - source to tokenize
150
+ * @param lang - language identifier; passed to the tokenize hooks
151
+ * @param grammar - grammar to tokenize with; defaults to `this.get_lang(lang)`
152
+ * @returns the resulting token stream
153
+ */
154
+ tokenize(
155
+ text: string,
156
+ lang: string,
157
+ grammar: SyntaxGrammar | undefined = this.get_lang(lang),
158
+ ): SyntaxTokenStream {
159
+ return this.#tokenize_hooked(text, lang, grammar).tokens;
160
+ }
161
+
162
+ /**
163
+ * Runs `before_tokenize` → `tokenize_syntax` → `after_tokenize`, returning the
164
+ * resolved context. Shared by `stylize` (which also needs the post-hook `lang`)
165
+ * and `tokenize` (which only needs `tokens`).
166
+ */
167
+ #tokenize_hooked(
168
+ text: string,
169
+ lang: string,
170
+ grammar: SyntaxGrammar,
171
+ ): HookAfterTokenizeCallbackContext {
172
+ const ctx: HookBeforeTokenizeCallbackContext = {
131
173
  code: text,
132
174
  grammar,
133
175
  lang,
@@ -137,7 +179,7 @@ export class SyntaxStyler {
137
179
  const c = ctx as any as HookAfterTokenizeCallbackContext;
138
180
  c.tokens = tokenize_syntax(c.code, c.grammar);
139
181
  this.run_hook_after_tokenize(c);
140
- return this.stringify_token(c.tokens, c.lang);
182
+ return c;
141
183
  }
142
184
 
143
185
  /**
@@ -263,10 +305,9 @@ export class SyntaxStyler {
263
305
  */
264
306
  stringify_token(o: string | SyntaxToken | SyntaxTokenStream, lang: string): string {
265
307
  if (typeof o === 'string') {
266
- return o
267
- .replace(/&/g, '&amp;')
268
- .replace(/</g, '&lt;')
269
- .replace(/\u00a0/g, ' ');
308
+ // single pass over the leaf text (only `&` and `<` need escaping in text
309
+ // content; `\u00a0` is normalized to a regular space)
310
+ return o.replace(/[&<\u00a0]/g, escape_text_char);
270
311
  }
271
312
  if (Array.isArray(o)) {
272
313
  var s = '';
@@ -276,21 +317,29 @@ export class SyntaxStyler {
276
317
  return s;
277
318
  }
278
319
 
320
+ var content = this.stringify_token(o.content, lang);
321
+
322
+ // build the class list once; aliases are always an array after normalization
323
+ var classes = `token_${o.type}`;
324
+ for (const a of o.alias) {
325
+ classes += ` token_${a}`;
326
+ }
327
+
328
+ // fast path: with no `wrap` hooks the tag is always a plain <span> with no
329
+ // attributes, so skip the per-token context object and hook dispatch
330
+ if (this.hooks_wrap.length === 0) {
331
+ return '<span class="' + classes + '">' + content + '</span>';
332
+ }
333
+
279
334
  var ctx: HookWrapCallbackContext = {
280
335
  type: o.type,
281
- content: this.stringify_token(o.content, lang),
336
+ content,
282
337
  tag: 'span',
283
- classes: [`token_${o.type}`],
338
+ classes: classes.split(' '),
284
339
  attributes: {},
285
340
  lang,
286
341
  };
287
342
 
288
- var aliases = o.alias;
289
- // alias is always an array after normalization
290
- for (const a of aliases) {
291
- ctx.classes.push(`token_${a}`);
292
- }
293
-
294
343
  this.run_hook_wrap(ctx);
295
344
 
296
345
  var attributes = '';