@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 +21 -0
- package/README.md +141 -0
- package/dist/index.d.mts +32 -0
- package/dist/index.mjs +551 -0
- package/package.json +59 -0
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`.
|
package/dist/index.d.mts
ADDED
|
@@ -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 = """;
|
|
425
|
+
break;
|
|
426
|
+
case 38:
|
|
427
|
+
replacement = "&";
|
|
428
|
+
break;
|
|
429
|
+
case 60:
|
|
430
|
+
replacement = "<";
|
|
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
|
+
}
|