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.
- package/README.md +112 -5
- package/core/build.js +237 -41
- package/core/cli/router.js +28 -0
- package/core/commands/create.js +44 -10
- package/core/commands/dev.js +34 -19
- package/core/commands/doctor.js +6 -1
- package/core/compiler/compileToJs.js +506 -36
- package/core/compiler/html.js +28 -0
- package/core/compiler/parser.js +204 -9
- package/core/compiler/resolveComponents.js +212 -39
- package/core/compiler/sourceResolver.js +42 -0
- package/core/nodeResolver.js +15 -0
- package/core/reactivity/signal.js +36 -1
- package/core/renderer/moduleBindings.js +88 -0
- package/core/renderer/renderToHtml.js +94 -18
- package/core/renderer/serverScope.js +83 -0
- package/core/router/navigate.js +211 -0
- package/package.json +1 -1
package/core/compiler/parser.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
92
|
-
const text = html.slice(i, next
|
|
93
|
-
|
|
94
|
-
|
|
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 =
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
41
|
-
const inner = resolveComponents(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|