@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.
@@ -6,18 +6,13 @@
6
6
  * Requires importing `theme_highlight.css` instead of `theme.css`.
7
7
  */
8
8
 
9
- import {onDestroy, type Snippet} from 'svelte';
10
- import {DEV} from 'esm-env';
9
+ import type {Snippet} from 'svelte';
11
10
  import type {SvelteHTMLElements} from 'svelte/elements';
12
11
 
13
12
  import {syntax_styler_global} from './syntax_styler_global.js';
14
13
  import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
15
- import {tokenize_syntax} from './tokenize_syntax.js';
16
- import {
17
- HighlightManager,
18
- supports_css_highlight_api,
19
- type HighlightMode,
20
- } from './highlight_manager.js';
14
+ import {supports_css_highlight_api, type HighlightMode} from './highlight_manager.js';
15
+ import {create_range_highlighting} from './range_highlighting.svelte.js';
21
16
 
22
17
  const {
23
18
  content,
@@ -121,58 +116,27 @@
121
116
 
122
117
  const supports_ranges = supports_css_highlight_api();
123
118
 
124
- const highlight_manager = supports_ranges ? new HighlightManager() : null;
125
-
126
119
  const use_ranges = $derived(supports_ranges && (mode === 'ranges' || mode === 'auto'));
127
120
 
128
- const language_supported = $derived(lang !== null && !!syntax_styler.langs[lang]);
129
-
130
- const highlighting_disabled = $derived(lang === null || (!language_supported && !grammar));
131
-
132
- // DEV-only validation warnings
133
- if (DEV) {
134
- $effect(() => {
135
- if (lang && !language_supported && !grammar) {
136
- const langs = Object.keys(syntax_styler.langs).join(', ');
137
- // eslint-disable-next-line no-console
138
- console.error(
139
- `[CodeHighlight] Language "${lang}" is not supported and no custom grammar provided. ` +
140
- `Highlighting disabled. Supported: ${langs}`,
141
- );
142
- }
143
- });
144
- }
121
+ const rh = create_range_highlighting({
122
+ element: () => code_element,
123
+ text: () => content,
124
+ enabled: () => use_ranges,
125
+ lang: () => lang,
126
+ grammar: () => grammar,
127
+ syntax_styler: () => syntax_styler,
128
+ dev_label: 'CodeHighlight',
129
+ });
145
130
 
146
131
  // Generate HTML markup for syntax highlighting in non-range mode
147
132
  const html_content = $derived.by(() => {
148
- if (use_ranges || !content || highlighting_disabled) {
133
+ if (use_ranges || !content || rh.highlighting_disabled) {
149
134
  return '';
150
135
  }
151
136
 
152
137
  return syntax_styler.stylize(content, lang!, grammar); // ! is safe bc of the `highlighting_disabled` calculation
153
138
  });
154
139
 
155
- // Apply highlights for range mode
156
- if (highlight_manager) {
157
- $effect(() => {
158
- if (!code_element || !content || !use_ranges || highlighting_disabled) {
159
- highlight_manager.clear_element_ranges();
160
- return;
161
- }
162
-
163
- // Get tokens from syntax styler
164
- const tokens = tokenize_syntax(content, grammar || syntax_styler.get_lang(lang!)); // ! is safe bc of the `highlighting_disabled` calculation
165
-
166
- // Apply highlights
167
- highlight_manager.highlight_from_syntax_tokens(code_element, tokens);
168
- });
169
- }
170
-
171
- onDestroy(() => {
172
- highlight_manager?.destroy();
173
- });
174
-
175
- // TODO use intersect attachment from fuz_ui to optimize ranges
176
140
  // TODO do syntax styling at compile-time in the normal case, and don't import these at runtime
177
141
  // TODO @html making me nervous
178
142
  </script>
@@ -182,7 +146,7 @@
182
146
  <code {...rest} class:inline class:wrap data-lang={lang} bind:this={code_element}
183
147
  >{#if use_ranges && children}{@render children(
184
148
  content,
185
- )}{:else if use_ranges || highlighting_disabled}{content}{:else if children}{@render children(
149
+ )}{:else if use_ranges || rh.highlighting_disabled}{content}{:else if children}{@render children(
186
150
  html_content,
187
151
  )}{:else}{@html html_content}{/if}</code
188
152
  >
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Requires importing `theme_highlight.css` instead of `theme.css`.
6
6
  */
7
- import { type Snippet } from 'svelte';
7
+ import type { Snippet } from 'svelte';
8
8
  import type { SvelteHTMLElements } from 'svelte/elements';
9
9
  import type { SyntaxStyler, SyntaxGrammar } from './syntax_styler.js';
10
10
  import { type HighlightMode } from './highlight_manager.js';
@@ -1 +1 @@
1
- {"version":3,"file":"CodeHighlight.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CodeHighlight.svelte"],"names":[],"mappings":"AAGA;;;;;OAKI;AACJ,OAAO,EAAY,KAAK,OAAO,EAAC,MAAM,QAAQ,CAAC;AAE/C,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAEpE,OAAO,EAGL,KAAK,aAAa,EAClB,MAAM,wBAAwB,CAAC;AAEhC,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,MAAM,CAAC,GAAG;IACrD,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrC,CAAC;AAiGH,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"CodeHighlight.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CodeHighlight.svelte"],"names":[],"mappings":"AAGA;;;;;OAKI;AACJ,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AACpC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AACpE,OAAO,EAA6B,KAAK,aAAa,EAAC,MAAM,wBAAwB,CAAC;AAGrF,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,MAAM,CAAC,GAAG;IACrD,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB;;;;;;;;;;;;;;;OAeG;IACH,OAAO,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACpC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrC,CAAC;AAiEH,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,156 @@
1
+ <script lang="ts">
2
+ /**
3
+ * A `<textarea>` with live syntax highlighting via the CSS Custom Highlight API.
4
+ *
5
+ * The text is rendered twice: an editable, visually-transparent `<textarea>`
6
+ * on top, and a backdrop `<pre>` mirror underneath whose text node receives
7
+ * the highlight ranges. The two share identical box metrics so characters line
8
+ * up exactly, and the backdrop is scroll-synced to the textarea.
9
+ *
10
+ * **Minimal by design**: this is a highlighted input, not a full editor. It
11
+ * does not provide line numbers, tab-to-indent, auto-resize, or undo handling
12
+ * — compose those on top via the spread `...rest` props and `bind:value`.
13
+ *
14
+ * **Experimental** — the Highlight API has limited browser support and cannot
15
+ * render font-weight/font-style. Requires importing `theme_highlight.css`.
16
+ */
17
+
18
+ import type {SvelteHTMLElements} from 'svelte/elements';
19
+
20
+ import {syntax_styler_global} from './syntax_styler_global.js';
21
+ import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
22
+ import {create_range_highlighting} from './range_highlighting.svelte.js';
23
+
24
+ let {
25
+ value = $bindable(''),
26
+ lang = 'svelte',
27
+ grammar,
28
+ syntax_styler = syntax_styler_global,
29
+ wrapper_attrs,
30
+ ...rest
31
+ }: SvelteHTMLElements['textarea'] & {
32
+ /** The editable source code. Bindable. */
33
+ value?: string;
34
+ /**
35
+ * Language identifier (e.g. 'ts', 'css', 'svelte'). `null` disables
36
+ * highlighting; `undefined` falls back to the default ('svelte').
37
+ */
38
+ lang?: string | null;
39
+ /** Optional custom grammar; takes precedence over `lang` for tokenization. */
40
+ grammar?: SyntaxGrammar | undefined;
41
+ /** Custom `SyntaxStyler` instance (defaults to the global one). */
42
+ syntax_styler?: SyntaxStyler;
43
+ /**
44
+ * Attributes for the wrapper `<div>` — the layout box that the textarea
45
+ * fills and `resize` grows. Use it for sizing/layout classes, `style`,
46
+ * `id`, or container-level handlers. Its `class` is merged with the
47
+ * internal `code_textarea` class; `data-lang` stays component-controlled.
48
+ * (`...rest` spreads onto the `<textarea>`; the backdrop `<pre>` is
49
+ * internal and intentionally not exposed.)
50
+ */
51
+ wrapper_attrs?: SvelteHTMLElements['div'];
52
+ } = $props();
53
+
54
+ // the backdrop <pre> holds the text node that gets highlighted *and* is the
55
+ // scroll container kept in sync with the textarea
56
+ let backdrop: HTMLElement | undefined = $state.raw();
57
+ let textarea: HTMLTextAreaElement | undefined = $state.raw();
58
+
59
+ // A trailing newline keeps the backdrop's last line aligned with the textarea:
60
+ // a textarea shows an empty final line after a trailing "\n", which a <pre>
61
+ // would otherwise collapse. Rendered as a single expression -> one text node,
62
+ // and tokenized as-is so range positions match the text node exactly.
63
+ const display_text = $derived(value + '\n');
64
+
65
+ create_range_highlighting({
66
+ element: () => backdrop,
67
+ text: () => display_text,
68
+ lang: () => lang,
69
+ grammar: () => grammar,
70
+ syntax_styler: () => syntax_styler,
71
+ dev_label: 'CodeTextarea',
72
+ });
73
+
74
+ // keep the (overflow-hidden) backdrop aligned with the textarea's scroll position
75
+ const sync_scroll = () => {
76
+ if (!backdrop || !textarea) return;
77
+ backdrop.scrollTop = textarea.scrollTop;
78
+ backdrop.scrollLeft = textarea.scrollLeft;
79
+ };
80
+ </script>
81
+
82
+ <div {...wrapper_attrs} class={['code_textarea', wrapper_attrs?.class]} data-lang={lang}>
83
+ <pre class="code_textarea_backdrop" aria-hidden="true" bind:this={backdrop}>{display_text}</pre>
84
+ <textarea
85
+ bind:this={textarea}
86
+ spellcheck="false"
87
+ {...rest}
88
+ bind:value
89
+ onscroll={(e) => {
90
+ sync_scroll();
91
+ rest.onscroll?.(e); // preserve a consumer-supplied handler
92
+ }}
93
+ ></textarea>
94
+ </div>
95
+
96
+ <style>
97
+ .code_textarea {
98
+ position: relative;
99
+ width: 100%;
100
+ }
101
+
102
+ /* Metrics shared by both layers so characters align exactly. fuz_css styles
103
+ `pre` and `textarea` differently, so each declaration here equalizes them;
104
+ anything fuz_css already applies identically (box-sizing, the reset margin,
105
+ colors) is left to it. */
106
+ .code_textarea_backdrop,
107
+ .code_textarea textarea {
108
+ /* the backdrop `pre` isn't the last child, so fuz_css's flow rule would give
109
+ it a bottom margin that shrinks its absolute box and drifts the last line */
110
+ margin: 0;
111
+ width: 100%;
112
+ /* fuz_css pads the textarea but not `pre` — pin both the same */
113
+ padding: var(--space_xs3) var(--space_xs);
114
+ /* fuz_css borders the textarea but not `pre`; a transparent border on both
115
+ keeps the box metrics identical (the textarea's is colored below) */
116
+ border: 1px solid transparent;
117
+ border-radius: var(--radius_xs, 2px);
118
+ /* the textarea would otherwise inherit the page's proportional font and
119
+ `line-height: normal`; the backdrop `pre` is already mono */
120
+ font-family: var(--font_family_mono);
121
+ font-size: var(--font_size_sm);
122
+ line-height: var(--line_height_md);
123
+ tab-size: 2;
124
+ /* fuz_css sets `pre` to `white-space: pre`; both must wrap to stay aligned */
125
+ white-space: pre-wrap;
126
+ overflow-wrap: break-word;
127
+ overflow: auto;
128
+ /* reserve gutter on both layers so the textarea's scrollbar doesn't shrink
129
+ its wrap width relative to the backdrop, which would drift highlights */
130
+ scrollbar-gutter: stable;
131
+ }
132
+
133
+ /* the backdrop is taken out of flow so the in-flow textarea (its `rows`/resize)
134
+ defines the box; `inset: 0` makes the backdrop fill and clip to that box,
135
+ scrolled programmatically to match the textarea */
136
+ .code_textarea_backdrop {
137
+ position: absolute;
138
+ inset: 0;
139
+ pointer-events: none;
140
+ user-select: none;
141
+ overflow: hidden;
142
+ }
143
+
144
+ .code_textarea textarea {
145
+ /* sits above the backdrop so the caret and selection are visible; the
146
+ textarea is the only in-flow layer, so it sizes the container */
147
+ position: relative;
148
+ z-index: 1;
149
+ /* the textarea's own text is invisible; the backdrop (a styled `pre`) shows
150
+ through, so restore a visible caret and border over the transparent ones */
151
+ background-color: transparent;
152
+ color: transparent;
153
+ caret-color: var(--text_color);
154
+ border-color: var(--border_color);
155
+ }
156
+ </style>
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A `<textarea>` with live syntax highlighting via the CSS Custom Highlight API.
3
+ *
4
+ * The text is rendered twice: an editable, visually-transparent `<textarea>`
5
+ * on top, and a backdrop `<pre>` mirror underneath whose text node receives
6
+ * the highlight ranges. The two share identical box metrics so characters line
7
+ * up exactly, and the backdrop is scroll-synced to the textarea.
8
+ *
9
+ * **Minimal by design**: this is a highlighted input, not a full editor. It
10
+ * does not provide line numbers, tab-to-indent, auto-resize, or undo handling
11
+ * — compose those on top via the spread `...rest` props and `bind:value`.
12
+ *
13
+ * **Experimental** — the Highlight API has limited browser support and cannot
14
+ * render font-weight/font-style. Requires importing `theme_highlight.css`.
15
+ */
16
+ import type { SvelteHTMLElements } from 'svelte/elements';
17
+ import type { SyntaxStyler, SyntaxGrammar } from './syntax_styler.js';
18
+ type $$ComponentProps = SvelteHTMLElements['textarea'] & {
19
+ /** The editable source code. Bindable. */
20
+ value?: string;
21
+ /**
22
+ * Language identifier (e.g. 'ts', 'css', 'svelte'). `null` disables
23
+ * highlighting; `undefined` falls back to the default ('svelte').
24
+ */
25
+ lang?: string | null;
26
+ /** Optional custom grammar; takes precedence over `lang` for tokenization. */
27
+ grammar?: SyntaxGrammar | undefined;
28
+ /** Custom `SyntaxStyler` instance (defaults to the global one). */
29
+ syntax_styler?: SyntaxStyler;
30
+ /**
31
+ * Attributes for the wrapper `<div>` — the layout box that the textarea
32
+ * fills and `resize` grows. Use it for sizing/layout classes, `style`,
33
+ * `id`, or container-level handlers. Its `class` is merged with the
34
+ * internal `code_textarea` class; `data-lang` stays component-controlled.
35
+ * (`...rest` spreads onto the `<textarea>`; the backdrop `<pre>` is
36
+ * internal and intentionally not exposed.)
37
+ */
38
+ wrapper_attrs?: SvelteHTMLElements['div'];
39
+ };
40
+ declare const CodeTextarea: import("svelte").Component<$$ComponentProps, {}, "value">;
41
+ type CodeTextarea = ReturnType<typeof CodeTextarea>;
42
+ export default CodeTextarea;
43
+ //# sourceMappingURL=CodeTextarea.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CodeTextarea.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CodeTextarea.svelte"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;OAcI;AACJ,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAGnE,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,UAAU,CAAC,GAAG;IACzD,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACpC,mEAAmE;IACnE,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,kBAAkB,CAAC,KAAK,CAAC,CAAC;CAC1C,CAAC;AA2DH,QAAA,MAAM,YAAY,2DAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -5,10 +5,12 @@ export type HighlightMode = 'auto' | 'ranges' | 'html';
5
5
  */
6
6
  export declare const supports_css_highlight_api: () => boolean;
7
7
  /**
8
- * Manages CSS Custom Highlight API ranges for a single element.
9
- * Tracks ranges per element and only removes its own ranges when clearing.
8
+ * Manages CSS Custom Highlight API ranges for a single element's text node.
9
+ * Tracks ranges per element and only removes its own ranges when clearing,
10
+ * cooperating with other managers that share the global `CSS.highlights` registry.
10
11
  *
11
- * **Experimental** — limited browser support. Use `Code.svelte` for production.
12
+ * **Experimental** — limited browser support. Use `Code.svelte` for production
13
+ * block code; this powers the experimental `CodeHighlight` and `CodeTextarea`.
12
14
  *
13
15
  * @example
14
16
  * ```ts
@@ -18,14 +20,26 @@ export declare const supports_css_highlight_api: () => boolean;
18
20
  */
19
21
  export declare class HighlightManager {
20
22
  #private;
21
- element_ranges: Map<string, Array<Range>>;
23
+ /**
24
+ * This manager's ranges, keyed by prefixed highlight name (e.g. `token_keyword`).
25
+ * A single range object may be shared across several names (a token type plus
26
+ * its aliases), since one range can belong to multiple `Highlight` sets.
27
+ */
28
+ element_ranges: Map<string, Array<AbstractRange>>;
22
29
  constructor();
23
30
  /**
24
- * Highlights from a `SyntaxTokenStream` produced by `tokenize_syntax`.
31
+ * Highlights `element`'s text node from a `SyntaxTokenStream` produced by
32
+ * `tokenize_syntax`. Clears this manager's previous ranges first.
33
+ *
34
+ * In production this never throws on a tokenizer/DOM mismatch: out-of-bounds
35
+ * tokens are clamped and a missing text node is a no-op. In DEV the same
36
+ * conditions throw loudly to surface grammar bugs.
25
37
  */
26
38
  highlight_from_syntax_tokens(element: Element, tokens: SyntaxTokenStream): void;
27
39
  /**
28
- * Clears only this element's ranges from highlights.
40
+ * Clears only this manager's ranges from the shared highlights. Defensive:
41
+ * a highlight may already be gone (e.g. another manager removed the last
42
+ * range, or HMR reset the registry), which is a valid state, not an error.
29
43
  */
30
44
  clear_element_ranges(): void;
31
45
  destroy(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"highlight_manager.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/highlight_manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAGzD,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEvD;;GAEG;AACH,eAAO,MAAM,0BAA0B,QAAO,OACS,CAAC;AAExD;;;;;;;;;;;GAWG;AACH,qBAAa,gBAAgB;;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;;IAS1C;;OAEG;IACH,4BAA4B,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAkD/E;;OAEG;IACH,oBAAoB,IAAI,IAAI;IAmB5B,OAAO,IAAI,IAAI;CAiFf"}
1
+ {"version":3,"file":"highlight_manager.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/highlight_manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAGzD,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEvD;;GAEG;AACH,eAAO,MAAM,0BAA0B,QAAO,OACS,CAAC;AA6CxD;;;;;;;;;;;;;GAaG;AACH,qBAAa,gBAAgB;;IAC5B;;;;OAIG;IACH,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC;;IAYlD;;;;;;;OAOG;IACH,4BAA4B,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAqE/E;;;;OAIG;IACH,oBAAoB,IAAI,IAAI;IAiB5B,OAAO,IAAI,IAAI;CAwEf"}
@@ -1,13 +1,40 @@
1
+ import { DEV } from 'esm-env';
1
2
  import { highlight_priorities } from './highlight_priorities.js';
2
3
  /**
3
4
  * Checks for CSS Highlights API support.
4
5
  */
5
6
  export const supports_css_highlight_api = () => !!(globalThis.CSS?.highlights && globalThis.Highlight);
7
+ const detect_range_kind = () => typeof globalThis.StaticRange === 'function' ? 'static' : 'live';
6
8
  /**
7
- * Manages CSS Custom Highlight API ranges for a single element.
8
- * Tracks ranges per element and only removes its own ranges when clearing.
9
+ * Finds the first text node child of `element`, or `null` if there is none.
9
10
  *
10
- * **Experimental** limited browser support. Use `Code.svelte` for production.
11
+ * The text node might not be `firstChild` because frameworks (e.g. Svelte) can
12
+ * insert comment/anchor nodes around it.
13
+ */
14
+ const find_text_node = (element) => {
15
+ for (const node of element.childNodes) {
16
+ if (node.nodeType === Node.TEXT_NODE)
17
+ return node;
18
+ }
19
+ return null;
20
+ };
21
+ const has_tokens = (tokens) => tokens.some((t) => typeof t !== 'string');
22
+ const push_range = (ranges_by_name, name, range) => {
23
+ const existing = ranges_by_name.get(name);
24
+ if (existing) {
25
+ existing.push(range);
26
+ }
27
+ else {
28
+ ranges_by_name.set(name, [range]);
29
+ }
30
+ };
31
+ /**
32
+ * Manages CSS Custom Highlight API ranges for a single element's text node.
33
+ * Tracks ranges per element and only removes its own ranges when clearing,
34
+ * cooperating with other managers that share the global `CSS.highlights` registry.
35
+ *
36
+ * **Experimental** — limited browser support. Use `Code.svelte` for production
37
+ * block code; this powers the experimental `CodeHighlight` and `CodeTextarea`.
11
38
  *
12
39
  * @example
13
40
  * ```ts
@@ -16,65 +43,100 @@ export const supports_css_highlight_api = () => !!(globalThis.CSS?.highlights &&
16
43
  * ```
17
44
  */
18
45
  export class HighlightManager {
46
+ /**
47
+ * This manager's ranges, keyed by prefixed highlight name (e.g. `token_keyword`).
48
+ * A single range object may be shared across several names (a token type plus
49
+ * its aliases), since one range can belong to multiple `Highlight` sets.
50
+ */
19
51
  element_ranges;
52
+ #range_kind;
20
53
  constructor() {
21
54
  if (!supports_css_highlight_api()) {
22
55
  throw Error('CSS Highlights API not supported');
23
56
  }
24
57
  this.element_ranges = new Map();
58
+ this.#range_kind = detect_range_kind();
25
59
  }
26
60
  /**
27
- * Highlights from a `SyntaxTokenStream` produced by `tokenize_syntax`.
61
+ * Highlights `element`'s text node from a `SyntaxTokenStream` produced by
62
+ * `tokenize_syntax`. Clears this manager's previous ranges first.
63
+ *
64
+ * In production this never throws on a tokenizer/DOM mismatch: out-of-bounds
65
+ * tokens are clamped and a missing text node is a no-op. In DEV the same
66
+ * conditions throw loudly to surface grammar bugs.
28
67
  */
29
68
  highlight_from_syntax_tokens(element, tokens) {
30
- // Find the text node (it might not be firstChild due to Svelte comment nodes)
31
- let text_node = null;
32
- for (const node of element.childNodes) {
33
- if (node.nodeType === Node.TEXT_NODE) {
34
- text_node = node;
35
- break;
69
+ this.clear_element_ranges();
70
+ const text_node = find_text_node(element);
71
+ if (!text_node) {
72
+ if (has_tokens(tokens)) {
73
+ if (DEV) {
74
+ throw new Error('no text node to highlight');
75
+ }
76
+ else {
77
+ // eslint-disable-next-line no-console
78
+ console.error('[HighlightManager] tokens present but no text node to highlight');
79
+ }
36
80
  }
81
+ return;
37
82
  }
38
- if (!text_node) {
39
- throw new Error('no text node to highlight');
83
+ try {
84
+ this.#apply(text_node, tokens);
40
85
  }
41
- this.clear_element_ranges();
42
- const ranges_by_type = new Map();
43
- const final_pos = this.#create_all_ranges(tokens, text_node, ranges_by_type, 0);
44
- // Validate that token positions matched text node length
45
- const text_length = text_node.textContent?.length ?? 0;
46
- if (final_pos !== text_length) {
47
- throw new Error(`Token stream length mismatch: tokens covered ${final_pos} chars but text node has ${text_length} chars`);
86
+ catch (err) {
87
+ // some engines may reject `StaticRange` in `Highlight.add` -- fall back to
88
+ // live `Range` once rather than letting the throw escape into the effect
89
+ if (this.#range_kind === 'static') {
90
+ this.#range_kind = 'live';
91
+ this.clear_element_ranges(); // undo any partial application
92
+ this.#apply(text_node, tokens);
93
+ }
94
+ else {
95
+ throw err;
96
+ }
97
+ }
98
+ }
99
+ #apply(text_node, tokens) {
100
+ const ranges_by_name = new Map();
101
+ const final_pos = this.#collect_ranges(tokens, text_node, ranges_by_name, 0);
102
+ if (DEV) {
103
+ const text_length = text_node.textContent?.length ?? 0;
104
+ if (final_pos !== text_length) {
105
+ throw new Error(`Token stream length mismatch: tokens covered ${final_pos} chars but text node has ${text_length} chars`);
106
+ }
48
107
  }
49
- // Apply highlights
50
- for (const [type, ranges] of ranges_by_type) {
51
- const prefixed_type = `token_${type}`;
52
- // Track ranges for this element
53
- this.element_ranges.set(prefixed_type, ranges);
54
- // Get or create the shared highlight
55
- let highlight = CSS.highlights.get(prefixed_type);
108
+ // TODO: cross-instance coupling -- all managers share one global `Highlight`
109
+ // per token type, so re-highlighting one element (e.g. a textarea on every
110
+ // keystroke) mutates highlights that also hold ranges from every other code
111
+ // block on the page, forcing the browser to re-evaluate the shared set.
112
+ // Isolating per-instance needs unique highlight names + runtime-injected
113
+ // `::highlight()` CSS (≈50 token types × N instances), trading the static
114
+ // theme file for generated CSS. Only worth it with many concurrently-updating
115
+ // instances; revisit if profiling shows it.
116
+ for (const [name, ranges] of ranges_by_name) {
117
+ this.element_ranges.set(name, ranges);
118
+ let highlight = CSS.highlights.get(name);
56
119
  if (!highlight) {
57
120
  highlight = new Highlight();
58
- // Set priority based on CSS cascade order (higher = later in CSS = wins)
59
- highlight.priority =
60
- highlight_priorities[prefixed_type] ?? 0;
61
- CSS.highlights.set(prefixed_type, highlight);
121
+ // priority follows CSS cascade order (higher = later in CSS = wins)
122
+ highlight.priority = highlight_priorities[name] ?? 0;
123
+ CSS.highlights.set(name, highlight);
62
124
  }
63
- // Add all ranges to the highlight
64
125
  for (const range of ranges) {
65
126
  highlight.add(range);
66
127
  }
67
128
  }
68
129
  }
69
130
  /**
70
- * Clears only this element's ranges from highlights.
131
+ * Clears only this manager's ranges from the shared highlights. Defensive:
132
+ * a highlight may already be gone (e.g. another manager removed the last
133
+ * range, or HMR reset the registry), which is a valid state, not an error.
71
134
  */
72
135
  clear_element_ranges() {
73
136
  for (const [name, ranges] of this.element_ranges) {
74
137
  const highlight = CSS.highlights.get(name);
75
- if (!highlight) {
76
- throw new Error('Expected to find CSS highlight: ' + name);
77
- }
138
+ if (!highlight)
139
+ continue;
78
140
  for (const range of ranges) {
79
141
  highlight.delete(range);
80
142
  }
@@ -87,10 +149,25 @@ export class HighlightManager {
87
149
  destroy() {
88
150
  this.clear_element_ranges();
89
151
  }
152
+ #make_range(text_node, start, end) {
153
+ if (this.#range_kind === 'static') {
154
+ return new StaticRange({
155
+ startContainer: text_node,
156
+ startOffset: start,
157
+ endContainer: text_node,
158
+ endOffset: end,
159
+ });
160
+ }
161
+ const range = new Range();
162
+ range.setStart(text_node, start);
163
+ range.setEnd(text_node, end);
164
+ return range;
165
+ }
90
166
  /**
91
- * Creates ranges for all tokens in the tree.
167
+ * Walks the token tree, collecting one range per non-empty token (shared
168
+ * across its type and aliases). Returns the end position covered.
92
169
  */
93
- #create_all_ranges(tokens, text_node, ranges_by_type, offset) {
170
+ #collect_ranges(tokens, text_node, ranges_by_name, offset) {
94
171
  const text_length = text_node.textContent?.length ?? 0;
95
172
  let pos = offset;
96
173
  for (const token of tokens) {
@@ -98,49 +175,28 @@ export class HighlightManager {
98
175
  pos += token.length;
99
176
  continue;
100
177
  }
101
- const length = token.length;
102
- const end_pos = pos + length;
103
- // Validate positions are within text node bounds before creating ranges
104
- if (end_pos > text_length) {
178
+ const end_pos = pos + token.length;
179
+ if (DEV && end_pos > text_length) {
105
180
  throw new Error(`Token ${token.type} extends beyond text node: position ${end_pos} > length ${text_length}`);
106
181
  }
107
- try {
108
- const range = new Range();
109
- range.setStart(text_node, pos);
110
- range.setEnd(text_node, end_pos);
111
- // Add range for the token type
112
- const type = token.type;
113
- if (!ranges_by_type.has(type)) {
114
- ranges_by_type.set(type, []);
115
- }
116
- ranges_by_type.get(type).push(range);
117
- // Also add range for any aliases (alias is always an array)
182
+ // production-safe: clamp rather than throw on a tokenizer edge case
183
+ const safe_end = end_pos > text_length ? text_length : end_pos;
184
+ if (safe_end > pos) {
185
+ // one range shared across the token type and all its aliases --
186
+ // the same range object can belong to multiple `Highlight` sets
187
+ const range = this.#make_range(text_node, pos, safe_end);
188
+ push_range(ranges_by_name, `token_${token.type}`, range);
118
189
  for (const alias of token.alias) {
119
- if (!ranges_by_type.has(alias)) {
120
- ranges_by_type.set(alias, []);
121
- }
122
- // Create a new range for each alias (ranges can't be reused)
123
- const alias_range = new Range();
124
- alias_range.setStart(text_node, pos);
125
- alias_range.setEnd(text_node, end_pos);
126
- ranges_by_type.get(alias).push(alias_range);
190
+ push_range(ranges_by_name, `token_${alias}`, range);
127
191
  }
128
192
  }
129
- catch (e) {
130
- throw new Error(`Failed to create range for ${token.type}: ${e}`);
131
- }
132
- // Process nested tokens
133
193
  if (Array.isArray(token.content)) {
134
- const actual_end_pos = this.#create_all_ranges(token.content, text_node, ranges_by_type, pos);
135
- // Validate that nested tokens match the parent token's claimed length
136
- if (actual_end_pos !== end_pos) {
137
- throw new Error(`Token ${token.type} length mismatch: claimed ${length} chars (${pos}-${end_pos}) but nested content covered ${actual_end_pos - pos} chars (${pos}-${actual_end_pos})`);
194
+ const nested_end = this.#collect_ranges(token.content, text_node, ranges_by_name, pos);
195
+ if (DEV && nested_end !== end_pos) {
196
+ throw new Error(`Token ${token.type} length mismatch: claimed ${token.length} chars (${pos}-${end_pos}) but nested content covered ${nested_end - pos} chars (${pos}-${nested_end})`);
138
197
  }
139
- pos = actual_end_pos;
140
- }
141
- else {
142
- pos = end_pos;
143
198
  }
199
+ pos = end_pos;
144
200
  }
145
201
  return pos;
146
202
  }