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.
@@ -4,13 +4,12 @@
4
4
  // job is narrow — turn markup + bindings into signal-driven DOM ops.
5
5
 
6
6
  import { BuildError } from '../buildError.js';
7
+ import { VOID_TAGS } from './html.js';
7
8
 
8
9
  // Extends BuildError so a malformed page is reported as the user's
9
10
  // problem, not as an Azox crash.
10
11
  export class ParseError extends BuildError {}
11
12
 
12
- const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
13
-
14
13
  // A capitalised tag is a component, the way a lowercase one is an
15
14
  // HTML element. That keeps the distinction visible in the markup
16
15
  // itself, with no separate registration step.
@@ -19,7 +18,17 @@ const isComponentName = (name) => /^[A-Z]/.test(name);
19
18
  export function parseAzox(source) {
20
19
  const scriptMatch = source.match(/<script>([\s\S]*?)<\/script>/);
21
20
  const script = scriptMatch ? scriptMatch[1].trim() : '';
22
- const template = source.replace(/<script>[\s\S]*?<\/script>/, '').trim();
21
+
22
+ // An optional <head> block is copied into the document head
23
+ // verbatim: stylesheets, meta tags, fonts. It is markup for the
24
+ // document, not for the page body, so it skips the AST entirely.
25
+ const headMatch = source.match(/<head>([\s\S]*?)<\/head>/);
26
+ const head = headMatch ? headMatch[1].trim() : '';
27
+
28
+ const template = source
29
+ .replace(/<script>[\s\S]*?<\/script>/, '')
30
+ .replace(/<head>[\s\S]*?<\/head>/, '')
31
+ .trim();
23
32
 
24
33
  const tokens = tokenize(template);
25
34
  const { node, rest } = parseNode(tokens);
@@ -29,9 +38,13 @@ export function parseAzox(source) {
29
38
 
30
39
  return {
31
40
  script,
41
+ head,
32
42
  markup: node,
33
43
  components: parseComponentImports(script),
34
44
  props: parsePropNames(script),
45
+ // A dynamic page destructures its route parameters the same way a
46
+ // component destructures its props.
47
+ params: parseParamNames(script),
35
48
  };
36
49
  }
37
50
 
@@ -53,6 +66,18 @@ function parseComponentImports(script) {
53
66
  // `const { title, count } = props();` declares what a component
54
67
  // accepts. Declaring them explicitly lets the compiler reject a
55
68
  // caller that passes something the component never asked for.
69
+ // `const { slug } = params()` names the route parameters the page
70
+ // reads, mirroring how props() declares a component's inputs.
71
+ function parseParamNames(script) {
72
+ const match = script.match(/const\s*\{([^}]*)\}\s*=\s*params\(\)/);
73
+ if (!match) return [];
74
+
75
+ return match[1]
76
+ .split(',')
77
+ .map((name) => name.trim())
78
+ .filter(Boolean);
79
+ }
80
+
56
81
  function parsePropNames(script) {
57
82
  const match = script.match(/const\s*\{([^}]*)\}\s*=\s*props\(\)/);
58
83
  if (!match) return [];
@@ -69,6 +94,29 @@ function tokenize(html) {
69
94
 
70
95
  while (i < html.length) {
71
96
  if (html[i] === '<') {
97
+ // An HTML comment is skipped outright. Treating it as a tag
98
+ // would fail on the "--" and report a confusing error about an
99
+ // element that was never written.
100
+ if (html.startsWith('<!--', i)) {
101
+ const close = html.indexOf('-->', i);
102
+ if (close === -1) throw new ParseError('Azox parse error: a comment is never closed');
103
+
104
+ i = close + '-->'.length;
105
+ continue;
106
+ }
107
+
108
+ // <text> holds literal content: no tags, no {interpolation}.
109
+ // Without it there is no way to show markup or braces on a
110
+ // page, which documentation for this framework obviously needs.
111
+ if (html.startsWith('<text>', i)) {
112
+ const close = html.indexOf('</text>', i);
113
+ if (close === -1) throw new ParseError('Azox parse error: <text> is never closed');
114
+
115
+ tokens.push({ type: 'raw', value: html.slice(i + '<text>'.length, close) });
116
+ i = close + '</text>'.length;
117
+ continue;
118
+ }
119
+
72
120
  const isClose = html[i + 1] === '/';
73
121
  const end = findTagEnd(html, i);
74
122
  const raw = html.slice(i + (isClose ? 2 : 1), end).trim();
@@ -88,14 +136,82 @@ function tokenize(html) {
88
136
  }
89
137
  i = end + 1;
90
138
  } else {
91
- const next = html.indexOf('<', i);
92
- const text = html.slice(i, next === -1 ? undefined : next);
93
- if (text.trim().length) tokens.push({ type: 'text', value: text.trim() });
94
- i = next === -1 ? html.length : next;
139
+ const next = findTextEnd(html, i);
140
+ const text = html.slice(i, next);
141
+ const collapsed = collapseWhitespace(text);
142
+ if (collapsed) tokens.push({ type: 'text', value: collapsed });
143
+ i = next;
95
144
  }
96
145
  }
97
146
 
98
- return tokens;
147
+ return trimEdgeWhitespace(tokens);
148
+ }
149
+
150
+ // Removes the space that sits immediately inside an element — right
151
+ // after its opening tag, or right before its closing one. Nothing sits
152
+ // on the other side of it to be kept apart, so it is indentation
153
+ // rather than a real space, and keeping it would pad an element's text
154
+ // content for no visible benefit.
155
+ function trimEdgeWhitespace(tokens) {
156
+ const out = [];
157
+
158
+ for (let i = 0; i < tokens.length; i++) {
159
+ const token = tokens[i];
160
+
161
+ if (token.type !== 'text') {
162
+ out.push(token);
163
+ continue;
164
+ }
165
+
166
+ const before = tokens[i - 1];
167
+ const after = tokens[i + 1];
168
+
169
+ let value = token.value;
170
+ if (!before || before.type === 'open') value = value.replace(/^ /, '');
171
+ if (!after || after.type === 'close') value = value.replace(/ $/, '');
172
+
173
+ if (value) out.push({ ...token, value });
174
+ }
175
+
176
+ return out;
177
+ }
178
+
179
+ // Collapses runs of whitespace to a single space, the way HTML does,
180
+ // and drops text that is only whitespace between block-level tags.
181
+ //
182
+ // Trimming the edges outright — which this used to do — deletes the
183
+ // space in "Read <a>this</a> for more", running the words together.
184
+ // Keeping one space preserves the sentence while still discarding
185
+ // the indentation between elements on their own lines.
186
+ function collapseWhitespace(text) {
187
+ if (!text.trim()) {
188
+ // Whitespace containing a newline is layout indentation between
189
+ // elements; a space on one line is a real space between them.
190
+ return text.includes('\n') ? '' : ' ';
191
+ }
192
+
193
+ return text.replace(/\s+/g, ' ');
194
+ }
195
+
196
+ // Finds where a run of text ends: at the next tag, but skipping over
197
+ // any {expression} on the way.
198
+ //
199
+ // Stopping at the first "<" is not enough — an expression may contain
200
+ // one, in a comparison like {a() < b()} or a template literal holding
201
+ // markup. Cutting there truncates the expression and reports a
202
+ // confusing error about an unterminated tag.
203
+ function findTextEnd(html, start) {
204
+ for (let i = start; i < html.length; i++) {
205
+ if (html[i] === '<') return i;
206
+
207
+ if (html[i] === '{') {
208
+ // Let the expression scanner find the matching brace; it already
209
+ // handles nesting and strings.
210
+ i = findExpressionEnd(html, i, 'an interpolated {expression}');
211
+ }
212
+ }
213
+
214
+ return html.length;
99
215
  }
100
216
 
101
217
  // Finds the ">" that actually closes a tag, ignoring any ">" inside
@@ -252,20 +368,111 @@ function findExpressionEnd(source, start, describe) {
252
368
  throw new ParseError(`Azox parse error: ${describe} is never closed`);
253
369
  }
254
370
 
371
+ // <each> and <if> are control flow rather than markup, so they become
372
+ // their own node types. Everything else is an element or a component.
373
+ function nodeTypeFor(name) {
374
+ if (name === 'each') return 'each';
375
+ if (name === 'if') return 'if';
376
+ if (name === 'else') return 'else';
377
+ return isComponentName(name) ? 'component' : 'element';
378
+ }
379
+
380
+ // <each item={list()} as="thing"> — `as` names the loop variable so
381
+ // the body can refer to it, the way a parameter names an argument.
382
+ function buildEach(attrs, children) {
383
+ const list = attrs.item ?? attrs.of;
384
+
385
+ if (!list || list.kind !== 'expr') {
386
+ throw new ParseError(
387
+ 'Azox parse error: <each> needs item={...} — for example <each item={todos()} as="todo">'
388
+ );
389
+ }
390
+
391
+ const alias = attrs.as;
392
+
393
+ if (!alias || alias.kind !== 'static' || !/^[A-Za-z_$][\w$]*$/.test(alias.value)) {
394
+ throw new ParseError(
395
+ 'Azox parse error: <each> needs as="name", where name is a plain identifier'
396
+ );
397
+ }
398
+
399
+ // An optional index, declared the same way.
400
+ const indexAttr = attrs.index;
401
+ const index =
402
+ indexAttr && indexAttr.kind === 'static' && /^[A-Za-z_$][\w$]*$/.test(indexAttr.value)
403
+ ? indexAttr.value
404
+ : null;
405
+
406
+ // An optional key gives each row an identity, so a change to the
407
+ // list can move and keep rows rather than rebuilding all of them.
408
+ // It is an expression evaluated per row, so it can read the alias.
409
+ const keyAttr = attrs.key;
410
+
411
+ if (keyAttr && keyAttr.kind !== 'expr') {
412
+ throw new ParseError(
413
+ 'Azox parse error: <each> needs key={...} as an expression — for example key={item.id}'
414
+ );
415
+ }
416
+
417
+ return {
418
+ type: 'each',
419
+ expr: list.expr,
420
+ alias: alias.value,
421
+ index,
422
+ key: keyAttr ? keyAttr.expr : null,
423
+ children,
424
+ };
425
+ }
426
+
427
+ // <if cond={...}> … <else /> … </if> — the marker splits the children
428
+ // into the two branches.
429
+ function buildIf(attrs, children) {
430
+ const condition = attrs.cond ?? attrs.when;
431
+
432
+ if (!condition || condition.kind !== 'expr') {
433
+ throw new ParseError(
434
+ 'Azox parse error: <if> needs cond={...} — for example <if cond={user()}>'
435
+ );
436
+ }
437
+
438
+ const splitAt = children.findIndex((child) => child.type === 'else');
439
+
440
+ // A second <else /> has no meaning, and silently rendering it as a
441
+ // literal tag — which is what happened before — hides the mistake.
442
+ const markers = children.filter((child) => child.type === 'else').length;
443
+ if (markers > 1) {
444
+ throw new ParseError('Azox parse error: <if> may contain only one <else />');
445
+ }
446
+
447
+ return {
448
+ type: 'if',
449
+ expr: condition.expr,
450
+ then: splitAt === -1 ? children : children.slice(0, splitAt),
451
+ otherwise: splitAt === -1 ? [] : children.slice(splitAt + 1),
452
+ };
453
+ }
454
+
255
455
  function parseNode(tokens) {
256
456
  const [token, ...rest] = tokens;
257
457
 
258
458
  if (!token) return { node: null, rest: [] };
259
459
 
460
+ // Literal content from <text>: one static part, never interpolated.
461
+ if (token.type === 'raw') {
462
+ return { node: { type: 'text', parts: [{ kind: 'literal', value: token.value }] }, rest };
463
+ }
464
+
260
465
  if (token.type === 'text') {
261
466
  return { node: { type: 'text', parts: splitInterpolation(token.value) }, rest };
262
467
  }
263
468
 
264
469
  if (token.type === 'open') {
265
470
  const { name, attrs, selfClosing } = token;
266
- const type = isComponentName(name) ? 'component' : 'element';
471
+ const type = nodeTypeFor(name);
267
472
 
268
473
  if (selfClosing) {
474
+ // <else /> is a marker inside <if>, not a node of its own; the
475
+ // <if> handler below is what gives it meaning.
269
476
  return { node: { type, name, attrs, children: [] }, rest };
270
477
  }
271
478
 
@@ -283,6 +490,9 @@ function parseNode(tokens) {
283
490
 
284
491
  remaining = remaining.slice(1); // drop the matching close tag
285
492
 
493
+ if (type === 'each') return { node: buildEach(attrs, children), rest: remaining };
494
+ if (type === 'if') return { node: buildIf(attrs, children), rest: remaining };
495
+
286
496
  return { node: { type, name, attrs, children }, rest: remaining };
287
497
  }
288
498
 
@@ -6,9 +6,6 @@
6
6
  // of reuse, so the emitted code looks the same as if the markup had
7
7
  // been written by hand.
8
8
 
9
- import { readFileSync, existsSync } from 'node:fs';
10
- import { dirname, resolve } from 'node:path';
11
-
12
9
  import { parseAzox } from './parser.js';
13
10
  import { BuildError } from '../buildError.js';
14
11
 
@@ -19,31 +16,151 @@ export class ComponentError extends BuildError {}
19
16
  // Returns the AST with every component reference replaced by that
20
17
  // component's markup. The caller's script is untouched: components
21
18
  // contribute markup only.
22
- export function resolveComponents(ast, sourcePath, seen = new Set()) {
23
- return { ...ast, markup: expand(ast.markup, ast, sourcePath, seen) };
19
+ //
20
+ // `resolver` decides how an import specifier becomes source text, so
21
+ // this runs unchanged against disk or against an in-memory map. See
22
+ // sourceResolver.js.
23
+ export function resolveComponents(ast, sourcePath, resolver, seen = new Set(), heads = null) {
24
+ // Imports from stateful components are hoisted here: they cannot
25
+ // live inside the scope function the compiler builds for each one.
26
+ //
27
+ // Each entry carries the file it was written in. A relative
28
+ // specifier means something different depending on where it was
29
+ // written, so the owning path has to travel with the statement —
30
+ // rebasing a component's import against the page's directory points
31
+ // it at a file that is not there.
32
+ const hoisted = new Map();
33
+
34
+ // A component's <head> block is collected the same way, keyed by the
35
+ // file it came from so the same component used twice contributes
36
+ // once. A stylesheet link belongs in the document head, and until
37
+ // now only a page could put one there.
38
+ const collected = heads ?? new Map();
39
+
40
+ const markup = expand(ast.markup, ast, sourcePath, resolver, seen, hoisted, collected);
41
+
42
+ return {
43
+ ...ast,
44
+ markup,
45
+ componentImports: [...hoisted.values()],
46
+ componentHeads: [...collected.values()],
47
+ };
24
48
  }
25
49
 
26
- function expand(node, ast, sourcePath, seen) {
50
+ function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
27
51
  if (!node || node.type === 'text') return node;
28
52
 
29
- const children = (node.children ?? []).map((child) => expand(child, ast, sourcePath, seen));
53
+ // <if> keeps its children in two branches rather than in `children`,
54
+ // so walking only `children` would leave components inside a
55
+ // conditional unresolved — they would reach the output as raw tags.
56
+ if (node.type === 'if') {
57
+ return {
58
+ ...node,
59
+ then: node.then.map((child) =>
60
+ expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
61
+ ),
62
+ otherwise: node.otherwise.map((child) =>
63
+ expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
64
+ ),
65
+ };
66
+ }
67
+
68
+ const children = (node.children ?? []).map((child) =>
69
+ expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
70
+ );
30
71
 
31
72
  if (node.type !== 'component') {
32
73
  return { ...node, children };
33
74
  }
34
75
 
35
- const component = loadComponent(node.name, ast, sourcePath, seen);
76
+ const component = loadComponent(node.name, ast, sourcePath, resolver, seen);
36
77
  validateProps(node, component);
37
- validateNoLocalState(node.name, component);
38
78
 
39
79
  // The component's own body may reference further components, so
40
- // expand it in its own directory against its own imports.
41
- const inner = resolveComponents(component.ast, component.path, new Set([...seen, component.path]));
80
+ // expand it against its own imports and its own location.
81
+ const inner = resolveComponents(
82
+ component.ast,
83
+ component.path,
84
+ resolver,
85
+ new Set([...seen, component.path]),
86
+ heads
87
+ );
88
+
89
+ // Keyed by path: a component used on a page twice must not emit its
90
+ // stylesheet link twice.
91
+ if (component.ast.head) heads.set(component.path, component.ast.head);
92
+
93
+ const values = propValues(node, component.ast.props);
94
+ const { logic, imports } = componentLogic(component.ast.script);
95
+
96
+ // Imports are hoisted whether or not the component has logic: a
97
+ // component whose script is only an import still has markup that
98
+ // references what it imported, and dropping the import left that
99
+ // name undefined.
100
+ for (const line of imports) {
101
+ hoisted.set(`${component.path}\u0000${line}`, { statement: line, path: component.path });
102
+ }
103
+
104
+ // Imports the component's own body pulled up are needed by anything
105
+ // nested inside it too, and keep the path they were written against.
106
+ for (const entry of inner.componentImports ?? []) {
107
+ hoisted.set(`${entry.path}\u0000${entry.statement}`, entry);
108
+ }
109
+
110
+ // Without logic the component is inlined outright, and its props are
111
+ // rewritten to the caller's expressions in place.
112
+ if (!logic) {
113
+ return substituteProps(inner.markup, values, children);
114
+ }
115
+
116
+ // With logic the markup gets a scope of its own, so each use has its
117
+ // own copy of whatever the component declares — two <Counter /> tags
118
+ // hold two independent counts rather than colliding over one
119
+ // binding. Props become parameters of that scope, so they are left
120
+ // as names here rather than being replaced by the caller's
121
+ // expressions; the values are passed in as arguments instead.
122
+ const markup = substituteProps(inner.markup, {}, children);
42
123
 
43
- return substituteProps(inner.markup, propValues(node, component.ast.props), children);
124
+ // This is ordinary JavaScript scoping, not a component instance:
125
+ // nothing about it survives into the runtime.
126
+ return {
127
+ type: 'scope',
128
+ name: node.name,
129
+ script: logic,
130
+ params: component.ast.props,
131
+ args: component.ast.props.map((prop) => values[prop] ?? 'undefined'),
132
+ children: [markup],
133
+ };
44
134
  }
45
135
 
46
- function loadComponent(name, ast, sourcePath, seen) {
136
+ // Splits a component's script into the imports it needs and the logic
137
+ // that belongs inside its scope.
138
+ //
139
+ // Imports cannot live inside a function, so they are hoisted to the
140
+ // module and collected on the AST for the compiler to emit. What is
141
+ // left — minus the props() line, which becomes parameters — is the
142
+ // component's own logic. `logic` is null when there is none, which is
143
+ // what lets a purely presentational component stay inlined.
144
+ function componentLogic(script) {
145
+ if (!script) return { logic: null, imports: [] };
146
+
147
+ const imports = [...script.matchAll(/^\s*(import\s[^;\n]+;?)\s*$/gm)]
148
+ .map((match) => match[1].trim())
149
+ // Component imports are resolved at build time and must not reach
150
+ // the browser as a .azox specifier.
151
+ .filter((line) => !/\.azox['"]/.test(line));
152
+
153
+ const body = script
154
+ .replace(/^\s*import\s.+?;?\s*$/gm, '')
155
+ .replace(/const\s*\{[^}]*\}\s*=\s*props\(\)\s*;?/, '');
156
+
157
+ // Comments alone are not logic.
158
+ const meaningful = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
159
+
160
+ return { logic: meaningful.trim() ? body.trim() : null, imports };
161
+ }
162
+
163
+ function loadComponent(name, ast, sourcePath, resolver, seen) {
47
164
  const specifier = ast.components[name];
48
165
 
49
166
  if (!specifier) {
@@ -52,38 +169,19 @@ function loadComponent(name, ast, sourcePath, seen) {
52
169
  );
53
170
  }
54
171
 
55
- const path = resolve(dirname(sourcePath), specifier);
172
+ const path = resolver.resolve(specifier, sourcePath);
56
173
 
57
174
  if (seen.has(path)) {
58
175
  throw new ComponentError(`component cycle detected: ${name} eventually renders itself`);
59
176
  }
60
177
 
61
- if (!existsSync(path)) {
178
+ const source = resolver.read(path);
179
+
180
+ if (source === null) {
62
181
  throw new ComponentError(`<${name}> points at ${specifier}, which does not exist`);
63
182
  }
64
183
 
65
- return { path, ast: parseAzox(readFileSync(path, 'utf8')) };
66
- }
67
-
68
- // Components are presentational in this version: they take props and
69
- // render markup. Because a component is inlined into its caller,
70
- // state declared inside one would share the caller's scope and could
71
- // collide with it — so it is rejected outright rather than producing
72
- // a subtle bug. Lifting the restriction needs per-component scoping,
73
- // which is a deliberate design step, not an accident.
74
- function validateNoLocalState(name, component) {
75
- const script = component.ast.script;
76
- if (!script) return;
77
-
78
- const withoutImports = script.replace(/^\s*import\s.+?;?\s*$/gm, '');
79
- const withoutProps = withoutImports.replace(/const\s*\{[^}]*\}\s*=\s*props\(\)\s*;?/, '');
80
-
81
- if (withoutProps.trim()) {
82
- throw new ComponentError(
83
- `<${name}> declares logic beyond props(), which this version does not support. ` +
84
- 'Components take props and render markup; keep state in the page that uses them.'
85
- );
86
- }
184
+ return { path, ast: parseAzox(source) };
87
185
  }
88
186
 
89
187
  // A caller passing something the component never declared is almost
@@ -147,12 +245,54 @@ function substituteProps(node, values, slotChildren) {
147
245
  }
148
246
 
149
247
  if (node.type === 'element' && node.name === 'slot') {
150
- return { type: 'fragment', children: slotChildren };
248
+ // Marked as slot content: it was written by the caller, so it
249
+ // must be rendered against the caller's scope rather than the
250
+ // component's. Without this a component's own declarations would
251
+ // shadow — or hide entirely — whatever the caller referenced.
252
+ return { type: 'fragment', slot: true, children: slotChildren };
253
+ }
254
+
255
+ // Control flow holds its expression and its children outside the
256
+ // usual `attrs`/`children` shape, so it needs substituting by hand.
257
+ // Without this, a component that loops over one of its own props
258
+ // compiles to an expression referring to a name that does not exist.
259
+ if (node.type === 'each') {
260
+ // The list expression is evaluated outside the loop, so it still
261
+ // sees the props. Only the body is shadowed by the loop variable.
262
+ const inner = shadow(values, [node.alias, node.index]);
263
+
264
+ return {
265
+ ...node,
266
+ expr: rewrite(node.expr, values),
267
+ children: node.children.map((child) => substituteProps(child, inner, slotChildren)),
268
+ };
269
+ }
270
+
271
+ if (node.type === 'if') {
272
+ return {
273
+ ...node,
274
+ expr: rewrite(node.expr, values),
275
+ then: node.then.map((child) => substituteProps(child, values, slotChildren)),
276
+ otherwise: node.otherwise.map((child) => substituteProps(child, values, slotChildren)),
277
+ };
151
278
  }
152
279
 
153
280
  const attrs = {};
154
281
  for (const [key, attr] of Object.entries(node.attrs ?? {})) {
155
- attrs[key] = attr.kind === 'expr' ? { ...attr, expr: rewrite(attr.expr, values) } : attr;
282
+ if (attr.kind !== 'expr' || key.startsWith('on:')) {
283
+ attrs[key] = attr;
284
+ continue;
285
+ }
286
+
287
+ const expr = rewrite(attr.expr, values);
288
+
289
+ // Once the caller's props are substituted in, an expression may
290
+ // have become entirely constant — `class={current === 'docs' ? …}`
291
+ // with a literal `current`, for instance. Folding it here means
292
+ // no effect is created for a value that can never change, and a
293
+ // page built only from such components stays static.
294
+ const folded = evaluateConstant(expr);
295
+ attrs[key] = folded === null ? { ...attr, expr } : { kind: 'static', value: folded };
156
296
  }
157
297
 
158
298
  return {
@@ -162,6 +302,41 @@ function substituteProps(node, values, slotChildren) {
162
302
  };
163
303
  }
164
304
 
305
+ // Evaluates an expression that refers to nothing outside itself, and
306
+ // returns the resulting string — or null if it cannot be folded.
307
+ //
308
+ // Deliberately conservative: anything containing an identifier that
309
+ // is not a literal or a keyword is refused, so this can never run a
310
+ // function call, read a signal, or touch anything with a side effect.
311
+ // Only string, number and boolean results are folded, since those are
312
+ // the only ones that render the same at build time as at runtime.
313
+ const FOLDABLE_KEYWORDS = new Set(['true', 'false', 'null', 'undefined']);
314
+
315
+ function evaluateConstant(expr) {
316
+ // Strip strings first, then look for anything identifier-shaped in
317
+ // what remains.
318
+ const withoutStrings = expr.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '');
319
+ const identifiers = withoutStrings.match(/[A-Za-z_$][\w$]*/g) ?? [];
320
+
321
+ if (identifiers.some((name) => !FOLDABLE_KEYWORDS.has(name))) return null;
322
+
323
+ // With no identifiers left, a "(" can only be grouping — there is
324
+ // nothing available to call. Braces, brackets, semicolons, arrows
325
+ // and assignment are still refused outright.
326
+ if (/[{}[\];]|=>/.test(withoutStrings)) return null;
327
+ if (/(^|[^!=<>])=([^=]|$)/.test(withoutStrings)) return null;
328
+
329
+ try {
330
+ const value = new Function(`"use strict"; return (${expr});`)();
331
+ const type = typeof value;
332
+
333
+ if (type === 'string' || type === 'number' || type === 'boolean') return String(value);
334
+ return null;
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+
165
340
  // Returns the string a wholly-literal expression represents, or null
166
341
  // when the expression is anything the compiler must evaluate.
167
342
  function asStringLiteral(expr) {
@@ -175,6 +350,19 @@ function asStringLiteral(expr) {
175
350
  }
176
351
  }
177
352
 
353
+ // A loop variable shadows a prop of the same name, the way a
354
+ // parameter shadows an outer binding in JavaScript. Removing the
355
+ // shadowed names stops the loop body from being rewritten to the
356
+ // caller's value.
357
+ function shadow(values, names) {
358
+ const shadowed = names.filter(Boolean);
359
+ if (!shadowed.some((name) => name in values)) return values;
360
+
361
+ const next = { ...values };
362
+ for (const name of shadowed) delete next[name];
363
+ return next;
364
+ }
365
+
178
366
  // Replaces whole-word prop identifiers. Property access (obj.title)
179
367
  // and string contents are left alone.
180
368
  function rewrite(expr, values) {
@@ -0,0 +1,42 @@
1
+ // How the compiler finds the source of an imported component.
2
+ //
3
+ // The compiler itself never touches a filesystem. It asks a resolver
4
+ // to turn an import specifier into an identity and a source string,
5
+ // which lets the same compiler run against files on disk (the CLI)
6
+ // and against an in-memory map (the browser playground) with no
7
+ // duplicated logic.
8
+ //
9
+ // A resolver implements:
10
+ // resolve(specifier, fromId) -> id a stable, absolute identity
11
+ // read(id) -> string the source, or null if absent
12
+
13
+ // Joins POSIX-style paths and collapses "." and "..", so the browser
14
+ // resolver behaves like the Node one without importing node:path.
15
+ export function joinPath(base, specifier) {
16
+ const segments = specifier.startsWith('/')
17
+ ? specifier.split('/')
18
+ : [...base.split('/').slice(0, -1), ...specifier.split('/')];
19
+
20
+ const out = [];
21
+ for (const segment of segments) {
22
+ if (segment === '' || segment === '.') continue;
23
+ if (segment === '..') out.pop();
24
+ else out.push(segment);
25
+ }
26
+
27
+ return `/${out.join('/')}`;
28
+ }
29
+
30
+ // Resolver over a plain object of { "/path/File.azox": "source" }.
31
+ // Used by the playground, and by tests that would rather not touch
32
+ // the filesystem.
33
+ export function createMemoryResolver(files) {
34
+ const normalised = new Map(
35
+ Object.entries(files).map(([path, source]) => [path.startsWith('/') ? path : `/${path}`, source])
36
+ );
37
+
38
+ return {
39
+ resolve: (specifier, fromId) => joinPath(fromId, specifier),
40
+ read: (id) => normalised.get(id) ?? null,
41
+ };
42
+ }