@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 +101 -87
- package/dist/Code.svelte.d.ts +13 -1
- package/dist/Code.svelte.d.ts.map +1 -1
- package/dist/svelte_preprocess_fuz_code.d.ts +24 -0
- package/dist/svelte_preprocess_fuz_code.d.ts.map +1 -0
- package/dist/svelte_preprocess_fuz_code.js +260 -0
- package/package.json +21 -11
- package/src/lib/svelte_preprocess_fuz_code.ts +350 -0
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 (
|
|
120
|
-
|
|
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
|
>
|
package/dist/Code.svelte.d.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
|
27
|
-
"benchmark:compare": "gro run
|
|
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
|
-
"
|
|
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.
|
|
49
|
-
"@fuzdev/fuz_ui": "^0.
|
|
50
|
-
"@fuzdev/fuz_util": "^0.48.
|
|
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.
|
|
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.
|
|
65
|
-
"svelte-check": "^4.3.
|
|
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
|
+
};
|