azoxjs 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,10 @@ import { join } from 'node:path';
11
11
 
12
12
  const DEBOUNCE_MS = 40;
13
13
 
14
- export function watchDirectory(dir, onChange, { filter = () => true } = {}) {
14
+ // `recursive: false` watches only the directory itself. The project
15
+ // root holds the build output and node_modules, so sweeping it
16
+ // recursively would rebuild in a loop.
17
+ export function watchDirectory(dir, onChange, { filter = () => true, recursive = true } = {}) {
15
18
  const watchers = [];
16
19
  let timer = null;
17
20
 
@@ -22,6 +25,14 @@ export function watchDirectory(dir, onChange, { filter = () => true } = {}) {
22
25
  timer = setTimeout(() => onChange(filename), DEBOUNCE_MS);
23
26
  };
24
27
 
28
+ if (!recursive) {
29
+ watchers.push(watch(dir, (_event, filename) => trigger(filename)));
30
+ return () => {
31
+ clearTimeout(timer);
32
+ for (const watcher of watchers) watcher.close();
33
+ };
34
+ }
35
+
25
36
  try {
26
37
  watchers.push(watch(dir, { recursive: true }, (_event, filename) => trigger(filename)));
27
38
  } catch {
@@ -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
+ }
@@ -11,7 +11,12 @@ export function signal(initialValue) {
11
11
  const subscribers = new Set();
12
12
 
13
13
  function read() {
14
- if (activeEffect) subscribers.add(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
- const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
11
-
12
- export function renderToHtml(ast, scope) {
13
- return renderNode(ast.markup, scope);
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
- function renderNode(node, scope) {
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 (from <slot />) contributes only its children.
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
- return node.children.map((child) => renderNode(child, scope)).join('');
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
+ // (&lt;) 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, '&amp;')
64
- .replace(/</g, '&lt;')
65
- .replace(/>/g, '&gt;')
66
- .replace(/"/g, '&quot;');
67
- }
@@ -0,0 +1,95 @@
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
+ // `extras` are further bindings the caller supplies by name — `params`
62
+ // for a dynamic page, and `routes` while its route list is collected.
63
+ //
64
+ // `modules` carries what the script's imports brought in, as local
65
+ // name → value. The body runs inside a `new Function`, which cannot
66
+ // use `import`, so the bindings arrive as arguments instead — the
67
+ // build resolves them, since loading a module needs the filesystem
68
+ // and this file has to stay usable in a browser.
69
+ export function evaluateScript(body, params = [], args = [], modules = {}, extras = {}) {
70
+ const imported = Object.keys(modules);
71
+ const extraNames = Object.keys(extras);
72
+ const reserved = new Set([...imported, ...extraNames]);
73
+ const names = declaredNames(body).filter((name) => !reserved.has(name));
74
+
75
+ const fn = new Function(
76
+ 'signal',
77
+ 'computed',
78
+ ...imported,
79
+ ...extraNames,
80
+ ...params,
81
+ `${body}\nreturn { ${names.join(', ')} };`
82
+ );
83
+
84
+ const declared = fn(
85
+ serverSignal,
86
+ serverComputed,
87
+ ...imported.map((n) => modules[n]),
88
+ ...extraNames.map((n) => extras[n]),
89
+ ...args
90
+ );
91
+
92
+ // An imported binding is in scope for the markup too, the same way
93
+ // it is in the compiled module.
94
+ return { ...modules, ...declared };
95
+ }
@@ -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
+ }