azoxjs 0.1.0 → 0.3.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.
@@ -3,37 +3,266 @@
3
3
  // its own effect — no Virtual DOM tree, no diffing. A signal update
4
4
  // touches exactly the text node or attribute it owns.
5
5
 
6
- import { relative, dirname, resolve } from 'node:path';
6
+ // This module has no Node built-ins on purpose: it runs unchanged in
7
+ // the browser, which is what makes the playground possible. Anything
8
+ // that needs to know about paths on disk belongs in build.js.
7
9
 
8
10
  let uid = 0;
9
11
  const nextId = () => `_el${uid++}`;
10
12
 
11
- // sourcePath: absolute path of the .azox file being compiled.
12
- // outPath: absolute path of the client module being written.
13
13
  // runtimeSpecifier: how the emitted module should import the Azox
14
- // runtime — a bare "azox/reactivity" for user projects, or a
15
- // relative path when compiling inside the framework itself.
16
- //
17
- // Relative import specifiers in the user's <script> are resolved
18
- // against sourcePath and re-expressed relative to outPath, since
19
- // compiled output lives in .azox/build/, not next to the page.
20
- // Bare specifiers are left untouched for the resolver to handle.
21
- export function compileToModule(ast, { sourcePath, outPath, runtimeSpecifier }) {
14
+ // runtime — a relative path to the copied runtime for a build, or
15
+ // whatever the playground wants to point at.
16
+ // rewriteImports: optional hook the build uses to rebase the user's
17
+ // own relative imports, since compiled output lives in
18
+ // .azox/build/ rather than next to the page. Called as
19
+ // (script, sourcePath) an import hoisted out of a component is
20
+ // relative to that component, not to the page. The playground has
21
+ // no output directory, so it omits this.
22
+ // inlineModules: values the build already loaded, as local name →
23
+ // value, whose import must not reach the browser. A JSON import
24
+ // points outside the build directory at a file that is never
25
+ // deployed, so the value is emitted as a constant instead.
26
+ // routeParams: the resolved parameters for this page of a dynamic
27
+ // route. routes() is a build-time declaration and params() is
28
+ // answered before the browser is involved, so both are removed from
29
+ // the emitted module and the values are inlined.
30
+ export function compileToModule(
31
+ ast,
32
+ { runtimeSpecifier, rewriteImports, inlineModules, routeParams }
33
+ ) {
22
34
  uid = 0;
23
35
  const statements = [];
24
36
  const rootVar = emitNode(ast.markup, statements, 'root');
25
- const script = rebaseImports(dropComponentImports(ast.script), dirname(sourcePath), outPath);
37
+
38
+ let script = dropComponentImports(ast.script);
39
+ script = resolveRouteDeclarations(script, routeParams);
40
+ if (rewriteImports) script = rewriteImports(script);
41
+
42
+ // A page with no bindings and no listeners has nothing to hydrate:
43
+ // the server-rendered markup is already the finished page. Leaving
44
+ // it alone avoids a pointless rebuild, and avoids destroying nodes
45
+ // that other scripts on the page may be holding.
46
+ const isStatic = !statements.some(
47
+ (line) =>
48
+ line.startsWith('effect(') || line.includes('.addEventListener(')
49
+ );
50
+
51
+ // Stateful components keep their logic inside a scope function, but
52
+ // their imports cannot live there, so the resolver hoisted them,
53
+ // each carrying the file it was written in. They need the same path
54
+ // rewriting the page's own imports get — but relative to their own
55
+ // file, not the page's — then merging with them: declaring the same
56
+ // binding twice is a syntax error.
57
+ let hoisted = (ast.componentImports ?? [])
58
+ // An inlined import becomes a constant below, so the statement
59
+ // must be dropped here too — it would declare the binding twice.
60
+ .filter((entry) => !isInlined(entry.statement, inlineModules))
61
+ .map((entry) =>
62
+ rewriteImports ? rewriteImports(entry.statement, entry.path) : entry.statement
63
+ );
64
+
65
+ // A keyed list disposes the effects of rows that leave, so the
66
+ // module needs dispose as well as effect.
67
+ const needsDispose = statements.some((line) => line.includes('dispose('));
68
+
69
+ const { imports, body } = mergeImports(
70
+ script,
71
+ hoisted,
72
+ runtimeSpecifier,
73
+ needsDispose,
74
+ inlineModules
75
+ );
76
+
77
+ // Narrowed to what the module actually reads, so importing
78
+ // package.json for a version does not publish the whole file.
79
+ const inlined = emitInlineModules(inlineModules, [...statements, body].join('\n'));
26
80
 
27
81
  return `
28
- import { effect } from '${runtimeSpecifier}';
29
- ${script}
82
+ ${imports}
83
+ ${inlined}${body}
30
84
 
31
85
  export function render(mount) {
32
86
  ${statements.map((line) => ' ' + line).join('\n')}
33
87
  mount.appendChild(${rootVar});
34
88
  return ${rootVar};
35
89
  }
90
+ ${isStatic ? staticNote() : hydrateBlock()}`.trimStart();
91
+ }
92
+
93
+ // Collects every import the module needs into one set of statements,
94
+ // pulling the page's own imports out of its script so they cannot be
95
+ // duplicated by an identical import hoisted from a component.
96
+ //
97
+ // Named imports from the same specifier are merged into one
98
+ // statement, so `signal` imported by both the page and a component is
99
+ // declared once rather than twice — which would be a syntax error.
100
+ function mergeImports(
101
+ script,
102
+ componentImports,
103
+ runtimeSpecifier,
104
+ needsDispose = false,
105
+ inlineModules = null
106
+ ) {
107
+ const pageImports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
108
+ .map((m) => m[1].trim())
109
+ // An inlined import becomes a constant below, so its statement
110
+ // must not also be emitted — the binding would be declared twice.
111
+ .filter((line) => !isInlined(line, inlineModules));
112
+ const body = script.replace(/^\s*import\s[^;\n]+;?\s*$/gm, '').trim();
113
+
114
+ // specifier -> set of named bindings; anything not a plain named
115
+ // import is kept verbatim.
116
+ const named = new Map();
117
+ const verbatim = new Set();
118
+
119
+ const record = (statement) => {
120
+ const match = statement.match(/^import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/);
121
+
122
+ if (!match) {
123
+ verbatim.add(statement);
124
+ return;
125
+ }
126
+
127
+ const [, bindings, specifier] = match;
128
+ const set = named.get(specifier) ?? new Set();
129
+ for (const binding of bindings.split(',')) {
130
+ if (binding.trim()) set.add(binding.trim());
131
+ }
132
+ named.set(specifier, set);
133
+ };
134
+
135
+ // `effect` is always needed: the compiler emits calls to it.
136
+ record(`import { effect } from '${runtimeSpecifier}';`);
137
+ if (needsDispose) record(`import { dispose } from '${runtimeSpecifier}';`);
138
+ for (const statement of [...componentImports, ...pageImports]) record(statement);
139
+
140
+ const lines = [
141
+ ...[...named].map(([specifier, bindings]) => {
142
+ return `import { ${[...bindings].join(', ')} } from '${specifier}';`;
143
+ }),
144
+ ...verbatim,
145
+ ];
146
+
147
+ return { imports: lines.join('\n'), body };
148
+ }
149
+
150
+ // An import is inlined when every binding it declares was loaded by
151
+ // the build. Matching on the specifier would be wrong: the same file
152
+ // could be imported for some other reason.
153
+ function isInlined(statement, inlineModules) {
154
+ if (!inlineModules) return false;
155
+
156
+ const match = statement.match(/^import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/);
157
+ if (!match) return false;
158
+
159
+ const locals = localNames(match[1]);
160
+ return locals.length > 0 && locals.every((name) => name in inlineModules);
161
+ }
162
+
163
+ // The local names an import clause declares, for deciding whether the
164
+ // statement has been replaced by constants.
165
+ function localNames(clause) {
166
+ const text = clause.trim();
167
+ const names = [];
168
+
169
+ const namespace = text.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/);
170
+ if (namespace) return [namespace[1]];
171
+
172
+ const braceAt = text.indexOf('{');
173
+ const head = (braceAt === -1 ? text : text.slice(0, braceAt)).replace(/,\s*$/, '').trim();
174
+ if (/^[A-Za-z_$][\w$]*$/.test(head)) names.push(head);
175
+
176
+ if (braceAt !== -1) {
177
+ const closeAt = text.lastIndexOf('}');
178
+ for (const part of text.slice(braceAt + 1, closeAt === -1 ? undefined : closeAt).split(',')) {
179
+ const entry = part.trim();
180
+ if (!entry) continue;
181
+ const aliased = entry.match(/^(.+?)\s+as\s+([A-Za-z_$][\w$]*)$/);
182
+ const local = (aliased ? aliased[2] : entry).trim();
183
+ if (/^[A-Za-z_$][\w$]*$/.test(local)) names.push(local);
184
+ }
185
+ }
36
186
 
187
+ return names;
188
+ }
189
+
190
+ // Emits a loaded value as a constant. JSON.stringify is exact for what
191
+ // JSON can hold, which is all this path accepts.
192
+ function emitInlineModules(inlineModules, usage = '') {
193
+ if (!inlineModules) return '';
194
+
195
+ const entries = Object.entries(inlineModules);
196
+ if (!entries.length) return '';
197
+
198
+ const lines = entries.map(
199
+ ([name, value]) => `const ${name} = ${JSON.stringify(narrow(name, value, usage))};`
200
+ );
201
+ return `${lines.join('\n')}\n`;
202
+ }
203
+
204
+ // Keeps only the properties the module reads by name. A page that
205
+ // imports package.json for its version has no business shipping the
206
+ // author's address to every visitor, and the unread half is dead
207
+ // weight in the bundle.
208
+ //
209
+ // Narrowing applies to a plain object read as `name.prop`. Anything
210
+ // else — a primitive, an array, or a value the code indexes
211
+ // dynamically — is kept whole, since what is needed cannot be known
212
+ // from the source alone.
213
+ function narrow(name, value, usage) {
214
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
215
+
216
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
217
+
218
+ // A dynamic read — name[expr] — could reach any property, and
219
+ // passing the object on (or spreading it) could read any of them
220
+ // later. Both mean the whole value has to stay.
221
+ if (new RegExp(`\\b${escaped}\\s*\\[`).test(usage)) return value;
222
+ if (new RegExp(`\\.\\.\\.\\s*${escaped}\\b`).test(usage)) return value;
223
+
224
+ const read = new Set();
225
+ for (const match of usage.matchAll(new RegExp(`\\b${escaped}\\.([A-Za-z_$][\\w$]*)`, 'g'))) {
226
+ read.add(match[1]);
227
+ }
228
+
229
+ // No property read by name at all: the object itself is being used,
230
+ // so it is kept as it is.
231
+ if (!read.size) return value;
232
+
233
+ const narrowed = {};
234
+ for (const key of read) {
235
+ if (key in value) narrowed[key] = value[key];
236
+ }
237
+
238
+ return narrowed;
239
+ }
240
+
241
+ // Removes the build-time route declarations from a page's script.
242
+ //
243
+ // routes([...]) says which pages to build, which the browser has no
244
+ // use for — and calling it there is a ReferenceError that leaves the
245
+ // page inert. params() is replaced by the values this page was built
246
+ // with, so the markup reads them as plain data.
247
+ function resolveRouteDeclarations(script, routeParams) {
248
+ if (!routeParams) return script;
249
+
250
+ return script
251
+ // A whole statement, so the trailing semicolon and newline go too.
252
+ .replace(/^[ \t]*routes\s*\([\s\S]*?\)\s*;?[ \t]*$/gm, '')
253
+ .replace(/\bparams\s*\(\s*\)/g, JSON.stringify(routeParams));
254
+ }
255
+
256
+ function staticNote() {
257
+ return `
258
+ // This page has no bindings and no listeners, so the server-rendered
259
+ // markup is already complete and is left untouched. render() is
260
+ // exported for anyone who wants to mount it somewhere else.
261
+ `;
262
+ }
263
+
264
+ function hydrateBlock() {
265
+ return `
37
266
  // Hydrate: the SSR markup is already on the page, so clear it and
38
267
  // mount the reactive version in its place.
39
268
  if (typeof document !== 'undefined') {
@@ -41,7 +270,7 @@ if (typeof document !== 'undefined') {
41
270
  mount.innerHTML = '';
42
271
  render(mount);
43
272
  }
44
- `.trimStart();
273
+ `;
45
274
  }
46
275
 
47
276
  function emitNode(node, statements, fallbackVar) {
@@ -60,6 +289,10 @@ function emitNode(node, statements, fallbackVar) {
60
289
  return varName;
61
290
  }
62
291
 
292
+ if (node.type === 'each') return emitEach(node, statements);
293
+ if (node.type === 'if') return emitIf(node, statements);
294
+ if (node.type === 'scope') return emitScope(node, statements, fallbackVar);
295
+
63
296
  const varName = nextId();
64
297
  statements.push(`const ${varName} = document.createElement(${JSON.stringify(node.name)});`);
65
298
 
@@ -72,6 +305,244 @@ function emitNode(node, statements, fallbackVar) {
72
305
  return varName;
73
306
  }
74
307
 
308
+ // A component that declares its own state becomes an immediately
309
+ // called function: its declarations are locals of that call, so two
310
+ // uses of the same component hold two independent sets of them.
311
+ //
312
+ // This is scoping the language already provides, not a component
313
+ // instance. There is nothing to mount, nothing to reconcile, and no
314
+ // object representing the component at runtime — just a function that
315
+ // runs once and returns the nodes it built.
316
+ function emitScope(node, statements, fallbackVar) {
317
+ const varName = nextId();
318
+ const bodyLines = [];
319
+
320
+ const roots = node.children.map((child) => emitNode(child, bodyLines, fallbackVar));
321
+
322
+ statements.push(`const ${varName} = ((${node.params.join(', ')}) => {`);
323
+
324
+ // The component's own script comes first, so its declarations exist
325
+ // before the markup that reads them is built.
326
+ for (const line of node.script.split('\n')) {
327
+ statements.push(` ${line}`);
328
+ }
329
+
330
+ for (const line of bodyLines) statements.push(` ${line}`);
331
+
332
+ // A component renders one root; more than one would need a fragment,
333
+ // and the parser already requires a single root element.
334
+ statements.push(` return ${roots[0] ?? 'null'};`);
335
+ statements.push(`})(${node.args.join(', ')});`);
336
+
337
+ return varName;
338
+ }
339
+
340
+ // Control flow needs a fixed point in the DOM to work from, since the
341
+ // nodes it manages come and go. An empty comment node serves as that
342
+ // anchor: it stays put, and everything the block renders is inserted
343
+ // before it and removed by walking back from it.
344
+ //
345
+ // Only the nodes between the markers are touched when the source
346
+ // signal changes — the rest of the page is never involved, which is
347
+ // the same guarantee a text binding gives.
348
+ function emitControlBlock(statements, buildBody, sourceExpr, renderCall) {
349
+ const start = nextId();
350
+ const end = nextId();
351
+ const frag = nextId();
352
+
353
+ const holder = nextId();
354
+
355
+ statements.push(`const ${start} = document.createComment('');`);
356
+ statements.push(`const ${end} = document.createComment('');`);
357
+
358
+ // The markers go into their own fragment straight away, so they
359
+ // always have a parent to insert into. Waiting for the page to be
360
+ // mounted instead would break a nested block: its markers are
361
+ // rebuilt every time the outer block re-runs, long after any
362
+ // one-time mount step has passed.
363
+ statements.push(`const ${holder} = document.createDocumentFragment();`);
364
+ statements.push(`${holder}.append(${start}, ${end});`);
365
+
366
+ // The body is compiled once into a function, then called as needed.
367
+ const bodyLines = [];
368
+ const bodyVar = buildBody(bodyLines);
369
+
370
+ statements.push(`const ${frag} = (${bodyVar.params}) => {`);
371
+ statements.push(` const _frag = document.createDocumentFragment();`);
372
+ for (const line of bodyLines) statements.push(` ${line}`);
373
+ for (const rootVar of bodyVar.roots) {
374
+ statements.push(` if (${rootVar} !== null) _frag.appendChild(${rootVar});`);
375
+ }
376
+ statements.push(` return _frag;`);
377
+ statements.push(`};`);
378
+
379
+ statements.push(`effect(() => {`);
380
+ statements.push(` // Read the source before anything can return early. An effect`);
381
+ statements.push(` // subscribes only to what it reads, so bailing out first would`);
382
+ statements.push(` // leave this block subscribed to nothing and never update.`);
383
+ statements.push(` const _source = ${sourceExpr};`);
384
+ statements.push(``);
385
+ statements.push(` // Clearing walks back from the end marker, so nothing outside`);
386
+ statements.push(` // the block can be removed by accident.`);
387
+ statements.push(` while (${end}.previousSibling && ${end}.previousSibling !== ${start}) {`);
388
+ statements.push(` ${end}.previousSibling.remove();`);
389
+ statements.push(` }`);
390
+ statements.push(``);
391
+ statements.push(` const _parent = ${end}.parentNode;`);
392
+ statements.push(` if (!_parent) return;`);
393
+ for (const line of renderCall(frag, end)) statements.push(` ${line}`);
394
+ statements.push(`});`);
395
+
396
+ return holder;
397
+ }
398
+
399
+ function emitEach(node, statements) {
400
+ return node.key ? emitKeyedEach(node, statements) : emitPlainEach(node, statements);
401
+ }
402
+
403
+ // A keyed list remembers the rows it built, so an update moves the
404
+ // ones that are still there instead of discarding every row and
405
+ // building it again. That is what lets a row keep its own state, its
406
+ // focus, and its scroll position across a change to the list.
407
+ //
408
+ // Rows that leave have their effects disposed, which is the one place
409
+ // the runtime's dispose() is needed: a row's bindings are created
410
+ // inside their own effect scope rather than inside the block's, since
411
+ // re-running the block must not tear down rows it is keeping.
412
+ function emitKeyedEach(node, statements) {
413
+ const params = node.index ? `${node.alias}, ${node.index}` : node.alias;
414
+
415
+ const start = nextId();
416
+ const end = nextId();
417
+ const build = nextId();
418
+ const rows = nextId();
419
+ const holder = nextId();
420
+
421
+ statements.push(`const ${start} = document.createComment('');`);
422
+ statements.push(`const ${end} = document.createComment('');`);
423
+ statements.push(`const ${holder} = document.createDocumentFragment();`);
424
+ statements.push(`${holder}.append(${start}, ${end});`);
425
+
426
+ // key -> { nodes, scope }. Kept across runs; this is the memory that
427
+ // makes the list keyed rather than rebuilt.
428
+ statements.push(`const ${rows} = new Map();`);
429
+
430
+ // The row builder returns its nodes and the effect owning them, so
431
+ // the block can both re-insert a row and dispose it later.
432
+ const bodyLines = [];
433
+ const roots = node.children.map((child) => emitNode(child, bodyLines, 'root'));
434
+
435
+ statements.push(`const ${build} = (${params}) => {`);
436
+ statements.push(` let _nodes;`);
437
+ statements.push(` const _scope = effect(() => {`);
438
+ for (const line of bodyLines) statements.push(` ${line}`);
439
+ statements.push(` _nodes = [${roots.filter((r) => r !== 'null').join(', ')}];`);
440
+ statements.push(` });`);
441
+ statements.push(` return { nodes: _nodes, scope: _scope };`);
442
+ statements.push(`};`);
443
+
444
+ statements.push(`effect(() => {`);
445
+ statements.push(` const _source = ${node.expr};`);
446
+ statements.push(` const _parent = ${end}.parentNode;`);
447
+ statements.push(` if (!_parent) return;`);
448
+ statements.push(``);
449
+ statements.push(` const _seen = new Set();`);
450
+ statements.push(` let _i = 0;`);
451
+ statements.push(``);
452
+ statements.push(` for (const _item of _source ?? []) {`);
453
+ statements.push(` const ${node.alias} = _item;`);
454
+ if (node.index) statements.push(` const ${node.index} = _i;`);
455
+ statements.push(` const _key = ${node.key};`);
456
+ statements.push(``);
457
+ statements.push(` if (_seen.has(_key)) {`);
458
+ statements.push(` throw new Error(`);
459
+ statements.push(
460
+ " `Azox: <each> saw the key ${String(_key)} twice. Keys must be unique within a list.`"
461
+ );
462
+ statements.push(` );`);
463
+ statements.push(` }`);
464
+ statements.push(` _seen.add(_key);`);
465
+ statements.push(``);
466
+ statements.push(` let _row = ${rows}.get(_key);`);
467
+ statements.push(` if (!_row) {`);
468
+ statements.push(` _row = ${build}(_item${node.index ? ', _i' : ''});`);
469
+ statements.push(` ${rows}.set(_key, _row);`);
470
+ statements.push(` }`);
471
+ statements.push(``);
472
+ statements.push(` // Moving a node that is already in place is a no-op in`);
473
+ statements.push(` // the DOM, so ordering costs nothing when nothing moved.`);
474
+ statements.push(` for (const _node of _row.nodes) _parent.insertBefore(_node, ${end});`);
475
+ statements.push(` _i++;`);
476
+ statements.push(` }`);
477
+ statements.push(``);
478
+ statements.push(` // Whatever is left in the map is a row that has gone.`);
479
+ statements.push(` for (const [_key, _row] of ${rows}) {`);
480
+ statements.push(` if (_seen.has(_key)) continue;`);
481
+ statements.push(` for (const _node of _row.nodes) _node.remove();`);
482
+ statements.push(` dispose(_row.scope);`);
483
+ statements.push(` ${rows}.delete(_key);`);
484
+ statements.push(` }`);
485
+ statements.push(`});`);
486
+
487
+ return holder;
488
+ }
489
+
490
+ function emitPlainEach(node, statements) {
491
+ const params = node.index ? `${node.alias}, ${node.index}` : node.alias;
492
+
493
+ return emitControlBlock(
494
+ statements,
495
+ (bodyLines) => {
496
+ // Compile the body once; each iteration calls it with its own
497
+ // item, so the DOM calls are shared rather than duplicated.
498
+ const roots = node.children.map((child) => emitNode(child, bodyLines, 'root'));
499
+ return { params, roots };
500
+ },
501
+ node.expr,
502
+ (frag, endVar) => [
503
+ `if (_source) {`,
504
+ ` let _i = 0;`,
505
+ ` for (const _item of _source) {`,
506
+ ` _parent.insertBefore(${frag}(_item${node.index ? ', _i' : ''}), ${endVar});`,
507
+ ` _i++;`,
508
+ ` }`,
509
+ `}`,
510
+ ]
511
+ );
512
+ }
513
+
514
+ function emitIf(node, statements) {
515
+ return emitControlBlock(
516
+ statements,
517
+ (bodyLines) => {
518
+ // Both branches are compiled into the same function, selected by
519
+ // a flag, so a conditional costs one function rather than two.
520
+ const thenLines = [];
521
+ const thenRoots = node.then.map((child) => emitNode(child, thenLines, 'root'));
522
+
523
+ const elseLines = [];
524
+ const elseRoots = node.otherwise.map((child) => emitNode(child, elseLines, 'root'));
525
+
526
+ bodyLines.push(`const _out = document.createDocumentFragment();`);
527
+ bodyLines.push(`if (_branch) {`);
528
+ for (const line of thenLines) bodyLines.push(` ${line}`);
529
+ for (const root of thenRoots) {
530
+ if (root !== 'null') bodyLines.push(` _out.appendChild(${root});`);
531
+ }
532
+ bodyLines.push(`} else {`);
533
+ for (const line of elseLines) bodyLines.push(` ${line}`);
534
+ for (const root of elseRoots) {
535
+ if (root !== 'null') bodyLines.push(` _out.appendChild(${root});`);
536
+ }
537
+ bodyLines.push(`}`);
538
+
539
+ return { params: '_branch', roots: ['_out'] };
540
+ },
541
+ node.expr,
542
+ (frag, endVar) => [`_parent.insertBefore(${frag}(Boolean(_source)), ${endVar});`]
543
+ );
544
+ }
545
+
75
546
  function appendChildren(parentVar, children, statements, fallbackVar) {
76
547
  for (const child of children) {
77
548
  const childVar = emitNode(child, statements, fallbackVar);
@@ -79,10 +550,36 @@ function appendChildren(parentVar, children, statements, fallbackVar) {
79
550
  }
80
551
  }
81
552
 
553
+ // createTextNode takes text, not markup, so an entity the author
554
+ // wrote in static markup has to be decoded here — otherwise the
555
+ // browser would show a literal "&lt;" where the server rendered "<",
556
+ // and the page would visibly change on hydration. Content from a
557
+ // <text> block is already literal and passes through untouched.
558
+ const textValue = (part) => (part.kind === 'literal' ? part.value : decodeEntities(part.value));
559
+
560
+ // `{'{'}` is how a page writes a literal brace, since bare braces
561
+ // start an expression. It is a constant, so it should not produce an
562
+ // effect — a page whose only "dynamic" content is escaped punctuation
563
+ // would otherwise be treated as reactive and hydrated needlessly.
564
+ function foldLiteralParts(parts) {
565
+ return parts.map((part) => {
566
+ if (part.kind !== 'expr') return part;
567
+
568
+ const match = part.expr.trim().match(/^'((?:[^'\\]|\\.)*)'$|^"((?:[^"\\]|\\.)*)"$/);
569
+ if (!match) return part;
570
+
571
+ const raw = match[1] ?? match[2];
572
+ return { kind: 'literal', value: raw.replace(/\\(['"\\])/g, '$1') };
573
+ });
574
+ }
575
+
82
576
  function emitText(node, statements, fallbackVar) {
83
- // Purely static text: one text node, no effect needed.
84
- if (node.parts.every((p) => p.kind === 'static')) {
85
- const value = node.parts.map((p) => p.value).join('');
577
+ const isFixed = (part) => part.kind === 'static' || part.kind === 'literal';
578
+ node = { ...node, parts: foldLiteralParts(node.parts) };
579
+
580
+ // Nothing dynamic: one text node, no effect needed.
581
+ if (node.parts.every(isFixed)) {
582
+ const value = node.parts.map(textValue).join('');
86
583
  const varName = nextId();
87
584
  statements.push(`const ${varName} = document.createTextNode(${JSON.stringify(value)});`);
88
585
  return varName;
@@ -92,12 +589,25 @@ function emitText(node, statements, fallbackVar) {
92
589
  const varName = nextId();
93
590
  statements.push(`const ${varName} = document.createTextNode('');`);
94
591
  const expr = node.parts
95
- .map((p) => (p.kind === 'static' ? JSON.stringify(p.value) : `String(${p.expr})`))
592
+ .map((part) => (isFixed(part) ? JSON.stringify(textValue(part)) : `String(${part.expr})`))
96
593
  .join(' + ');
97
594
  statements.push(`effect(() => { ${varName}.data = ${expr}; });`);
98
595
  return varName;
99
596
  }
100
597
 
598
+ // The five entities that matter for text content. Numeric forms are
599
+ // handled too, since documentation snippets tend to use them.
600
+ function decodeEntities(text) {
601
+ return text
602
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
603
+ .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
604
+ .replace(/&lt;/g, '<')
605
+ .replace(/&gt;/g, '>')
606
+ .replace(/&quot;/g, '"')
607
+ .replace(/&#39;/g, "'")
608
+ .replace(/&amp;/g, '&');
609
+ }
610
+
101
611
  function emitAttr(varName, key, attr, statements) {
102
612
  if (key.startsWith('on:')) {
103
613
  const event = key.slice(3);
@@ -120,20 +630,3 @@ function dropComponentImports(script) {
120
630
  return script.replace(/^\s*import\s+[A-Z]\w*\s+from\s+['"][^'"]+\.azox['"]\s*;?\s*$/gm, '');
121
631
  }
122
632
 
123
- // Rewrites every relative import specifier in the user's <script>
124
- // block so it still resolves once the module lives in outPath
125
- // instead of next to sourceDir.
126
- function rebaseImports(script, sourceDir, outPath) {
127
- return script.replace(
128
- /(from\s+|import\s+)(['"])(\.[^'"]*)\2/g,
129
- (full, keyword, quote, specifier) =>
130
- `${keyword}${quote}${rebaseSpecifier(resolve(sourceDir, specifier), outPath)}${quote}`
131
- );
132
- }
133
-
134
- // Re-expresses an absolute target path as a path relative to outPath.
135
- function rebaseSpecifier(absoluteTarget, outPath) {
136
- let rebased = relative(dirname(outPath), absoluteTarget);
137
- if (!rebased.startsWith('.')) rebased = `./${rebased}`;
138
- return rebased;
139
- }
@@ -0,0 +1,28 @@
1
+ // Facts about HTML shared by the parser and the renderer. Keeping one
2
+ // list means the two can never disagree about what a void element is,
3
+ // which would show up as a mismatch between server output and what
4
+ // the browser builds.
5
+
6
+ export const VOID_TAGS = new Set([
7
+ 'area',
8
+ 'base',
9
+ 'br',
10
+ 'col',
11
+ 'embed',
12
+ 'hr',
13
+ 'img',
14
+ 'input',
15
+ 'link',
16
+ 'meta',
17
+ 'source',
18
+ 'track',
19
+ 'wbr',
20
+ ]);
21
+
22
+ export function escapeHtml(str) {
23
+ return String(str)
24
+ .replace(/&/g, '&amp;')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;');
28
+ }