@praxisjs/vite-plugin 4.0.6 → 4.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/decorators.d.ts +15 -0
  3. package/dist/decorators.d.ts.map +1 -1
  4. package/dist/decorators.js +437 -16
  5. package/dist/decorators.js.map +1 -1
  6. package/dist/hmr.d.ts +5 -0
  7. package/dist/hmr.d.ts.map +1 -0
  8. package/dist/hmr.js +106 -0
  9. package/dist/hmr.js.map +1 -0
  10. package/dist/index.d.ts +16 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +11 -18
  13. package/dist/index.js.map +1 -1
  14. package/dist/keyed-lists.d.ts +27 -0
  15. package/dist/keyed-lists.d.ts.map +1 -0
  16. package/dist/keyed-lists.js +248 -0
  17. package/dist/keyed-lists.js.map +1 -0
  18. package/dist/mapped-output.d.ts +32 -0
  19. package/dist/mapped-output.d.ts.map +1 -0
  20. package/dist/mapped-output.js +107 -0
  21. package/dist/mapped-output.js.map +1 -0
  22. package/dist/templates.d.ts +51 -0
  23. package/dist/templates.d.ts.map +1 -0
  24. package/dist/templates.js +707 -0
  25. package/dist/templates.js.map +1 -0
  26. package/package.json +13 -5
  27. package/src/decorators.ts +456 -16
  28. package/src/hmr.ts +108 -0
  29. package/src/index.ts +22 -25
  30. package/src/keyed-lists.ts +282 -0
  31. package/src/mapped-output.ts +116 -0
  32. package/src/templates.ts +729 -0
  33. package/dist/__tests__/css-esbuild-error.test.d.ts +0 -2
  34. package/dist/__tests__/css-esbuild-error.test.d.ts.map +0 -1
  35. package/dist/__tests__/css-esbuild-error.test.js +0 -25
  36. package/dist/__tests__/css-esbuild-error.test.js.map +0 -1
  37. package/dist/__tests__/css-tsconfig-error.test.d.ts +0 -2
  38. package/dist/__tests__/css-tsconfig-error.test.d.ts.map +0 -1
  39. package/dist/__tests__/css-tsconfig-error.test.js +0 -39
  40. package/dist/__tests__/css-tsconfig-error.test.js.map +0 -1
  41. package/dist/__tests__/css-tsconfig-no-config.test.d.ts +0 -2
  42. package/dist/__tests__/css-tsconfig-no-config.test.d.ts.map +0 -1
  43. package/dist/__tests__/css-tsconfig-no-config.test.js +0 -38
  44. package/dist/__tests__/css-tsconfig-no-config.test.js.map +0 -1
  45. package/dist/__tests__/css.test.d.ts +0 -2
  46. package/dist/__tests__/css.test.d.ts.map +0 -1
  47. package/dist/__tests__/css.test.js +0 -288
  48. package/dist/__tests__/css.test.js.map +0 -1
  49. package/dist/__tests__/index.test.d.ts +0 -2
  50. package/dist/__tests__/index.test.d.ts.map +0 -1
  51. package/dist/__tests__/index.test.js +0 -301
  52. package/dist/__tests__/index.test.js.map +0 -1
  53. package/src/__tests__/css-esbuild-error.test.ts +0 -39
  54. package/src/__tests__/css-tsconfig-error.test.ts +0 -53
  55. package/src/__tests__/css-tsconfig-no-config.test.ts +0 -52
  56. package/src/__tests__/css.test.ts +0 -381
  57. package/src/__tests__/index.test.ts +0 -351
  58. package/tsconfig.json +0 -11
