@ape-egg/vibe 2.1.22 → 2.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.
- package/CHANGELOG.md +37 -0
- package/README.md +98 -1
- package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
- package/compiler/native/vibe-compiler-linux-x64 +0 -0
- package/compiler/src/Cargo.lock +1 -1
- package/compiler/src/Cargo.toml +1 -1
- package/compiler/src/compiler/compile.rs +367 -9
- package/compiler/src/compiler/mod.rs +1 -0
- package/compiler/src/compiler/spa.rs +477 -0
- package/compiler/src/compiler/watcher.rs +149 -18
- package/compiler/src/config.rs +41 -1
- package/compiler/src/main.rs +11 -0
- package/index.js +16 -2
- package/llms.txt +29 -0
- package/package.json +2 -1
- package/runtime/component.js +53 -4
- package/runtime/hydrate.js +30 -3
- package/runtime/index.js +15 -0
- package/runtime/parse.js +12 -6
- package/spa.js +143 -0
package/runtime/parse.js
CHANGED
|
@@ -22,13 +22,14 @@ const findComponentIdForElement = (element) => {
|
|
|
22
22
|
// Called from both the root handler and recursive() so they can't drift. Any
|
|
23
23
|
// element classified as a fetched component (`<component src>` or
|
|
24
24
|
// `<div class="component" src>`) captures ONLY a bound src (`src="@[page.src]"`
|
|
25
|
-
// — resolved by hydration before the fetch, re-mounted on change)
|
|
25
|
+
// — resolved by hydration before the fetch, re-mounted on change) and a bound
|
|
26
|
+
// key (`key="@[page.path]"` — a key change remounts the same src); every other
|
|
26
27
|
// attribute is a prop owned by processComponent and must stay raw — hydrating
|
|
27
28
|
// them would coerce objects to "[object Object]" or strip boolean-like attrs
|
|
28
|
-
// to empty. A mounted wrapper carries the authored
|
|
29
|
-
// (stamped by finalize — the src attribute was consumed by the
|
|
30
|
-
// knowledge survives every wrapper replacement: reparsing the
|
|
31
|
-
// rebuilds it.
|
|
29
|
+
// to empty. A mounted wrapper carries the authored bindings in data-vibe-src /
|
|
30
|
+
// data-vibe-key (stamped by finalize — the src attribute was consumed by the
|
|
31
|
+
// fetch), so the knowledge survives every wrapper replacement: reparsing the
|
|
32
|
+
// live DOM alone rebuilds it.
|
|
32
33
|
const captureAttributeBindings = (element, aliasSet) => {
|
|
33
34
|
const nodeName = element.nodeName;
|
|
34
35
|
const isFetchedComponent =
|
|
@@ -37,9 +38,14 @@ const captureAttributeBindings = (element, aliasSet) => {
|
|
|
37
38
|
|
|
38
39
|
if (isFetchedComponent) {
|
|
39
40
|
const src = element.getAttribute?.('data-vibe-src') ?? element.getAttribute?.('src');
|
|
41
|
+
const key = element.getAttribute?.('data-vibe-key') ?? element.getAttribute?.('key');
|
|
42
|
+
const attributes = {};
|
|
40
43
|
BINDING_REGEX.lastIndex = 0;
|
|
44
|
+
if (BINDING_REGEX.test(src)) attributes.src = src;
|
|
45
|
+
BINDING_REGEX.lastIndex = 0;
|
|
46
|
+
if (key && BINDING_REGEX.test(key)) attributes.key = key;
|
|
41
47
|
return {
|
|
42
|
-
attributes:
|
|
48
|
+
attributes: Object.keys(attributes).length ? attributes : null,
|
|
43
49
|
nameBindings: null,
|
|
44
50
|
};
|
|
45
51
|
}
|
package/spa.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @ape-egg/vibe/spa
|
|
2
|
+
//
|
|
3
|
+
// Standalone SPA router — the tier between a handrolled router and compiler
|
|
4
|
+
// SPA mode. Maintains one contract: $.page = { path, route, params, src,
|
|
5
|
+
// name }, which a reactive component src (<component src="@[page.src]">)
|
|
6
|
+
// turns into a route outlet.
|
|
7
|
+
//
|
|
8
|
+
// SOFT DEPENDENCY: nothing in Vibe's runtime imports this, it imports nothing
|
|
9
|
+
// from Vibe, and deleting it leaves a working framework. The default
|
|
10
|
+
// onNavigate writes to the runtime global window.$ at call time; pass a
|
|
11
|
+
// custom onNavigate and the module is a pure router (parse/claim/history)
|
|
12
|
+
// with no Vibe in sight.
|
|
13
|
+
//
|
|
14
|
+
// Route grammar (shared with the compiler's route table and route scanners):
|
|
15
|
+
// /static/path literal segments
|
|
16
|
+
// /brawlers/:index :param captures one segment
|
|
17
|
+
// /docs/:rest* trailing :name* captures zero or more segments
|
|
18
|
+
// * declared no-match fallback — deep links and popstate
|
|
19
|
+
// only, never claims a click
|
|
20
|
+
// Tables are pre-sorted most-specific-first; first match wins.
|
|
21
|
+
|
|
22
|
+
// Match one pathname against one route template. Returns the params object
|
|
23
|
+
// (possibly empty) on match, null otherwise. Empty segments are dropped, so
|
|
24
|
+
// trailing slashes resolve to the same route.
|
|
25
|
+
const matchRoute = (path, route) => {
|
|
26
|
+
const routeSegments = route.split('/').filter(Boolean);
|
|
27
|
+
const pathSegments = path.split('/').filter(Boolean);
|
|
28
|
+
const params = {};
|
|
29
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
30
|
+
const segment = routeSegments[i];
|
|
31
|
+
if (segment.startsWith(':') && segment.endsWith('*')) {
|
|
32
|
+
params[segment.slice(1, -1)] = pathSegments.slice(i).join('/');
|
|
33
|
+
return params;
|
|
34
|
+
}
|
|
35
|
+
if (pathSegments[i] === undefined) return null;
|
|
36
|
+
if (segment.startsWith(':')) params[segment.slice(1)] = pathSegments[i];
|
|
37
|
+
else if (segment !== pathSegments[i]) return null;
|
|
38
|
+
}
|
|
39
|
+
return pathSegments.length === routeSegments.length ? params : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Route slug: segments joined with dashes, params flattened to their bare
|
|
43
|
+
// name — '/' → 'home', '/pve/:id' → 'pve-id', '/docs/:rest*' → 'docs-rest'.
|
|
44
|
+
// Stable across param values, so markup hangs page-scoped attributes and
|
|
45
|
+
// active checks on it: <page @[page.name]>, page.name.startsWith('pve').
|
|
46
|
+
const routeName = (route) =>
|
|
47
|
+
route
|
|
48
|
+
.replace(/^\/+|\/+$/g, '')
|
|
49
|
+
.replace(/:(\w+)\*?/g, '$1')
|
|
50
|
+
.replace(/\//g, '-') || 'home';
|
|
51
|
+
|
|
52
|
+
// Resolve a location (anything with a .pathname, or a bare path string)
|
|
53
|
+
// against a route table → { path, route, params, src, name, title? } | null.
|
|
54
|
+
// Pure: the compiled shell seeds initial $.page with it, unit tests drive it
|
|
55
|
+
// directly. '*' is skipped during matching and applied only when nothing
|
|
56
|
+
// real matched — it is a declared fallback, not a positional route.
|
|
57
|
+
export const resolve = (location, routes) => {
|
|
58
|
+
const path = location.pathname ?? location;
|
|
59
|
+
for (const entry of routes) {
|
|
60
|
+
if (entry.route === '*') continue;
|
|
61
|
+
const params = matchRoute(path, entry.route);
|
|
62
|
+
if (params)
|
|
63
|
+
return {
|
|
64
|
+
path,
|
|
65
|
+
route: entry.route,
|
|
66
|
+
params,
|
|
67
|
+
src: entry.src,
|
|
68
|
+
name: routeName(entry.route),
|
|
69
|
+
title: entry.title,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const fallback = routes.find((entry) => entry.route === '*');
|
|
73
|
+
return fallback
|
|
74
|
+
? { path, route: '*', params: {}, src: fallback.src, name: '*', title: fallback.title }
|
|
75
|
+
: null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Wire the router: one document-level click listener + popstate. Claiming
|
|
79
|
+
// rule: same-origin, unmodified, untargeted clicks whose pathname matches a
|
|
80
|
+
// REAL route — '*' never claims, so unrouted paths navigate natively (which
|
|
81
|
+
// is what makes mixed MPA/SPA output work). Returns { navigate, dispose }.
|
|
82
|
+
export const setupSpa = ({ routes, onNavigate }) => {
|
|
83
|
+
// Default Vibe binding: one fresh-object assignment so bindings diff
|
|
84
|
+
// cleanly, reading window.$ at call time (page scripts boot vibe before
|
|
85
|
+
// setupSpa runs). A route-supplied title swaps document.title.
|
|
86
|
+
const apply = onNavigate ?? (({ path, route, params, src, name, title }) => {
|
|
87
|
+
window.$.page = { path, route, params, src, name };
|
|
88
|
+
if (title) document.title = title;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const claim = (target) => {
|
|
92
|
+
const resolved = resolve(target, routes);
|
|
93
|
+
return resolved && resolved.route !== '*' ? resolved : null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const push = (url, resolved) => {
|
|
97
|
+
history.pushState({}, '', url);
|
|
98
|
+
apply(resolved);
|
|
99
|
+
scrollTo(0, 0);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const onClick = (event) => {
|
|
103
|
+
const anchor = event.target.closest('a[href]');
|
|
104
|
+
if (!anchor || anchor.origin !== location.origin) return;
|
|
105
|
+
if (anchor.target || anchor.hasAttribute('download')) return;
|
|
106
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button) return;
|
|
107
|
+
if (event.defaultPrevented) return;
|
|
108
|
+
// Same-page hash links keep the native anchor jump.
|
|
109
|
+
if (anchor.hash && anchor.pathname === location.pathname) return;
|
|
110
|
+
const resolved = claim(anchor);
|
|
111
|
+
if (!resolved) return;
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
push(anchor.href, resolved);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// History traversal re-resolves WITH the '*' fallback — symmetric with
|
|
117
|
+
// deep-link entry, whose URL may itself be a '*' page. No scroll: the
|
|
118
|
+
// browser restores scroll position on popstate.
|
|
119
|
+
const onPopstate = () => {
|
|
120
|
+
const resolved = resolve(location, routes);
|
|
121
|
+
if (resolved) apply(resolved);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Programmatic navigation, claiming like a link click: real route match →
|
|
125
|
+
// SPA navigation, anything else → native load (the server rewrite serves
|
|
126
|
+
// the shell, whose deep-link resolution may then mount '*').
|
|
127
|
+
const navigate = (path) => {
|
|
128
|
+
const url = new URL(path, location.origin);
|
|
129
|
+
const resolved = claim(url);
|
|
130
|
+
if (resolved) push(url, resolved);
|
|
131
|
+
else location.assign(url);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
document.addEventListener('click', onClick);
|
|
135
|
+
addEventListener('popstate', onPopstate);
|
|
136
|
+
|
|
137
|
+
const dispose = () => {
|
|
138
|
+
document.removeEventListener('click', onClick);
|
|
139
|
+
removeEventListener('popstate', onPopstate);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { navigate, dispose };
|
|
143
|
+
};
|