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
|
@@ -11,7 +11,12 @@ export function signal(initialValue) {
|
|
|
11
11
|
const subscribers = new Set();
|
|
12
12
|
|
|
13
13
|
function read() {
|
|
14
|
-
if (activeEffect)
|
|
14
|
+
if (activeEffect) {
|
|
15
|
+
subscribers.add(activeEffect);
|
|
16
|
+
// Remember the link from both ends, so disposing an effect can
|
|
17
|
+
// remove it from every signal it read.
|
|
18
|
+
activeEffect.sources.add(subscribers);
|
|
19
|
+
}
|
|
15
20
|
return value;
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -29,6 +34,10 @@ export function signal(initialValue) {
|
|
|
29
34
|
|
|
30
35
|
export function effect(fn) {
|
|
31
36
|
const wrapped = () => {
|
|
37
|
+
// Drop last run's subscriptions before re-reading. Without this,
|
|
38
|
+
// an effect stays subscribed to signals it no longer reads.
|
|
39
|
+
unsubscribe(wrapped);
|
|
40
|
+
|
|
32
41
|
const previous = activeEffect;
|
|
33
42
|
activeEffect = wrapped;
|
|
34
43
|
try {
|
|
@@ -37,10 +46,36 @@ export function effect(fn) {
|
|
|
37
46
|
activeEffect = previous;
|
|
38
47
|
}
|
|
39
48
|
};
|
|
49
|
+
|
|
50
|
+
wrapped.sources = new Set();
|
|
51
|
+
|
|
52
|
+
// Effects created while another effect runs — a control-flow block
|
|
53
|
+
// rebuilding its body, say — belong to it, so they can be disposed
|
|
54
|
+
// together when it re-runs.
|
|
55
|
+
if (activeEffect) activeEffect.children.add(wrapped);
|
|
56
|
+
wrapped.children = new Set();
|
|
57
|
+
|
|
40
58
|
wrapped();
|
|
41
59
|
return wrapped;
|
|
42
60
|
}
|
|
43
61
|
|
|
62
|
+
// Detaches an effect from every signal it read, and disposes anything
|
|
63
|
+
// it created. Called before a re-run and by dispose().
|
|
64
|
+
function unsubscribe(effectFn) {
|
|
65
|
+
for (const child of effectFn.children) unsubscribe(child);
|
|
66
|
+
effectFn.children.clear();
|
|
67
|
+
|
|
68
|
+
for (const subscribers of effectFn.sources) subscribers.delete(effectFn);
|
|
69
|
+
effectFn.sources.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Stops an effect permanently. Control-flow blocks use this to clean
|
|
73
|
+
// up the effects belonging to content they are about to remove;
|
|
74
|
+
// without it, every list item ever rendered would stay subscribed.
|
|
75
|
+
export function dispose(effectFn) {
|
|
76
|
+
if (effectFn) unsubscribe(effectFn);
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
export function computed(fn) {
|
|
45
80
|
const derived = signal(undefined);
|
|
46
81
|
effect(() => derived.set(fn()));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Parses the import statements in a <script> block so the server can
|
|
2
|
+
// supply what they bring in.
|
|
3
|
+
//
|
|
4
|
+
// The compiler hoists a plain JS import into the emitted module, so
|
|
5
|
+
// the browser loads it natively. Server rendering has no module
|
|
6
|
+
// context — the script runs inside a `new Function` — so the bindings
|
|
7
|
+
// are resolved here and passed in as arguments instead.
|
|
8
|
+
//
|
|
9
|
+
// `azox/reactivity` is the exception: the server supplies its own
|
|
10
|
+
// non-reactive primitives rather than loading the real runtime.
|
|
11
|
+
|
|
12
|
+
const RUNTIME_SPECIFIERS = new Set(['azox/reactivity', 'azoxjs/reactivity']);
|
|
13
|
+
|
|
14
|
+
// Matches the import forms a .azox script can use:
|
|
15
|
+
// import x from '…' default
|
|
16
|
+
// import { a, b as c } from '…' named
|
|
17
|
+
// import * as ns from '…' namespace
|
|
18
|
+
// import '…' side effect only
|
|
19
|
+
//
|
|
20
|
+
// A trailing import attribute — `with { type: 'json' }`, which is how
|
|
21
|
+
// a script reads package.json — is matched and ignored; Node applies
|
|
22
|
+
// it when the module is actually loaded.
|
|
23
|
+
const IMPORT_RE =
|
|
24
|
+
/^[ \t]*import\s+(?:([\s\S]*?)\s+from\s+)?['"]([^'"]+)['"][ \t]*(?:(?:with|assert)\s*\{[^}]*\})?[ \t]*;?[ \t]*$/gm;
|
|
25
|
+
|
|
26
|
+
// Returns one entry per import that the server must resolve, each
|
|
27
|
+
// naming the specifier and the local bindings it introduces.
|
|
28
|
+
export function parseImports(script) {
|
|
29
|
+
if (!script) return [];
|
|
30
|
+
|
|
31
|
+
const found = [];
|
|
32
|
+
|
|
33
|
+
for (const match of script.matchAll(IMPORT_RE)) {
|
|
34
|
+
const [, clause, specifier] = match;
|
|
35
|
+
|
|
36
|
+
if (RUNTIME_SPECIFIERS.has(specifier)) continue;
|
|
37
|
+
// A .azox import is a component, resolved at build time.
|
|
38
|
+
if (specifier.endsWith('.azox')) continue;
|
|
39
|
+
// Side-effect-only import introduces no bindings, and the server
|
|
40
|
+
// has nothing to bind, so there is nothing to do.
|
|
41
|
+
if (!clause) continue;
|
|
42
|
+
|
|
43
|
+
found.push({ specifier, bindings: parseClause(clause) });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Turns the text between `import` and `from` into the local names it
|
|
50
|
+
// declares, each paired with the export it comes from. A default
|
|
51
|
+
// import reads the `default` export; a namespace import takes the
|
|
52
|
+
// module object itself.
|
|
53
|
+
function parseClause(clause) {
|
|
54
|
+
const bindings = [];
|
|
55
|
+
const text = clause.trim();
|
|
56
|
+
|
|
57
|
+
const namespace = text.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
58
|
+
if (namespace) return [{ local: namespace[1], imported: '*' }];
|
|
59
|
+
|
|
60
|
+
// A default import may be followed by a named list:
|
|
61
|
+
// import def, { a, b } from '…'
|
|
62
|
+
const braceAt = text.indexOf('{');
|
|
63
|
+
const head = (braceAt === -1 ? text : text.slice(0, braceAt)).replace(/,\s*$/, '').trim();
|
|
64
|
+
|
|
65
|
+
if (head) {
|
|
66
|
+
const ns = head.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
67
|
+
if (ns) bindings.push({ local: ns[1], imported: '*' });
|
|
68
|
+
else if (/^[A-Za-z_$][\w$]*$/.test(head)) bindings.push({ local: head, imported: 'default' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (braceAt !== -1) {
|
|
72
|
+
const closeAt = text.lastIndexOf('}');
|
|
73
|
+
const names = text.slice(braceAt + 1, closeAt === -1 ? undefined : closeAt);
|
|
74
|
+
|
|
75
|
+
for (const part of names.split(',')) {
|
|
76
|
+
const entry = part.trim();
|
|
77
|
+
if (!entry) continue;
|
|
78
|
+
|
|
79
|
+
const aliased = entry.match(/^(.+?)\s+as\s+([A-Za-z_$][\w$]*)$/);
|
|
80
|
+
const imported = (aliased ? aliased[1] : entry).trim();
|
|
81
|
+
const local = aliased ? aliased[2] : imported;
|
|
82
|
+
|
|
83
|
+
if (/^[A-Za-z_$][\w$]*$/.test(local)) bindings.push({ local, imported });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return bindings;
|
|
88
|
+
}
|
|
@@ -6,27 +6,42 @@
|
|
|
6
6
|
// top of this markup.
|
|
7
7
|
|
|
8
8
|
import { BuildError } from '../buildError.js';
|
|
9
|
+
import { VOID_TAGS, escapeHtml } from '../compiler/html.js';
|
|
10
|
+
import { evaluateScript } from './serverScope.js';
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// `modules` is what the page's and its components' imports brought in,
|
|
13
|
+
// as local name → value. A component's script is evaluated here too,
|
|
14
|
+
// and its imports were hoisted to the page, so the bindings have to be
|
|
15
|
+
// handed down rather than re-resolved — this file has no filesystem
|
|
16
|
+
// access by design.
|
|
17
|
+
export function renderToHtml(ast, scope, modules = {}) {
|
|
18
|
+
return renderNode(ast.markup, scope, scope, modules);
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
// `outer` is the scope the surrounding markup was written in. It is
|
|
22
|
+
// almost always the same as `scope`; they differ inside a component,
|
|
23
|
+
// where slot content belongs to whoever wrote the tag rather than to
|
|
24
|
+
// the component rendering it.
|
|
25
|
+
function renderNode(node, scope, outer, modules = {}) {
|
|
17
26
|
if (!node) return '';
|
|
18
27
|
|
|
19
|
-
// A fragment
|
|
28
|
+
// A fragment contributes only its children. Slot content is the one
|
|
29
|
+
// place the two scopes come apart.
|
|
20
30
|
if (node.type === 'fragment') {
|
|
21
|
-
|
|
31
|
+
const childScope = node.slot ? outer : scope;
|
|
32
|
+
return node.children.map((child) => renderNode(child, childScope, outer, modules)).join('');
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
if (node.type === 'text') {
|
|
25
|
-
return node.parts
|
|
26
|
-
.map((part) => (part.kind === 'static' ? escapeHtml(part.value) : escapeHtml(String(evalExpr(part.expr, scope)))))
|
|
27
|
-
.join('');
|
|
36
|
+
return node.parts.map((part) => renderTextPart(part, scope)).join('');
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
// Control flow is evaluated once here, so the page arrives with its
|
|
40
|
+
// list already rendered rather than filling in when scripts run.
|
|
41
|
+
if (node.type === 'each') return renderEach(node, scope, outer, modules);
|
|
42
|
+
if (node.type === 'if') return renderIf(node, scope, outer, modules);
|
|
43
|
+
if (node.type === 'scope') return renderScope(node, scope, modules);
|
|
44
|
+
|
|
30
45
|
const attrs = Object.entries(node.attrs)
|
|
31
46
|
.filter(([key]) => !key.startsWith('on:'))
|
|
32
47
|
.map(([key, attr]) => {
|
|
@@ -37,10 +52,78 @@ function renderNode(node, scope) {
|
|
|
37
52
|
|
|
38
53
|
if (VOID_TAGS.has(node.name)) return `<${node.name}${attrs}>`;
|
|
39
54
|
|
|
40
|
-
const inner = node.children.map((child) => renderNode(child, scope)).join('');
|
|
55
|
+
const inner = node.children.map((child) => renderNode(child, scope, outer, modules)).join('');
|
|
41
56
|
return `<${node.name}${attrs}>${inner}</${node.name}>`;
|
|
42
57
|
}
|
|
43
58
|
|
|
59
|
+
// A stateful component runs its script here too, with the caller's
|
|
60
|
+
// prop values bound as arguments, so the server sees the same initial
|
|
61
|
+
// state the browser will build. Its declarations are added to a copy
|
|
62
|
+
// of the scope, so they cannot leak into the surrounding page.
|
|
63
|
+
function renderScope(node, scope, modules = {}) {
|
|
64
|
+
const args = node.args.map((expr) => evalExpr(expr, scope));
|
|
65
|
+
|
|
66
|
+
let declared;
|
|
67
|
+
try {
|
|
68
|
+
// A component's own imports were hoisted to the page and loaded
|
|
69
|
+
// there, so they are passed in rather than resolved again.
|
|
70
|
+
declared = evaluateScript(node.script, node.params, args, modules);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new BuildError(`in <${node.name}>: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const inner = { ...scope, ...declared };
|
|
76
|
+
for (const [i, param] of node.params.entries()) inner[param] = args[i];
|
|
77
|
+
|
|
78
|
+
// `scope` is passed on as the outer one: slot content nested in
|
|
79
|
+
// this component was written by whoever used the tag.
|
|
80
|
+
return node.children.map((child) => renderNode(child, inner, scope, modules)).join('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Each iteration renders with the loop variable added to the scope,
|
|
84
|
+
// so the body sees it the same way the compiled version does.
|
|
85
|
+
function renderEach(node, scope, outer, modules = {}) {
|
|
86
|
+
const items = evalExpr(node.expr, scope);
|
|
87
|
+
if (items === null || items === undefined) return '';
|
|
88
|
+
|
|
89
|
+
if (typeof items[Symbol.iterator] !== 'function') {
|
|
90
|
+
throw new BuildError(
|
|
91
|
+
`<each item={${node.expr}}> needs something iterable, such as an array — got ${typeof items}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let html = '';
|
|
96
|
+
let index = 0;
|
|
97
|
+
|
|
98
|
+
for (const item of items) {
|
|
99
|
+
const inner = { ...scope, [node.alias]: item };
|
|
100
|
+
if (node.index) inner[node.index] = index;
|
|
101
|
+
|
|
102
|
+
html += node.children.map((child) => renderNode(child, inner, outer, modules)).join('');
|
|
103
|
+
index++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return html;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderIf(node, scope, outer, modules = {}) {
|
|
110
|
+
const branch = evalExpr(node.expr, scope) ? node.then : node.otherwise;
|
|
111
|
+
return branch.map((child) => renderNode(child, scope, outer, modules)).join('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Three kinds of text, three rules:
|
|
115
|
+
// static — markup the author wrote, so an entity they typed
|
|
116
|
+
// (<) is meant to stay an entity. Passed through.
|
|
117
|
+
// literal — content of a <text> block, meant to appear exactly as
|
|
118
|
+
// written, so it is escaped into entities.
|
|
119
|
+
// expr — data, which is where untrusted content could enter.
|
|
120
|
+
// Always escaped.
|
|
121
|
+
function renderTextPart(part, scope) {
|
|
122
|
+
if (part.kind === 'static') return part.value;
|
|
123
|
+
if (part.kind === 'literal') return escapeHtml(part.value);
|
|
124
|
+
return escapeHtml(String(evalExpr(part.expr, scope)));
|
|
125
|
+
}
|
|
126
|
+
|
|
44
127
|
function evalExpr(expr, scope) {
|
|
45
128
|
const keys = Object.keys(scope);
|
|
46
129
|
|
|
@@ -58,10 +141,3 @@ function evalExpr(expr, scope) {
|
|
|
58
141
|
}
|
|
59
142
|
}
|
|
60
143
|
|
|
61
|
-
function escapeHtml(str) {
|
|
62
|
-
return str
|
|
63
|
-
.replace(/&/g, '&')
|
|
64
|
-
.replace(/</g, '<')
|
|
65
|
-
.replace(/>/g, '>')
|
|
66
|
-
.replace(/"/g, '"');
|
|
67
|
-
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Runs a page's or a component's <script> block during server
|
|
2
|
+
// rendering, and hands back the bindings it declared.
|
|
3
|
+
//
|
|
4
|
+
// The script is trusted project source, not user input — the same
|
|
5
|
+
// assumption any template engine's server step makes. It must never
|
|
6
|
+
// be pointed at .azox content submitted by someone else.
|
|
7
|
+
//
|
|
8
|
+
// One implementation, used by both the build and the renderer: two
|
|
9
|
+
// copies drifted apart once already, leaving pages able to use
|
|
10
|
+
// `computed` while components could not.
|
|
11
|
+
|
|
12
|
+
// Server rendering needs only the current value, not reactivity, so
|
|
13
|
+
// a signal here is a plain box. The real runtime takes over in the
|
|
14
|
+
// browser.
|
|
15
|
+
export function serverSignal(initial) {
|
|
16
|
+
let value = initial;
|
|
17
|
+
|
|
18
|
+
const read = () => value;
|
|
19
|
+
read.set = (next) => {
|
|
20
|
+
value = typeof next === 'function' ? next(value) : next;
|
|
21
|
+
};
|
|
22
|
+
read.peek = () => value;
|
|
23
|
+
|
|
24
|
+
return read;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function serverComputed(fn) {
|
|
28
|
+
return () => fn();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Every top-level binding the script introduces, so they can all be
|
|
32
|
+
// handed to the markup. Function and class declarations count too — a
|
|
33
|
+
// component may well define a helper the template calls.
|
|
34
|
+
export function declaredNames(script) {
|
|
35
|
+
const names = new Set();
|
|
36
|
+
|
|
37
|
+
for (const match of script.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) {
|
|
38
|
+
names.add(match[1]);
|
|
39
|
+
}
|
|
40
|
+
for (const match of script.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
41
|
+
names.add(match[1]);
|
|
42
|
+
}
|
|
43
|
+
for (const match of script.matchAll(/class\s+([A-Za-z_$][\w$]*)/g)) {
|
|
44
|
+
names.add(match[1]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Destructured declarations: const { a, b: c, d = 1 } = …
|
|
48
|
+
for (const match of script.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=/g)) {
|
|
49
|
+
for (const part of match[1].split(',')) {
|
|
50
|
+
const name = part.split(':').pop().split('=')[0].trim();
|
|
51
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return [...names];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Evaluates a script body and returns its declarations. `params` and
|
|
59
|
+
// `args` pass a component's props in as arguments.
|
|
60
|
+
//
|
|
61
|
+
// `modules` carries what the script's imports brought in, as local
|
|
62
|
+
// name → value. The body runs inside a `new Function`, which cannot
|
|
63
|
+
// use `import`, so the bindings arrive as arguments instead — the
|
|
64
|
+
// build resolves them, since loading a module needs the filesystem
|
|
65
|
+
// and this file has to stay usable in a browser.
|
|
66
|
+
export function evaluateScript(body, params = [], args = [], modules = {}) {
|
|
67
|
+
const imported = Object.keys(modules);
|
|
68
|
+
const names = declaredNames(body).filter((name) => !imported.includes(name));
|
|
69
|
+
|
|
70
|
+
const fn = new Function(
|
|
71
|
+
'signal',
|
|
72
|
+
'computed',
|
|
73
|
+
...imported,
|
|
74
|
+
...params,
|
|
75
|
+
`${body}\nreturn { ${names.join(', ')} };`
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const declared = fn(serverSignal, serverComputed, ...imported.map((n) => modules[n]), ...args);
|
|
79
|
+
|
|
80
|
+
// An imported binding is in scope for the markup too, the same way
|
|
81
|
+
// it is in the compiled module.
|
|
82
|
+
return { ...modules, ...declared };
|
|
83
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Client-side navigation for Azox.
|
|
2
|
+
//
|
|
3
|
+
// Swaps one page's markup for another without a full reload, and
|
|
4
|
+
// keeps the browser's history in step. Deliberately small: there is
|
|
5
|
+
// no route table, no matching, and no component tree to keep alive —
|
|
6
|
+
// the server already produced every page, so navigating is fetching
|
|
7
|
+
// the next one and putting it in place.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is an enhancement over links that already work.
|
|
10
|
+
// With the script absent or broken, an <a> is still an <a>.
|
|
11
|
+
|
|
12
|
+
const ROOT = '[data-azox-root]';
|
|
13
|
+
const PREFETCH_LIMIT = 24;
|
|
14
|
+
|
|
15
|
+
// Documents already fetched, keyed by URL. Bounded so a long session
|
|
16
|
+
// browsing a large site cannot grow it without limit.
|
|
17
|
+
const cache = new Map();
|
|
18
|
+
|
|
19
|
+
let currentUrl = location.href;
|
|
20
|
+
|
|
21
|
+
/** Starts intercepting navigation. Safe to call more than once. */
|
|
22
|
+
export function startRouter(options = {}) {
|
|
23
|
+
if (typeof document === 'undefined' || document.__azoxRouter) return;
|
|
24
|
+
document.__azoxRouter = true;
|
|
25
|
+
|
|
26
|
+
const prefetch = options.prefetch !== false;
|
|
27
|
+
|
|
28
|
+
document.addEventListener('click', onClick);
|
|
29
|
+
window.addEventListener('popstate', onPopState);
|
|
30
|
+
|
|
31
|
+
if (prefetch) {
|
|
32
|
+
document.addEventListener('pointerenter', onPointerEnter, { capture: true });
|
|
33
|
+
document.addEventListener('touchstart', onPointerEnter, { capture: true, passive: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function onClick(event) {
|
|
38
|
+
// Leave anything the browser should handle itself: modified clicks
|
|
39
|
+
// open in a new tab or window, and a non-primary button is not a
|
|
40
|
+
// plain navigation.
|
|
41
|
+
if (event.defaultPrevented || event.button !== 0) return;
|
|
42
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
43
|
+
|
|
44
|
+
const link = event.target.closest?.('a');
|
|
45
|
+
const url = navigableUrl(link);
|
|
46
|
+
if (!url) return;
|
|
47
|
+
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
|
|
50
|
+
// Clicking the current page's own link should not push a duplicate
|
|
51
|
+
// entry onto the history stack.
|
|
52
|
+
if (url.href === location.href) return;
|
|
53
|
+
|
|
54
|
+
history.pushState(null, '', url.href);
|
|
55
|
+
go(url.href);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onPopState() {
|
|
59
|
+
go(location.href, { restore: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onPointerEnter(event) {
|
|
63
|
+
const link = event.target?.closest?.('a');
|
|
64
|
+
const url = navigableUrl(link);
|
|
65
|
+
if (url) load(url.href);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Decides whether a link is one this router should handle. Anything
|
|
69
|
+
// off-site, downloadable, or explicitly opted out is left alone.
|
|
70
|
+
function navigableUrl(link) {
|
|
71
|
+
if (!link || !link.href) return null;
|
|
72
|
+
if (link.target && link.target !== '_self') return null;
|
|
73
|
+
if (link.hasAttribute('download') || link.hasAttribute('data-no-router')) return null;
|
|
74
|
+
if (link.getAttribute('rel')?.includes('external')) return null;
|
|
75
|
+
|
|
76
|
+
let url;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(link.href, location.href);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (url.origin !== location.origin) return null;
|
|
84
|
+
|
|
85
|
+
// A link to a different spot on the same page is the browser's job.
|
|
86
|
+
if (url.pathname === location.pathname && url.hash) return null;
|
|
87
|
+
|
|
88
|
+
return url;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function load(href) {
|
|
92
|
+
if (cache.has(href)) return cache.get(href);
|
|
93
|
+
|
|
94
|
+
const pending = fetch(href, { headers: { Accept: 'text/html' } })
|
|
95
|
+
.then((response) => {
|
|
96
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
97
|
+
return response.text();
|
|
98
|
+
})
|
|
99
|
+
.catch((error) => {
|
|
100
|
+
// A failed prefetch must not poison the cache: the click that
|
|
101
|
+
// follows should be able to try again, and fall back to a real
|
|
102
|
+
// navigation if it fails too.
|
|
103
|
+
cache.delete(href);
|
|
104
|
+
throw error;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (cache.size >= PREFETCH_LIMIT) cache.delete(cache.keys().next().value);
|
|
108
|
+
cache.set(href, pending);
|
|
109
|
+
|
|
110
|
+
return pending;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function go(href, { restore = false } = {}) {
|
|
114
|
+
let html;
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
html = await load(href);
|
|
118
|
+
} catch {
|
|
119
|
+
// Anything unexpected — a network failure, a 404, a redirect to
|
|
120
|
+
// another origin — hands control back to the browser rather than
|
|
121
|
+
// leaving the reader on a page that did not change.
|
|
122
|
+
location.href = href;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A second navigation may have started while this one was in
|
|
127
|
+
// flight. Comparing against the address bar is enough to tell: it
|
|
128
|
+
// already holds wherever the reader last asked to go.
|
|
129
|
+
if (location.href !== href) return;
|
|
130
|
+
|
|
131
|
+
const next = new DOMParser().parseFromString(html, 'text/html');
|
|
132
|
+
const incoming = next.querySelector(ROOT);
|
|
133
|
+
const target = document.querySelector(ROOT);
|
|
134
|
+
|
|
135
|
+
if (!incoming || !target) {
|
|
136
|
+
location.href = href;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
document.title = next.title;
|
|
141
|
+
syncHead(next);
|
|
142
|
+
target.replaceWith(incoming.cloneNode(true));
|
|
143
|
+
|
|
144
|
+
await runPageModule(next, href);
|
|
145
|
+
|
|
146
|
+
currentUrl = href;
|
|
147
|
+
restoreScroll(restore);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Brings across anything in the new page's head that this one lacks —
|
|
151
|
+
// its description, its canonical link — and drops what it no longer
|
|
152
|
+
// needs. Stylesheets are left in place: they are shared across pages,
|
|
153
|
+
// and removing one mid-navigation causes a visible flash.
|
|
154
|
+
function syncHead(next) {
|
|
155
|
+
const selector = 'meta[name], link[rel="canonical"]';
|
|
156
|
+
const keep = new Set();
|
|
157
|
+
|
|
158
|
+
for (const node of next.head.querySelectorAll(selector)) {
|
|
159
|
+
const key = node.outerHTML;
|
|
160
|
+
keep.add(key);
|
|
161
|
+
|
|
162
|
+
const existing = [...document.head.querySelectorAll(selector)].find(
|
|
163
|
+
(candidate) => candidate.outerHTML === key
|
|
164
|
+
);
|
|
165
|
+
if (!existing) document.head.append(node.cloneNode(true));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const node of document.head.querySelectorAll(selector)) {
|
|
169
|
+
if (!keep.has(node.outerHTML)) node.remove();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Each page ships its own module of bindings. Importing it runs its
|
|
174
|
+
// hydration, which is what makes the newly inserted markup live.
|
|
175
|
+
//
|
|
176
|
+
// `pageHref` matters: a page's module is referenced relatively, and
|
|
177
|
+
// resolving that against the address bar is wrong. Every route is a
|
|
178
|
+
// directory, so "/about" and "/about/" resolve "./page.client.js"
|
|
179
|
+
// differently — the first lands on the *root* page's module, which
|
|
180
|
+
// then hydrates the page you just navigated away from back into place.
|
|
181
|
+
async function runPageModule(next, pageHref) {
|
|
182
|
+
const script = next.querySelector('script[type="module"][src]');
|
|
183
|
+
if (!script) return;
|
|
184
|
+
|
|
185
|
+
const base = pageHref.endsWith('/') ? pageHref : `${pageHref}/`;
|
|
186
|
+
const url = new URL(script.getAttribute('src'), base).href;
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// The cache-busting parameter matters: a module is evaluated once
|
|
190
|
+
// per URL, so returning to a page would otherwise insert markup
|
|
191
|
+
// that nothing ever binds to.
|
|
192
|
+
await import(/* @vite-ignore */ `${url}${url.includes('?') ? '&' : '?'}azox=${Date.now()}`);
|
|
193
|
+
} catch {
|
|
194
|
+
// A page whose bindings fail still shows its server-rendered
|
|
195
|
+
// markup, which is better than replacing it with nothing.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function restoreScroll(restore) {
|
|
200
|
+
if (restore) return; // The browser restores the position itself.
|
|
201
|
+
|
|
202
|
+
const { hash } = location;
|
|
203
|
+
const target = hash && document.querySelector(hash);
|
|
204
|
+
|
|
205
|
+
if (target) target.scrollIntoView();
|
|
206
|
+
else window.scrollTo(0, 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function currentHref() {
|
|
210
|
+
return currentUrl;
|
|
211
|
+
}
|