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