@sigil-dev/compiler 0.9.10 → 0.9.12

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.
@@ -1,38 +1,7 @@
1
1
  import { types as t } from "@babel/core";
2
2
  import { isGlobalComponent } from "../handlers/dom/globals";
3
3
  import { getTagName } from "../util/helpers";
4
-
5
- /**
6
- * Attributes that are on by presence alone: checked="" and even
7
- * checked="false" both mean checked, so these can't go through the generic
8
- * name="value" path.
9
- */
10
- const BOOLEAN_ATTRS = new Set([
11
- "allowfullscreen",
12
- "async",
13
- "autofocus",
14
- "autoplay",
15
- "checked",
16
- "controls",
17
- "default",
18
- "defer",
19
- "disabled",
20
- "formnovalidate",
21
- "hidden",
22
- "inert",
23
- "ismap",
24
- "loop",
25
- "multiple",
26
- "muted",
27
- "nomodule",
28
- "novalidate",
29
- "open",
30
- "playsinline",
31
- "readonly",
32
- "required",
33
- "reversed",
34
- "selected",
35
- ]);
4
+ import { jsxText } from "./utils";
36
5
 
37
6
  const VOID_ELEMENTS = new Set([
38
7
  "area",
@@ -60,94 +29,89 @@ function escape(expr: t.Expression): t.CallExpression {
60
29
  return t.callExpression(t.identifier("__e"), [expr]);
61
30
  }
62
31
 
32
+ /** Compile-time HTML escaping for static JSX text and attribute strings. */
33
+ function escapeText(s: string): string {
34
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
35
+ }
36
+ function escapeAttr(s: string): string {
37
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
38
+ }
39
+
63
40
  // builds a template literal from alternating string parts and expressions
64
41
  function buildTemplate(parts: Array<string | t.Expression>): t.TemplateLiteral {
65
42
  const quasis: t.TemplateElement[] = [];
66
43
  const exprs: t.Expression[] = [];
67
44
  let current = "";
45
+ // raw must be valid template source: a backslash, backtick or ${ in
46
+ // the page text would otherwise end or corrupt the literal
47
+ const element = (cooked: string, tail = false) =>
48
+ t.templateElement(
49
+ { raw: cooked.replace(/\\|`|\$\{/g, (m) => `\\${m}`), cooked },
50
+ tail,
51
+ );
68
52
 
69
53
  for (const part of parts) {
70
54
  if (typeof part === "string") {
71
55
  current += part;
72
56
  } else {
73
- quasis.push(t.templateElement({ raw: current, cooked: current }));
57
+ quasis.push(element(current));
74
58
  current = "";
75
59
  exprs.push(part);
76
60
  }
77
61
  }
78
- quasis.push(t.templateElement({ raw: current, cooked: current }, true));
62
+ quasis.push(element(current, true));
79
63
  return t.templateLiteral(quasis, exprs);
80
64
  }
81
65
 
82
66
  /**
83
- * <Component /> is always JSX so its always safe to render as is
84
- * but the compiler can also register its own safe variables, like:
85
- * ```ts
86
- * const rendered = <Component foo="bar" />
87
- * return <div>{rendered}</div>
88
- * ```
89
- * @param expr
90
- * @param safeIdents
91
- * @returns
67
+ * Children of an element, component or fragment as HTML parts. `raw` is for
68
+ * <script>: its text is code, so it is neither escaped nor marked for
69
+ * hydration (see processElementSSR).
92
70
  */
93
- /*
94
- function isSSRSafe(expr: t.Expression, safeIdents: Set<string>): boolean {
95
- // direct JSX - compiled to HTML string by this very plugin
96
- if (t.isJSXElement(expr) || t.isJSXFragment(expr)) return true;
97
-
98
- // component call - returns HTML string by convention
99
- if (
100
- t.isCallExpression(expr) &&
101
- t.isIdentifier(expr.callee) &&
102
- /^[A-Z]/.test(expr.callee.name)
103
- ) return true;
104
-
105
- // registered by compiler (e.g. children)
106
- if (t.isIdentifier(expr)) {
107
- // if sigil as a compiler doesnt have an insane CVE
108
- // this will always be true
109
- if (expr.name === "children") return true;
110
- return safeIdents.has(expr.name);
111
- }
112
-
113
- // condition && <JSX> — right side produces HTML
114
- if (t.isLogicalExpression(expr))
115
- return isSSRSafe(expr.right as t.Expression, safeIdents);
116
-
117
- // condition ? <JSX> : <JSX> — both branches must be checked
118
- if (t.isConditionalExpression(expr)) {
119
- return (
120
- isSSRSafe(expr.consequent as t.Expression, safeIdents) ||
121
- isSSRSafe(expr.alternate as t.Expression, safeIdents)
122
- );
123
- }
124
- if (
125
- t.isCallExpression(expr) &&
126
- t.isMemberExpression(expr.callee) &&
127
- t.isIdentifier(expr.callee.property) &&
128
- expr.callee.property.name === "map"
129
- ) {
130
- const cb = expr.arguments[0];
131
- if (t.isArrowFunctionExpression(cb) || t.isFunctionExpression(cb)) {
132
- const body = cb.body;
133
- if (t.isJSXElement(body) || t.isJSXFragment(body)) return true;
134
- if (t.isBlockStatement(body)) {
135
- for (const stmt of body.body) {
136
- if (
137
- t.isReturnStatement(stmt) &&
138
- stmt.argument &&
139
- (t.isJSXElement(stmt.argument) || t.isJSXFragment(stmt.argument))
140
- ) return true;
141
- }
142
- }
143
- }
144
- }
145
- return false;
71
+ function childrenSSR(
72
+ children: t.JSXElement["children"],
73
+ signals: Set<string>,
74
+ hash: string | undefined,
75
+ raw?: string,
76
+ ): Array<string | t.Expression> {
77
+ const parts: Array<string | t.Expression> = [];
78
+ for (const child of children) {
79
+ if (t.isJSXText(child)) {
80
+ const text = jsxText(child);
81
+ if (text !== null) parts.push(raw ? text : escapeText(text));
82
+ continue;
83
+ }
84
+ let node: t.Node = child;
85
+ if (t.isJSXExpressionContainer(child)) {
86
+ if (t.isJSXEmptyExpression(child.expression)) continue;
87
+ node = child.expression;
88
+ }
89
+ if (t.isJSXElement(node)) {
90
+ parts.push(processElementSSR(node, signals, hash));
91
+ } else if (t.isJSXFragment(node)) {
92
+ parts.push(processFragmentSSR(node, signals, hash));
93
+ } else if (raw) {
94
+ parts.push(
95
+ t.callExpression(t.identifier("__r"), [
96
+ node as t.Expression,
97
+ t.stringLiteral(raw),
98
+ ]),
99
+ );
100
+ } else if (!t.isJSXSpreadChild(node)) {
101
+ // Delimit dynamic text segments so SSR produces separate text
102
+ // nodes that the hydrate compiler can claim individually.
103
+ parts.push("<!--g-->");
104
+ parts.push(escape(node as t.Expression));
105
+ parts.push("<!--/g-->");
106
+ }
107
+ }
108
+ return parts;
146
109
  }
147
- */
110
+
148
111
  export function processElementSSR(
149
112
  node: t.JSXElement,
150
113
  signals: Set<string>,
114
+ hash?: string,
151
115
  ): t.TemplateLiteral {
152
116
  const tagName = getTagName(node.openingElement.name);
153
117
  const isComponent = /^[A-Z]/.test(tagName);
@@ -160,85 +124,73 @@ export function processElementSSR(
160
124
  [],
161
125
  );
162
126
  }
163
- return processComponentSSR(node, tagName, signals);
127
+ return processComponentSSR(node, tagName, signals, hash);
164
128
  }
165
129
  let dangerousHTML: t.Expression | null = null;
130
+ // <textarea value> is its content; <select value> picks an <option>
131
+ let textareaValue: t.Expression | null = null;
132
+ let selectValue: t.Expression | null = null;
133
+ let hasClass = false;
166
134
  const parts: Array<string | t.Expression> = [];
167
135
  parts.push(`<${tagName}`);
168
136
 
169
- // attributes
170
137
  for (const attr of node.openingElement.attributes) {
171
- if (!t.isJSXAttribute(attr)) continue;
138
+ if (t.isJSXSpreadAttribute(attr)) {
139
+ parts.push(t.callExpression(t.identifier("__s"), [attr.argument]));
140
+ continue;
141
+ }
172
142
 
173
143
  // namespaced attributes are client-only directives
174
144
  // (transition:/in:/out:/bind:) — SSR drops them entirely
175
145
  if (t.isJSXNamespacedName(attr.name)) continue;
176
146
 
177
- const attrName = (attr.name as t.JSXIdentifier).name;
147
+ const attrName = attr.name.name;
148
+ // drop event handlers and client-only props
149
+ if (/^on[A-Z]/.test(attrName)) continue;
150
+ if (attrName === "use" || /^bind[A-Z]/.test(attrName)) continue;
151
+
152
+ const value: t.Expression | null = t.isStringLiteral(attr.value)
153
+ ? attr.value
154
+ : t.isJSXExpressionContainer(attr.value) &&
155
+ !t.isJSXEmptyExpression(attr.value.expression)
156
+ ? attr.value.expression
157
+ : null;
178
158
 
179
159
  if (attrName === "innerHTML") {
180
- if (t.isJSXExpressionContainer(attr.value)) {
181
- dangerousHTML = attr.value.expression as t.Expression;
182
- }
160
+ dangerousHTML = value;
183
161
  continue; // don't output as attribute
184
162
  }
185
- // drop event handlers
186
- if (attrName.startsWith("on")) continue;
187
- // drop client-only props
188
- if (attrName === "use") continue;
189
- if (attrName.startsWith("bind")) continue;
163
+ if (tagName === "textarea" && attrName === "value") {
164
+ textareaValue = value;
165
+ continue;
166
+ }
167
+ if (tagName === "select" && attrName === "value") {
168
+ selectValue = value;
169
+ continue;
170
+ }
190
171
 
191
172
  const domAttr = ATTR_MAP_SSR[attrName] ?? attrName;
192
-
193
- if (t.isStringLiteral(attr.value)) {
194
- parts.push(` ${domAttr}="${attr.value.value}"`);
195
- } else if (
196
- t.isJSXExpressionContainer(attr.value) &&
197
- BOOLEAN_ATTRS.has(domAttr.toLowerCase())
198
- ) {
199
- // __b: true → bare name, false/null/undefined → omitted, anything
200
- // else keeps its value (hidden="until-found"). The generic path
201
- // below rendered checked={false} as checked="", i.e. checked.
173
+ const isClass = domAttr === "class" && !!hash;
174
+ if (domAttr === "class") hasClass = true;
175
+
176
+ if (attr.value === null) {
177
+ // bare attribute: <input disabled>, <script defer>
178
+ parts.push(` ${domAttr}`);
179
+ } else if (t.isStringLiteral(attr.value)) {
180
+ const v = escapeAttr(attr.value.value);
181
+ parts.push(` ${domAttr}="${isClass ? `${v} ${hash}` : v}"`);
182
+ } else if (value && isClass) {
183
+ parts.push(` class="`, escape(value), ` ${hash}"`);
184
+ } else if (value) {
202
185
  parts.push(
203
- t.callExpression(t.identifier("__b"), [
186
+ t.callExpression(t.identifier("__a"), [
204
187
  t.stringLiteral(domAttr),
205
- attr.value.expression as t.Expression,
188
+ value,
206
189
  ]),
207
190
  );
208
- } else if (t.isJSXExpressionContainer(attr.value)) {
209
- const expr = attr.value.expression as t.Expression;
210
- if (
211
- attrName === "style" &&
212
- t.isObjectExpression(expr) &&
213
- expr.properties.every((prop) => t.isObjectProperty(prop))
214
- ) {
215
- // style={{ key: value, ... }} — serialize to real "key:value;"
216
- // CSS text instead of the generic String(expr) path below, which
217
- // would stringify the object itself to the literal "[object Object]"
218
- parts.push(` style="`);
219
- for (const prop of expr.properties as t.ObjectProperty[]) {
220
- const key = t.isIdentifier(prop.key)
221
- ? prop.key.name
222
- : t.isStringLiteral(prop.key)
223
- ? prop.key.value
224
- : null;
225
- if (key === null) continue;
226
- const cssKey = key.replace(
227
- /[A-Z]/g,
228
- (letter) => `-${letter.toLowerCase()}`,
229
- );
230
- parts.push(`${cssKey}:`);
231
- parts.push(escape(prop.value as t.Expression));
232
- parts.push(";");
233
- }
234
- parts.push(`"`);
235
- } else {
236
- parts.push(` ${domAttr}="`);
237
- parts.push(escape(expr));
238
- parts.push(`"`);
239
- }
240
191
  }
241
192
  }
193
+ if (hash && !hasClass) parts.push(` class="${hash}"`);
242
194
 
243
195
  if (VOID_ELEMENTS.has(tagName)) {
244
196
  parts.push(">");
@@ -249,52 +201,29 @@ export function processElementSSR(
249
201
 
250
202
  if (dangerousHTML) {
251
203
  parts.push(dangerousHTML);
252
- parts.push(`</${tagName}>`);
253
- return buildTemplate(parts);
254
- }
255
-
256
- // <script> holds raw text: HTML escaping would corrupt the code (&& →
257
- // &amp;&amp;), and <!--g--> markers are just text inside it, not comments,
258
- // so there is nothing for hydration to find. __r only guards against the
259
- // code closing its own element. (<style> never gets here: scoped style
260
- // blocks are stripped from the source before compiling.)
261
- const rawText = tagName === "script";
262
-
263
- // children
264
- for (const child of node.children) {
265
- if (rawText && t.isJSXExpressionContainer(child)) {
266
- if (t.isJSXEmptyExpression(child.expression)) continue;
204
+ } else if (textareaValue) {
205
+ parts.push(escape(textareaValue));
206
+ } else {
207
+ // <script> holds raw text: HTML escaping would corrupt the code (&& →
208
+ // &amp;&amp;), and <!--g--> markers are just text inside it, not
209
+ // comments, so there is nothing for hydration to find. __r only guards
210
+ // against the code closing its own element. (<style> never gets here:
211
+ // scoped style blocks are stripped from the source before compiling.)
212
+ const children = childrenSSR(
213
+ node.children,
214
+ signals,
215
+ hash,
216
+ tagName === "script" ? tagName : undefined,
217
+ );
218
+ if (selectValue) {
267
219
  parts.push(
268
- t.callExpression(t.identifier("__r"), [
269
- child.expression as t.Expression,
270
- t.stringLiteral(tagName),
220
+ t.callExpression(t.identifier("__sel"), [
221
+ buildTemplate(children),
222
+ selectValue,
271
223
  ]),
272
224
  );
273
- continue;
274
- }
275
- if (t.isJSXText(child)) {
276
- const text = child.value;
277
- if (!text.trim()) continue;
278
- const normalized = text.replace(/\s*\n\s*/g, " ");
279
- parts.push(normalized);
280
- } else if (t.isJSXElement(child)) {
281
- parts.push(processElementSSR(child, signals));
282
- } else if (t.isJSXFragment(child)) {
283
- parts.push(processFragmentSSR(child, signals));
284
- } else if (t.isJSXExpressionContainer(child)) {
285
- if (t.isJSXEmptyExpression(child.expression)) continue;
286
- const expr = child.expression as t.Expression;
287
- if (t.isJSXElement(expr)) {
288
- parts.push(processElementSSR(expr, signals));
289
- } else if (t.isJSXFragment(expr)) {
290
- parts.push(processFragmentSSR(expr, signals));
291
- } else {
292
- // Delimit dynamic text segments so SSR produces separate text
293
- // nodes that the hydrate compiler can claim individually.
294
- parts.push("<!--g-->");
295
- parts.push(escape(expr));
296
- parts.push("<!--/g-->");
297
- }
225
+ } else {
226
+ parts.push(...children);
298
227
  }
299
228
  }
300
229
 
@@ -306,11 +235,15 @@ function processComponentSSR(
306
235
  node: t.JSXElement,
307
236
  tagName: string,
308
237
  signals: Set<string>,
238
+ hash: string | undefined,
309
239
  ): t.TemplateLiteral {
310
240
  const props = t.objectExpression([]);
311
241
 
312
242
  for (const attr of node.openingElement.attributes) {
313
- if (!t.isJSXAttribute(attr)) continue;
243
+ if (t.isJSXSpreadAttribute(attr)) {
244
+ props.properties.push(t.spreadElement(attr.argument));
245
+ continue;
246
+ }
314
247
  const attrName = (attr.name as t.JSXIdentifier).name;
315
248
  // data-key is a framework-internal attribute for HTML elements only; skip for components
316
249
  if (attrName === "data-key" || attrName === "key") continue;
@@ -318,7 +251,9 @@ function processComponentSSR(
318
251
  const propKey = /[^a-zA-Z_$0-9]/.test(attrName)
319
252
  ? t.stringLiteral(attrName)
320
253
  : t.identifier(attrName);
321
- if (t.isStringLiteral(attr.value)) {
254
+ if (attr.value === null) {
255
+ props.properties.push(t.objectProperty(propKey, t.booleanLiteral(true)));
256
+ } else if (t.isStringLiteral(attr.value)) {
322
257
  props.properties.push(t.objectProperty(propKey, attr.value));
323
258
  } else if (t.isJSXExpressionContainer(attr.value)) {
324
259
  props.properties.push(
@@ -327,33 +262,7 @@ function processComponentSSR(
327
262
  }
328
263
  }
329
264
 
330
- // children as string
331
- const childParts: Array<string | t.Expression> = [];
332
- for (const child of node.children) {
333
- if (t.isJSXText(child)) {
334
- const text = child.value;
335
- if (!text.trim()) continue;
336
- const normalized = text.replace(/\s*\n\s*/g, " ");
337
- childParts.push(normalized);
338
- } else if (t.isJSXElement(child)) {
339
- childParts.push(processElementSSR(child, signals));
340
- } else if (t.isJSXFragment(child)) {
341
- childParts.push(processFragmentSSR(child, signals));
342
- } else if (t.isJSXExpressionContainer(child)) {
343
- if (t.isJSXEmptyExpression(child.expression)) continue;
344
- const expr = child.expression as t.Expression;
345
- if (t.isJSXElement(expr)) {
346
- childParts.push(processElementSSR(expr, signals));
347
- } else if (t.isJSXFragment(expr)) {
348
- childParts.push(processFragmentSSR(expr, signals));
349
- } else {
350
- childParts.push("<!--g-->");
351
- childParts.push(escape(expr));
352
- childParts.push("<!--/g-->");
353
- }
354
- }
355
- }
356
-
265
+ const childParts = childrenSSR(node.children, signals, hash);
357
266
  if (childParts.length > 0) {
358
267
  props.properties.push(
359
268
  t.objectProperty(
@@ -381,33 +290,7 @@ function processComponentSSR(
381
290
  export function processFragmentSSR(
382
291
  node: t.JSXFragment,
383
292
  signals: Set<string>,
293
+ hash?: string,
384
294
  ): t.TemplateLiteral {
385
- const parts: Array<string | t.Expression> = [];
386
-
387
- for (const child of node.children) {
388
- if (t.isJSXText(child)) {
389
- const text = child.value;
390
- if (!text.trim()) continue;
391
- const normalized = text.replace(/\s*\n\s*/g, " ");
392
- parts.push(normalized);
393
- } else if (t.isJSXElement(child)) {
394
- parts.push(processElementSSR(child, signals));
395
- } else if (t.isJSXFragment(child)) {
396
- parts.push(processFragmentSSR(child, signals));
397
- } else if (t.isJSXExpressionContainer(child)) {
398
- if (t.isJSXEmptyExpression(child.expression)) continue;
399
- const expr = child.expression as t.Expression;
400
- if (t.isJSXElement(expr)) {
401
- parts.push(processElementSSR(expr, signals));
402
- } else if (t.isJSXFragment(expr)) {
403
- parts.push(processFragmentSSR(expr, signals));
404
- } else {
405
- parts.push("<!--g-->");
406
- parts.push(escape(expr));
407
- parts.push("<!--/g-->");
408
- }
409
- }
410
- }
411
-
412
- return buildTemplate(parts);
295
+ return buildTemplate(childrenSSR(node.children, signals, hash));
413
296
  }
@@ -1,4 +1,5 @@
1
1
  import { types as t } from "@babel/core";
2
+ import { isImportRead, isStoreRead } from "../util/props.ts";
2
3
 
3
4
  export const ATTR_MAP: Record<string, string> = {
4
5
  class: "className",
@@ -8,15 +9,19 @@ export const ATTR_MAP: Record<string, string> = {
8
9
  export function containsSignal(
9
10
  expr: t.Expression | t.Node,
10
11
  signals: Set<string>,
12
+ asCallee = false,
11
13
  ): boolean {
12
14
  if (!expr) return false;
13
15
  if (t.isIdentifier(expr)) return signals.has(expr.name);
14
16
  if (t.isCallExpression(expr)) {
17
+ if (isStoreRead(expr)) return true;
15
18
  return (
16
- containsSignal(expr.callee as t.Expression, signals) ||
19
+ containsSignal(expr.callee as t.Expression, signals, true) ||
17
20
  expr.arguments.some((a) => containsSignal(a as t.Expression, signals))
18
21
  );
19
22
  }
23
+ // a read on an imported store; `utils.format` as a callee is not one
24
+ if (!asCallee && isImportRead(expr)) return true;
20
25
  if (t.isBinaryExpression(expr)) {
21
26
  return (
22
27
  containsSignal(expr.left as t.Expression, signals) ||
@@ -177,3 +182,44 @@ export function buildHydrationScope(
177
182
  ]),
178
183
  );
179
184
  }
185
+
186
+ /**
187
+ * The text a JSXText child renders, or null when nothing is left. This is
188
+ * the standard JSX whitespace rule (React and Babel use it too): whitespace
189
+ * touching a line break is dropped, lines that are only whitespace vanish,
190
+ * and the remaining lines join with one space. Whitespace that doesn't touch
191
+ * a line break is kept, so `Hello <b>big</b> world` keeps both spaces.
192
+ *
193
+ * Every mode (ssr, hydrate, dom) must go through this: if they disagree,
194
+ * server HTML and client-rendered pages show different text.
195
+ */
196
+ export function jsxText(child: t.JSXText): string | null {
197
+ const lines = child.value.split(/\r\n|\n|\r/);
198
+ let lastNonEmpty = 0;
199
+ for (let i = 0; i < lines.length; i++) {
200
+ if (/[^ \t]/.test(lines[i] as string)) lastNonEmpty = i;
201
+ }
202
+ let out = "";
203
+ for (let i = 0; i < lines.length; i++) {
204
+ let line = (lines[i] as string).replace(/\t/g, " ");
205
+ if (i !== 0) line = line.replace(/^ +/, "");
206
+ if (i !== lines.length - 1) line = line.replace(/ +$/, "");
207
+ if (!line) continue;
208
+ out += i === lastNonEmpty ? line : `${line} `;
209
+ }
210
+ return out || null;
211
+ }
212
+
213
+ /** JSX that runs when the expression is evaluated (not inside a function). */
214
+ export function hasJSX(expr: t.Node): boolean {
215
+ if (t.isJSXElement(expr) || t.isJSXFragment(expr)) return true;
216
+ if (t.isConditionalExpression(expr))
217
+ return hasJSX(expr.consequent) || hasJSX(expr.alternate);
218
+ if (t.isLogicalExpression(expr))
219
+ return hasJSX(expr.left) || hasJSX(expr.right);
220
+ if (t.isParenthesizedExpression(expr) || t.isTSAsExpression(expr))
221
+ return hasJSX(expr.expression);
222
+ if (t.isArrayExpression(expr))
223
+ return expr.elements.some((e) => e !== null && hasJSX(e));
224
+ return false;
225
+ }
@@ -6,6 +6,10 @@ const BIND_EVENT: Record<string, string> = {
6
6
  innerHTML: "input",
7
7
  textContent: "input",
8
8
  group: "change",
9
+ // element state that isn't form input changes on its own event
10
+ scrollTop: "scroll",
11
+ scrollLeft: "scroll",
12
+ open: "toggle",
9
13
  };
10
14
 
11
15
  export function buildBind(