@@ -0,0 +1,729 @@
1
+ import ts from "typescript";
2
+
3
+ import { MappedOutput, type SourceMapV3 } from "./mapped-output.js";
4
+
5
+ import type { Plugin } from "vite";
6
+
7
+ // Compiles JSX trees of host elements into cloned templates. The static part of a tree (tags,
8
+ // literal attributes, text, the anchors of reactive children) is built once by the runtime, from
9
+ // markup when the parser provably builds the same tree (see HTML templates below), otherwise with the
10
+ // same DOM calls jsx() makes; each render clones it with one cloneNode(true) and binds only the
11
+ // dynamic parts, reached by walking firstChild/nextSibling before anything is inserted. A lone
12
+ // element is worth a template when it has something static to clone (attributes, text) or a
13
+ // reactive child, which then needs no anchor comment. Trees with spread attributes, an explicit
14
+ // `children` attribute, unknown HTML entities or `await`/`yield` inside an expression are left to
15
+ // jsx().
16
+
17
+ const JSX_FILE = /\.[jt]sx(?:$|\?)/;
18
+ const P = "_$px";
19
+ const HELPERS = [
20
+ "template", "html", "element", "prop", "reactive", "insert", "reactiveOnly", "insertOnly", "scope", "event",
21
+ "onEvent", "className",
22
+ ] as const;
23
+ const EVENT_PROP = /^on[A-Z]/;
24
+
25
+ type JsxEl = ts.JsxElement | ts.JsxSelfClosingElement;
26
+
27
+ interface ElementModel {
28
+ kind: "element";
29
+ node: JsxEl;
30
+ tag: string;
31
+ statics: Array<[name: string, code: string]>;
32
+ props: Array<[name: string, value: ts.Expression]>;
33
+ children: ChildModel[];
34
+ ref?: string;
35
+ }
36
+
37
+ interface TextModel {
38
+ kind: "text";
39
+ value: string;
40
+ }
41
+
42
+ interface SlotModel {
43
+ kind: "slot";
44
+ /** The expression, or the JSX element/fragment that is inserted as is. */
45
+ value: ts.Node;
46
+ /** An arrow or function literal: always reactive, so it gets an anchor in the template. */
47
+ reactive: boolean;
48
+ /** The element's only child: the template holds an empty text node for it instead of an anchor. */
49
+ sole?: boolean;
50
+ ref?: string;
51
+ }
52
+
53
+ type ChildModel = ElementModel | TextModel | SlotModel;
54
+
55
+ interface Root {
56
+ start: number;
57
+ end: number;
58
+ model: ElementModel;
59
+ /** Sits where JSX children go, so the emitted expression needs braces. */
60
+ braces: boolean;
61
+ /**
62
+ * Where the tree's value goes when it is emitted as statements: "arrow" for the whole expression
63
+ * body of an arrow function (its parentheses included in start/end), "return" for the operand of
64
+ * a return statement (the statement included). "expression" otherwise.
65
+ */
66
+ position: "arrow" | "return" | "expression";
67
+ }
68
+
69
+ // ── JSX text and entities ────────────────────────────────────────────────────
70
+
71
+ const ENTITIES: Record<string, string> = {
72
+ amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ", copy: "©", reg: "®",
73
+ trade: "™", hellip: "…", mdash: "—", ndash: "–", laquo: "«", raquo: "»",
74
+ lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", middot: "·", bull: "•",
75
+ times: "×", divide: "÷", deg: "°", euro: "€", larr: "←", rarr: "→",
76
+ uarr: "↑", darr: "↓", check: "✓", hearts: "♥", zwj: "‍", zwnj: "‌",
77
+ shy: "­", ensp: " ", emsp: " ", thinsp: " ",
78
+ };
79
+
80
+ const ENTITY = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g;
81
+
82
+ /** Decodes the entities JSX allows; null when one isn't known here, so the tree stays on jsx(). */
83
+ export function decodeEntities(text: string): string | null {
84
+ if (!text.includes("&")) return text;
85
+ for (const [, body] of text.matchAll(ENTITY)) {
86
+ if (!body.startsWith("#") && !(body in ENTITIES)) return null;
87
+ }
88
+ return text.replace(ENTITY, (_match, body: string) => {
89
+ if (body.startsWith("#x") || body.startsWith("#X")) return String.fromCodePoint(parseInt(body.slice(2), 16));
90
+ if (body.startsWith("#")) return String.fromCodePoint(parseInt(body.slice(1), 10));
91
+ return ENTITIES[body];
92
+ });
93
+ }
94
+
95
+ /**
96
+ * JSX whitespace rules, as the JSX transform applies them (oxc's, which jsx() output goes through):
97
+ * lines are trimmed of spaces and tabs at their inner edges, lines of only whitespace drop, and the
98
+ * remaining lines are joined with one space. Tabs inside a line are kept.
99
+ */
100
+ export function cleanJsxText(raw: string): string {
101
+ const lines = raw.split(/\r\n|\n|\r/);
102
+ let lastNonEmpty = 0;
103
+ for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i])) lastNonEmpty = i;
104
+ let out = "";
105
+ for (let i = 0; i < lines.length; i++) {
106
+ let line = lines[i];
107
+ if (i !== 0) line = line.replace(/^[ \t]+/, "");
108
+ if (i !== lines.length - 1) line = line.replace(/[ \t]+$/, "");
109
+ if (line === "") continue;
110
+ if (i !== lastNonEmpty) line += " ";
111
+ out += line;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ // ── model ────────────────────────────────────────────────────────────────────
117
+
118
+ function openingOf(node: JsxEl): ts.JsxOpeningLikeElement {
119
+ return ts.isJsxElement(node) ? node.openingElement : node;
120
+ }
121
+
122
+ function hostTag(node: JsxEl): string | null {
123
+ const name = openingOf(node).tagName;
124
+ if (!ts.isIdentifier(name)) return null;
125
+ const tag = name.text;
126
+ return /^[a-z]/.test(tag) ? tag : null;
127
+ }
128
+
129
+ function attributeName(attr: ts.JsxAttribute): string {
130
+ const name = attr.name;
131
+ return ts.isIdentifier(name) ? name.text : `${name.namespace.text}:${name.name.text}`;
132
+ }
133
+
134
+ function unwrap(node: ts.Expression): ts.Expression {
135
+ let current = node;
136
+ while (
137
+ ts.isParenthesizedExpression(current) ||
138
+ ts.isAsExpression(current) ||
139
+ ts.isSatisfiesExpression(current) ||
140
+ ts.isTypeAssertionExpression(current) ||
141
+ ts.isNonNullExpression(current)
142
+ ) {
143
+ current = current.expression;
144
+ }
145
+ return current;
146
+ }
147
+
148
+ function literalCode(expr: ts.Expression, source: ts.SourceFile): string | null {
149
+ const node = unwrap(expr);
150
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return JSON.stringify(node.text);
151
+ if (ts.isNumericLiteral(node)) return node.getText(source);
152
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return "true";
153
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return "false";
154
+ if (node.kind === ts.SyntaxKind.NullKeyword) return "null";
155
+ return null;
156
+ }
157
+
158
+ function literalText(expr: ts.Expression): string | null {
159
+ const node = unwrap(expr);
160
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
161
+ if (ts.isNumericLiteral(node)) return String(Number(node.text));
162
+ return null;
163
+ }
164
+
165
+ // An `await` or `yield` would be moved into the arrow that binds the clone, where it isn't valid.
166
+ function suspends(node: ts.Node): boolean {
167
+ let found = false;
168
+ const visit = (child: ts.Node): void => {
169
+ if (found) return;
170
+ if (ts.isAwaitExpression(child) || ts.isYieldExpression(child)) {
171
+ found = true;
172
+ return;
173
+ }
174
+ if (ts.isFunctionLike(child) || ts.isClassLike(child)) return;
175
+ ts.forEachChild(child, visit);
176
+ };
177
+ visit(node);
178
+ return found;
179
+ }
180
+
181
+ // Set when a tree contains something a template cannot express; the tree then stays on jsx().
182
+ let bailed = false;
183
+
184
+ function model(node: JsxEl, source: ts.SourceFile): ElementModel | null {
185
+ const tag = hostTag(node);
186
+ if (tag === null) return null;
187
+ const statics: ElementModel["statics"] = [];
188
+ const props: ElementModel["props"] = [];
189
+ for (const attr of openingOf(node).attributes.properties) {
190
+ if (!ts.isJsxAttribute(attr)) return null;
191
+ const name = attributeName(attr);
192
+ if (name === "children") return null;
193
+ if (name === "key") continue;
194
+ const init = attr.initializer;
195
+ if (init === undefined) {
196
+ statics.push([name, "true"]);
197
+ } else if (ts.isStringLiteral(init)) {
198
+ const raw = init.getText(source).slice(1, -1);
199
+ const value = decodeEntities(raw);
200
+ if (value === null) bailed = true;
201
+ statics.push([name, JSON.stringify(value)]);
202
+ } else if (ts.isJsxExpression(init)) {
203
+ if (init.expression === undefined) continue;
204
+ if (suspends(init.expression)) bailed = true;
205
+ const code = literalCode(init.expression, source);
206
+ if (code !== null) statics.push([name, code]);
207
+ else props.push([name, init.expression]);
208
+ } else {
209
+ // A JSX element as an attribute value.
210
+ props.push([name, init as unknown as ts.Expression]);
211
+ }
212
+ }
213
+
214
+ const children: ChildModel[] = [];
215
+ if (ts.isJsxElement(node)) {
216
+ for (const child of node.children) {
217
+ if (ts.isJsxText(child)) {
218
+ // The node's full range: getText() starts after leading whitespace, which JSX keeps when the
219
+ // line holds anything else (`a <b /> c` keeps the space before `c`).
220
+ const cleaned = cleanJsxText(source.text.slice(child.pos, child.end));
221
+ if (cleaned === "") continue;
222
+ const value = decodeEntities(cleaned);
223
+ if (value === null) bailed = true;
224
+ else children.push({ kind: "text", value });
225
+ } else if (ts.isJsxExpression(child)) {
226
+ if (child.expression === undefined) continue;
227
+ if (suspends(child.expression)) bailed = true;
228
+ const text = child.dotDotDotToken === undefined ? literalText(child.expression) : null;
229
+ if (text !== null) {
230
+ children.push({ kind: "text", value: text });
231
+ continue;
232
+ }
233
+ const expr = unwrap(child.expression);
234
+ const reactive = child.dotDotDotToken === undefined && (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr));
235
+ children.push({ kind: "slot", value: child.expression, reactive });
236
+ } else if (ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child)) {
237
+ const nested = model(child, source);
238
+ children.push(nested ?? { kind: "slot", value: child, reactive: false });
239
+ } else {
240
+ children.push({ kind: "slot", value: child, reactive: false });
241
+ }
242
+ }
243
+ }
244
+ if (children.length === 1 && children[0].kind === "slot") children[0].sole = true;
245
+ return { kind: "element", node, tag, statics, props, children };
246
+ }
247
+
248
+ function takeBail(): boolean {
249
+ const value = bailed;
250
+ bailed = false;
251
+ return value;
252
+ }
253
+
254
+ function worthTemplate(m: ElementModel): boolean {
255
+ if (m.statics.length > 0) return true;
256
+ return m.children.some((c) => c.kind !== "slot" || c.reactive);
257
+ }
258
+
259
+ function tryModel(node: JsxEl, source: ts.SourceFile): ElementModel | null {
260
+ takeBail();
261
+ const m = model(node, source);
262
+ if (takeBail()) return null;
263
+ return m !== null && worthTemplate(m) ? m : null;
264
+ }
265
+
266
+ function dynamicNodes(m: ElementModel, out: ts.Node[]): void {
267
+ for (const child of m.children) {
268
+ if (child.kind === "element") dynamicNodes(child, out);
269
+ else if (child.kind === "slot") out.push(child.value);
270
+ }
271
+ for (const [, value] of m.props) out.push(value);
272
+ }
273
+
274
+ function collectRoots(source: ts.SourceFile): Root[] {
275
+ const roots: Root[] = [];
276
+ // `moved`: the node is a dynamic part of a template, re-emitted as an argument of a binding call,
277
+ // so a tree there is an expression even when it sat in a JSX attribute.
278
+ const visit = (node: ts.Node, moved: boolean): void => {
279
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
280
+ const m = tryModel(node, source);
281
+ if (m !== null) {
282
+ const parent = node.parent;
283
+ let outer: ts.Node = node;
284
+ while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent;
285
+ const host = outer.parent;
286
+ const position = ts.isArrowFunction(host) && host.body === outer
287
+ ? "arrow"
288
+ : ts.isReturnStatement(host) && host.expression === outer ? "return" : "expression";
289
+ const span = position === "arrow" ? outer : position === "return" ? host : node;
290
+ roots.push({
291
+ start: span.getStart(source),
292
+ end: span.getEnd(),
293
+ model: m,
294
+ braces: !moved && (ts.isJsxElement(parent) || ts.isJsxFragment(parent) || ts.isJsxAttribute(parent)),
295
+ position,
296
+ });
297
+ const nested: ts.Node[] = [];
298
+ dynamicNodes(m, nested);
299
+ for (const n of nested) visit(n, true);
300
+ return;
301
+ }
302
+ }
303
+ ts.forEachChild(node, (child) => { visit(child, false); });
304
+ };
305
+ visit(source, false);
306
+ return roots.sort((a, b) => a.start - b.start);
307
+ }
308
+
309
+ // ── emit ─────────────────────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * The template's static nodes: elements, text and the anchors of reactive slots. A reactive slot
313
+ * that is the only child needs no anchor: it fills its parent.
314
+ */
315
+ function templateChildren(m: ElementModel): ChildModel[] {
316
+ return m.children.filter((c) => c.kind !== "slot" || (c.reactive && c.sole !== true));
317
+ }
318
+
319
+ function builderCode(m: ElementModel): string {
320
+ const props = m.statics.length === 0
321
+ ? "null"
322
+ : `{ ${m.statics.map(([name, code]) => `${JSON.stringify(name)}: ${code}`).join(", ")} }`;
323
+ const children = templateChildren(m).map((c) => {
324
+ if (c.kind === "element") return builderCode(c);
325
+ if (c.kind === "text") return JSON.stringify(c.value);
326
+ return "0";
327
+ });
328
+ return `${P}element(${JSON.stringify(m.tag)}, ${props}, ${children.length === 0 ? "null" : `[${children.join(", ")}]`})`;
329
+ }
330
+
331
+ // ── HTML templates ───────────────────────────────────────────────────────────
332
+ //
333
+ // A template whose static part the HTML parser reads back as exactly the tree element() would build
334
+ // is emitted as markup instead: shorter in the bundle, and one parse in place of a DOM call per node.
335
+ // That holds only within rules the parser cannot bend, so every tree outside them keeps the builder:
336
+ // known HTML elements only (no custom element, SVG, form control state, raw text or select), no
337
+ // element the parser would close or move (nested p content, lists, tables, headings, links,
338
+ // buttons, forms), no text it would merge, drop or foster-parent, and attributes whose property, when
339
+ // element() sets it, writes the same attribute the markup holds.
340
+
341
+ // Elements the parser treats specially for p/li/dd/dt closing (the "special" category).
342
+ const SPECIAL = new Set([
343
+ "address", "article", "aside", "blockquote", "br", "button", "caption", "col", "colgroup", "dd", "details",
344
+ "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4",
345
+ "h5", "h6", "header", "hgroup", "hr", "img", "input", "li", "main", "menu", "nav", "ol", "p", "pre",
346
+ "search", "section", "source", "summary", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul", "wbr",
347
+ ]);
348
+
349
+ const PHRASING = new Set([
350
+ "a", "abbr", "b", "bdi", "bdo", "br", "button", "cite", "code", "data", "dfn", "em", "i", "img", "input",
351
+ "kbd", "label", "mark", "q", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr",
352
+ "meter", "progress", "output", "del", "ins",
353
+ ]);
354
+
355
+ const HTML_TAGS = new Set([
356
+ ...SPECIAL, ...PHRASING, "dialog", "legend", "picture", "canvas",
357
+ ]);
358
+
359
+ const VOID = new Set(["br", "col", "hr", "img", "input", "source", "wbr"]);
360
+
361
+ // Children each table part accepts; text inside them would be moved out of the table.
362
+ const TABLE_CHILDREN: Record<string, ReadonlySet<string>> = {
363
+ table: new Set(["caption", "colgroup", "thead", "tbody", "tfoot"]),
364
+ thead: new Set(["tr"]),
365
+ tbody: new Set(["tr"]),
366
+ tfoot: new Set(["tr"]),
367
+ tr: new Set(["td", "th"]),
368
+ colgroup: new Set(["col"]),
369
+ };
370
+ const TABLE_PARTS = new Set(["caption", "colgroup", "col", "thead", "tbody", "tfoot", "tr", "td", "th"]);
371
+
372
+ // Attributes whose property (when the element has one) reflects exactly the string it is set to,
373
+ // and attributes no element has a property for. Names with a dash have no property either.
374
+ const STRING_ATTRS = new Set([
375
+ "id", "class", "title", "lang", "dir", "role", "href", "src", "alt", "rel", "target", "name", "type",
376
+ "placeholder", "for", "tabindex", "colspan", "rowspan", "slot", "style", "autocomplete", "action",
377
+ "method", "min", "max", "step", "pattern", "accept", "label", "scope", "loading", "decoding", "download",
378
+ ]);
379
+ // Boolean attributes whose property, set to true, adds the empty attribute.
380
+ const BOOLEAN_ATTRS = new Set([
381
+ "disabled", "hidden", "required", "multiple", "autofocus", "readonly", "novalidate", "formnovalidate",
382
+ "reversed", "inert",
383
+ ]);
384
+
385
+ function escapeText(text: string): string {
386
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/\r/g, "&#13;");
387
+ }
388
+
389
+ function attributeCode(name: string, value: string): string {
390
+ if (value !== "" && /^[^\s"'=<>`&]+$/.test(value)) return `${name}=${value}`;
391
+ return `${name}="${value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/\r/g, "&#13;")}"`;
392
+ }
393
+
394
+ function markupName(key: string): string {
395
+ return key === "className" ? "class" : key === "htmlFor" ? "for" : key;
396
+ }
397
+
398
+ // The markup of one static attribute, "" when it is left out, or null when it is not safe.
399
+ function attributeHtml(key: string, code: string): string | null {
400
+ const name = markupName(key);
401
+ const dashed = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(name);
402
+ if (!dashed && !STRING_ATTRS.has(name) && !BOOLEAN_ATTRS.has(name)) return null;
403
+ if (code === "true") return dashed || BOOLEAN_ATTRS.has(name) ? name : null;
404
+ if (code === "false" || code === "null") return dashed || BOOLEAN_ATTRS.has(name) ? "" : null;
405
+ if (BOOLEAN_ATTRS.has(name)) return null;
406
+ const text = code.startsWith("\"") ? JSON.parse(code) as string : String(Number(code.replace(/_/g, "")));
407
+ return unsafeText(text) ? null : attributeCode(name, text);
408
+ }
409
+
410
+ // The parser drops \0, and lone surrogates are not kept the same way by every DOM.
411
+ function unsafeText(text: string): boolean {
412
+ return /[\0\uD800-\uDFFF]/.test(text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, ""));
413
+ }
414
+
415
+ // An li (or dd/dt) start tag closes an open one unless a special element other than address, div
416
+ // or p sits between them.
417
+ function closesOpenItem(ancestors: string[], items: string[]): boolean {
418
+ for (let i = ancestors.length - 1; i >= 0; i--) {
419
+ const tag = ancestors[i];
420
+ if (items.includes(tag)) return true;
421
+ if (SPECIAL.has(tag) && tag !== "address" && tag !== "div" && tag !== "p") return false;
422
+ }
423
+ return false;
424
+ }
425
+
426
+ /** `ancestors`: the tags around `m` in the template, outermost first. */
427
+ function elementHtml(m: ElementModel, ancestors: string[]): string | null {
428
+ const { tag } = m;
429
+ const parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null;
430
+ if (!HTML_TAGS.has(tag)) return null;
431
+ // Everything inside a p must be phrasing content, or the parser closes the p early.
432
+ if (ancestors.includes("p") && !PHRASING.has(tag)) return null;
433
+ if ((tag === "a" || tag === "button" || tag === "form") && ancestors.includes(tag)) return null;
434
+ if (/^h[1-6]$/.test(tag) && parent !== null && /^h[1-6]$/.test(parent)) return null;
435
+ if (tag === "li" && closesOpenItem(ancestors, ["li"])) return null;
436
+ if ((tag === "dd" || tag === "dt") && closesOpenItem(ancestors, ["dd", "dt"])) return null;
437
+ if (TABLE_PARTS.has(tag)) {
438
+ // A table part goes where the table model puts it: at the root of the template, or under its parent.
439
+ const allowed = parent === null ? true : TABLE_CHILDREN[parent] as ReadonlySet<string> | undefined;
440
+ if (allowed !== true && allowed?.has(tag) !== true) return null;
441
+ } else if (parent !== null && parent in TABLE_CHILDREN) {
442
+ return null;
443
+ }
444
+
445
+ let open = `<${tag}`;
446
+ const seen = new Set<string>();
447
+ for (const [key, code] of m.statics) {
448
+ const name = markupName(key);
449
+ // element() lets a later value win, the parser keeps the first one.
450
+ if (seen.has(name)) return null;
451
+ seen.add(name);
452
+ const attr = attributeHtml(key, code);
453
+ if (attr === null) return null;
454
+ if (attr !== "") open += ` ${attr}`;
455
+ }
456
+ open += ">";
457
+
458
+ const children = templateChildren(m);
459
+ if (VOID.has(tag)) return children.length === 0 ? open : null;
460
+ if (tag === "pre" && children[0]?.kind === "text" && /^[\n\r]/.test(children[0].value)) return null;
461
+ let inner = "";
462
+ const nested = [...ancestors, tag];
463
+ let previousText = false;
464
+ for (const child of children) {
465
+ if (child.kind === "element") {
466
+ const html = elementHtml(child, nested);
467
+ if (html === null) return null;
468
+ inner += html;
469
+ previousText = false;
470
+ } else if (child.kind === "text") {
471
+ // Adjacent texts would be one node, an empty one none, and text in a table part moves out.
472
+ if (previousText || child.value === "" || tag in TABLE_CHILDREN || unsafeText(child.value)) return null;
473
+ inner += escapeText(child.value);
474
+ previousText = true;
475
+ } else {
476
+ inner += "<!---->";
477
+ previousText = false;
478
+ }
479
+ }
480
+ return `${open}${inner}</${tag}>`;
481
+ }
482
+
483
+ /** The markup of a template's static part, or null when only element() builds it faithfully. */
484
+ export function templateHtml(m: ElementModel): string | null {
485
+ const html = elementHtml(m, []);
486
+ // The parser closes every element still open at the end of the input.
487
+ return html === null ? null : html.replace(/(?:<\/[a-z0-9]+>)+$/, "");
488
+ }
489
+
490
+ function isFunctionLiteral(node: ts.Expression): boolean {
491
+ const expr = unwrap(node);
492
+ return ts.isArrowFunction(expr) || ts.isFunctionExpression(expr);
493
+ }
494
+
495
+ class Emitter {
496
+ private readonly templates: string[] = [];
497
+ /** One hoisted binding target per event prop name used in the module. */
498
+ private readonly events = new Map<string, string>();
499
+ private counter = 0;
500
+
501
+ constructor(
502
+ private readonly out: MappedOutput,
503
+ private readonly roots: Root[],
504
+ private readonly source: ts.SourceFile,
505
+ ) {}
506
+
507
+ hoisted(): string {
508
+ let events = "";
509
+ for (const [name, id] of this.events) events += `const ${id} = /* @__PURE__ */ ${P}event(${JSON.stringify(name)});\n`;
510
+ return events + this.templates.join("");
511
+ }
512
+
513
+ private eventTarget(name: string): string {
514
+ let id = this.events.get(name);
515
+ if (id === undefined) {
516
+ id = `${P}e${String(this.events.size)}`;
517
+ this.events.set(name, id);
518
+ }
519
+ return id;
520
+ }
521
+
522
+ range(from: number, to: number): void {
523
+ let cursor = from;
524
+ for (const root of this.roots) {
525
+ if (root.start < cursor || root.end > to) continue;
526
+ this.out.copy(cursor, root.start);
527
+ this.root(root);
528
+ cursor = root.end;
529
+ }
530
+ this.out.copy(cursor, to);
531
+ }
532
+
533
+ private node(node: ts.Node): void {
534
+ this.range(node.getStart(this.source), node.getEnd());
535
+ }
536
+
537
+ private root(root: Root): void {
538
+ const { model: m } = root;
539
+ const id = `${P}T${String(this.templates.length)}`;
540
+ const html = templateHtml(m);
541
+ this.templates.push(html === null
542
+ ? `const ${id} = /* @__PURE__ */ ${P}template(() => ${builderCode(m)});\n`
543
+ : `const ${id} = /* @__PURE__ */ ${P}html(${JSON.stringify(html)});\n`);
544
+ this.counter = 0;
545
+ const refs: string[] = [];
546
+ const rootVar = `${P}r`;
547
+ m.ref = rootVar;
548
+ const needed = this.assignRefs(m, refs);
549
+ const at = root.start;
550
+ const open = root.braces ? "{" : "";
551
+ const close = root.braces ? "}" : "";
552
+ if (!needed) {
553
+ this.out.insert(root.position === "return" ? `return ${id}();` : `${open}${id}()${close}`, at);
554
+ return;
555
+ }
556
+ // An arrow function's body or a returned tree becomes statements in a block: no closure to
557
+ // create and call on every run, which a row renderer or a component's render() would pay.
558
+ if (root.position !== "expression") {
559
+ this.out.insert(`{ const ${P}s = ${P}scope(); const ${rootVar} = ${id}(); ${refs.join(" ")} `, at);
560
+ this.ops(m);
561
+ this.out.insert(`return ${rootVar}; }`, root.end);
562
+ return;
563
+ }
564
+ this.out.insert(`${open}((${P}s) => { const ${rootVar} = ${id}(); ${refs.join(" ")} `, at);
565
+ this.ops(m);
566
+ this.out.insert(`return ${rootVar}; })(${P}scope())${close}`, root.end);
567
+ }
568
+
569
+ /** Names the nodes the bindings need and emits the walk to them; true if anything is dynamic. */
570
+ private assignRefs(m: ElementModel, refs: string[]): boolean {
571
+ const nodes = templateChildren(m);
572
+ const wanted = new Set<ChildModel>();
573
+ let dynamic = m.props.length > 0;
574
+ // Non-reactive slots insert before the next static node, or append when none follows.
575
+ for (let i = 0; i < m.children.length; i++) {
576
+ const child = m.children[i];
577
+ if (child.kind !== "slot") continue;
578
+ dynamic = true;
579
+ if (child.sole === true) continue;
580
+ if (child.reactive) {
581
+ wanted.add(child);
582
+ continue;
583
+ }
584
+ for (let j = i + 1; j < m.children.length; j++) {
585
+ const next = m.children[j];
586
+ if (next.kind !== "slot" || next.reactive) {
587
+ wanted.add(next);
588
+ break;
589
+ }
590
+ }
591
+ }
592
+ for (const child of nodes) {
593
+ if (child.kind === "element" && this.subtreeDynamic(child)) wanted.add(child);
594
+ }
595
+ if (!dynamic && wanted.size === 0) return false;
596
+
597
+ let base = `${m.ref as string}.firstChild`;
598
+ let baseIndex = 0;
599
+ for (let i = 0; i < nodes.length; i++) {
600
+ const child = nodes[i];
601
+ if (!wanted.has(child)) continue;
602
+ const name = `${P}n${String(this.counter++)}`;
603
+ refs.push(`const ${name} = ${base}${".nextSibling".repeat(i - baseIndex)};`);
604
+ (child as ElementModel | SlotModel).ref = name;
605
+ base = `${name}.nextSibling`;
606
+ baseIndex = i + 1;
607
+ if (child.kind === "text") (child as TextModel & { ref?: string }).ref = name;
608
+ }
609
+ for (const child of nodes) {
610
+ if (child.kind === "element" && wanted.has(child)) this.assignRefs(child, refs);
611
+ }
612
+ return true;
613
+ }
614
+
615
+ private subtreeDynamic(m: ElementModel): boolean {
616
+ if (m.props.length > 0) return true;
617
+ for (const child of m.children) {
618
+ if (child.kind === "slot") return true;
619
+ if (child.kind === "element" && this.subtreeDynamic(child)) return true;
620
+ }
621
+ return false;
622
+ }
623
+
624
+ // Children before their parent's props, so a parent binding (a <select> value, a ref) sees its
625
+ // children in place.
626
+ private ops(m: ElementModel): void {
627
+ for (let i = 0; i < m.children.length; i++) {
628
+ const child = m.children[i];
629
+ if (child.kind === "element") {
630
+ if (this.subtreeDynamic(child)) this.ops(child);
631
+ continue;
632
+ }
633
+ if (child.kind === "text") continue;
634
+ const start = child.value.getStart(this.source);
635
+ if (child.sole === true) {
636
+ this.out.insert(`${P}${child.reactive ? "reactiveOnly" : "insertOnly"}(${m.ref as string}, `, start);
637
+ this.node(child.value);
638
+ this.out.insert(`, ${P}s); `, child.value.getEnd());
639
+ continue;
640
+ }
641
+ if (child.reactive) {
642
+ this.out.insert(`${P}reactive(${child.ref as string}, `, start);
643
+ this.node(child.value);
644
+ this.out.insert(`, ${P}s); `, child.value.getEnd());
645
+ continue;
646
+ }
647
+ let before = "null";
648
+ for (let j = i + 1; j < m.children.length; j++) {
649
+ const next = m.children[j];
650
+ if (next.kind !== "slot" || next.reactive) {
651
+ before = (next as { ref?: string }).ref as string;
652
+ break;
653
+ }
654
+ }
655
+ this.out.insert(`${P}insert(${m.ref as string}, `, start);
656
+ this.node(child.value);
657
+ this.out.insert(`, ${before}, ${P}s); `, child.value.getEnd());
658
+ }
659
+ // className() records under "class" for hydration: with both keys reactive on one element, one
660
+ // record would overwrite the other, so that pair stays on prop().
661
+ const classKeys = m.props.filter(([name, value]) => (name === "class" || name === "className") && isFunctionLiteral(value));
662
+ for (const [name, value] of m.props) {
663
+ const start = value.getStart(this.source);
664
+ const ref = m.ref as string;
665
+ if (EVENT_PROP.test(name)) {
666
+ // Event props bind through the runtime's delegated path: no listener per element at render.
667
+ this.out.insert(`${P}onEvent(${ref}, ${this.eventTarget(name)}, `, start);
668
+ } else if (classKeys.length === 1 && classKeys[0][0] === name) {
669
+ this.out.insert(`${P}className(${ref}, `, start);
670
+ } else {
671
+ this.out.insert(`${P}prop(${ref}, ${JSON.stringify(name)}, `, start);
672
+ }
673
+ this.node(value);
674
+ this.out.insert(`, ${P}s); `, value.getEnd());
675
+ }
676
+ }
677
+ }
678
+
679
+ export interface TemplateResult {
680
+ code: string;
681
+ map: SourceMapV3;
682
+ }
683
+
684
+ /** Compiles every eligible host-element JSX tree in a file; null when there is none. */
685
+ export function transformTemplates(code: string, fileName: string): TemplateResult | null {
686
+ if (!code.includes("<")) return null;
687
+ const kind = fileName.endsWith(".jsx") ? ts.ScriptKind.JSX : ts.ScriptKind.TSX;
688
+ const source = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, kind);
689
+ const roots = collectRoots(source);
690
+ if (roots.length === 0) return null;
691
+
692
+ const body = new MappedOutput(code);
693
+ const emitter = new Emitter(body, roots, source);
694
+ emitter.range(0, code.length);
695
+
696
+ const imports = `import { ${HELPERS.map((h) => `_$${h} as ${P}${h}`).join(", ")} } from "@praxisjs/runtime";\n`;
697
+ const out = new MappedOutput(code);
698
+ out.insert(imports + emitter.hoisted(), 0);
699
+ const result = body.result(fileName);
700
+ return prepend(out.result(fileName), result);
701
+ }
702
+
703
+ // The hoisted header is generated text with no source: it shifts every mapped line down by its
704
+ // own line count, which in VLQ terms is a run of empty line entries in front of the body's.
705
+ function prepend(header: TemplateResult, body: TemplateResult): TemplateResult {
706
+ const headerLines = header.code.split("\n").length - 1;
707
+ return {
708
+ code: header.code + body.code,
709
+ map: { ...body.map, mappings: ";".repeat(headerLines) + body.map.mappings },
710
+ };
711
+ }
712
+
713
+ export interface TemplatesPluginOptions {
714
+ /** Restricts the transform to matching module ids. Default: every .jsx/.tsx outside node_modules. */
715
+ include?: RegExp;
716
+ }
717
+
718
+ export function templatesPlugin(options: TemplatesPluginOptions = {}): Plugin {
719
+ const include = options.include ?? JSX_FILE;
720
+ return {
721
+ name: "praxisjs:templates",
722
+ enforce: "pre",
723
+ transform(code, id) {
724
+ if (!include.test(id) || id.includes("/node_modules/")) return null;
725
+ const result = transformTemplates(code, id.split("?")[0]);
726
+ return result === null ? null : { code: result.code, map: result.map };
727
+ },
728
+ };
729
+ }