@pracht/preact-ssr-precompile 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jovi De Croock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @pracht/preact-ssr-precompile
2
+
3
+ Experimental Rolldown/Vite plugin that precompiles safe Preact JSX DOM subtrees
4
+ into `preact/jsx-runtime` template calls for faster server-side string rendering.
5
+
6
+ It targets SSR/SSG server bundles only. Client bundles should keep using the
7
+ normal Preact JSX transform so hydration still builds a normal VNode tree.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pnpm add -D @pracht/preact-ssr-precompile
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### Vite / Pracht
18
+
19
+ Place the plugin before `pracht()` or `@preact/preset-vite` so it sees JSX before
20
+ the normal Preact transform runs:
21
+
22
+ ```ts
23
+ import { defineConfig } from "vite";
24
+ import { pracht } from "@pracht/vite-plugin";
25
+ import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
26
+
27
+ export default defineConfig({
28
+ plugins: [
29
+ preactSsrPrecompile(),
30
+ pracht(),
31
+ ],
32
+ });
33
+ ```
34
+
35
+ Pracht also exposes the transform as an opt-in framework flag:
36
+
37
+ ```ts
38
+ export default defineConfig({
39
+ plugins: [pracht({ precompileSsrJsx: true })],
40
+ });
41
+ ```
42
+
43
+ For non-Pracht Vite SSR builds:
44
+
45
+ ```ts
46
+ import preact from "@preact/preset-vite";
47
+ import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
48
+
49
+ export default defineConfig({
50
+ plugins: [preactSsrPrecompile(), preact()],
51
+ });
52
+ ```
53
+
54
+ By default the transform runs only when Vite calls plugin transforms with
55
+ `options.ssr === true`.
56
+
57
+ ### Rolldown server builds
58
+
59
+ For a dedicated server-only Rolldown build, disable the Vite SSR guard:
60
+
61
+ ```ts
62
+ import { defineConfig } from "rolldown";
63
+ import { preactSsrPrecompile } from "@pracht/preact-ssr-precompile";
64
+
65
+ export default defineConfig({
66
+ plugins: [preactSsrPrecompile({ ssrOnly: false })],
67
+ });
68
+ ```
69
+
70
+ ## What it does
71
+
72
+ ```tsx
73
+ <div class="card">Hello {name}</div>
74
+ ```
75
+
76
+ becomes roughly:
77
+
78
+ ```ts
79
+ import { jsxTemplate as _jsxTemplate, jsxEscape as _jsxEscape } from "preact/jsx-runtime";
80
+
81
+ const $$_tpl_1 = ["<div class=\"card\">Hello ", "</div>"];
82
+
83
+ _jsxTemplate($$_tpl_1, _jsxEscape(name));
84
+ ```
85
+
86
+ `preact-render-to-string` recognizes these template VNodes and concatenates the
87
+ pre-escaped strings directly, avoiding most VNode/props allocations for static
88
+ HTML.
89
+
90
+ ## Safety model
91
+
92
+ The plugin is conservative. It precompiles lowercase native HTML elements only
93
+ and falls back to the normal `jsx()` runtime for cases where Preact has special
94
+ SSR semantics, including:
95
+
96
+ - components and member-expression tags;
97
+ - spread props;
98
+ - `dangerouslySetInnerHTML`;
99
+ - custom elements;
100
+ - SVG/MathML;
101
+ - `textarea`, `select`, and `option`.
102
+
103
+ Dynamic children are wrapped in `jsxEscape()`. Dynamic attributes are serialized
104
+ with `jsxAttr()`, with extra handling for ARIA and enumerated boolean attributes
105
+ so output matches `preact-render-to-string`.
106
+
107
+ ## Options
108
+
109
+ ```ts
110
+ preactSsrPrecompile({
111
+ include: [/\.[cm]?[tj]sx$/],
112
+ exclude: [/node_modules/],
113
+ importSource: "preact",
114
+ ssrOnly: true,
115
+ skipElements: ["canvas"],
116
+ dynamicProps: ["data-client"],
117
+ });
118
+ ```
119
+
120
+ - `include` / `exclude`: Vite filter patterns.
121
+ - `importSource`: runtime import source. The plugin imports from
122
+ `${importSource}/jsx-runtime`.
123
+ - `ssrOnly`: keep the default `true` for apps. Set to `false` only for dedicated
124
+ server-only builds where transform hooks do not receive an SSR flag.
125
+ - `skipElements`: additional lowercase element names that should always fall
126
+ back to normal JSX.
127
+ - `dynamicProps`: attributes that should always be emitted through `jsxAttr()`.
128
+
129
+ ## Benchmarks
130
+
131
+ Run the render-to-string microbenchmark from the workspace root:
132
+
133
+ ```sh
134
+ pnpm --filter @pracht/preact-ssr-precompile bench
135
+ ```
136
+
137
+ The benchmark compiles the same Preact component twice — once with the normal
138
+ automatic JSX transform and once through this precompile transform — then renders
139
+ each version with `preact-render-to-string` and reports ops/sec and speedup.
140
+ Tune the loop size with `BENCH_ITERATIONS=100000` and the warmup count with
141
+ `BENCH_WARMUP=5000`.
@@ -0,0 +1,32 @@
1
+ import { Plugin } from "vite";
2
+
3
+ //#region src/index.d.ts
4
+ type FilterPattern = string | RegExp | ReadonlyArray<string | RegExp>;
5
+ interface PreactSsrPrecompileOptions {
6
+ /** Files to transform. Defaults to JS/TS files, including JSX/TSX. */
7
+ include?: FilterPattern;
8
+ /** Files to skip. Defaults to node_modules. */
9
+ exclude?: FilterPattern;
10
+ /** JSX runtime import source. Imports are generated from `${importSource}/jsx-runtime`. */
11
+ importSource?: string;
12
+ /** Run only for Vite SSR transforms. Defaults to true. */
13
+ ssrOnly?: boolean;
14
+ /** Additional lowercase HTML element names to keep on the normal JSX path. */
15
+ skipElements?: string[];
16
+ /** Attributes that should always be serialized at runtime with `jsxAttr()`. */
17
+ dynamicProps?: string[];
18
+ }
19
+ interface TransformPreactSsrJsxOptions {
20
+ importSource?: string;
21
+ skipElements?: string[];
22
+ dynamicProps?: string[];
23
+ }
24
+ /**
25
+ * Create a Vite/Rolldown plugin that precompiles safe Preact JSX for server
26
+ * bundles into `jsxTemplate()` calls understood by `preact-render-to-string`.
27
+ */
28
+ declare function preactSsrPrecompile(options?: PreactSsrPrecompileOptions): Plugin;
29
+ /** Transform JSX in a single module. Exposed for tests and non-Vite integrations. */
30
+ declare function transformPreactSsrJsx(code: string, id?: string, options?: TransformPreactSsrJsxOptions): string | null;
31
+ //#endregion
32
+ export { PreactSsrPrecompileOptions, TransformPreactSsrJsxOptions, preactSsrPrecompile as default, preactSsrPrecompile, transformPreactSsrJsx };
package/dist/index.mjs ADDED
@@ -0,0 +1,551 @@
1
+ import { parseSync } from "rolldown/utils";
2
+ import { generateTransform, rolldownString, withMagicString } from "rolldown-string";
3
+ //#region src/index.ts
4
+ const DEFAULT_INCLUDE = [/\.[cm]?[tj]sx?$/];
5
+ const DEFAULT_EXCLUDE = [/node_modules/];
6
+ const DEFAULT_IMPORT_SOURCE = "preact";
7
+ const DEFAULT_SKIP_ELEMENTS = new Set([
8
+ "svg",
9
+ "math",
10
+ "textarea",
11
+ "select",
12
+ "option"
13
+ ]);
14
+ const VOID_ELEMENTS = new Set([
15
+ "area",
16
+ "base",
17
+ "br",
18
+ "col",
19
+ "embed",
20
+ "hr",
21
+ "img",
22
+ "input",
23
+ "link",
24
+ "meta",
25
+ "param",
26
+ "source",
27
+ "track",
28
+ "wbr"
29
+ ]);
30
+ const HTML_ENUMERATED_ATTRS = new Set(["draggable", "spellcheck"]);
31
+ const NAMESPACE_REPLACE_REGEX = /^(xlink|xmlns|xml)([A-Z])/;
32
+ const HTML_LOWER_CASE = /^(?:accessK|auto[A-Z]|cell|ch|col|cont|cross|dateT|encT|form[A-Z]|frame|hrefL|inputM|maxL|minL|noV|playsI|popoverT|readO|rowS|src[A-Z]|tabI|useM|item[A-Z])/;
33
+ const UNSAFE_NAME = /[\s\n\\/='"<>]/;
34
+ const ENCODED_ENTITIES = /["&<]/;
35
+ const IDENTIFIER_NAME = /^[$A-Z_a-z][$\w]*$/;
36
+ /**
37
+ * Create a Vite/Rolldown plugin that precompiles safe Preact JSX for server
38
+ * bundles into `jsxTemplate()` calls understood by `preact-render-to-string`.
39
+ */
40
+ function preactSsrPrecompile(options = {}) {
41
+ const filter = createSimpleFilter(options.include ?? DEFAULT_INCLUDE, options.exclude ?? DEFAULT_EXCLUDE);
42
+ const ssrOnly = options.ssrOnly ?? true;
43
+ return {
44
+ name: "preact-ssr-precompile",
45
+ enforce: "pre",
46
+ transform: {
47
+ filter: { id: /\.[cm]?[jt]sx?(?:$|\?)/ },
48
+ handler: withMagicString(function(s, id, transformOptions) {
49
+ const filename = stripQuery(id);
50
+ if (ssrOnly && transformOptions?.ssr !== true) return;
51
+ if (!filter(filename)) return;
52
+ if (!looksLikeJSX(s.original)) return;
53
+ transformPreactSsrMagicString(s, filename, options);
54
+ })
55
+ }
56
+ };
57
+ }
58
+ /** Transform JSX in a single module. Exposed for tests and non-Vite integrations. */
59
+ function transformPreactSsrJsx(code, id = "preact-ssr.tsx", options = {}) {
60
+ const s = rolldownString(code, id);
61
+ if (!transformPreactSsrMagicString(s, id, options)) return null;
62
+ const result = generateTransform(s, id, true);
63
+ return result ? String(result.code) : null;
64
+ }
65
+ function transformPreactSsrMagicString(s, id, options) {
66
+ let program;
67
+ try {
68
+ program = parseProgram(id, s.original);
69
+ } catch (error) {
70
+ const message = error instanceof Error ? error.message : String(error);
71
+ console.warn(`[preact-ssr-precompile] Skipping ${id}: ${message}`);
72
+ return false;
73
+ }
74
+ const ctx = new TransformContext(s.original, options, collectIdentifierNames(program));
75
+ const replacements = ctx.collectJsxReplacements(program);
76
+ if (replacements.length === 0) return false;
77
+ for (const replacement of replacements) s.update(replacement.start, replacement.end, replacement.code);
78
+ insertPrelude(s, program, ctx.renderPrelude());
79
+ return true;
80
+ }
81
+ var TransformContext = class {
82
+ code;
83
+ dynamicProps;
84
+ importSource;
85
+ skipElements;
86
+ jsxIdent;
87
+ jsxTemplateIdent;
88
+ jsxAttrIdent;
89
+ jsxEscapeIdent;
90
+ templateIndex = 0;
91
+ takenNames;
92
+ templates = [];
93
+ usedHelpers = /* @__PURE__ */ new Set();
94
+ constructor(code, options, takenNames) {
95
+ this.takenNames = takenNames;
96
+ this.code = code;
97
+ this.importSource = options.importSource ?? DEFAULT_IMPORT_SOURCE;
98
+ this.dynamicProps = new Set(options.dynamicProps ?? []);
99
+ this.skipElements = new Set([...DEFAULT_SKIP_ELEMENTS, ...options.skipElements ?? []]);
100
+ this.jsxIdent = uniqueName("_jsx", takenNames);
101
+ this.jsxTemplateIdent = uniqueName("_jsxTemplate", takenNames);
102
+ this.jsxAttrIdent = uniqueName("_jsxAttr", takenNames);
103
+ this.jsxEscapeIdent = uniqueName("_jsxEscape", takenNames);
104
+ }
105
+ collectJsxReplacements(node) {
106
+ const replacements = [];
107
+ this.collectJsxReplacementsInto(node, replacements);
108
+ return replacements.sort((a, b) => a.start - b.start);
109
+ }
110
+ renderPrelude() {
111
+ const imports = [
112
+ ["jsx", this.jsxIdent],
113
+ ["jsxTemplate", this.jsxTemplateIdent],
114
+ ["jsxAttr", this.jsxAttrIdent],
115
+ ["jsxEscape", this.jsxEscapeIdent]
116
+ ].filter(([helper]) => this.usedHelpers.has(helper)).map(([helper, alias]) => `${helper} as ${alias}`);
117
+ if (imports.length === 0 && this.templates.length === 0) return "";
118
+ const lines = [`import { ${imports.join(", ")} } from ${JSON.stringify(`${this.importSource}/jsx-runtime`)};`];
119
+ for (const template of this.templates) lines.push(`const ${template.name} = [${template.strings.map((value) => JSON.stringify(value)).join(", ")}];`);
120
+ return `${lines.join("\n")}\n`;
121
+ }
122
+ serializeJsx(node) {
123
+ if (node.type === "JSXFragment") {
124
+ const strings = [""];
125
+ const dynamics = [];
126
+ this.serializeChildrenToTemplate(getNodeArray(node.children), strings, dynamics, false);
127
+ return this.genTemplate(strings, dynamics);
128
+ }
129
+ if (node.type !== "JSXElement") return this.code.slice(node.start, node.end);
130
+ const opening = node.openingElement;
131
+ if (!this.isSerializableOpening(opening)) return this.serializeJsxToCall(node);
132
+ const strings = [];
133
+ const dynamics = [];
134
+ this.serializeElementToTemplate(node, strings, dynamics);
135
+ return this.genTemplate(strings, dynamics);
136
+ }
137
+ renderExpression(expr) {
138
+ const replacements = [];
139
+ this.collectJsxReplacementsInto(expr, replacements);
140
+ if (replacements.length === 0) return this.code.slice(expr.start, expr.end);
141
+ return applyReplacementsInRange(this.code, expr.start, expr.end, replacements.sort((a, b) => a.start - b.start));
142
+ }
143
+ collectJsxReplacementsInto(node, replacements) {
144
+ if (!isNode(node)) return;
145
+ if (node.type === "JSXElement" || node.type === "JSXFragment") {
146
+ replacements.push({
147
+ start: node.start,
148
+ end: node.end,
149
+ code: this.serializeJsx(node)
150
+ });
151
+ return;
152
+ }
153
+ for (const [key, value] of Object.entries(node)) {
154
+ if (key === "parent" || key === "comments") continue;
155
+ if (Array.isArray(value)) for (const item of value) this.collectJsxReplacementsInto(item, replacements);
156
+ else if (isNode(value)) this.collectJsxReplacementsInto(value, replacements);
157
+ }
158
+ }
159
+ serializeElementToTemplate(node, strings, dynamics) {
160
+ const opening = node.openingElement;
161
+ if (!this.isSerializableOpening(opening)) {
162
+ strings.push("");
163
+ dynamics.push(this.serializeJsxToCall(node));
164
+ return;
165
+ }
166
+ if (strings.length === 0) strings.push("");
167
+ const tagName = getElementIdentifierName(opening.name) ?? "";
168
+ strings[strings.length - 1] += `<${encodeEntities(tagName)}`;
169
+ for (const attr of getNodeArray(opening.attributes)) {
170
+ if (attr.type !== "JSXAttribute") continue;
171
+ this.serializeAttributeToTemplate(attr, strings, dynamics);
172
+ }
173
+ const children = getNodeArray(node.children);
174
+ if (VOID_ELEMENTS.has(tagName)) {
175
+ strings[strings.length - 1] += "/>";
176
+ return;
177
+ }
178
+ strings[strings.length - 1] += ">";
179
+ this.serializeChildrenToTemplate(children, strings, dynamics, true);
180
+ strings[strings.length - 1] += `</${tagName}>`;
181
+ }
182
+ serializeAttributeToTemplate(attr, strings, dynamics) {
183
+ const rawAttrName = getAttributeName(attr, this.code);
184
+ if (!rawAttrName) return;
185
+ const attrName = normalizeHtmlAttrName(rawAttrName);
186
+ if (this.dynamicProps.has(rawAttrName) || attrName === "key" || attrName === "ref") {
187
+ strings.push("");
188
+ dynamics.push(this.jsxAttrCall(attrName, this.getAttributeValueExpression(attr)));
189
+ return;
190
+ }
191
+ const value = attr.value;
192
+ if (!value) {
193
+ this.appendStaticAttribute(strings, attrName, true);
194
+ return;
195
+ }
196
+ if (value.type === "Literal") {
197
+ this.appendStaticAttribute(strings, attrName, value.value);
198
+ return;
199
+ }
200
+ if (value.type === "JSXExpressionContainer") {
201
+ const expr = value.expression;
202
+ if (!expr || expr.type === "JSXEmptyExpression") return;
203
+ if (expr.type === "Literal") {
204
+ this.appendStaticAttribute(strings, attrName, expr.value);
205
+ return;
206
+ }
207
+ strings.push("");
208
+ dynamics.push(this.jsxAttrCall(attrName, this.renderExpression(expr)));
209
+ return;
210
+ }
211
+ if (value.type === "JSXElement" || value.type === "JSXFragment") {
212
+ strings.push("");
213
+ dynamics.push(this.jsxAttrCall(attrName, this.serializeJsx(value)));
214
+ }
215
+ }
216
+ serializeChildrenToTemplate(children, strings, dynamics, isParentSerializable) {
217
+ for (const [index, child] of children.entries()) {
218
+ if (child.type === "JSXText") {
219
+ const text = jsxTextToString(String(child.value ?? ""), true, isParentSerializable && index === children.length - 1);
220
+ strings[strings.length - 1] += text;
221
+ continue;
222
+ }
223
+ if (child.type === "JSXExpressionContainer") {
224
+ const expr = child.expression;
225
+ if (!expr || expr.type === "JSXEmptyExpression") continue;
226
+ const staticText = getStaticChildText(expr);
227
+ if (staticText != null) {
228
+ strings[strings.length - 1] += staticText;
229
+ continue;
230
+ }
231
+ strings.push("");
232
+ this.usedHelpers.add("jsxEscape");
233
+ dynamics.push(`${this.jsxEscapeIdent}(${this.renderExpression(expr)})`);
234
+ continue;
235
+ }
236
+ if (child.type === "JSXElement") {
237
+ this.serializeElementToTemplate(child, strings, dynamics);
238
+ continue;
239
+ }
240
+ if (child.type === "JSXFragment") this.serializeChildrenToTemplate(getNodeArray(child.children), strings, dynamics, false);
241
+ }
242
+ }
243
+ serializeJsxToCall(node) {
244
+ const opening = node.openingElement;
245
+ const isComponent = isComponentElementName(opening.name);
246
+ const typeExpr = jsxElementNameToExpression(opening.name, this.code, isComponent);
247
+ const props = [];
248
+ let keyExpr;
249
+ for (const attr of getNodeArray(opening.attributes)) {
250
+ if (attr.type === "JSXSpreadAttribute") {
251
+ const argument = attr.argument;
252
+ props.push(`...${this.renderExpression(argument)}`);
253
+ continue;
254
+ }
255
+ if (attr.type !== "JSXAttribute") continue;
256
+ const rawAttrName = getAttributeName(attr, this.code);
257
+ if (!rawAttrName) continue;
258
+ const propName = isComponent ? rawAttrName : normalizeHtmlAttrName(rawAttrName);
259
+ const value = attr.value;
260
+ if (propName === "key") {
261
+ keyExpr = value ? this.getAttributeValueExpression(attr) : "true";
262
+ continue;
263
+ }
264
+ props.push(`${objectPropertyKey(propName)}: ${value ? this.getAttributeValueExpression(attr) : "true"}`);
265
+ }
266
+ const children = this.serializeChildrenToExpression(getNodeArray(node.children));
267
+ if (children) props.push(`children: ${children}`);
268
+ const args = [typeExpr, props.length > 0 ? `{ ${props.join(", ")} }` : "null"];
269
+ if (keyExpr) args.push(keyExpr);
270
+ this.usedHelpers.add("jsx");
271
+ return `${this.jsxIdent}(${args.join(", ")})`;
272
+ }
273
+ serializeChildrenToExpression(children) {
274
+ const values = [];
275
+ for (const [index, child] of children.entries()) {
276
+ if (child.type === "JSXText") {
277
+ const text = jsxTextToString(String(child.value ?? ""), false, index === children.length - 1);
278
+ if (text !== "") values.push(JSON.stringify(text));
279
+ continue;
280
+ }
281
+ if (child.type === "JSXExpressionContainer") {
282
+ const expr = child.expression;
283
+ if (!expr || expr.type === "JSXEmptyExpression") continue;
284
+ if (isIgnoredLiteralChild(expr)) continue;
285
+ values.push(this.renderExpression(expr));
286
+ continue;
287
+ }
288
+ if (child.type === "JSXElement" || child.type === "JSXFragment") values.push(this.serializeJsx(child));
289
+ }
290
+ if (values.length === 0) return null;
291
+ if (values.length === 1) return values[0];
292
+ return `[${values.join(", ")}]`;
293
+ }
294
+ getAttributeValueExpression(attr) {
295
+ const value = attr.value;
296
+ if (!value) return "true";
297
+ if (value.type === "Literal") return JSON.stringify(String(value.value ?? ""));
298
+ if (value.type === "JSXExpressionContainer") {
299
+ const expr = value.expression;
300
+ if (!expr || expr.type === "JSXEmptyExpression") return "undefined";
301
+ return this.renderExpression(expr);
302
+ }
303
+ if (value.type === "JSXElement" || value.type === "JSXFragment") return this.serializeJsx(value);
304
+ return this.code.slice(value.start, value.end);
305
+ }
306
+ appendStaticAttribute(strings, attrName, value) {
307
+ if (value == null) return;
308
+ if (isAriaOrEnumerated(attrName) && typeof value === "boolean") {
309
+ strings[strings.length - 1] += ` ${encodeEntities(attrName)}=${JSON.stringify(String(value))}`;
310
+ return;
311
+ }
312
+ if (value === false || typeof value === "function" || typeof value === "object") return;
313
+ if (value === true || value === "") {
314
+ strings[strings.length - 1] += ` ${encodeEntities(attrName)}`;
315
+ return;
316
+ }
317
+ strings[strings.length - 1] += ` ${encodeEntities(attrName)}=${JSON.stringify(encodeEntities(String(value)))}`;
318
+ }
319
+ jsxAttrCall(attrName, expression) {
320
+ const serializedName = JSON.stringify(attrName);
321
+ this.usedHelpers.add("jsxAttr");
322
+ return `((attr) => attr ? " " + attr : "")(${isAriaOrEnumerated(attrName) ? `((value) => typeof value === "boolean" ? ${this.jsxAttrIdent}(${serializedName}, String(value)) : ${this.jsxAttrIdent}(${serializedName}, value))(${expression})` : `${this.jsxAttrIdent}(${serializedName}, ${expression})`})`;
323
+ }
324
+ genTemplate(strings, dynamics) {
325
+ const templateName = uniqueName(`$$_tpl_${++this.templateIndex}`, this.takenNames);
326
+ this.templates.push({
327
+ name: templateName,
328
+ strings
329
+ });
330
+ this.usedHelpers.add("jsxTemplate");
331
+ return `${this.jsxTemplateIdent}(${[templateName, ...dynamics].join(", ")})`;
332
+ }
333
+ isSerializableOpening(opening) {
334
+ const name = getElementIdentifierName(opening.name);
335
+ if (!name) return false;
336
+ if (isComponentTagName(name)) return false;
337
+ if (this.skipElements.has(name)) return false;
338
+ if (name.includes("-")) return false;
339
+ if (name.includes("\0") || UNSAFE_NAME.test(name)) return false;
340
+ for (const attr of getNodeArray(opening.attributes)) {
341
+ if (attr.type === "JSXSpreadAttribute") return false;
342
+ if (attr.type !== "JSXAttribute") continue;
343
+ if (getAttributeName(attr, this.code) === "dangerouslySetInnerHTML") return false;
344
+ }
345
+ return true;
346
+ }
347
+ };
348
+ function getStaticChildText(expr) {
349
+ if (expr.type !== "Literal") return null;
350
+ if (expr.value == null || typeof expr.value === "boolean") return "";
351
+ return encodeEntities(String(expr.value));
352
+ }
353
+ function isIgnoredLiteralChild(expr) {
354
+ return expr.type === "Literal" && (expr.value == null || typeof expr.value === "boolean");
355
+ }
356
+ function jsxElementNameToExpression(name, code, isComponent) {
357
+ if (name.type === "JSXIdentifier") {
358
+ const tagName = String(name.name ?? "");
359
+ return isComponent ? tagName : JSON.stringify(tagName);
360
+ }
361
+ if (name.type === "JSXMemberExpression" || name.type === "JSXNamespacedName") {
362
+ if (name.type === "JSXNamespacedName") return JSON.stringify(code.slice(name.start, name.end));
363
+ return code.slice(name.start, name.end);
364
+ }
365
+ return code.slice(name.start, name.end);
366
+ }
367
+ function isComponentElementName(name) {
368
+ if (name.type === "JSXMemberExpression") return true;
369
+ if (name.type !== "JSXIdentifier") return false;
370
+ return isComponentTagName(String(name.name ?? ""));
371
+ }
372
+ function isComponentTagName(name) {
373
+ const first = name.charCodeAt(0);
374
+ return first >= 65 && first <= 90;
375
+ }
376
+ function getElementIdentifierName(name) {
377
+ return name.type === "JSXIdentifier" ? String(name.name ?? "") : null;
378
+ }
379
+ function getAttributeName(attr, code) {
380
+ const name = attr.name;
381
+ if (!name) return null;
382
+ if (name.type === "JSXIdentifier") return String(name.name ?? "");
383
+ if (name.type === "JSXNamespacedName") return code.slice(name.start, name.end);
384
+ return null;
385
+ }
386
+ function normalizeHtmlAttrName(name) {
387
+ switch (name) {
388
+ case "htmlFor": return "for";
389
+ case "className": return "class";
390
+ case "defaultChecked": return "checked";
391
+ case "defaultSelected": return "selected";
392
+ case "defaultValue": return "value";
393
+ case "acceptCharset": return "accept-charset";
394
+ case "httpEquiv": return "http-equiv";
395
+ default:
396
+ if (NAMESPACE_REPLACE_REGEX.test(name)) return name.replace(NAMESPACE_REPLACE_REGEX, "$1:$2").toLowerCase();
397
+ if (HTML_LOWER_CASE.test(name)) return name.toLowerCase();
398
+ return name;
399
+ }
400
+ }
401
+ function objectPropertyKey(name) {
402
+ return IDENTIFIER_NAME.test(name) ? name : JSON.stringify(name);
403
+ }
404
+ function jsxTextToString(value, escape, trimLastChild) {
405
+ let text = "";
406
+ const lines = value.split(/\r\n|\r|\n/);
407
+ for (const [index, originalLine] of lines.entries()) {
408
+ let line = index === 0 ? originalLine : originalLine.trimStart();
409
+ if (index < lines.length - 1 || trimLastChild) line = line.trimEnd();
410
+ if (line === "") continue;
411
+ if (index > 0 && text !== "") text += " ";
412
+ text += line;
413
+ }
414
+ return escape ? encodeEntities(text) : text;
415
+ }
416
+ function encodeEntities(value) {
417
+ if (value.length === 0 || ENCODED_ENTITIES.test(value) === false) return value;
418
+ let last = 0;
419
+ let out = "";
420
+ for (let index = 0; index < value.length; index++) {
421
+ let replacement = "";
422
+ switch (value.charCodeAt(index)) {
423
+ case 34:
424
+ replacement = "&quot;";
425
+ break;
426
+ case 38:
427
+ replacement = "&amp;";
428
+ break;
429
+ case 60:
430
+ replacement = "&lt;";
431
+ break;
432
+ default: continue;
433
+ }
434
+ if (index !== last) out += value.slice(last, index);
435
+ out += replacement;
436
+ last = index + 1;
437
+ }
438
+ if (last !== value.length) out += value.slice(last);
439
+ return out;
440
+ }
441
+ function isAriaOrEnumerated(name) {
442
+ return name.startsWith("aria-") || HTML_ENUMERATED_ATTRS.has(name);
443
+ }
444
+ function insertPrelude(s, program, prelude) {
445
+ if (prelude.trim() === "") return;
446
+ const insertAt = findPreludeInsertionPoint(s.original, program);
447
+ const needsLeadingNewline = insertAt > 0 && !s.original.slice(0, insertAt).endsWith("\n");
448
+ s.appendLeft(insertAt, `${needsLeadingNewline ? "\n" : ""}${prelude}`);
449
+ }
450
+ function findPreludeInsertionPoint(code, program) {
451
+ const body = getNodeArray(program.body);
452
+ let insertAt = code.startsWith("#!") ? code.indexOf("\n") + 1 : 0;
453
+ for (const statement of body) {
454
+ if (statement.type === "ImportDeclaration") {
455
+ insertAt = Math.max(insertAt, statement.end);
456
+ continue;
457
+ }
458
+ if (statement.type === "ExpressionStatement") {
459
+ const expression = statement.expression;
460
+ if (expression?.type === "Literal" && typeof expression.value === "string") {
461
+ insertAt = Math.max(insertAt, statement.end);
462
+ continue;
463
+ }
464
+ }
465
+ break;
466
+ }
467
+ while (code[insertAt] === "\r" || code[insertAt] === "\n") insertAt++;
468
+ return insertAt;
469
+ }
470
+ function applyReplacementsInRange(code, start, end, replacements) {
471
+ let cursor = start;
472
+ let out = "";
473
+ for (const replacement of replacements) {
474
+ if (replacement.start < cursor || replacement.end > end) continue;
475
+ out += code.slice(cursor, replacement.start);
476
+ out += replacement.code;
477
+ cursor = replacement.end;
478
+ }
479
+ out += code.slice(cursor, end);
480
+ return out;
481
+ }
482
+ function collectIdentifierNames(node) {
483
+ const names = /* @__PURE__ */ new Set();
484
+ function visit(value) {
485
+ if (!isNode(value)) return;
486
+ if ((value.type === "Identifier" || value.type === "JSXIdentifier") && typeof value.name === "string") names.add(value.name);
487
+ for (const [key, child] of Object.entries(value)) {
488
+ if (key === "parent" || key === "comments") continue;
489
+ if (Array.isArray(child)) for (const item of child) visit(item);
490
+ else if (isNode(child)) visit(child);
491
+ }
492
+ }
493
+ visit(node);
494
+ return names;
495
+ }
496
+ function uniqueName(base, takenNames) {
497
+ let name = base;
498
+ let index = 1;
499
+ while (takenNames.has(name)) name = `${base}_${index++}`;
500
+ takenNames.add(name);
501
+ return name;
502
+ }
503
+ function getNodeArray(value) {
504
+ return Array.isArray(value) ? value.filter(isNode) : [];
505
+ }
506
+ function isNode(value) {
507
+ return !!value && typeof value === "object" && typeof value.type === "string";
508
+ }
509
+ function stripQuery(id) {
510
+ return id.split("?", 1)[0];
511
+ }
512
+ function parseProgram(id, code) {
513
+ const parseOptions = getParseOptions(id, code);
514
+ return parseSync(id, code, {
515
+ lang: parseOptions.lang,
516
+ sourceType: parseOptions.sourceType
517
+ }).program;
518
+ }
519
+ function getParseOptions(id, code) {
520
+ const filename = stripQuery(id);
521
+ const isCommonJS = /(^|\W)require\s*\(|(^|\W)module\.exports\b|(^|\W)exports\./.test(code);
522
+ let lang = "js";
523
+ if (/\.[cm]?tsx$/i.test(filename)) lang = "tsx";
524
+ else if (/\.[cm]?ts$/i.test(filename)) lang = looksLikeJSX(code) ? "tsx" : "ts";
525
+ else if (/\.[cm]?jsx$/i.test(filename)) lang = "jsx";
526
+ else if (looksLikeJSX(code)) lang = "jsx";
527
+ return {
528
+ lang,
529
+ sourceType: isCommonJS ? "commonjs" : "module"
530
+ };
531
+ }
532
+ function looksLikeJSX(code) {
533
+ return /<>|<\/[A-Za-z]|<[A-Za-z]/.test(code);
534
+ }
535
+ function createSimpleFilter(include, exclude) {
536
+ const includes = normalizeFilterPattern(include);
537
+ const excludes = normalizeFilterPattern(exclude);
538
+ return (id) => matchesAny(id, includes) && !matchesAny(id, excludes);
539
+ }
540
+ function normalizeFilterPattern(pattern) {
541
+ if (Array.isArray(pattern)) return [...pattern];
542
+ return [pattern];
543
+ }
544
+ function matchesAny(id, patterns) {
545
+ return patterns.some((pattern) => {
546
+ if (typeof pattern === "string") return id.includes(pattern);
547
+ return pattern.test(id);
548
+ });
549
+ }
550
+ //#endregion
551
+ export { preactSsrPrecompile as default, preactSsrPrecompile, transformPreactSsrJsx };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@pracht/preact-ssr-precompile",
3
+ "version": "0.1.0",
4
+ "description": "Experimental Vite/Rolldown plugin that precompiles safe Preact JSX for faster server-side rendering.",
5
+ "keywords": [
6
+ "pracht",
7
+ "preact",
8
+ "ssr",
9
+ "server-rendering",
10
+ "jsx",
11
+ "precompile",
12
+ "vite",
13
+ "rolldown",
14
+ "performance",
15
+ "plugin"
16
+ ],
17
+ "license": "MIT",
18
+ "homepage": "https://github.com/JoviDeCroock/pracht/tree/main/packages/preact-ssr-precompile",
19
+ "bugs": {
20
+ "url": "https://github.com/JoviDeCroock/pracht/issues"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/JoviDeCroock/pracht",
25
+ "directory": "packages/preact-ssr-precompile"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "type": "module",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.mts",
34
+ "default": "./dist/index.mjs"
35
+ }
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "provenance": false
40
+ },
41
+ "peerDependencies": {
42
+ "preact": "^10.26.0",
43
+ "preact-render-to-string": "^6.5.13",
44
+ "vite": "^8.0.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "vite": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "dependencies": {
52
+ "rolldown": "^1.0.0-rc.12",
53
+ "rolldown-string": "^0.3.0"
54
+ },
55
+ "scripts": {
56
+ "build": "tsdown",
57
+ "bench": "pnpm run build && node bench/render-to-string.mjs"
58
+ }
59
+ }