diff --git a/.changeset/moody-socks-look.md b/.changeset/moody-socks-look.md
new file mode 100644
index 00000000..20e083b4
--- /dev/null
+++ b/.changeset/moody-socks-look.md
@@ -0,0 +1,5 @@
+---
+'@fuzdev/fuz_code': patch
+---
+
+feat: add `CodeTextarea`
diff --git a/package-lock.json b/package-lock.json
index afd34422..5da74b2d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,7 @@
"devDependencies": {
"@changesets/changelog-git": "^0.2.1",
"@fuzdev/blake3_wasm": "^0.1.1",
- "@fuzdev/fuz_css": "^0.63.0",
+ "@fuzdev/fuz_css": "^0.63.2",
"@fuzdev/fuz_ui": "^0.205.0",
"@fuzdev/fuz_util": "^0.65.1",
"@fuzdev/gro": "^0.204.0",
@@ -759,9 +759,9 @@
}
},
"node_modules/@fuzdev/fuz_css": {
- "version": "0.63.0",
- "resolved": "https://registry.npmjs.org/@fuzdev/fuz_css/-/fuz_css-0.63.0.tgz",
- "integrity": "sha512-DuSlmlY9e0taTW4yUU+BHU79uKvIJSV2Uf+ZwmGU4AHOr67jp0iJbDlW14bGmOL2K/NPlJ/fFhDrrn2dshObAw==",
+ "version": "0.63.2",
+ "resolved": "https://registry.npmjs.org/@fuzdev/fuz_css/-/fuz_css-0.63.2.tgz",
+ "integrity": "sha512-df2j970oRPHPJVGoMIhkHltRI6ZMTrhVoBEPfek8hY8RiFMnJMW8cFq+X6oTSGpdfo1COQpjHbeLZemd74z+mg==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index 58c0b447..0f05a3e3 100644
--- a/package.json
+++ b/package.json
@@ -55,7 +55,7 @@
"devDependencies": {
"@changesets/changelog-git": "^0.2.1",
"@fuzdev/blake3_wasm": "^0.1.1",
- "@fuzdev/fuz_css": "^0.63.0",
+ "@fuzdev/fuz_css": "^0.63.2",
"@fuzdev/fuz_ui": "^0.205.0",
"@fuzdev/fuz_util": "^0.65.1",
"@fuzdev/gro": "^0.204.0",
diff --git a/src/lib/CodeHighlight.svelte b/src/lib/CodeHighlight.svelte
index 3672be7d..fdfe591b 100644
--- a/src/lib/CodeHighlight.svelte
+++ b/src/lib/CodeHighlight.svelte
@@ -6,18 +6,13 @@
* Requires importing `theme_highlight.css` instead of `theme.css`.
*/
- import {onDestroy, type Snippet} from 'svelte';
- import {DEV} from 'esm-env';
+ import type {Snippet} from 'svelte';
import type {SvelteHTMLElements} from 'svelte/elements';
import {syntax_styler_global} from './syntax_styler_global.js';
import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
- import {tokenize_syntax} from './tokenize_syntax.js';
- import {
- HighlightManager,
- supports_css_highlight_api,
- type HighlightMode,
- } from './highlight_manager.js';
+ import {supports_css_highlight_api, type HighlightMode} from './highlight_manager.js';
+ import {create_range_highlighting} from './range_highlighting.svelte.js';
const {
content,
@@ -121,58 +116,27 @@
const supports_ranges = supports_css_highlight_api();
- const highlight_manager = supports_ranges ? new HighlightManager() : null;
-
const use_ranges = $derived(supports_ranges && (mode === 'ranges' || mode === 'auto'));
- const language_supported = $derived(lang !== null && !!syntax_styler.langs[lang]);
-
- const highlighting_disabled = $derived(lang === null || (!language_supported && !grammar));
-
- // DEV-only validation warnings
- if (DEV) {
- $effect(() => {
- if (lang && !language_supported && !grammar) {
- const langs = Object.keys(syntax_styler.langs).join(', ');
- // eslint-disable-next-line no-console
- console.error(
- `[CodeHighlight] Language "${lang}" is not supported and no custom grammar provided. ` +
- `Highlighting disabled. Supported: ${langs}`,
- );
- }
- });
- }
+ const rh = create_range_highlighting({
+ element: () => code_element,
+ text: () => content,
+ enabled: () => use_ranges,
+ lang: () => lang,
+ grammar: () => grammar,
+ syntax_styler: () => syntax_styler,
+ dev_label: 'CodeHighlight',
+ });
// Generate HTML markup for syntax highlighting in non-range mode
const html_content = $derived.by(() => {
- if (use_ranges || !content || highlighting_disabled) {
+ if (use_ranges || !content || rh.highlighting_disabled) {
return '';
}
return syntax_styler.stylize(content, lang!, grammar); // ! is safe bc of the `highlighting_disabled` calculation
});
- // Apply highlights for range mode
- if (highlight_manager) {
- $effect(() => {
- if (!code_element || !content || !use_ranges || highlighting_disabled) {
- highlight_manager.clear_element_ranges();
- return;
- }
-
- // Get tokens from syntax styler
- const tokens = tokenize_syntax(content, grammar || syntax_styler.get_lang(lang!)); // ! is safe bc of the `highlighting_disabled` calculation
-
- // Apply highlights
- highlight_manager.highlight_from_syntax_tokens(code_element, tokens);
- });
- }
-
- onDestroy(() => {
- highlight_manager?.destroy();
- });
-
- // TODO use intersect attachment from fuz_ui to optimize ranges
// TODO do syntax styling at compile-time in the normal case, and don't import these at runtime
// TODO @html making me nervous
@@ -182,7 +146,7 @@
{#if use_ranges && children}{@render children(
content,
- )}{:else if use_ranges || highlighting_disabled}{content}{:else if children}{@render children(
+ )}{:else if use_ranges || rh.highlighting_disabled}{content}{:else if children}{@render children(
html_content,
)}{:else}{@html html_content}{/if}
diff --git a/src/lib/CodeTextarea.svelte b/src/lib/CodeTextarea.svelte
new file mode 100644
index 00000000..f44bef75
--- /dev/null
+++ b/src/lib/CodeTextarea.svelte
@@ -0,0 +1,149 @@
+
+
+
+
{display_text}
+
+
+
+
diff --git a/src/lib/highlight_manager.ts b/src/lib/highlight_manager.ts
index 477963c2..c3fb110b 100644
--- a/src/lib/highlight_manager.ts
+++ b/src/lib/highlight_manager.ts
@@ -1,3 +1,5 @@
+import {DEV} from 'esm-env';
+
import type {SyntaxTokenStream} from './syntax_token.js';
import {highlight_priorities} from './highlight_priorities.js';
@@ -10,10 +12,55 @@ export const supports_css_highlight_api = (): boolean =>
!!(globalThis.CSS?.highlights && globalThis.Highlight);
/**
- * Manages CSS Custom Highlight API ranges for a single element.
- * Tracks ranges per element and only removes its own ranges when clearing.
+ * How a manager builds ranges for the Highlight API.
*
- * **Experimental** — limited browser support. Use `Code.svelte` for production.
+ * `StaticRange` is preferred: it's an immutable snapshot the browser does *not*
+ * track across DOM mutations. Since highlights are rebuilt wholesale whenever
+ * content changes, liveness buys nothing and only costs per-mutation boundary
+ * bookkeeping and paint work — the main efficiency (and Safari-stability) lever.
+ * Falls back to a live `Range` where `StaticRange` is unavailable.
+ */
+type RangeKind = 'static' | 'live';
+
+const detect_range_kind = (): RangeKind =>
+ typeof globalThis.StaticRange === 'function' ? 'static' : 'live';
+
+/**
+ * Finds the first text node child of `element`, or `null` if there is none.
+ *
+ * The text node might not be `firstChild` because frameworks (e.g. Svelte) can
+ * insert comment/anchor nodes around it.
+ */
+const find_text_node = (element: Element): Node | null => {
+ for (const node of element.childNodes) {
+ if (node.nodeType === Node.TEXT_NODE) return node;
+ }
+ return null;
+};
+
+const has_tokens = (tokens: SyntaxTokenStream): boolean =>
+ tokens.some((t) => typeof t !== 'string');
+
+const push_range = (
+ ranges_by_name: Map>,
+ name: string,
+ range: AbstractRange,
+): void => {
+ const existing = ranges_by_name.get(name);
+ if (existing) {
+ existing.push(range);
+ } else {
+ ranges_by_name.set(name, [range]);
+ }
+};
+
+/**
+ * Manages CSS Custom Highlight API ranges for a single element's text node.
+ * Tracks ranges per element and only removes its own ranges when clearing,
+ * cooperating with other managers that share the global `CSS.highlights` registry.
+ *
+ * **Experimental** — limited browser support. Use `Code.svelte` for production
+ * block code; this powers the experimental `CodeHighlight` and `CodeTextarea`.
*
* @example
* ```ts
@@ -22,62 +69,94 @@ export const supports_css_highlight_api = (): boolean =>
* ```
*/
export class HighlightManager {
- element_ranges: Map>;
+ /**
+ * This manager's ranges, keyed by prefixed highlight name (e.g. `token_keyword`).
+ * A single range object may be shared across several names (a token type plus
+ * its aliases), since one range can belong to multiple `Highlight` sets.
+ */
+ element_ranges: Map>;
+
+ #range_kind: RangeKind;
constructor() {
if (!supports_css_highlight_api()) {
throw Error('CSS Highlights API not supported');
}
this.element_ranges = new Map();
+ this.#range_kind = detect_range_kind();
}
/**
- * Highlights from a `SyntaxTokenStream` produced by `tokenize_syntax`.
+ * Highlights `element`'s text node from a `SyntaxTokenStream` produced by
+ * `tokenize_syntax`. Clears this manager's previous ranges first.
+ *
+ * In production this never throws on a tokenizer/DOM mismatch: out-of-bounds
+ * tokens are clamped and a missing text node is a no-op. In DEV the same
+ * conditions throw loudly to surface grammar bugs.
*/
highlight_from_syntax_tokens(element: Element, tokens: SyntaxTokenStream): void {
- // Find the text node (it might not be firstChild due to Svelte comment nodes)
- let text_node: Node | null = null;
- for (const node of element.childNodes) {
- if (node.nodeType === Node.TEXT_NODE) {
- text_node = node;
- break;
- }
- }
+ this.clear_element_ranges();
+ const text_node = find_text_node(element);
if (!text_node) {
- throw new Error('no text node to highlight');
+ if (has_tokens(tokens)) {
+ if (DEV) {
+ throw new Error('no text node to highlight');
+ } else {
+ // eslint-disable-next-line no-console
+ console.error('[HighlightManager] tokens present but no text node to highlight');
+ }
+ }
+ return;
}
- this.clear_element_ranges();
+ try {
+ this.#apply(text_node, tokens);
+ } catch (err) {
+ // some engines may reject `StaticRange` in `Highlight.add` -- fall back to
+ // live `Range` once rather than letting the throw escape into the effect
+ if (this.#range_kind === 'static') {
+ this.#range_kind = 'live';
+ this.clear_element_ranges(); // undo any partial application
+ this.#apply(text_node, tokens);
+ } else {
+ throw err;
+ }
+ }
+ }
- const ranges_by_type: Map> = new Map();
- const final_pos = this.#create_all_ranges(tokens, text_node, ranges_by_type, 0);
+ #apply(text_node: Node, tokens: SyntaxTokenStream): void {
+ const ranges_by_name: Map> = new Map();
+ const final_pos = this.#collect_ranges(tokens, text_node, ranges_by_name, 0);
- // Validate that token positions matched text node length
- const text_length = text_node.textContent?.length ?? 0;
- if (final_pos !== text_length) {
- throw new Error(
- `Token stream length mismatch: tokens covered ${final_pos} chars but text node has ${text_length} chars`,
- );
+ if (DEV) {
+ const text_length = text_node.textContent?.length ?? 0;
+ if (final_pos !== text_length) {
+ throw new Error(
+ `Token stream length mismatch: tokens covered ${final_pos} chars but text node has ${text_length} chars`,
+ );
+ }
}
- // Apply highlights
- for (const [type, ranges] of ranges_by_type) {
- const prefixed_type = `token_${type}`;
- // Track ranges for this element
- this.element_ranges.set(prefixed_type, ranges);
+ // TODO: cross-instance coupling -- all managers share one global `Highlight`
+ // per token type, so re-highlighting one element (e.g. a textarea on every
+ // keystroke) mutates highlights that also hold ranges from every other code
+ // block on the page, forcing the browser to re-evaluate the shared set.
+ // Isolating per-instance needs unique highlight names + runtime-injected
+ // `::highlight()` CSS (≈50 token types × N instances), trading the static
+ // theme file for generated CSS. Only worth it with many concurrently-updating
+ // instances; revisit if profiling shows it.
+ for (const [name, ranges] of ranges_by_name) {
+ this.element_ranges.set(name, ranges);
- // Get or create the shared highlight
- let highlight = CSS.highlights.get(prefixed_type);
+ let highlight = CSS.highlights.get(name);
if (!highlight) {
highlight = new Highlight();
- // Set priority based on CSS cascade order (higher = later in CSS = wins)
- highlight.priority =
- highlight_priorities[prefixed_type as keyof typeof highlight_priorities] ?? 0;
- CSS.highlights.set(prefixed_type, highlight);
+ // priority follows CSS cascade order (higher = later in CSS = wins)
+ highlight.priority = highlight_priorities[name as keyof typeof highlight_priorities] ?? 0;
+ CSS.highlights.set(name, highlight);
}
- // Add all ranges to the highlight
for (const range of ranges) {
highlight.add(range);
}
@@ -85,14 +164,14 @@ export class HighlightManager {
}
/**
- * Clears only this element's ranges from highlights.
+ * Clears only this manager's ranges from the shared highlights. Defensive:
+ * a highlight may already be gone (e.g. another manager removed the last
+ * range, or HMR reset the registry), which is a valid state, not an error.
*/
clear_element_ranges(): void {
for (const [name, ranges] of this.element_ranges) {
const highlight = CSS.highlights.get(name);
- if (!highlight) {
- throw new Error('Expected to find CSS highlight: ' + name);
- }
+ if (!highlight) continue;
for (const range of ranges) {
highlight.delete(range);
@@ -110,13 +189,29 @@ export class HighlightManager {
this.clear_element_ranges();
}
+ #make_range(text_node: Node, start: number, end: number): AbstractRange {
+ if (this.#range_kind === 'static') {
+ return new StaticRange({
+ startContainer: text_node,
+ startOffset: start,
+ endContainer: text_node,
+ endOffset: end,
+ });
+ }
+ const range = new Range();
+ range.setStart(text_node, start);
+ range.setEnd(text_node, end);
+ return range;
+ }
+
/**
- * Creates ranges for all tokens in the tree.
+ * Walks the token tree, collecting one range per non-empty token (shared
+ * across its type and aliases). Returns the end position covered.
*/
- #create_all_ranges(
+ #collect_ranges(
tokens: SyntaxTokenStream,
text_node: Node,
- ranges_by_type: Map>,
+ ranges_by_name: Map>,
offset: number,
): number {
const text_length = text_node.textContent?.length ?? 0;
@@ -128,61 +223,36 @@ export class HighlightManager {
continue;
}
- const length = token.length;
- const end_pos = pos + length;
+ const end_pos = pos + token.length;
- // Validate positions are within text node bounds before creating ranges
- if (end_pos > text_length) {
+ if (DEV && end_pos > text_length) {
throw new Error(
`Token ${token.type} extends beyond text node: position ${end_pos} > length ${text_length}`,
);
}
- try {
- const range = new Range();
- range.setStart(text_node, pos);
- range.setEnd(text_node, end_pos);
-
- // Add range for the token type
- const type = token.type;
- if (!ranges_by_type.has(type)) {
- ranges_by_type.set(type, []);
- }
- ranges_by_type.get(type)!.push(range);
-
- // Also add range for any aliases (alias is always an array)
+ // production-safe: clamp rather than throw on a tokenizer edge case
+ const safe_end = end_pos > text_length ? text_length : end_pos;
+ if (safe_end > pos) {
+ // one range shared across the token type and all its aliases --
+ // the same range object can belong to multiple `Highlight` sets
+ const range = this.#make_range(text_node, pos, safe_end);
+ push_range(ranges_by_name, `token_${token.type}`, range);
for (const alias of token.alias) {
- if (!ranges_by_type.has(alias)) {
- ranges_by_type.set(alias, []);
- }
- // Create a new range for each alias (ranges can't be reused)
- const alias_range = new Range();
- alias_range.setStart(text_node, pos);
- alias_range.setEnd(text_node, end_pos);
- ranges_by_type.get(alias)!.push(alias_range);
+ push_range(ranges_by_name, `token_${alias}`, range);
}
- } catch (e) {
- throw new Error(`Failed to create range for ${token.type}: ${e}`);
}
- // Process nested tokens
if (Array.isArray(token.content)) {
- const actual_end_pos = this.#create_all_ranges(
- token.content,
- text_node,
- ranges_by_type,
- pos,
- );
- // Validate that nested tokens match the parent token's claimed length
- if (actual_end_pos !== end_pos) {
+ const nested_end = this.#collect_ranges(token.content, text_node, ranges_by_name, pos);
+ if (DEV && nested_end !== end_pos) {
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})`,
+ `Token ${token.type} length mismatch: claimed ${token.length} chars (${pos}-${end_pos}) but nested content covered ${nested_end - pos} chars (${pos}-${nested_end})`,
);
}
- pos = actual_end_pos;
- } else {
- pos = end_pos;
}
+
+ pos = end_pos;
}
return pos;
diff --git a/src/lib/range_highlighting.svelte.ts b/src/lib/range_highlighting.svelte.ts
new file mode 100644
index 00000000..4b6f2a00
--- /dev/null
+++ b/src/lib/range_highlighting.svelte.ts
@@ -0,0 +1,100 @@
+import {onDestroy} from 'svelte';
+import {DEV} from 'esm-env';
+
+import type {SyntaxStyler, SyntaxGrammar} from './syntax_styler.js';
+import {HighlightManager, supports_css_highlight_api} from './highlight_manager.js';
+
+/**
+ * Reactive inputs for `create_range_highlighting`. All values are getters so the
+ * helper can track the consuming component's reactive state across the call
+ * boundary (the Svelte 5 getter-injection pattern).
+ */
+export interface RangeHighlightingOptions {
+ /** The element whose first text node receives the highlight ranges. */
+ element: () => Element | undefined;
+ /**
+ * The text to tokenize. Must match the element's text node exactly (e.g. a
+ * textarea backdrop includes its trailing newline here too).
+ */
+ text: () => string;
+ /** Language id; `null` disables highlighting. */
+ lang: () => string | null;
+ /** Optional custom grammar; takes precedence over `lang` for tokenization. */
+ grammar: () => SyntaxGrammar | undefined;
+ /** The syntax styler whose registered grammars back `lang` lookups. */
+ syntax_styler: () => SyntaxStyler;
+ /** Extra gate — ranges are only applied when this returns true. Defaults to always-on. */
+ enabled?: () => boolean;
+ /** Component name used in DEV warnings. */
+ dev_label: string;
+}
+
+/** Reactive outputs from `create_range_highlighting`. */
+export interface RangeHighlighting {
+ readonly highlighting_disabled: boolean;
+}
+
+/**
+ * Wires up CSS Custom Highlight API range highlighting for a single element's
+ * text node, shared by `CodeHighlight` and `CodeTextarea`. Creates a
+ * `HighlightManager`, memoizes tokenization, applies/clears ranges in an effect,
+ * emits DEV warnings for unsupported languages, and tears down on destroy.
+ *
+ * Must be called during component initialization (it uses `$effect`/`onDestroy`).
+ */
+export const create_range_highlighting = (options: RangeHighlightingOptions): RangeHighlighting => {
+ const manager = supports_css_highlight_api() ? new HighlightManager() : null;
+ const is_enabled = options.enabled ?? (() => true);
+
+ const language_supported = $derived(
+ options.lang() !== null && !!options.syntax_styler().langs[options.lang()!],
+ );
+ const highlighting_disabled = $derived(
+ options.lang() === null || (!language_supported && !options.grammar()),
+ );
+
+ // tokenize once per (text, grammar, lang) change -- memoized so unrelated
+ // reactivity doesn't trigger a full re-tokenization
+ const range_tokens = $derived.by(() => {
+ if (!manager || !is_enabled() || highlighting_disabled) return null;
+ const text = options.text();
+ if (!text) return null;
+ // route through the styler so `before_tokenize`/`after_tokenize` hooks run,
+ // matching `stylize`'s HTML path; `grammar` undefined falls back to the
+ // registered lang grammar (`! safe bc of `highlighting_disabled`)
+ return options.syntax_styler().tokenize(text, options.lang()!, options.grammar());
+ });
+
+ if (manager) {
+ $effect(() => {
+ const element = options.element();
+ if (!element || !range_tokens) {
+ manager.clear_element_ranges();
+ return;
+ }
+ manager.highlight_from_syntax_tokens(element, range_tokens);
+ });
+ }
+
+ if (DEV) {
+ $effect(() => {
+ // a lang was requested but we can't highlight it (unknown id, no grammar)
+ if (options.lang() && highlighting_disabled) {
+ const langs = Object.keys(options.syntax_styler().langs).join(', ');
+ // eslint-disable-next-line no-console
+ console.error(
+ `[${options.dev_label}] Language "${options.lang()}" is not supported and no custom grammar provided. ` +
+ `Highlighting disabled. Supported: ${langs}`,
+ );
+ }
+ });
+ }
+
+ onDestroy(() => manager?.destroy());
+
+ return {
+ get highlighting_disabled() {
+ return highlighting_disabled;
+ },
+ };
+};
diff --git a/src/lib/syntax_styler.ts b/src/lib/syntax_styler.ts
index 729c38f0..a48211c4 100644
--- a/src/lib/syntax_styler.ts
+++ b/src/lib/syntax_styler.ts
@@ -3,6 +3,13 @@ import {tokenize_syntax} from './tokenize_syntax.js';
export type AddSyntaxGrammar = (syntax_styler: SyntaxStyler) => void;
+/**
+ * Maps a matched `&`, `<`, or non-breaking space in text content to its
+ * HTML-safe form. Used as the replacer in `stringify_token` for leaf strings
+ * (non-breaking spaces are normalized to a regular space).
+ */
+const escape_text_char = (ch: string): string => (ch === '&' ? '&' : ch === '<' ? '<' : ' ');
+
/**
* Based on Prism (https://github.com/PrismJS/prism)
* by Lea Verou (https://lea.verou.me/)
@@ -127,7 +134,42 @@ export class SyntaxStyler {
lang: string,
grammar: SyntaxGrammar | undefined = this.get_lang(lang),
): string {
- var ctx: HookBeforeTokenizeCallbackContext = {
+ // stringify with the post-hook `lang`, which a `before_tokenize` hook may
+ // have rewritten (it flows into each token's `wrap` hook context)
+ const c = this.#tokenize_hooked(text, lang, grammar);
+ return this.stringify_token(c.tokens, c.lang);
+ }
+
+ /**
+ * Tokenizes `text` into a `SyntaxTokenStream`, running the `before_tokenize`
+ * and `after_tokenize` hooks. This is the tokenization half of `stylize` — use
+ * it when you need the token stream itself (e.g. CSS Custom Highlight API range
+ * highlighting) rather than HTML.
+ *
+ * @param text - source to tokenize
+ * @param lang - language identifier; passed to the tokenize hooks
+ * @param grammar - grammar to tokenize with; defaults to `this.get_lang(lang)`
+ * @returns the resulting token stream
+ */
+ tokenize(
+ text: string,
+ lang: string,
+ grammar: SyntaxGrammar | undefined = this.get_lang(lang),
+ ): SyntaxTokenStream {
+ return this.#tokenize_hooked(text, lang, grammar).tokens;
+ }
+
+ /**
+ * Runs `before_tokenize` → `tokenize_syntax` → `after_tokenize`, returning the
+ * resolved context. Shared by `stylize` (which also needs the post-hook `lang`)
+ * and `tokenize` (which only needs `tokens`).
+ */
+ #tokenize_hooked(
+ text: string,
+ lang: string,
+ grammar: SyntaxGrammar,
+ ): HookAfterTokenizeCallbackContext {
+ const ctx: HookBeforeTokenizeCallbackContext = {
code: text,
grammar,
lang,
@@ -137,7 +179,7 @@ export class SyntaxStyler {
const c = ctx as any as HookAfterTokenizeCallbackContext;
c.tokens = tokenize_syntax(c.code, c.grammar);
this.run_hook_after_tokenize(c);
- return this.stringify_token(c.tokens, c.lang);
+ return c;
}
/**
@@ -263,10 +305,9 @@ export class SyntaxStyler {
*/
stringify_token(o: string | SyntaxToken | SyntaxTokenStream, lang: string): string {
if (typeof o === 'string') {
- return o
- .replace(/&/g, '&')
- .replace(/ with no
+ // attributes, so skip the per-token context object and hook dispatch
+ if (this.hooks_wrap.length === 0) {
+ return '' + content + '';
+ }
+
var ctx: HookWrapCallbackContext = {
type: o.type,
- content: this.stringify_token(o.content, lang),
+ content,
tag: 'span',
- classes: [`token_${o.type}`],
+ classes: classes.split(' '),
attributes: {},
lang,
};
- var aliases = o.alias;
- // alias is always an array after normalization
- for (const a of aliases) {
- ctx.classes.push(`token_${a}`);
- }
-
this.run_hook_wrap(ctx);
var attributes = '';
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 40fd757c..c03fd8a9 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -63,6 +63,7 @@ import Code from '@fuzdev/fuz_code/Code.svelte';`}