@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/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); every other
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 binding in data-vibe-src
29
- // (stamped by finalize — the src attribute was consumed by the fetch), so the
30
- // knowledge survives every wrapper replacement: reparsing the live DOM alone
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: BINDING_REGEX.test(src) ? { src } : null,
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
+ };