@fuzdev/fuz_code 0.41.0 → 0.42.0

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/Code.svelte CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  const {
10
10
  content,
11
+ dangerous_raw_html,
11
12
  lang = 'svelte',
12
13
  grammar,
13
14
  inline = false,
@@ -16,85 +17,101 @@
16
17
  syntax_styler = syntax_styler_global,
17
18
  children,
18
19
  ...rest
19
- }: SvelteHTMLElements['code'] & {
20
- /** The source code to syntax highlight. */
21
- content: string;
22
- /**
23
- * Language identifier (e.g., 'ts', 'css', 'html', 'json', 'svelte', 'md').
24
- *
25
- * **Purpose:**
26
- * - When `grammar` is not provided, used to look up the grammar via `syntax_styler.get_lang(lang)`
27
- * - Used for metadata: sets the `data-lang` attribute and determines `language_supported`
28
- *
29
- * **Special values:**
30
- * - `null` - Explicitly disables syntax highlighting (content rendered as plain text)
31
- * - `undefined` - Falls back to default ('svelte')
32
- *
33
- * **Relationship with `grammar`:**
34
- * - If both `lang` and `grammar` are provided, `grammar` takes precedence for tokenization
35
- * - However, `lang` is still used for the `data-lang` attribute and language detection
36
- *
37
- * @default 'svelte'
38
- */
39
- lang?: string | null;
40
- /**
41
- * Optional custom grammar object for syntax tokenization.
42
- *
43
- * **When to use:**
44
- * - To provide a custom language definition not registered in `syntax_styler.langs`
45
- * - To use a modified/extended version of an existing grammar
46
- * - For one-off grammar variations without registering globally
47
- *
48
- * **Behavior:**
49
- * - When provided, this grammar is used for tokenization instead of looking up via `lang`
50
- * - Enables highlighting even if `lang` is not in the registry (useful for custom languages)
51
- * - The `lang` parameter is still used for metadata (data-lang attribute)
52
- * - When undefined, the grammar is automatically looked up via `syntax_styler.get_lang(lang)`
53
- *
54
- * @default undefined (uses grammar from `syntax_styler.langs[lang]`)
55
- */
56
- grammar?: SyntaxGrammar | undefined;
57
- /**
58
- * Whether to render as inline code or block code.
59
- * Controls display via CSS classes.
60
- *
61
- * @default false
62
- */
63
- inline?: boolean;
64
- /**
65
- * Whether to wrap long lines in block code.
66
- * Sets `white-space: pre-wrap` instead of `white-space: pre`.
67
- *
68
- * **Behavior:**
69
- * - Wraps at whitespace (spaces, newlines)
70
- * - Long tokens without spaces (URLs, hashes) will still scroll horizontally
71
- * - Default `false` provides traditional code block behavior
72
- *
73
- * Only affects block code (ignored for inline mode).
74
- *
75
- * @default false
76
- */
77
- wrap?: boolean;
78
- /**
79
- * Whether to disable the default margin-bottom on block code.
80
- * Block code has `margin-bottom: var(--space_lg)` by default when not `:last-child`.
81
- *
82
- * @default false
83
- */
84
- nomargin?: boolean;
85
- /**
86
- * Custom SyntaxStyler instance to use for highlighting.
87
- * Allows using a different styler with custom grammars or configuration.
88
- *
89
- * @default syntax_styler_global
90
- */
91
- syntax_styler?: SyntaxStyler;
92
- /**
93
- * Optional snippet to customize how the highlighted markup is rendered.
94
- * Receives the generated HTML string as a parameter.
95
- */
96
- children?: Snippet<[markup: string]>;
97
- } = $props();
20
+ }: SvelteHTMLElements['code'] &
21
+ (
22
+ | {
23
+ /** The source code to syntax highlight. */
24
+ content: string;
25
+ dangerous_raw_html?: undefined;
26
+ }
27
+ | {
28
+ content?: undefined;
29
+ /**
30
+ * Pre-highlighted HTML from the `svelte_preprocess_fuz_code` preprocessor.
31
+ * When provided, skips runtime syntax highlighting entirely.
32
+ *
33
+ * Named `dangerous_raw_html` to signal that it bypasses sanitization,
34
+ * matching the `{@html}` pattern already used by this component.
35
+ */
36
+ dangerous_raw_html: string;
37
+ }
38
+ ) & {
39
+ /**
40
+ * Language identifier (e.g., 'ts', 'css', 'html', 'json', 'svelte', 'md').
41
+ *
42
+ * **Purpose:**
43
+ * - When `grammar` is not provided, used to look up the grammar via `syntax_styler.get_lang(lang)`
44
+ * - Used for metadata: sets the `data-lang` attribute and determines `language_supported`
45
+ *
46
+ * **Special values:**
47
+ * - `null` - Explicitly disables syntax highlighting (content rendered as plain text)
48
+ * - `undefined` - Falls back to default ('svelte')
49
+ *
50
+ * **Relationship with `grammar`:**
51
+ * - If both `lang` and `grammar` are provided, `grammar` takes precedence for tokenization
52
+ * - However, `lang` is still used for the `data-lang` attribute and language detection
53
+ *
54
+ * @default 'svelte'
55
+ */
56
+ lang?: string | null;
57
+ /**
58
+ * Optional custom grammar object for syntax tokenization.
59
+ *
60
+ * **When to use:**
61
+ * - To provide a custom language definition not registered in `syntax_styler.langs`
62
+ * - To use a modified/extended version of an existing grammar
63
+ * - For one-off grammar variations without registering globally
64
+ *
65
+ * **Behavior:**
66
+ * - When provided, this grammar is used for tokenization instead of looking up via `lang`
67
+ * - Enables highlighting even if `lang` is not in the registry (useful for custom languages)
68
+ * - The `lang` parameter is still used for metadata (data-lang attribute)
69
+ * - When undefined, the grammar is automatically looked up via `syntax_styler.get_lang(lang)`
70
+ *
71
+ * @default undefined (uses grammar from `syntax_styler.langs[lang]`)
72
+ */
73
+ grammar?: SyntaxGrammar | undefined;
74
+ /**
75
+ * Whether to render as inline code or block code.
76
+ * Controls display via CSS classes.
77
+ *
78
+ * @default false
79
+ */
80
+ inline?: boolean;
81
+ /**
82
+ * Whether to wrap long lines in block code.
83
+ * Sets `white-space: pre-wrap` instead of `white-space: pre`.
84
+ *
85
+ * **Behavior:**
86
+ * - Wraps at whitespace (spaces, newlines)
87
+ * - Long tokens without spaces (URLs, hashes) will still scroll horizontally
88
+ * - Default `false` provides traditional code block behavior
89
+ *
90
+ * Only affects block code (ignored for inline mode).
91
+ *
92
+ * @default false
93
+ */
94
+ wrap?: boolean;
95
+ /**
96
+ * Whether to disable the default margin-bottom on block code.
97
+ * Block code has `margin-bottom: var(--space_lg)` by default when not `:last-child`.
98
+ *
99
+ * @default false
100
+ */
101
+ nomargin?: boolean;
102
+ /**
103
+ * Custom SyntaxStyler instance to use for highlighting.
104
+ * Allows using a different styler with custom grammars or configuration.
105
+ *
106
+ * @default syntax_styler_global
107
+ */
108
+ syntax_styler?: SyntaxStyler;
109
+ /**
110
+ * Optional snippet to customize how the highlighted markup is rendered.
111
+ * Receives the generated HTML string as a parameter.
112
+ */
113
+ children?: Snippet<[markup: string]>;
114
+ } = $props();
98
115
 
