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