azoxjs 0.1.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/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/azox.js +18 -0
- package/core/build.js +218 -0
- package/core/buildError.js +8 -0
- package/core/cli/parseArgs.js +39 -0
- package/core/cli/router.js +76 -0
- package/core/commands/compile.js +35 -0
- package/core/commands/create.js +146 -0
- package/core/commands/dev.js +127 -0
- package/core/commands/doctor.js +68 -0
- package/core/commands/help.js +31 -0
- package/core/commands/version.js +7 -0
- package/core/compiler/compileToJs.js +139 -0
- package/core/compiler/index.js +4 -0
- package/core/compiler/parser.js +317 -0
- package/core/compiler/resolveComponents.js +184 -0
- package/core/dev/liveReload.js +20 -0
- package/core/dev/server.js +179 -0
- package/core/dev/watcher.js +52 -0
- package/core/index.js +7 -0
- package/core/meta.js +15 -0
- package/core/reactivity/signal.js +48 -0
- package/core/renderer/renderToHtml.js +67 -0
- package/core/routes.js +78 -0
- package/package.json +47 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Parses a .azox source file into a small AST. Deliberately not
|
|
2
|
+
// JSX: no React-flavored syntax, just HTML with {expr} interpolation
|
|
3
|
+
// and on:event bindings. This stays simple because the compiler's
|
|
4
|
+
// job is narrow — turn markup + bindings into signal-driven DOM ops.
|
|
5
|
+
|
|
6
|
+
import { BuildError } from '../buildError.js';
|
|
7
|
+
|
|
8
|
+
// Extends BuildError so a malformed page is reported as the user's
|
|
9
|
+
// problem, not as an Azox crash.
|
|
10
|
+
export class ParseError extends BuildError {}
|
|
11
|
+
|
|
12
|
+
const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
|
|
13
|
+
|
|
14
|
+
// A capitalised tag is a component, the way a lowercase one is an
|
|
15
|
+
// HTML element. That keeps the distinction visible in the markup
|
|
16
|
+
// itself, with no separate registration step.
|
|
17
|
+
const isComponentName = (name) => /^[A-Z]/.test(name);
|
|
18
|
+
|
|
19
|
+
export function parseAzox(source) {
|
|
20
|
+
const scriptMatch = source.match(/<script>([\s\S]*?)<\/script>/);
|
|
21
|
+
const script = scriptMatch ? scriptMatch[1].trim() : '';
|
|
22
|
+
const template = source.replace(/<script>[\s\S]*?<\/script>/, '').trim();
|
|
23
|
+
|
|
24
|
+
const tokens = tokenize(template);
|
|
25
|
+
const { node, rest } = parseNode(tokens);
|
|
26
|
+
if (rest.length) {
|
|
27
|
+
throw new ParseError(`Azox parse error: unexpected trailing markup near "${rest[0]?.value ?? ''}"`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
script,
|
|
32
|
+
markup: node,
|
|
33
|
+
components: parseComponentImports(script),
|
|
34
|
+
props: parsePropNames(script),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Component imports are written as ordinary import statements, so an
|
|
39
|
+
// editor treats them like any other module reference:
|
|
40
|
+
// import Card from '../components/Card.azox';
|
|
41
|
+
function parseComponentImports(script) {
|
|
42
|
+
const imports = {};
|
|
43
|
+
const regex = /import\s+([A-Z]\w*)\s+from\s+['"]([^'"]+\.azox)['"]\s*;?/g;
|
|
44
|
+
|
|
45
|
+
let match;
|
|
46
|
+
while ((match = regex.exec(script))) {
|
|
47
|
+
imports[match[1]] = match[2];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return imports;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `const { title, count } = props();` declares what a component
|
|
54
|
+
// accepts. Declaring them explicitly lets the compiler reject a
|
|
55
|
+
// caller that passes something the component never asked for.
|
|
56
|
+
function parsePropNames(script) {
|
|
57
|
+
const match = script.match(/const\s*\{([^}]*)\}\s*=\s*props\(\)/);
|
|
58
|
+
if (!match) return [];
|
|
59
|
+
|
|
60
|
+
return match[1]
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((name) => name.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tokenize(html) {
|
|
67
|
+
const tokens = [];
|
|
68
|
+
let i = 0;
|
|
69
|
+
|
|
70
|
+
while (i < html.length) {
|
|
71
|
+
if (html[i] === '<') {
|
|
72
|
+
const isClose = html[i + 1] === '/';
|
|
73
|
+
const end = findTagEnd(html, i);
|
|
74
|
+
const raw = html.slice(i + (isClose ? 2 : 1), end).trim();
|
|
75
|
+
|
|
76
|
+
if (isClose) {
|
|
77
|
+
tokens.push({ type: 'close', name: raw });
|
|
78
|
+
} else {
|
|
79
|
+
const selfClosing = raw.endsWith('/');
|
|
80
|
+
const body = selfClosing ? raw.slice(0, -1).trim() : raw;
|
|
81
|
+
const [name, ...attrParts] = splitTag(body);
|
|
82
|
+
tokens.push({
|
|
83
|
+
type: 'open',
|
|
84
|
+
name,
|
|
85
|
+
attrs: parseAttrs(attrParts.join(' ')),
|
|
86
|
+
selfClosing: selfClosing || VOID_TAGS.has(name),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
i = end + 1;
|
|
90
|
+
} 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;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return tokens;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Finds the ">" that actually closes a tag, ignoring any ">" inside
|
|
102
|
+
// a {expr} attribute value (arrow functions) or inside a string.
|
|
103
|
+
function findTagEnd(html, start) {
|
|
104
|
+
let depth = 0;
|
|
105
|
+
let quote = null;
|
|
106
|
+
|
|
107
|
+
for (let i = start; i < html.length; i++) {
|
|
108
|
+
const char = html[i];
|
|
109
|
+
|
|
110
|
+
if (quote) {
|
|
111
|
+
if (char === '\\') i++;
|
|
112
|
+
else if (char === quote) quote = null;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (depth > 0 && (char === '"' || char === "'" || char === '`')) quote = char;
|
|
117
|
+
else if (char === '{') depth++;
|
|
118
|
+
else if (char === '}') depth--;
|
|
119
|
+
else if (char === '>' && depth === 0) return i;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
throw new ParseError('Azox parse error: unterminated tag');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Splits a tag body into its name and attribute chunks, keeping any
|
|
126
|
+
// whitespace that falls inside an expression or a quoted value.
|
|
127
|
+
function splitTag(body) {
|
|
128
|
+
const parts = [];
|
|
129
|
+
let depth = 0;
|
|
130
|
+
let quote = null;
|
|
131
|
+
let current = '';
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < body.length; i++) {
|
|
134
|
+
const char = body[i];
|
|
135
|
+
|
|
136
|
+
if (quote) {
|
|
137
|
+
current += char;
|
|
138
|
+
if (char === '\\') current += body[++i] ?? '';
|
|
139
|
+
else if (char === quote) quote = null;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
144
|
+
quote = char;
|
|
145
|
+
current += char;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (char === '{') depth++;
|
|
150
|
+
else if (char === '}') depth--;
|
|
151
|
+
|
|
152
|
+
if (/\s/.test(char) && depth === 0) {
|
|
153
|
+
if (current) parts.push(current);
|
|
154
|
+
current = '';
|
|
155
|
+
} else {
|
|
156
|
+
current += char;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (current) parts.push(current);
|
|
161
|
+
return parts;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Scanned rather than matched with a regex: an expression can nest
|
|
165
|
+
// braces (an object literal, a template literal), and no regex can
|
|
166
|
+
// pair those. Getting this wrong silently truncates the expression.
|
|
167
|
+
function parseAttrs(attrString) {
|
|
168
|
+
const attrs = {};
|
|
169
|
+
let i = 0;
|
|
170
|
+
|
|
171
|
+
while (i < attrString.length) {
|
|
172
|
+
if (/\s/.test(attrString[i])) {
|
|
173
|
+
i++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const nameEnd = findAttrNameEnd(attrString, i);
|
|
178
|
+
const name = attrString.slice(i, nameEnd);
|
|
179
|
+
|
|
180
|
+
if (!name) {
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A bare attribute with no value, e.g. `disabled`.
|
|
186
|
+
if (attrString[nameEnd] !== '=') {
|
|
187
|
+
attrs[name] = { kind: 'static', value: '' };
|
|
188
|
+
i = nameEnd;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const valueStart = nameEnd + 1;
|
|
193
|
+
const quote = attrString[valueStart];
|
|
194
|
+
|
|
195
|
+
if (quote === '"' || quote === "'") {
|
|
196
|
+
const end = attrString.indexOf(quote, valueStart + 1);
|
|
197
|
+
if (end === -1) {
|
|
198
|
+
throw new ParseError(`Azox parse error: unterminated value for attribute "${name}"`);
|
|
199
|
+
}
|
|
200
|
+
attrs[name] = { kind: 'static', value: attrString.slice(valueStart + 1, end) };
|
|
201
|
+
i = end + 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (quote === '{') {
|
|
206
|
+
const end = findExpressionEnd(attrString, valueStart, `the {expression} for "${name}"`);
|
|
207
|
+
attrs[name] = { kind: 'expr', expr: attrString.slice(valueStart + 1, end).trim() };
|
|
208
|
+
i = end + 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw new ParseError(
|
|
213
|
+
`Azox parse error: attribute "${name}" needs a quoted value or a {expression}`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return attrs;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function findAttrNameEnd(source, start) {
|
|
221
|
+
let i = start;
|
|
222
|
+
while (i < source.length && /[\w:.@-]/.test(source[i])) i++;
|
|
223
|
+
return i;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Walks from the opening brace to its match, tracking nesting depth
|
|
227
|
+
// and skipping over string and template literals so a brace inside
|
|
228
|
+
// quotes never ends the expression.
|
|
229
|
+
function findExpressionEnd(source, start, describe) {
|
|
230
|
+
let depth = 0;
|
|
231
|
+
let quote = null;
|
|
232
|
+
|
|
233
|
+
for (let i = start; i < source.length; i++) {
|
|
234
|
+
const char = source[i];
|
|
235
|
+
|
|
236
|
+
if (quote) {
|
|
237
|
+
if (char === '\\') i++;
|
|
238
|
+
else if (char === quote) quote = null;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
243
|
+
quote = char;
|
|
244
|
+
} else if (char === '{') {
|
|
245
|
+
depth++;
|
|
246
|
+
} else if (char === '}') {
|
|
247
|
+
depth--;
|
|
248
|
+
if (depth === 0) return i;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
throw new ParseError(`Azox parse error: ${describe} is never closed`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function parseNode(tokens) {
|
|
256
|
+
const [token, ...rest] = tokens;
|
|
257
|
+
|
|
258
|
+
if (!token) return { node: null, rest: [] };
|
|
259
|
+
|
|
260
|
+
if (token.type === 'text') {
|
|
261
|
+
return { node: { type: 'text', parts: splitInterpolation(token.value) }, rest };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (token.type === 'open') {
|
|
265
|
+
const { name, attrs, selfClosing } = token;
|
|
266
|
+
const type = isComponentName(name) ? 'component' : 'element';
|
|
267
|
+
|
|
268
|
+
if (selfClosing) {
|
|
269
|
+
return { node: { type, name, attrs, children: [] }, rest };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const children = [];
|
|
273
|
+
let remaining = rest;
|
|
274
|
+
while (remaining.length && !(remaining[0].type === 'close' && remaining[0].name === name)) {
|
|
275
|
+
const result = parseNode(remaining);
|
|
276
|
+
if (result.node) children.push(result.node);
|
|
277
|
+
remaining = result.rest;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!remaining.length) {
|
|
281
|
+
throw new ParseError(`Azox parse error: <${name}> is never closed`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
remaining = remaining.slice(1); // drop the matching close tag
|
|
285
|
+
|
|
286
|
+
return { node: { type, name, attrs, children }, rest: remaining };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { node: null, rest };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Splits "Clicks: {count()}" into
|
|
293
|
+
// [{kind:'static', value:'Clicks: '}, {kind:'expr', expr:'count()'}]
|
|
294
|
+
//
|
|
295
|
+
// Uses the same brace-depth scan as attributes, so an interpolated
|
|
296
|
+
// expression may contain nested braces and strings.
|
|
297
|
+
function splitInterpolation(text) {
|
|
298
|
+
const parts = [];
|
|
299
|
+
let i = 0;
|
|
300
|
+
|
|
301
|
+
while (i < text.length) {
|
|
302
|
+
const start = text.indexOf('{', i);
|
|
303
|
+
|
|
304
|
+
if (start === -1) {
|
|
305
|
+
parts.push({ kind: 'static', value: text.slice(i) });
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (start > i) parts.push({ kind: 'static', value: text.slice(i, start) });
|
|
310
|
+
|
|
311
|
+
const end = findExpressionEnd(text, start, 'an interpolated {expression}');
|
|
312
|
+
parts.push({ kind: 'expr', expr: text.slice(start + 1, end).trim() });
|
|
313
|
+
i = end + 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return parts;
|
|
317
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Resolves <Component /> usage at build time by inlining each
|
|
2
|
+
// component's markup into its caller.
|
|
3
|
+
//
|
|
4
|
+
// Nothing survives into the runtime: there is no component instance,
|
|
5
|
+
// no lifecycle, no reconciliation. A component is a compile-time unit
|
|
6
|
+
// of reuse, so the emitted code looks the same as if the markup had
|
|
7
|
+
// been written by hand.
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { dirname, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { parseAzox } from './parser.js';
|
|
13
|
+
import { BuildError } from '../buildError.js';
|
|
14
|
+
|
|
15
|
+
// Extends BuildError so the CLI reports it as a user-facing problem
|
|
16
|
+
// rather than an internal crash with a stack trace.
|
|
17
|
+
export class ComponentError extends BuildError {}
|
|
18
|
+
|
|
19
|
+
// Returns the AST with every component reference replaced by that
|
|
20
|
+
// component's markup. The caller's script is untouched: components
|
|
21
|
+
// contribute markup only.
|
|
22
|
+
export function resolveComponents(ast, sourcePath, seen = new Set()) {
|
|
23
|
+
return { ...ast, markup: expand(ast.markup, ast, sourcePath, seen) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function expand(node, ast, sourcePath, seen) {
|
|
27
|
+
if (!node || node.type === 'text') return node;
|
|
28
|
+
|
|
29
|
+
const children = (node.children ?? []).map((child) => expand(child, ast, sourcePath, seen));
|
|
30
|
+
|
|
31
|
+
if (node.type !== 'component') {
|
|
32
|
+
return { ...node, children };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const component = loadComponent(node.name, ast, sourcePath, seen);
|
|
36
|
+
validateProps(node, component);
|
|
37
|
+
validateNoLocalState(node.name, component);
|
|
38
|
+
|
|
39
|
+
// 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]));
|
|
42
|
+
|
|
43
|
+
return substituteProps(inner.markup, propValues(node, component.ast.props), children);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadComponent(name, ast, sourcePath, seen) {
|
|
47
|
+
const specifier = ast.components[name];
|
|
48
|
+
|
|
49
|
+
if (!specifier) {
|
|
50
|
+
throw new ComponentError(
|
|
51
|
+
`<${name}> is used but never imported. Add: import ${name} from './components/${name}.azox';`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const path = resolve(dirname(sourcePath), specifier);
|
|
56
|
+
|
|
57
|
+
if (seen.has(path)) {
|
|
58
|
+
throw new ComponentError(`component cycle detected: ${name} eventually renders itself`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!existsSync(path)) {
|
|
62
|
+
throw new ComponentError(`<${name}> points at ${specifier}, which does not exist`);
|
|
63
|
+
}
|
|
64
|
+
|
|
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
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A caller passing something the component never declared is almost
|
|
90
|
+
// always a typo, and silently dropping it hides the mistake.
|
|
91
|
+
function validateProps(node, component) {
|
|
92
|
+
const declared = new Set(component.ast.props);
|
|
93
|
+
|
|
94
|
+
for (const attr of Object.keys(node.attrs)) {
|
|
95
|
+
if (attr.startsWith('on:')) continue;
|
|
96
|
+
|
|
97
|
+
if (!declared.has(attr)) {
|
|
98
|
+
const known = component.ast.props.length
|
|
99
|
+
? `It accepts: ${component.ast.props.join(', ')}.`
|
|
100
|
+
: 'It declares no props.';
|
|
101
|
+
throw new ComponentError(`<${node.name}> was given "${attr}", which it does not declare. ${known}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Maps each declared prop to the expression the caller supplied.
|
|
107
|
+
// A static attribute becomes a quoted string; an {expr} attribute is
|
|
108
|
+
// passed through so it keeps its reactivity.
|
|
109
|
+
function propValues(node, declared) {
|
|
110
|
+
const values = {};
|
|
111
|
+
|
|
112
|
+
for (const name of declared) {
|
|
113
|
+
const attr = node.attrs[name];
|
|
114
|
+
|
|
115
|
+
if (!attr) {
|
|
116
|
+
values[name] = 'undefined';
|
|
117
|
+
} else if (attr.kind === 'static') {
|
|
118
|
+
values[name] = JSON.stringify(attr.value);
|
|
119
|
+
} else {
|
|
120
|
+
values[name] = attr.expr;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return values;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Rewrites references to a component's props inside its markup with
|
|
128
|
+
// the caller's expressions, and drops <slot /> in favour of the
|
|
129
|
+
// children the caller nested inside the tag.
|
|
130
|
+
function substituteProps(node, values, slotChildren) {
|
|
131
|
+
if (!node) return node;
|
|
132
|
+
|
|
133
|
+
if (node.type === 'text') {
|
|
134
|
+
return {
|
|
135
|
+
...node,
|
|
136
|
+
parts: node.parts.map((part) => {
|
|
137
|
+
if (part.kind !== 'expr') return part;
|
|
138
|
+
|
|
139
|
+
const expr = rewrite(part.expr, values);
|
|
140
|
+
|
|
141
|
+
// A prop passed as a plain string collapses to a literal, and
|
|
142
|
+
// static text needs no effect wrapping it at runtime.
|
|
143
|
+
const literal = asStringLiteral(expr);
|
|
144
|
+
return literal === null ? { ...part, expr } : { kind: 'static', value: literal };
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (node.type === 'element' && node.name === 'slot') {
|
|
150
|
+
return { type: 'fragment', children: slotChildren };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const attrs = {};
|
|
154
|
+
for (const [key, attr] of Object.entries(node.attrs ?? {})) {
|
|
155
|
+
attrs[key] = attr.kind === 'expr' ? { ...attr, expr: rewrite(attr.expr, values) } : attr;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
...node,
|
|
160
|
+
attrs,
|
|
161
|
+
children: (node.children ?? []).map((child) => substituteProps(child, values, slotChildren)),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Returns the string a wholly-literal expression represents, or null
|
|
166
|
+
// when the expression is anything the compiler must evaluate.
|
|
167
|
+
function asStringLiteral(expr) {
|
|
168
|
+
const match = expr.trim().match(/^\((("(?:[^"\\]|\\.)*")|('(?:[^'\\]|\\.)*'))\)$/);
|
|
169
|
+
if (!match) return null;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(match[2] ?? match[3].replace(/^'|'$/g, '"'));
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Replaces whole-word prop identifiers. Property access (obj.title)
|
|
179
|
+
// and string contents are left alone.
|
|
180
|
+
function rewrite(expr, values) {
|
|
181
|
+
return expr.replace(/(?<![.\w$])([A-Za-z_$][\w$]*)/g, (match) =>
|
|
182
|
+
Object.hasOwn(values, match) ? `(${values[match]})` : match
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The snippet injected into pages served by `azox dev`. It is never
|
|
2
|
+
// written to disk by `azox compile`, so production output stays free
|
|
3
|
+
// of dev machinery.
|
|
4
|
+
|
|
5
|
+
import { RELOAD_PATH } from './server.js';
|
|
6
|
+
|
|
7
|
+
const SNIPPET = `<script>
|
|
8
|
+
(() => {
|
|
9
|
+
const source = new EventSource('${RELOAD_PATH}');
|
|
10
|
+
source.addEventListener('reload', () => location.reload());
|
|
11
|
+
// Losing the connection means the dev server stopped; EventSource
|
|
12
|
+
// retries on its own, so the page reloads when it comes back.
|
|
13
|
+
})();
|
|
14
|
+
</script>`;
|
|
15
|
+
|
|
16
|
+
export function injectLiveReload(html) {
|
|
17
|
+
return html.includes('</body>')
|
|
18
|
+
? html.replace('</body>', `${SNIPPET}\n</body>`)
|
|
19
|
+
: html + SNIPPET;
|
|
20
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Static file server for `azox dev`, with a Server-Sent Events
|
|
2
|
+
// channel used to tell connected browsers to reload.
|
|
3
|
+
//
|
|
4
|
+
// SSE rather than WebSockets: reload is a one-way signal, and SSE
|
|
5
|
+
// needs no handshake implementation, which keeps this dependency-free
|
|
6
|
+
// without carrying a protocol implementation.
|
|
7
|
+
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
10
|
+
import { join, extname, normalize, resolve, sep } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const MIME_TYPES = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
15
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.json': 'application/json; charset=utf-8',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.webp': 'image/webp',
|
|
24
|
+
'.avif': 'image/avif',
|
|
25
|
+
'.ico': 'image/x-icon',
|
|
26
|
+
'.woff': 'font/woff',
|
|
27
|
+
'.woff2': 'font/woff2',
|
|
28
|
+
'.ttf': 'font/ttf',
|
|
29
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const RELOAD_PATH = '/__azox_reload';
|
|
33
|
+
|
|
34
|
+
export function createDevServer({ rootDir }) {
|
|
35
|
+
const clients = new Set();
|
|
36
|
+
|
|
37
|
+
// While a build is broken, stale output would look like the edit
|
|
38
|
+
// simply didn't apply. Serving the error instead makes the actual
|
|
39
|
+
// problem visible in the browser.
|
|
40
|
+
let buildError = null;
|
|
41
|
+
|
|
42
|
+
const server = createServer(async (req, res) => {
|
|
43
|
+
if (req.url === RELOAD_PATH) {
|
|
44
|
+
openReloadStream(req, res, clients);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (buildError && isDocumentRequest(req.url)) {
|
|
49
|
+
send(res, 500, 'text/html; charset=utf-8', errorPage(buildError));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
await serveFile(req, res, rootDir);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
server,
|
|
58
|
+
listen: (port, host) => new Promise((done) => server.listen(port, host, done)),
|
|
59
|
+
setBuildError(error) {
|
|
60
|
+
buildError = error;
|
|
61
|
+
},
|
|
62
|
+
reload() {
|
|
63
|
+
for (const client of clients) client.write('event: reload\ndata: {}\n\n');
|
|
64
|
+
},
|
|
65
|
+
close() {
|
|
66
|
+
for (const client of clients) client.end();
|
|
67
|
+
clients.clear();
|
|
68
|
+
return new Promise((done) => server.close(done));
|
|
69
|
+
},
|
|
70
|
+
get connections() {
|
|
71
|
+
return clients.size;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only pages show the error overlay; assets keep their real status so
|
|
77
|
+
// a failed script doesn't turn into an HTML body.
|
|
78
|
+
function isDocumentRequest(url) {
|
|
79
|
+
const path = new URL(url, 'http://localhost').pathname;
|
|
80
|
+
return path.endsWith('/') || path.endsWith('.html') || !extname(path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function openReloadStream(req, res, clients) {
|
|
84
|
+
res.writeHead(200, {
|
|
85
|
+
'Content-Type': 'text/event-stream',
|
|
86
|
+
'Cache-Control': 'no-cache',
|
|
87
|
+
Connection: 'keep-alive',
|
|
88
|
+
});
|
|
89
|
+
res.write('retry: 500\n\n');
|
|
90
|
+
|
|
91
|
+
clients.add(res);
|
|
92
|
+
req.on('close', () => clients.delete(res));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function serveFile(req, res, rootDir) {
|
|
96
|
+
const requested = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
97
|
+
const filePath = resolveWithinRoot(rootDir, requested);
|
|
98
|
+
|
|
99
|
+
if (!filePath) {
|
|
100
|
+
send(res, 403, 'text/plain; charset=utf-8', 'Forbidden');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const target = (await stat(filePath)).isDirectory() ? join(filePath, 'index.html') : filePath;
|
|
106
|
+
const body = await readFile(target);
|
|
107
|
+
const type = MIME_TYPES[extname(target).toLowerCase()] ?? 'application/octet-stream';
|
|
108
|
+
|
|
109
|
+
// Dev output changes constantly; never let the browser cache it.
|
|
110
|
+
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
111
|
+
res.end(body);
|
|
112
|
+
} catch {
|
|
113
|
+
send(res, 404, 'text/html; charset=utf-8', notFoundPage(requested));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Blocks path traversal: a request for ../../etc/passwd must not
|
|
118
|
+
// escape the served directory.
|
|
119
|
+
function resolveWithinRoot(rootDir, requestedPath) {
|
|
120
|
+
const root = resolve(rootDir);
|
|
121
|
+
const candidate = resolve(join(root, normalize(requestedPath)));
|
|
122
|
+
|
|
123
|
+
return candidate === root || candidate.startsWith(root + sep) ? candidate : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function send(res, status, type, body) {
|
|
127
|
+
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
128
|
+
res.end(body);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function notFoundPage(path) {
|
|
132
|
+
return page('404', `<h1>404</h1><p>No file at <code>${escapeHtml(path)}</code></p>`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function errorPage(error) {
|
|
136
|
+
return page(
|
|
137
|
+
'Build failed',
|
|
138
|
+
`<h1>Build failed</h1><pre>${escapeHtml(error.message)}</pre>`,
|
|
139
|
+
/* live */ true
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function page(title, body, live = false) {
|
|
144
|
+
return `<!doctype html>
|
|
145
|
+
<html lang="en">
|
|
146
|
+
<head>
|
|
147
|
+
<meta charset="utf-8" />
|
|
148
|
+
<title>${title} · Azox</title>
|
|
149
|
+
<style>
|
|
150
|
+
:root { color-scheme: light dark; }
|
|
151
|
+
body {
|
|
152
|
+
margin: 0; min-height: 100vh;
|
|
153
|
+
display: grid; place-items: center;
|
|
154
|
+
font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
155
|
+
padding: 2rem;
|
|
156
|
+
}
|
|
157
|
+
main { max-width: 60ch; }
|
|
158
|
+
h1 { font-size: 1.2rem; margin: 0 0 .75rem; }
|
|
159
|
+
pre {
|
|
160
|
+
white-space: pre-wrap; margin: 0;
|
|
161
|
+
padding: 1rem; border-radius: 8px;
|
|
162
|
+
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
163
|
+
}
|
|
164
|
+
code { background: color-mix(in srgb, currentColor 8%, transparent); padding: .1em .35em; border-radius: 4px; }
|
|
165
|
+
</style>
|
|
166
|
+
</head>
|
|
167
|
+
<body>
|
|
168
|
+
<main>${body}</main>
|
|
169
|
+
${live ? `<script>new EventSource('${RELOAD_PATH}').addEventListener('reload', () => location.reload());</script>` : ''}
|
|
170
|
+
</body>
|
|
171
|
+
</html>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function escapeHtml(str) {
|
|
175
|
+
return String(str)
|
|
176
|
+
.replace(/&/g, '&')
|
|
177
|
+
.replace(/</g, '<')
|
|
178
|
+
.replace(/>/g, '>');
|
|
179
|
+
}
|