99
116
  const language_supported = $derived(lang !== null && !!syntax_styler.langs[lang]);
100
117
 
@@ -103,6 +120,8 @@
103
120
  // DEV-only validation warnings
104
121
  if (DEV) {
105
122
  $effect(() => {
123
+ if (dangerous_raw_html != null) return;
124
+
106
125
  if (lang && !language_supported && !grammar) {
107
126
  const langs = Object.keys(syntax_styler.langs).join(', ');
108
127
  // eslint-disable-next-line no-console
@@ -116,21 +135,16 @@
116
135
 
117
136
  // Generate HTML markup for syntax highlighting
118
137
  const html_content = $derived.by(() => {
119
- if (!content || highlighting_disabled) {
120
- return '';
121
- }
122
-
138
+ if (dangerous_raw_html != null) return dangerous_raw_html;
139
+ if (!content || highlighting_disabled) return '';
123
140
  return syntax_styler.stylize(content, lang!, grammar); // ! is safe bc of the `highlighting_disabled` calculation
124
141
  });
125
-
126
- // TODO do syntax styling at compile-time in the normal case, and don't import these at runtime
127
- // TODO @html making me nervous
128
142
  </script>
129
143
 
130
144
  <!-- eslint-disable svelte/no-at-html-tags -->
131
145
 
132
146
  <code {...rest} class:inline class:wrap class:nomargin data-lang={lang}
133
- >{#if highlighting_disabled}{content}{:else if children}{@render children(
147
+ >{#if highlighting_disabled && dangerous_raw_html == null}{content}{:else if children}{@render children(
134
148
  html_content,
135
149
  )}{:else}{@html html_content}{/if}</code
136
150
  >
@@ -1,9 +1,21 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { SvelteHTMLElements } from 'svelte/elements';
3
3
  import type { SyntaxStyler, SyntaxGrammar } from './syntax_styler.js';
4
- type $$ComponentProps = SvelteHTMLElements['code'] & {
4
+ type $$ComponentProps = SvelteHTMLElements['code'] & ({
5
5
  /** The source code to syntax highlight. */
6
6
  content: string;
7
+ dangerous_raw_html?: undefined;
8
+ } | {
9
+ content?: undefined;
10
+ /**
11
+ * Pre-highlighted HTML from the `svelte_preprocess_fuz_code` preprocessor.
12
+ * When provided, skips runtime syntax highlighting entirely.
13
+ *
14
+ * Named `dangerous_raw_html` to signal that it bypasses sanitization,
15
+ * matching the `{@html}` pattern already used by this component.
16
+ */
17
+ dangerous_raw_html: string;
18
+ }) & {
7
19
  /**
8
20
  * Language identifier (e.g., 'ts', 'css', 'html', 'json', 'svelte', 'md').
9
21
  *
@@ -1 +1 @@
1
- {"version":3,"file":"Code.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/Code.svelte"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AAEpC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAEnE,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;;;;;;;;;;;;;;;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,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrC,CAAC;AA8DH,QAAA,MAAM,IAAI,sDAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
1
+ {"version":3,"file":"Code.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/Code.svelte"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AAEpC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGxD,OAAO,KAAK,EAAC,YAAY,EAAE,aAAa,EAAC,MAAM,oBAAoB,CAAC;AAEnE,KAAK,gBAAgB,GAAI,kBAAkB,CAAC,MAAM,CAAC,GAClD,CACG;IACA,2CAA2C;IAC3C,OAAO,EAAE,MAAM,CAAC;IAChB,kBAAkB,CAAC,EAAE,SAAS,CAAC;CAC9B,GACD;IACA,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB;;;;;;OAMG;IACH,kBAAkB,EAAE,MAAM,CAAC;CAC1B,CACH,GAAG;IACH;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,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,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACrC,CAAC;AA4DJ,QAAA,MAAM,IAAI,sDAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
@@ -0,0 +1,24 @@
1
+ import { type PreprocessorGroup } from 'svelte/compiler';
2
+ import type { SyntaxStyler } from './syntax_styler.js';
3
+ export interface PreprocessFuzCodeOptions {
4
+ /** File patterns to exclude. */
5
+ exclude?: Array<string | RegExp>;
6
+ /** Custom syntax styler. @default syntax_styler_global */
7
+ syntax_styler?: SyntaxStyler;
8
+ /** Enable in-memory caching. @default true */
9
+ cache?: boolean;
10
+ /**
11
+ * Import sources that resolve to the Code component.
12
+ * Used to verify that `<Code>` in templates actually refers to fuz_code's Code.svelte.
13
+ *
14
+ * @default ['@fuzdev/fuz_code/Code.svelte']
15
+ */
16
+ component_imports?: Array<string>;
17
+ /**
18
+ * How to handle errors.
19
+ * @default 'throw' in CI, 'log' otherwise
20
+ */
21
+ on_error?: 'log' | 'throw';
22
+ }
23
+ export declare const svelte_preprocess_fuz_code: (options?: PreprocessFuzCodeOptions) => PreprocessorGroup;
24
+ //# sourceMappingURL=svelte_preprocess_fuz_code.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"svelte_preprocess_fuz_code.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/svelte_preprocess_fuz_code.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,iBAAiB,EAAW,MAAM,iBAAiB,CAAC;AAKxE,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,oBAAoB,CAAC;AAErD,MAAM,WAAW,wBAAwB;IACxC,gCAAgC;IAChC,OAAO,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;IAEjC,0DAA0D;IAC1D,aAAa,CAAC,EAAE,YAAY,CAAC;IAE7B,8CAA8C;IAC9C,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAElC;;;OAGG;IACH,QAAQ,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC;CAC3B;AAED,eAAO,MAAM,0BAA0B,GACtC,UAAS,wBAA6B,KACpC,iBA0DF,CAAC"}
@@ -0,0 +1,260 @@
1
+ import { parse } from 'svelte/compiler';
2
+ import MagicString from 'magic-string';
3
+ import { walk } from 'zimmerframe';
4
+ import { syntax_styler_global } from './syntax_styler_global.js';
5
+ export const svelte_preprocess_fuz_code = (options = {}) => {
6
+ const { exclude = [], syntax_styler = syntax_styler_global, cache = true, component_imports = ['@fuzdev/fuz_code/Code.svelte'], on_error = process.env.CI ? 'throw' : 'log', } = options;
7
+ // In-memory cache: content+lang hash → highlighted HTML
8
+ const highlight_cache = new Map();
9
+ return {
10
+ name: 'fuz-code',
11
+ markup: ({ content, filename }) => {
12
+ // Skip excluded files
13
+ if (should_exclude(filename, exclude)) {
14
+ return { code: content };
15
+ }
16
+ // Quick check: does file import from a known Code component source?
17
+ if (!component_imports.some((source) => content.includes(source))) {
18
+ return { code: content };
19
+ }
20
+ const s = new MagicString(content);
21
+ const ast = parse(content, { filename, modern: true });
22
+ // Resolve which local names map to the Code component
23
+ const code_names = resolve_code_names(ast, component_imports);
24
+ if (code_names.size === 0) {
25
+ return { code: content };
26
+ }
27
+ // Find Code component usages with static content
28
+ const transformations = find_code_usages(ast, syntax_styler, code_names, {
29
+ cache: cache ? highlight_cache : null,
30
+ on_error,
31
+ filename,
32
+ source: content,
33
+ });
34
+ if (transformations.length === 0) {
35
+ return { code: content };
36
+ }
37
+ // Apply transformations
38
+ for (const t of transformations) {
39
+ s.overwrite(t.start, t.end, t.replacement);
40
+ }
41
+ return {
42
+ code: s.toString(),
43
+ map: s.generateMap({ hires: true }),
44
+ };
45
+ },
46
+ };
47
+ };
48
+ /**
49
+ * Check if a filename matches any exclusion pattern.
50
+ */
51
+ const should_exclude = (filename, exclude) => {
52
+ if (!filename || exclude.length === 0)
53
+ return false;
54
+ return exclude.some((pattern) => typeof pattern === 'string' ? filename.includes(pattern) : pattern.test(filename));
55
+ };
56
+ /**
57
+ * Scans import declarations to find local names that import from known Code component sources.
58
+ * Handles default imports, named imports, and aliased imports.
59
+ * Checks both instance (`<script>`) and module (`<script module>`) scripts.
60
+ */
61
+ const resolve_code_names = (ast, component_imports) => {
62
+ const names = new Set();
63
+ for (const script of [ast.instance, ast.module]) {
64
+ if (!script)
65
+ continue;
66
+ for (const node of script.content.body) {
67
+ if (node.type !== 'ImportDeclaration')
68
+ continue;
69
+ if (!component_imports.includes(node.source.value))
70
+ continue;
71
+ for (const specifier of node.specifiers) {
72
+ // default import: `import Code from '...'`
73
+ // aliased: `import Highlighter from '...'`
74
+ // named: `import { default as Code } from '...'`
75
+ names.add(specifier.local.name);
76
+ }
77
+ }
78
+ }
79
+ return names;
80
+ };
81
+ /**
82
+ * Attempt to highlight content, using cache if available.
83
+ * Returns the highlighted HTML, or `null` on error.
84
+ */
85
+ const try_highlight = (text, lang, syntax_styler, options) => {
86
+ const cache_key = `${lang}:${text}`;
87
+ let html = options.cache?.get(cache_key);
88
+ if (html == null) {
89
+ try {
90
+ html = syntax_styler.stylize(text, lang);
91
+ options.cache?.set(cache_key, html);
92
+ }
93
+ catch (error) {
94
+ handle_error(error, options);
95
+ return null;
96
+ }
97
+ }
98
+ return html;
99
+ };
100
+ /**
101
+ * Walks the AST to find Code component usages with static `content` props
102
+ * and generates transformations to replace them with `dangerous_raw_html`.
103
+ */
104
+ const find_code_usages = (ast, syntax_styler, code_names, options) => {
105
+ const transformations = [];
106
+ walk(ast.fragment, null, {
107
+ Component(node, context) {
108
+ // Always recurse into children - without this, Code components
109
+ // nested inside other components would be missed, because zimmerframe
110
+ // does not auto-recurse when a visitor is defined for a node type.
111
+ context.next();
112
+ if (!code_names.has(node.name))
113
+ return;
114
+ const content_attr = find_attribute(node, 'content');
115
+ if (!content_attr)
116
+ return;
117
+ // Skip if already preprocessed or custom grammar/syntax_styler is provided
118
+ if (find_attribute(node, 'dangerous_raw_html') ||
119
+ find_attribute(node, 'grammar') ||
120
+ find_attribute(node, 'syntax_styler')) {
121
+ return;
122
+ }
123
+ // Resolve language - must be static and supported
124
+ const lang_attr = find_attribute(node, 'lang');
125
+ const lang_value = lang_attr ? extract_static_string(lang_attr.value) : 'svelte';
126
+ if (lang_value === null)
127
+ return;
128
+ if (!syntax_styler.langs[lang_value])
129
+ return;
130
+ // Try simple static string
131
+ const content_value = extract_static_string(content_attr.value);
132
+ if (content_value !== null) {
133
+ const html = try_highlight(content_value, lang_value, syntax_styler, options);
134
+ if (html === null || html === content_value)
135
+ return;
136
+ transformations.push({
137
+ start: content_attr.start,
138
+ end: content_attr.end,
139
+ replacement: `dangerous_raw_html={'${escape_js_string(html)}'}`,
140
+ });
141
+ return;
142
+ }
143
+ // Try conditional expression with static string branches
144
+ const conditional = try_extract_conditional(content_attr.value, options.source);
145
+ if (conditional) {
146
+ const html_a = try_highlight(conditional.consequent, lang_value, syntax_styler, options);
147
+ const html_b = try_highlight(conditional.alternate, lang_value, syntax_styler, options);
148
+ if (html_a === null || html_b === null)
149
+ return;
150
+ if (html_a === conditional.consequent && html_b === conditional.alternate)
151
+ return;
152
+ transformations.push({
153
+ start: content_attr.start,
154
+ end: content_attr.end,
155
+ replacement: `dangerous_raw_html={${conditional.test_source} ? '${escape_js_string(html_a)}' : '${escape_js_string(html_b)}'}`,
156
+ });
157
+ }
158
+ },
159
+ });
160
+ return transformations;
161
+ };
162
+ /**
163
+ * Find an attribute by name on a component node.
164
+ */
165
+ const find_attribute = (node, name) => {
166
+ for (const attr of node.attributes) {
167
+ if (attr.type === 'Attribute' && attr.name === name) {
168
+ return attr;
169
+ }
170
+ }
171
+ return undefined;
172
+ };
173
+ /**
174
+ * Recursively evaluate an expression AST node to a static string value.
175
+ * Handles string literals, template literals (no interpolation), and string concatenation.
176
+ * Returns `null` for dynamic or non-string expressions.
177
+ */
178
+ const evaluate_static_expr = (expr) => {
179
+ if (expr.type === 'Literal' && typeof expr.value === 'string')
180
+ return expr.value;
181
+ if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) {
182
+ return expr.quasis.map((q) => q.value.cooked ?? q.value.raw).join('');
183
+ }
184
+ if (expr.type === 'BinaryExpression' && expr.operator === '+') {
185
+ const left = evaluate_static_expr(expr.left);
186
+ if (left === null)
187
+ return null;
188
+ const right = evaluate_static_expr(expr.right);
189
+ if (right === null)
190
+ return null;
191
+ return left + right;
192
+ }
193
+ return null;
194
+ };
195
+ /**
196
+ * Extract the string value from a static attribute value.
197
+ * Returns `null` for dynamic, non-string, or null literal values.
198
+ */
199
+ const extract_static_string = (value) => {
200
+ // Boolean attribute
201
+ if (value === true)
202
+ return null;
203
+ // Plain attribute: content="text"
204
+ if (Array.isArray(value)) {
205
+ const first = value[0];
206
+ if (value.length === 1 && first?.type === 'Text') {
207
+ return first.data;
208
+ }
209
+ return null;
210
+ }
211
+ // ExpressionTag
212
+ const expr = value.expression;
213
+ // Null literal
214
+ if (expr.type === 'Literal' && expr.value === null)
215
+ return null;
216
+ return evaluate_static_expr(expr);
217
+ };
218
+ /**
219
+ * Try to extract a conditional expression where both branches are static strings.
220
+ * Returns the condition source text and both branch values, or `null` if not applicable.
221
+ */
222
+ const try_extract_conditional = (value, source) => {
223
+ if (value === true || Array.isArray(value))
224
+ return null;
225
+ const expr = value.expression;
226
+ if (expr.type !== 'ConditionalExpression')
227
+ return null;
228
+ const consequent = evaluate_static_expr(expr.consequent);
229
+ if (consequent === null)
230
+ return null;
231
+ const alternate = evaluate_static_expr(expr.alternate);
232
+ if (alternate === null)
233
+ return null;
234
+ const test = expr.test;
235
+ const test_source = source.slice(test.start, test.end);
236
+ return { test_source, consequent, alternate };
237
+ };
238
+ /**
239
+ * Escapes a string for use inside a single-quoted JS string literal.
240
+ * Single quotes are used because `stylize()` output contains double quotes
241
+ * on every token span, so wrapping with single quotes avoids escaping those.
242
+ */
243
+ const escape_js_string = (html) => {
244
+ return html
245
+ .replace(/\\/g, '\\\\') // backslashes first
246
+ .replace(/'/g, "\\'") // single quotes
247
+ .replace(/\n/g, '\\n') // newlines
248
+ .replace(/\r/g, '\\r'); // carriage returns
249
+ };
250
+ /**
251
+ * Handle errors during highlighting.
252
+ */
253
+ const handle_error = (error, options) => {
254
+ const message = `[fuz-code] Highlighting failed${options.filename ? ` in ${options.filename}` : ''}: ${error instanceof Error ? error.message : String(error)}`;
255
+ if (options.on_error === 'throw') {
256
+ throw new Error(message);
257
+ }
258
+ // eslint-disable-next-line no-console
259
+ console.error(message);
260
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_code",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "syntax styling utilities and components for TypeScript, Svelte, and Markdown",
5
5
  "glyph": "🎨",
6
6
  "logo": "logo.svg",
@@ -23,8 +23,8 @@
23
23
  "test": "gro test",
24
24
  "preview": "vite preview",
25
25
  "deploy": "gro deploy",
26
- "benchmark": "gro run src/benchmark/run_benchmarks.ts",
27
- "benchmark:compare": "gro run src/benchmark/compare/run_compare.ts",
26
+ "benchmark": "gro run benchmark/run_benchmarks.ts",
27
+ "benchmark:compare": "gro run benchmark/compare/run_compare.ts",
28
28
  "fixtures:update": "gro src/test/fixtures/update"
29
29
  },
30
30
  "type": "module",
@@ -33,23 +33,31 @@
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@fuzdev/fuz_css": ">=0.44.1",
36
- "svelte": "^5"
36
+ "magic-string": "^0.30",
37
+ "svelte": "^5",
38
+ "zimmerframe": "^1"
37
39
  },
38
40
  "peerDependenciesMeta": {
39
41
  "@fuzdev/fuz_css": {
40
42
  "optional": true
41
43
  },
44
+ "magic-string": {
45
+ "optional": true
46
+ },
42
47
  "svelte": {
43
48
  "optional": true
49
+ },
50
+ "zimmerframe": {
51
+ "optional": true
44
52
  }
45
53
  },
46
54
  "devDependencies": {
47
55
  "@changesets/changelog-git": "^0.2.1",
48
- "@fuzdev/fuz_css": "^0.45.0",
49
- "@fuzdev/fuz_ui": "^0.180.0",
50
- "@fuzdev/fuz_util": "^0.48.2",
56
+ "@fuzdev/fuz_css": "^0.47.0",
57
+ "@fuzdev/fuz_ui": "^0.181.1",
58
+ "@fuzdev/fuz_util": "^0.48.3",
51
59
  "@ryanatkn/eslint-config": "^0.9.0",
52
- "@ryanatkn/gro": "^0.189.3",
60
+ "@ryanatkn/gro": "^0.190.0",
53
61
  "@sveltejs/adapter-static": "^3.0.10",
54
62
  "@sveltejs/kit": "^2.50.1",
55
63
  "@sveltejs/package": "^2.5.7",
@@ -59,14 +67,16 @@
59
67
  "eslint": "^9.39.1",
60
68
  "eslint-plugin-svelte": "^3.13.1",
61
69
  "esm-env": "^1.2.2",
70
+ "magic-string": "^0.30.21",
62
71
  "prettier": "^3.7.4",
63
72
  "prettier-plugin-svelte": "^3.4.1",
64
- "svelte": "^5.48.5",
65
- "svelte-check": "^4.3.5",
73
+ "svelte": "^5.49.1",
74
+ "svelte-check": "^4.3.6",
66
75
  "tslib": "^2.8.1",
67
76
  "typescript": "^5.9.3",
68
77
  "typescript-eslint": "^8.48.1",
69
- "vitest": "^4.0.15"
78
+ "vitest": "^4.0.15",
79
+ "zimmerframe": "^1.1.4"
70
80
  },
71
81
  "prettier": {
72
82
  "plugins": [
@@ -0,0 +1,350 @@
1
+ import {parse, type PreprocessorGroup, type AST} from 'svelte/compiler';
2
+ import MagicString from 'magic-string';
3
+ import {walk} from 'zimmerframe';
4
+
5
+ import {syntax_styler_global} from './syntax_styler_global.js';
6
+ import type {SyntaxStyler} from './syntax_styler.js';
7
+
8
+ export interface PreprocessFuzCodeOptions {
9
+ /** File patterns to exclude. */
10
+ exclude?: Array<string | RegExp>;
11
+
12
+ /** Custom syntax styler. @default syntax_styler_global */
13
+ syntax_styler?: SyntaxStyler;
14
+
15
+ /** Enable in-memory caching. @default true */
16
+ cache?: boolean;
17
+
18
+ /**
19
+ * Import sources that resolve to the Code component.
20
+ * Used to verify that `<Code>` in templates actually refers to fuz_code's Code.svelte.
21
+ *
22
+ * @default ['@fuzdev/fuz_code/Code.svelte']
23
+ */
24
+ component_imports?: Array<string>;
25
+
26
+ /**
27
+ * How to handle errors.
28
+ * @default 'throw' in CI, 'log' otherwise
29
+ */
30
+ on_error?: 'log' | 'throw';
31
+ }
32
+
33
+ export const svelte_preprocess_fuz_code = (
34
+ options: PreprocessFuzCodeOptions = {},
35
+ ): PreprocessorGroup => {
36
+ const {
37
+ exclude = [],
38
+ syntax_styler = syntax_styler_global,
39
+ cache = true,
40
+ component_imports = ['@fuzdev/fuz_code/Code.svelte'],
41
+ on_error = process.env.CI ? 'throw' : 'log',
42
+ } = options;
43
+
44
+ // In-memory cache: content+lang hash → highlighted HTML
45
+ const highlight_cache: Map<string, string> = new Map();
46
+
47
+ return {
48
+ name: 'fuz-code',
49
+
50
+ markup: ({content, filename}) => {
51
+ // Skip excluded files
52
+ if (should_exclude(filename, exclude)) {
53
+ return {code: content};
54
+ }
55
+
56
+ // Quick check: does file import from a known Code component source?
57
+ if (!component_imports.some((source) => content.includes(source))) {
58
+ return {code: content};
59
+ }
60
+
61
+ const s = new MagicString(content);
62
+ const ast = parse(content, {filename, modern: true});
63
+
64
+ // Resolve which local names map to the Code component
65
+ const code_names = resolve_code_names(ast, component_imports);
66
+ if (code_names.size === 0) {
67
+ return {code: content};
68
+ }
69
+
70
+ // Find Code component usages with static content
71
+ const transformations = find_code_usages(ast, syntax_styler, code_names, {
72
+ cache: cache ? highlight_cache : null,
73
+ on_error,
74
+ filename,
75
+ source: content,
76
+ });
77
+
78
+ if (transformations.length === 0) {
79
+ return {code: content};
80
+ }
81
+
82
+ // Apply transformations
83
+ for (const t of transformations) {
84
+ s.overwrite(t.start, t.end, t.replacement);
85
+ }
86
+
87
+ return {
88
+ code: s.toString(),
89
+ map: s.generateMap({hires: true}),
90
+ };
91
+ },
92
+ };
93
+ };
94
+
95
+ /**
96
+ * Check if a filename matches any exclusion pattern.
97
+ */
98
+ const should_exclude = (filename: string | undefined, exclude: Array<string | RegExp>): boolean => {
99
+ if (!filename || exclude.length === 0) return false;
100
+ return exclude.some((pattern) =>
101
+ typeof pattern === 'string' ? filename.includes(pattern) : pattern.test(filename),
102
+ );
103
+ };
104
+
105
+ /**
106
+ * Scans import declarations to find local names that import from known Code component sources.
107
+ * Handles default imports, named imports, and aliased imports.
108
+ * Checks both instance (`<script>`) and module (`<script module>`) scripts.
109
+ */
110
+ const resolve_code_names = (ast: AST.Root, component_imports: Array<string>): Set<string> => {
111
+ const names: Set<string> = new Set();
112
+
113
+ for (const script of [ast.instance, ast.module]) {
114
+ if (!script) continue;
115
+
116
+ for (const node of script.content.body) {
117
+ if (node.type !== 'ImportDeclaration') continue;
118
+ if (!component_imports.includes(node.source.value as string)) continue;
119
+
120
+ for (const specifier of node.specifiers) {
121
+ // default import: `import Code from '...'`
122
+ // aliased: `import Highlighter from '...'`
123
+ // named: `import { default as Code } from '...'`
124
+ names.add(specifier.local.name);
125
+ }
126
+ }
127
+ }
128
+
129
+ return names;
130
+ };
131
+
132
+ interface Transformation {
133
+ start: number;
134
+ end: number;
135
+ replacement: string;
136
+ }
137
+
138
+ interface FindCodeUsagesOptions {
139
+ cache: Map<string, string> | null;
140
+ on_error: 'log' | 'throw';
141
+ filename: string | undefined;
142
+ source: string;
143
+ }
144
+
145
+ /**
146
+ * Attempt to highlight content, using cache if available.
147
+ * Returns the highlighted HTML, or `null` on error.
148
+ */
149
+ const try_highlight = (
150
+ text: string,
151
+ lang: string,
152
+ syntax_styler: SyntaxStyler,
153
+ options: FindCodeUsagesOptions,
154
+ ): string | null => {
155
+ const cache_key = `${lang}:${text}`;
156
+ let html = options.cache?.get(cache_key);
157
+ if (html == null) {
158
+ try {
159
+ html = syntax_styler.stylize(text, lang);
160
+ options.cache?.set(cache_key, html);
161
+ } catch (error) {
162
+ handle_error(error, options);
163
+ return null;
164
+ }
165
+ }
166
+ return html;
167
+ };
168
+
169
+ /**
170
+ * Walks the AST to find Code component usages with static `content` props
171
+ * and generates transformations to replace them with `dangerous_raw_html`.
172
+ */
173
+ const find_code_usages = (
174
+ ast: AST.Root,
175
+ syntax_styler: SyntaxStyler,
176
+ code_names: Set<string>,
177
+ options: FindCodeUsagesOptions,
178
+ ): Array<Transformation> => {
179
+ const transformations: Array<Transformation> = [];
180
+
181
+ walk(ast.fragment as any, null, {
182
+ Component(node: AST.Component, context: {next: () => void}) {
183
+ // Always recurse into children - without this, Code components
184
+ // nested inside other components would be missed, because zimmerframe
185
+ // does not auto-recurse when a visitor is defined for a node type.
186
+ context.next();
187
+
188
+ if (!code_names.has(node.name)) return;
189
+
190
+ const content_attr = find_attribute(node, 'content');
191
+ if (!content_attr) return;
192
+
193
+ // Skip if already preprocessed or custom grammar/syntax_styler is provided
194
+ if (
195
+ find_attribute(node, 'dangerous_raw_html') ||
196
+ find_attribute(node, 'grammar') ||
197
+ find_attribute(node, 'syntax_styler')
198
+ ) {
199
+ return;
200
+ }
201
+
202
+ // Resolve language - must be static and supported
203
+ const lang_attr = find_attribute(node, 'lang');
204
+ const lang_value = lang_attr ? extract_static_string(lang_attr.value) : 'svelte';
205
+ if (lang_value === null) return;
206
+ if (!syntax_styler.langs[lang_value]) return;
207
+
208
+ // Try simple static string
209
+ const content_value = extract_static_string(content_attr.value);
210
+ if (content_value !== null) {
211
+ const html = try_highlight(content_value, lang_value, syntax_styler, options);
212
+ if (html === null || html === content_value) return;
213
+ transformations.push({
214
+ start: content_attr.start,
215
+ end: content_attr.end,
216
+ replacement: `dangerous_raw_html={'${escape_js_string(html)}'}`,
217
+ });
218
+ return;
219
+ }
220
+
221
+ // Try conditional expression with static string branches
222
+ const conditional = try_extract_conditional(content_attr.value, options.source);
223
+ if (conditional) {
224
+ const html_a = try_highlight(conditional.consequent, lang_value, syntax_styler, options);
225
+ const html_b = try_highlight(conditional.alternate, lang_value, syntax_styler, options);
226
+ if (html_a === null || html_b === null) return;
227
+ if (html_a === conditional.consequent && html_b === conditional.alternate) return;
228
+ transformations.push({
229
+ start: content_attr.start,
230
+ end: content_attr.end,
231
+ replacement: `dangerous_raw_html={${conditional.test_source} ? '${escape_js_string(html_a)}' : '${escape_js_string(html_b)}'}`,
232
+ });
233
+ }
234
+ },
235
+ });
236
+
237
+ return transformations;
238
+ };
239
+
240
+ /**
241
+ * Find an attribute by name on a component node.
242
+ */
243
+ const find_attribute = (node: AST.Component, name: string): AST.Attribute | undefined => {
244
+ for (const attr of node.attributes) {
245
+ if (attr.type === 'Attribute' && attr.name === name) {
246
+ return attr;
247
+ }
248
+ }
249
+ return undefined;
250
+ };
251
+
252
+ type Attribute_Value = AST.Attribute['value'];
253
+
254
+ /**
255
+ * Recursively evaluate an expression AST node to a static string value.
256
+ * Handles string literals, template literals (no interpolation), and string concatenation.
257
+ * Returns `null` for dynamic or non-string expressions.
258
+ */
259
+ const evaluate_static_expr = (expr: any): string | null => {
260
+ if (expr.type === 'Literal' && typeof expr.value === 'string') return expr.value;
261
+ if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) {
262
+ return expr.quasis.map((q: any) => q.value.cooked ?? q.value.raw).join('');
263
+ }
264
+ if (expr.type === 'BinaryExpression' && expr.operator === '+') {
265
+ const left = evaluate_static_expr(expr.left);
266
+ if (left === null) return null;
267
+ const right = evaluate_static_expr(expr.right);
268
+ if (right === null) return null;
269
+ return left + right;
270
+ }
271
+ return null;
272
+ };
273
+
274
+ /**
275
+ * Extract the string value from a static attribute value.
276
+ * Returns `null` for dynamic, non-string, or null literal values.
277
+ */
278
+ const extract_static_string = (value: Attribute_Value): string | null => {
279
+ // Boolean attribute
280
+ if (value === true) return null;
281
+
282
+ // Plain attribute: content="text"
283
+ if (Array.isArray(value)) {
284
+ const first = value[0];
285
+ if (value.length === 1 && first?.type === 'Text') {
286
+ return first.data;
287
+ }
288
+ return null;
289
+ }
290
+
291
+ // ExpressionTag
292
+ const expr = value.expression;
293
+ // Null literal
294
+ if (expr.type === 'Literal' && expr.value === null) return null;
295
+ return evaluate_static_expr(expr);
296
+ };
297
+
298
+ interface ConditionalStaticStrings {
299
+ test_source: string;
300
+ consequent: string;
301
+ alternate: string;
302
+ }
303
+
304
+ /**
305
+ * Try to extract a conditional expression where both branches are static strings.
306
+ * Returns the condition source text and both branch values, or `null` if not applicable.
307
+ */
308
+ const try_extract_conditional = (
309
+ value: Attribute_Value,
310
+ source: string,
311
+ ): ConditionalStaticStrings | null => {
312
+ if (value === true || Array.isArray(value)) return null;
313
+ const expr = value.expression;
314
+ if (expr.type !== 'ConditionalExpression') return null;
315
+
316
+ const consequent = evaluate_static_expr(expr.consequent);
317
+ if (consequent === null) return null;
318
+ const alternate = evaluate_static_expr(expr.alternate);
319
+ if (alternate === null) return null;
320
+
321
+ const test = expr.test as any;
322
+ const test_source = source.slice(test.start, test.end);
323
+ return {test_source, consequent, alternate};
324
+ };
325
+
326
+ /**
327
+ * Escapes a string for use inside a single-quoted JS string literal.
328
+ * Single quotes are used because `stylize()` output contains double quotes
329
+ * on every token span, so wrapping with single quotes avoids escaping those.
330
+ */
331
+ const escape_js_string = (html: string): string => {
332
+ return html
333
+ .replace(/\\/g, '\\\\') // backslashes first
334
+ .replace(/'/g, "\\'") // single quotes
335
+ .replace(/\n/g, '\\n') // newlines
336
+ .replace(/\r/g, '\\r'); // carriage returns
337
+ };
338
+
339
+ /**
340
+ * Handle errors during highlighting.
341
+ */
342
+ const handle_error = (error: unknown, options: FindCodeUsagesOptions): void => {
343
+ const message = `[fuz-code] Highlighting failed${options.filename ? ` in ${options.filename}` : ''}: ${error instanceof Error ? error.message : String(error)}`;
344
+
345
+ if (options.on_error === 'throw') {
346
+ throw new Error(message);
347
+ }
348
+ // eslint-disable-next-line no-console
349
+ console.error(message);
350
+ };