@wippy-fe/router 0.0.34 → 0.0.35
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/dist/index.d.ts +290 -0
- package/dist/index.js +235 -0
- package/dist/index.js.map +1 -0
- package/package.json +19 -7
- package/src/classify.ts +0 -11
- package/src/create-app-router.ts +0 -93
- package/src/host-router-link.ts +0 -45
- package/src/index.ts +0 -13
- package/src/router-link.ts +0 -132
- package/src/validate-mount-routes.ts +0 -110
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { host, on } from '@wippy-fe/proxy';
|
|
2
|
+
export { ClassifierContext, LinkClassification, LinkKind, classifyLink, setLocalRouter } from '@wippy-fe/proxy';
|
|
3
|
+
import { RouteRecordRaw, Router, RouteLocationRaw } from 'vue-router';
|
|
4
|
+
import * as vue from 'vue';
|
|
5
|
+
import { PropType } from 'vue';
|
|
6
|
+
|
|
7
|
+
interface CreateAppRouterOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Initial route path to prime history with. Usually `config.context?.route`
|
|
10
|
+
* or the subapp's current location from its mount context.
|
|
11
|
+
*/
|
|
12
|
+
initialPath?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Override the host API (testing / dev harness). Defaults to the `host`
|
|
15
|
+
* export from `@wippy-fe/proxy`.
|
|
16
|
+
*/
|
|
17
|
+
host?: typeof host;
|
|
18
|
+
/**
|
|
19
|
+
* Override the event subscription API. Set to `null` to disable the
|
|
20
|
+
* `@history` subscription entirely. Defaults to `on` from `@wippy-fe/proxy`.
|
|
21
|
+
*/
|
|
22
|
+
on?: typeof on | null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Canonical memory-router factory for Wippy subapps.
|
|
26
|
+
*
|
|
27
|
+
* Every subapp currently duplicates the same boilerplate:
|
|
28
|
+
* - create a memory history
|
|
29
|
+
* - prime it with an initial path from context
|
|
30
|
+
* - call `host.onRouteChanged` in `afterEach`
|
|
31
|
+
* - listen for `@history` events from the host and mirror them to the router
|
|
32
|
+
*
|
|
33
|
+
* This factory encapsulates that boilerplate so subapps become one-liners:
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { createAppRouter } from '@wippy-fe/router'
|
|
37
|
+
* import { routes } from './routes'
|
|
38
|
+
*
|
|
39
|
+
* const router = createAppRouter(routes, {
|
|
40
|
+
* initialPath: appConfig.context?.route,
|
|
41
|
+
* })
|
|
42
|
+
* app.use(router)
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* Memory history is hardcoded — srcdoc iframes do not support `createWebHistory`
|
|
46
|
+
* (confirmed dead end — `window.location` is non-configurable in srcdoc, and
|
|
47
|
+
* framework routers read `location.pathname` directly). Only memory-based
|
|
48
|
+
* routing works inside child iframes.
|
|
49
|
+
*/
|
|
50
|
+
declare function createAppRouter(routes: RouteRecordRaw[], options?: CreateAppRouterOptions): Router;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Explicit host-level navigation link. Use when you want to navigate
|
|
54
|
+
* to a known host page regardless of the current nesting level.
|
|
55
|
+
*
|
|
56
|
+
* Unlike `AutoRouterLink` (which classifies links automatically),
|
|
57
|
+
* `HostRouterLink` ALWAYS sends navigation to the host via postMessage.
|
|
58
|
+
*
|
|
59
|
+
* Use cases:
|
|
60
|
+
* - Inside a nav-owner child linking to a different host page
|
|
61
|
+
* - Anywhere you explicitly want host-level navigation
|
|
62
|
+
*
|
|
63
|
+
* Renders a real `<a>` for accessibility / right-click / SEO.
|
|
64
|
+
* Left-click calls `host.navigate()`. Modifier keys (ctrl, meta, shift)
|
|
65
|
+
* fall through to the browser default (open in new tab).
|
|
66
|
+
*/
|
|
67
|
+
declare const HostRouterLink: vue.DefineComponent<vue.ExtractPropTypes<{
|
|
68
|
+
to: {
|
|
69
|
+
type: PropType<string>;
|
|
70
|
+
required: true;
|
|
71
|
+
};
|
|
72
|
+
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
|
|
73
|
+
[key: string]: any;
|
|
74
|
+
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly<vue.ExtractPropTypes<{
|
|
75
|
+
to: {
|
|
76
|
+
type: PropType<string>;
|
|
77
|
+
required: true;
|
|
78
|
+
};
|
|
79
|
+
}>> & Readonly<{}>, {}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Drop-in replacement for Vue Router's `<RouterLink>` that classifies the
|
|
83
|
+
* target href and dispatches intelligently:
|
|
84
|
+
*
|
|
85
|
+
* - `host-nav` → preventDefault + `host.navigate(href)` via postMessage
|
|
86
|
+
* - `child-nav` → delegate to the real `<RouterLink>` (normal in-app nav)
|
|
87
|
+
* - `external` → render an `<a>` with `target="_blank" rel="noopener"`
|
|
88
|
+
* - `ignore` → render an `<a>` with a click-preventing handler
|
|
89
|
+
*
|
|
90
|
+
* Subapps migrate by changing a single import:
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* // before
|
|
94
|
+
* import { RouterLink } from 'vue-router'
|
|
95
|
+
*
|
|
96
|
+
* // after
|
|
97
|
+
* import { RouterLink } from '@wippy-fe/router'
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* Props are a compatible subset of vue-router's RouterLink. Slot API
|
|
101
|
+
* mirrors the default (non-custom) slot of the real RouterLink.
|
|
102
|
+
*/
|
|
103
|
+
declare const AutoRouterLink: vue.DefineComponent<vue.ExtractPropTypes<{
|
|
104
|
+
to: {
|
|
105
|
+
type: PropType<RouteLocationRaw>;
|
|
106
|
+
required: true;
|
|
107
|
+
};
|
|
108
|
+
replace: {
|
|
109
|
+
type: BooleanConstructor;
|
|
110
|
+
default: boolean;
|
|
111
|
+
};
|
|
112
|
+
activeClass: {
|
|
113
|
+
type: StringConstructor;
|
|
114
|
+
default: undefined;
|
|
115
|
+
};
|
|
116
|
+
exactActiveClass: {
|
|
117
|
+
type: StringConstructor;
|
|
118
|
+
default: undefined;
|
|
119
|
+
};
|
|
120
|
+
ariaCurrentValue: {
|
|
121
|
+
type: PropType<"page" | "step" | "location" | "date" | "time" | "true" | "false">;
|
|
122
|
+
default: string;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* For `external` kind: sets the anchor's target attribute.
|
|
126
|
+
* Defaults to `_blank` with safe `rel`. Pass empty string for same-tab.
|
|
127
|
+
*/
|
|
128
|
+
externalTarget: {
|
|
129
|
+
type: StringConstructor;
|
|
130
|
+
default: string;
|
|
131
|
+
};
|
|
132
|
+
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
|
|
133
|
+
[key: string]: any;
|
|
134
|
+
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly<vue.ExtractPropTypes<{
|
|
135
|
+
to: {
|
|
136
|
+
type: PropType<RouteLocationRaw>;
|
|
137
|
+
required: true;
|
|
138
|
+
};
|
|
139
|
+
replace: {
|
|
140
|
+
type: BooleanConstructor;
|
|
141
|
+
default: boolean;
|
|
142
|
+
};
|
|
143
|
+
activeClass: {
|
|
144
|
+
type: StringConstructor;
|
|
145
|
+
default: undefined;
|
|
146
|
+
};
|
|
147
|
+
exactActiveClass: {
|
|
148
|
+
type: StringConstructor;
|
|
149
|
+
default: undefined;
|
|
150
|
+
};
|
|
151
|
+
ariaCurrentValue: {
|
|
152
|
+
type: PropType<"page" | "step" | "location" | "date" | "time" | "true" | "false">;
|
|
153
|
+
default: string;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* For `external` kind: sets the anchor's target attribute.
|
|
157
|
+
* Defaults to `_blank` with safe `rel`. Pass empty string for same-tab.
|
|
158
|
+
*/
|
|
159
|
+
externalTarget: {
|
|
160
|
+
type: StringConstructor;
|
|
161
|
+
default: string;
|
|
162
|
+
};
|
|
163
|
+
}>> & Readonly<{}>, {
|
|
164
|
+
replace: boolean;
|
|
165
|
+
activeClass: string;
|
|
166
|
+
exactActiveClass: string;
|
|
167
|
+
ariaCurrentValue: "time" | "page" | "step" | "location" | "date" | "true" | "false";
|
|
168
|
+
externalTarget: string;
|
|
169
|
+
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
|
|
170
|
+
/** @deprecated Use AutoRouterLink instead */
|
|
171
|
+
declare const RouterLink: vue.DefineComponent<vue.ExtractPropTypes<{
|
|
172
|
+
to: {
|
|
173
|
+
type: PropType<RouteLocationRaw>;
|
|
174
|
+
required: true;
|
|
175
|
+
};
|
|
176
|
+
replace: {
|
|
177
|
+
type: BooleanConstructor;
|
|
178
|
+
default: boolean;
|
|
179
|
+
};
|
|
180
|
+
activeClass: {
|
|
181
|
+
type: StringConstructor;
|
|
182
|
+
default: undefined;
|
|
183
|
+
};
|
|
184
|
+
exactActiveClass: {
|
|
185
|
+
type: StringConstructor;
|
|
186
|
+
default: undefined;
|
|
187
|
+
};
|
|
188
|
+
ariaCurrentValue: {
|
|
189
|
+
type: PropType<"page" | "step" | "location" | "date" | "time" | "true" | "false">;
|
|
190
|
+
default: string;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* For `external` kind: sets the anchor's target attribute.
|
|
194
|
+
* Defaults to `_blank` with safe `rel`. Pass empty string for same-tab.
|
|
195
|
+
*/
|
|
196
|
+
externalTarget: {
|
|
197
|
+
type: StringConstructor;
|
|
198
|
+
default: string;
|
|
199
|
+
};
|
|
200
|
+
}>, () => vue.VNode<vue.RendererNode, vue.RendererElement, {
|
|
201
|
+
[key: string]: any;
|
|
202
|
+
}>, {}, {}, {}, vue.ComponentOptionsMixin, vue.ComponentOptionsMixin, {}, string, vue.PublicProps, Readonly<vue.ExtractPropTypes<{
|
|
203
|
+
to: {
|
|
204
|
+
type: PropType<RouteLocationRaw>;
|
|
205
|
+
required: true;
|
|
206
|
+
};
|
|
207
|
+
replace: {
|
|
208
|
+
type: BooleanConstructor;
|
|
209
|
+
default: boolean;
|
|
210
|
+
};
|
|
211
|
+
activeClass: {
|
|
212
|
+
type: StringConstructor;
|
|
213
|
+
default: undefined;
|
|
214
|
+
};
|
|
215
|
+
exactActiveClass: {
|
|
216
|
+
type: StringConstructor;
|
|
217
|
+
default: undefined;
|
|
218
|
+
};
|
|
219
|
+
ariaCurrentValue: {
|
|
220
|
+
type: PropType<"page" | "step" | "location" | "date" | "time" | "true" | "false">;
|
|
221
|
+
default: string;
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* For `external` kind: sets the anchor's target attribute.
|
|
225
|
+
* Defaults to `_blank` with safe `rel`. Pass empty string for same-tab.
|
|
226
|
+
*/
|
|
227
|
+
externalTarget: {
|
|
228
|
+
type: StringConstructor;
|
|
229
|
+
default: string;
|
|
230
|
+
};
|
|
231
|
+
}>> & Readonly<{}>, {
|
|
232
|
+
replace: boolean;
|
|
233
|
+
activeClass: string;
|
|
234
|
+
exactActiveClass: string;
|
|
235
|
+
ariaCurrentValue: "time" | "page" | "step" | "location" | "date" | "true" | "false";
|
|
236
|
+
externalTarget: string;
|
|
237
|
+
}, {}, {}, {}, string, vue.ComponentProvideOptions, true, {}, any>;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Client-side validator for mountRoute patterns.
|
|
241
|
+
*
|
|
242
|
+
* This mirrors the backend (wippy-framework/src/views/page_registry.lua)
|
|
243
|
+
* validator logic. The backend is the authoritative source — the frontend
|
|
244
|
+
* re-validates as a defensive safety net for two scenarios:
|
|
245
|
+
*
|
|
246
|
+
* 1. A subapp receives `mountRoutes` from `ChildAppConfig` and wants to
|
|
247
|
+
* sanity-check them before registering or relying on them (e.g. during
|
|
248
|
+
* nested-navigation mount, if a subapp acts as a mini-host).
|
|
249
|
+
* 2. The host's `syncMountRoutes` calls this to detect conflicts before
|
|
250
|
+
* mutating the live Vue Router (fail-fast with a clear error rather
|
|
251
|
+
* than leaving the router in a hybrid state).
|
|
252
|
+
*
|
|
253
|
+
* The validator is intentionally strict. v1 accepts ONLY the canonical form:
|
|
254
|
+
* /:part(.*)* root mount
|
|
255
|
+
* /<literal>/:part(.*)* prefix mount (one or more literal segments)
|
|
256
|
+
* /<lit1>/<lit2>/:part(.*)* nested prefix mount
|
|
257
|
+
*
|
|
258
|
+
* Literal segments must match `[a-z0-9-]+` (lowercase alphanumerics + hyphens).
|
|
259
|
+
* The param name `:part` (not `:pathMatch`) matches gen-2-chat's existing
|
|
260
|
+
* `/c/:id/:part*` convention, so `route.params.part` is a uniform shape
|
|
261
|
+
* regardless of whether a page was reached via a mount route or via `/c/<id>`.
|
|
262
|
+
*/
|
|
263
|
+
declare const MOUNT_ROUTE_V1_REGEX: RegExp;
|
|
264
|
+
type MountRouteIssueKind = 'syntax' | 'duplicate';
|
|
265
|
+
interface MountRouteIssue {
|
|
266
|
+
kind: MountRouteIssueKind;
|
|
267
|
+
mountRoute: string;
|
|
268
|
+
pageId: string;
|
|
269
|
+
conflictingPageId?: string;
|
|
270
|
+
}
|
|
271
|
+
declare class MountRouteValidationError extends Error {
|
|
272
|
+
readonly issues: MountRouteIssue[];
|
|
273
|
+
constructor(issues: MountRouteIssue[]);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Validate an array of [mountRoute, pageId] tuples.
|
|
277
|
+
* Returns every issue found (does not stop at first). Empty result = clean.
|
|
278
|
+
*
|
|
279
|
+
* Accepts tuples (not a plain object) so duplicate mount routes surface as
|
|
280
|
+
* separate entries rather than being silently collapsed by object-key dedup.
|
|
281
|
+
*/
|
|
282
|
+
declare function validateMountRoutes(entries: Array<[mountRoute: string, pageId: string]>): MountRouteIssue[];
|
|
283
|
+
/**
|
|
284
|
+
* Assert that a map of mountRoutes is valid. Throws MountRouteValidationError
|
|
285
|
+
* with a detailed message on any issue. Convenience for subapps that want to
|
|
286
|
+
* fail-fast rather than iterate the issues themselves.
|
|
287
|
+
*/
|
|
288
|
+
declare function assertValidMountRoutes(mountRoutes: Record<string, string>): void;
|
|
289
|
+
|
|
290
|
+
export { AutoRouterLink, type CreateAppRouterOptions, HostRouterLink, MOUNT_ROUTE_V1_REGEX, type MountRouteIssue, type MountRouteIssueKind, MountRouteValidationError, RouterLink, assertValidMountRoutes, createAppRouter, validateMountRoutes };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
4
|
+
|
|
5
|
+
// src/classify.ts
|
|
6
|
+
import { classifyLink, setLocalRouter } from "@wippy-fe/proxy";
|
|
7
|
+
|
|
8
|
+
// src/create-app-router.ts
|
|
9
|
+
import { host as defaultHost, on as defaultOn, setLocalRouter as setLocalRouter2 } from "@wippy-fe/proxy";
|
|
10
|
+
import { createMemoryHistory, createRouter } from "vue-router";
|
|
11
|
+
function createAppRouter(routes, options = {}) {
|
|
12
|
+
const host3 = options.host ?? defaultHost;
|
|
13
|
+
const on = options.on === void 0 ? defaultOn : options.on;
|
|
14
|
+
const history = createMemoryHistory();
|
|
15
|
+
if (options.initialPath)
|
|
16
|
+
history.replace(options.initialPath);
|
|
17
|
+
const router = createRouter({ history, routes });
|
|
18
|
+
setLocalRouter2((path) => router.resolve(path));
|
|
19
|
+
let lastAppliedNavId;
|
|
20
|
+
router.afterEach((to) => {
|
|
21
|
+
const navId = lastAppliedNavId;
|
|
22
|
+
lastAppliedNavId = void 0;
|
|
23
|
+
host3.onRouteChanged(to.fullPath, navId);
|
|
24
|
+
});
|
|
25
|
+
if (on) {
|
|
26
|
+
on("@history", ({ path, navId }) => {
|
|
27
|
+
if (!path)
|
|
28
|
+
return;
|
|
29
|
+
if (navId !== void 0)
|
|
30
|
+
lastAppliedNavId = navId;
|
|
31
|
+
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
32
|
+
if (router.currentRoute.value.fullPath !== normalized)
|
|
33
|
+
router.push(normalized);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return router;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/host-router-link.ts
|
|
40
|
+
import { host } from "@wippy-fe/proxy";
|
|
41
|
+
import { defineComponent, h } from "vue";
|
|
42
|
+
var HostRouterLink = defineComponent({
|
|
43
|
+
name: "WippyHostRouterLink",
|
|
44
|
+
props: {
|
|
45
|
+
to: {
|
|
46
|
+
type: String,
|
|
47
|
+
required: true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
setup(props, { slots }) {
|
|
51
|
+
return () => h(
|
|
52
|
+
"a",
|
|
53
|
+
{
|
|
54
|
+
href: props.to,
|
|
55
|
+
onClick: (e) => {
|
|
56
|
+
if (e.defaultPrevented || e.button !== 0)
|
|
57
|
+
return;
|
|
58
|
+
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
|
|
59
|
+
return;
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
host.navigate(props.to);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
slots.default?.()
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// src/router-link.ts
|
|
70
|
+
import { host as host2 } from "@wippy-fe/proxy";
|
|
71
|
+
import { defineComponent as defineComponent2, h as h2 } from "vue";
|
|
72
|
+
import { useRouter, RouterLink as VueRouterLink } from "vue-router";
|
|
73
|
+
var AutoRouterLink = defineComponent2({
|
|
74
|
+
name: "WippyAutoRouterLink",
|
|
75
|
+
props: {
|
|
76
|
+
to: {
|
|
77
|
+
type: [String, Object],
|
|
78
|
+
required: true
|
|
79
|
+
},
|
|
80
|
+
replace: {
|
|
81
|
+
type: Boolean,
|
|
82
|
+
default: false
|
|
83
|
+
},
|
|
84
|
+
activeClass: {
|
|
85
|
+
type: String,
|
|
86
|
+
default: void 0
|
|
87
|
+
},
|
|
88
|
+
exactActiveClass: {
|
|
89
|
+
type: String,
|
|
90
|
+
default: void 0
|
|
91
|
+
},
|
|
92
|
+
ariaCurrentValue: {
|
|
93
|
+
type: String,
|
|
94
|
+
default: "page"
|
|
95
|
+
},
|
|
96
|
+
/**
|
|
97
|
+
* For `external` kind: sets the anchor's target attribute.
|
|
98
|
+
* Defaults to `_blank` with safe `rel`. Pass empty string for same-tab.
|
|
99
|
+
*/
|
|
100
|
+
externalTarget: {
|
|
101
|
+
type: String,
|
|
102
|
+
default: "_blank"
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
setup(props, { slots }) {
|
|
106
|
+
const router = useRouter();
|
|
107
|
+
return () => {
|
|
108
|
+
const resolved = router.resolve(props.to);
|
|
109
|
+
const classification = host2.classifyLink(resolved.href);
|
|
110
|
+
if (classification.kind === "host-nav") {
|
|
111
|
+
return h2(
|
|
112
|
+
"a",
|
|
113
|
+
{
|
|
114
|
+
"href": resolved.href,
|
|
115
|
+
"onClick": (event) => {
|
|
116
|
+
if (event.defaultPrevented)
|
|
117
|
+
return;
|
|
118
|
+
if (event.button !== 0)
|
|
119
|
+
return;
|
|
120
|
+
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
|
|
121
|
+
return;
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
host2.navigate(classification.normalizedPath ?? classification.href);
|
|
124
|
+
},
|
|
125
|
+
"aria-current": props.ariaCurrentValue
|
|
126
|
+
},
|
|
127
|
+
slots.default?.()
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (classification.kind === "external") {
|
|
131
|
+
const isBlank = props.externalTarget === "_blank";
|
|
132
|
+
return h2(
|
|
133
|
+
"a",
|
|
134
|
+
{
|
|
135
|
+
href: resolved.href,
|
|
136
|
+
target: props.externalTarget || void 0,
|
|
137
|
+
rel: isBlank ? "noopener noreferrer" : void 0
|
|
138
|
+
},
|
|
139
|
+
slots.default?.()
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (classification.kind === "ignore") {
|
|
143
|
+
return h2(
|
|
144
|
+
"a",
|
|
145
|
+
{
|
|
146
|
+
href: resolved.href || "#",
|
|
147
|
+
onClick: (event) => event.preventDefault()
|
|
148
|
+
},
|
|
149
|
+
slots.default?.()
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return h2(
|
|
153
|
+
VueRouterLink,
|
|
154
|
+
{
|
|
155
|
+
to: props.to,
|
|
156
|
+
replace: props.replace,
|
|
157
|
+
activeClass: props.activeClass,
|
|
158
|
+
exactActiveClass: props.exactActiveClass,
|
|
159
|
+
ariaCurrentValue: props.ariaCurrentValue
|
|
160
|
+
},
|
|
161
|
+
slots.default ? { default: (scope) => slots.default?.(scope) } : void 0
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
var RouterLink = AutoRouterLink;
|
|
167
|
+
|
|
168
|
+
// src/validate-mount-routes.ts
|
|
169
|
+
var MOUNT_ROUTE_V1_REGEX = /^\/([a-z0-9-]+(\/[a-z0-9-]+)*\/)?:part\(\.\*\)\*$/;
|
|
170
|
+
var MountRouteValidationError = class extends Error {
|
|
171
|
+
constructor(issues) {
|
|
172
|
+
super(formatMessage(issues));
|
|
173
|
+
__publicField(this, "issues", issues);
|
|
174
|
+
this.name = "MountRouteValidationError";
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
function formatMessage(issues) {
|
|
178
|
+
const lines = ["[wippy-fe/router] mountRoute validation failed:", ""];
|
|
179
|
+
for (const issue of issues) {
|
|
180
|
+
if (issue.kind === "syntax") {
|
|
181
|
+
lines.push(
|
|
182
|
+
` \u2022 Page "${issue.pageId}" has invalid mountRoute "${issue.mountRoute}".`,
|
|
183
|
+
` v1 only supports "/<literal>/:part(.*)*" and "/:part(.*)*".`
|
|
184
|
+
);
|
|
185
|
+
} else {
|
|
186
|
+
lines.push(
|
|
187
|
+
` \u2022 Pages "${issue.pageId}" and "${issue.conflictingPageId}" both claim`,
|
|
188
|
+
` mountRoute "${issue.mountRoute}". Mount routes must be unique.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
lines.push("");
|
|
192
|
+
}
|
|
193
|
+
return lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
function validateMountRoutes(entries) {
|
|
196
|
+
const issues = [];
|
|
197
|
+
const seen = /* @__PURE__ */ new Map();
|
|
198
|
+
for (const [mountRoute, pageId] of entries) {
|
|
199
|
+
if (typeof mountRoute !== "string" || !MOUNT_ROUTE_V1_REGEX.test(mountRoute)) {
|
|
200
|
+
issues.push({ kind: "syntax", mountRoute: String(mountRoute), pageId });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const previousOwner = seen.get(mountRoute);
|
|
204
|
+
if (previousOwner && previousOwner !== pageId) {
|
|
205
|
+
issues.push({
|
|
206
|
+
kind: "duplicate",
|
|
207
|
+
mountRoute,
|
|
208
|
+
pageId,
|
|
209
|
+
conflictingPageId: previousOwner
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
seen.set(mountRoute, pageId);
|
|
214
|
+
}
|
|
215
|
+
return issues;
|
|
216
|
+
}
|
|
217
|
+
function assertValidMountRoutes(mountRoutes) {
|
|
218
|
+
const entries = Object.entries(mountRoutes);
|
|
219
|
+
const issues = validateMountRoutes(entries);
|
|
220
|
+
if (issues.length > 0)
|
|
221
|
+
throw new MountRouteValidationError(issues);
|
|
222
|
+
}
|
|
223
|
+
export {
|
|
224
|
+
AutoRouterLink,
|
|
225
|
+
HostRouterLink,
|
|
226
|
+
MOUNT_ROUTE_V1_REGEX,
|
|
227
|
+
MountRouteValidationError,
|
|
228
|
+
RouterLink,
|
|
229
|
+
assertValidMountRoutes,
|
|
230
|
+
classifyLink,
|
|
231
|
+
createAppRouter,
|
|
232
|
+
setLocalRouter,
|
|
233
|
+
validateMountRoutes
|
|
234
|
+
};
|
|
235
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/classify.ts","../src/create-app-router.ts","../src/host-router-link.ts","../src/router-link.ts","../src/validate-mount-routes.ts"],"sourcesContent":["/**\n * Re-export of the link classifier from @wippy-fe/proxy for one-import\n * convenience in Vue subapps. Canonical home for the classifier is\n * @wippy-fe/proxy — vanilla JS subapps should import directly from there.\n */\nexport { classifyLink, setLocalRouter } from '@wippy-fe/proxy'\nexport type {\n ClassifierContext,\n LinkClassification,\n LinkKind,\n} from '@wippy-fe/proxy'\n","import type { Router, RouteRecordRaw } from 'vue-router'\nimport { host as defaultHost, on as defaultOn, setLocalRouter } from '@wippy-fe/proxy'\nimport { createMemoryHistory, createRouter } from 'vue-router'\n\nexport interface CreateAppRouterOptions {\n /**\n * Initial route path to prime history with. Usually `config.context?.route`\n * or the subapp's current location from its mount context.\n */\n initialPath?: string\n /**\n * Override the host API (testing / dev harness). Defaults to the `host`\n * export from `@wippy-fe/proxy`.\n */\n host?: typeof defaultHost\n /**\n * Override the event subscription API. Set to `null` to disable the\n * `@history` subscription entirely. Defaults to `on` from `@wippy-fe/proxy`.\n */\n on?: typeof defaultOn | null\n}\n\n/**\n * Canonical memory-router factory for Wippy subapps.\n *\n * Every subapp currently duplicates the same boilerplate:\n * - create a memory history\n * - prime it with an initial path from context\n * - call `host.onRouteChanged` in `afterEach`\n * - listen for `@history` events from the host and mirror them to the router\n *\n * This factory encapsulates that boilerplate so subapps become one-liners:\n *\n * ```ts\n * import { createAppRouter } from '@wippy-fe/router'\n * import { routes } from './routes'\n *\n * const router = createAppRouter(routes, {\n * initialPath: appConfig.context?.route,\n * })\n * app.use(router)\n * ```\n *\n * Memory history is hardcoded — srcdoc iframes do not support `createWebHistory`\n * (confirmed dead end — `window.location` is non-configurable in srcdoc, and\n * framework routers read `location.pathname` directly). Only memory-based\n * routing works inside child iframes.\n */\nexport function createAppRouter(\n routes: RouteRecordRaw[],\n options: CreateAppRouterOptions = {},\n): Router {\n const host = options.host ?? defaultHost\n const on = options.on === undefined ? defaultOn : options.on\n\n const history = createMemoryHistory()\n if (options.initialPath)\n history.replace(options.initialPath)\n\n const router = createRouter({ history, routes })\n\n // Register this router with the link classifier so it can check own\n // routes before falling back to mount route / system route matching.\n setLocalRouter((path: string) => router.resolve(path))\n\n // Track the last navId applied from a parent-initiated update.\n // When the router navigates in response to a parent @history event,\n // suppress the resulting afterEach from sending CmdRouteChanged back up\n // (which would create an echo loop).\n let lastAppliedNavId: number | undefined\n\n router.afterEach((to) => {\n // If this navigation was triggered by a parent @history update,\n // pass the navId so the host can identify it as an echo and suppress.\n const navId = lastAppliedNavId\n lastAppliedNavId = undefined\n host.onRouteChanged(to.fullPath, navId)\n })\n\n if (on) {\n on('@history', ({ path, navId }: { path?: string, navId?: number }) => {\n if (!path)\n return\n if (navId !== undefined)\n lastAppliedNavId = navId\n const normalized = path.startsWith('/') ? path : `/${path}`\n if (router.currentRoute.value.fullPath !== normalized)\n router.push(normalized)\n })\n }\n\n return router\n}\n","import type { PropType } from 'vue'\nimport { host } from '@wippy-fe/proxy'\nimport { defineComponent, h } from 'vue'\n\n/**\n * Explicit host-level navigation link. Use when you want to navigate\n * to a known host page regardless of the current nesting level.\n *\n * Unlike `AutoRouterLink` (which classifies links automatically),\n * `HostRouterLink` ALWAYS sends navigation to the host via postMessage.\n *\n * Use cases:\n * - Inside a nav-owner child linking to a different host page\n * - Anywhere you explicitly want host-level navigation\n *\n * Renders a real `<a>` for accessibility / right-click / SEO.\n * Left-click calls `host.navigate()`. Modifier keys (ctrl, meta, shift)\n * fall through to the browser default (open in new tab).\n */\nexport const HostRouterLink = defineComponent({\n name: 'WippyHostRouterLink',\n props: {\n to: {\n type: String as PropType<string>,\n required: true,\n },\n },\n setup(props, { slots }) {\n return () => h(\n 'a',\n {\n href: props.to,\n onClick: (e: MouseEvent) => {\n if (e.defaultPrevented || e.button !== 0)\n return\n if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)\n return\n e.preventDefault()\n host.navigate(props.to)\n },\n },\n slots.default?.(),\n )\n },\n})\n","import type { PropType } from 'vue'\nimport type { RouteLocationRaw } from 'vue-router'\nimport { host } from '@wippy-fe/proxy'\nimport { defineComponent, h } from 'vue'\nimport { useRouter, RouterLink as VueRouterLink } from 'vue-router'\n\n/**\n * Drop-in replacement for Vue Router's `<RouterLink>` that classifies the\n * target href and dispatches intelligently:\n *\n * - `host-nav` → preventDefault + `host.navigate(href)` via postMessage\n * - `child-nav` → delegate to the real `<RouterLink>` (normal in-app nav)\n * - `external` → render an `<a>` with `target=\"_blank\" rel=\"noopener\"`\n * - `ignore` → render an `<a>` with a click-preventing handler\n *\n * Subapps migrate by changing a single import:\n *\n * ```ts\n * // before\n * import { RouterLink } from 'vue-router'\n *\n * // after\n * import { RouterLink } from '@wippy-fe/router'\n * ```\n *\n * Props are a compatible subset of vue-router's RouterLink. Slot API\n * mirrors the default (non-custom) slot of the real RouterLink.\n */\nexport const AutoRouterLink = defineComponent({\n name: 'WippyAutoRouterLink',\n props: {\n to: {\n type: [String, Object] as PropType<RouteLocationRaw>,\n required: true,\n },\n replace: {\n type: Boolean,\n default: false,\n },\n activeClass: {\n type: String,\n default: undefined,\n },\n exactActiveClass: {\n type: String,\n default: undefined,\n },\n ariaCurrentValue: {\n type: String as PropType<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>,\n default: 'page',\n },\n /**\n * For `external` kind: sets the anchor's target attribute.\n * Defaults to `_blank` with safe `rel`. Pass empty string for same-tab.\n */\n externalTarget: {\n type: String,\n default: '_blank',\n },\n },\n setup(props, { slots }) {\n const router = useRouter()\n\n return () => {\n const resolved = router.resolve(props.to)\n const classification = host.classifyLink(resolved.href)\n\n if (classification.kind === 'host-nav') {\n return h(\n 'a',\n {\n 'href': resolved.href,\n 'onClick': (event: MouseEvent) => {\n if (event.defaultPrevented)\n return\n if (event.button !== 0)\n return\n if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)\n return\n event.preventDefault()\n host.navigate(classification.normalizedPath ?? classification.href)\n },\n 'aria-current': props.ariaCurrentValue,\n },\n slots.default?.(),\n )\n }\n\n if (classification.kind === 'external') {\n const isBlank = props.externalTarget === '_blank'\n return h(\n 'a',\n {\n href: resolved.href,\n target: props.externalTarget || undefined,\n rel: isBlank ? 'noopener noreferrer' : undefined,\n },\n slots.default?.(),\n )\n }\n\n if (classification.kind === 'ignore') {\n return h(\n 'a',\n {\n href: resolved.href || '#',\n onClick: (event: MouseEvent) => event.preventDefault(),\n },\n slots.default?.(),\n )\n }\n\n // child-nav → delegate to real vue-router RouterLink\n return h(\n VueRouterLink,\n {\n to: props.to,\n replace: props.replace,\n activeClass: props.activeClass,\n exactActiveClass: props.exactActiveClass,\n ariaCurrentValue: props.ariaCurrentValue,\n },\n slots.default\n ? { default: (scope: unknown) => slots.default?.(scope) }\n : undefined,\n )\n }\n },\n})\n\n/** @deprecated Use AutoRouterLink instead */\nexport const RouterLink = AutoRouterLink\n","/**\n * Client-side validator for mountRoute patterns.\n *\n * This mirrors the backend (wippy-framework/src/views/page_registry.lua)\n * validator logic. The backend is the authoritative source — the frontend\n * re-validates as a defensive safety net for two scenarios:\n *\n * 1. A subapp receives `mountRoutes` from `ChildAppConfig` and wants to\n * sanity-check them before registering or relying on them (e.g. during\n * nested-navigation mount, if a subapp acts as a mini-host).\n * 2. The host's `syncMountRoutes` calls this to detect conflicts before\n * mutating the live Vue Router (fail-fast with a clear error rather\n * than leaving the router in a hybrid state).\n *\n * The validator is intentionally strict. v1 accepts ONLY the canonical form:\n * /:part(.*)* root mount\n * /<literal>/:part(.*)* prefix mount (one or more literal segments)\n * /<lit1>/<lit2>/:part(.*)* nested prefix mount\n *\n * Literal segments must match `[a-z0-9-]+` (lowercase alphanumerics + hyphens).\n * The param name `:part` (not `:pathMatch`) matches gen-2-chat's existing\n * `/c/:id/:part*` convention, so `route.params.part` is a uniform shape\n * regardless of whether a page was reached via a mount route or via `/c/<id>`.\n */\n\nexport const MOUNT_ROUTE_V1_REGEX = /^\\/([a-z0-9-]+(\\/[a-z0-9-]+)*\\/)?:part\\(\\.\\*\\)\\*$/\n\nexport type MountRouteIssueKind = 'syntax' | 'duplicate'\n\nexport interface MountRouteIssue {\n kind: MountRouteIssueKind\n mountRoute: string\n pageId: string\n conflictingPageId?: string\n}\n\nexport class MountRouteValidationError extends Error {\n constructor(public readonly issues: MountRouteIssue[]) {\n super(formatMessage(issues))\n this.name = 'MountRouteValidationError'\n }\n}\n\nfunction formatMessage(issues: MountRouteIssue[]): string {\n const lines: string[] = ['[wippy-fe/router] mountRoute validation failed:', '']\n for (const issue of issues) {\n if (issue.kind === 'syntax') {\n lines.push(\n ` • Page \"${issue.pageId}\" has invalid mountRoute \"${issue.mountRoute}\".`,\n ` v1 only supports \"/<literal>/:part(.*)*\" and \"/:part(.*)*\".`,\n )\n }\n else {\n lines.push(\n ` • Pages \"${issue.pageId}\" and \"${issue.conflictingPageId}\" both claim`,\n ` mountRoute \"${issue.mountRoute}\". Mount routes must be unique.`,\n )\n }\n lines.push('')\n }\n return lines.join('\\n')\n}\n\n/**\n * Validate an array of [mountRoute, pageId] tuples.\n * Returns every issue found (does not stop at first). Empty result = clean.\n *\n * Accepts tuples (not a plain object) so duplicate mount routes surface as\n * separate entries rather than being silently collapsed by object-key dedup.\n */\nexport function validateMountRoutes(\n entries: Array<[mountRoute: string, pageId: string]>,\n): MountRouteIssue[] {\n const issues: MountRouteIssue[] = []\n const seen = new Map<string, string>()\n\n for (const [mountRoute, pageId] of entries) {\n if (typeof mountRoute !== 'string' || !MOUNT_ROUTE_V1_REGEX.test(mountRoute)) {\n issues.push({ kind: 'syntax', mountRoute: String(mountRoute), pageId })\n continue\n }\n const previousOwner = seen.get(mountRoute)\n if (previousOwner && previousOwner !== pageId) {\n issues.push({\n kind: 'duplicate',\n mountRoute,\n pageId,\n conflictingPageId: previousOwner,\n })\n continue\n }\n seen.set(mountRoute, pageId)\n }\n\n return issues\n}\n\n/**\n * Assert that a map of mountRoutes is valid. Throws MountRouteValidationError\n * with a detailed message on any issue. Convenience for subapps that want to\n * fail-fast rather than iterate the issues themselves.\n */\nexport function assertValidMountRoutes(\n mountRoutes: Record<string, string>,\n): void {\n const entries: Array<[string, string]> = Object.entries(mountRoutes)\n const issues = validateMountRoutes(entries)\n if (issues.length > 0)\n throw new MountRouteValidationError(issues)\n}\n"],"mappings":"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","names":["setLocalRouter","host","host","defineComponent","h"]}
|
package/package.json
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wippy-fe/router",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Drop-in Vue Router helpers for Wippy subapps
|
|
3
|
+
"version": "0.0.35",
|
|
4
|
+
"description": "Drop-in Vue Router helpers for Wippy subapps - RouterLink with host-nav detection, canonical memory-router factory, and link classifier re-export.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"module": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
9
18
|
"files": [
|
|
10
|
-
"
|
|
11
|
-
"
|
|
19
|
+
"package.json",
|
|
20
|
+
"dist/"
|
|
12
21
|
],
|
|
13
22
|
"peerDependencies": {
|
|
14
23
|
"vue": "^3.5.0",
|
|
15
24
|
"vue-router": "^4.5.0",
|
|
16
|
-
"@wippy-fe/proxy": "0.0.
|
|
25
|
+
"@wippy-fe/proxy": "^0.0.35"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup"
|
|
17
29
|
}
|
|
18
30
|
}
|
package/src/classify.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Re-export of the link classifier from @wippy-fe/proxy for one-import
|
|
3
|
-
* convenience in Vue subapps. Canonical home for the classifier is
|
|
4
|
-
* @wippy-fe/proxy — vanilla JS subapps should import directly from there.
|
|
5
|
-
*/
|
|
6
|
-
export { classifyLink, setLocalRouter } from '@wippy-fe/proxy'
|
|
7
|
-
export type {
|
|
8
|
-
ClassifierContext,
|
|
9
|
-
LinkClassification,
|
|
10
|
-
LinkKind,
|
|
11
|
-
} from '@wippy-fe/proxy'
|
package/src/create-app-router.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import type { Router, RouteRecordRaw } from 'vue-router'
|
|
2
|
-
import { host as defaultHost, on as defaultOn, setLocalRouter } from '@wippy-fe/proxy'
|
|
3
|
-
import { createMemoryHistory, createRouter } from 'vue-router'
|
|
4
|
-
|
|
5
|
-
export interface CreateAppRouterOptions {
|
|
6
|
-
/**
|
|
7
|
-
* Initial route path to prime history with. Usually `config.context?.route`
|
|
8
|
-
* or the subapp's current location from its mount context.
|
|
9
|
-
*/
|
|
10
|
-
initialPath?: string
|
|
11
|
-
/**
|
|
12
|
-
* Override the host API (testing / dev harness). Defaults to the `host`
|
|
13
|
-
* export from `@wippy-fe/proxy`.
|
|
14
|
-
*/
|
|
15
|
-
host?: typeof defaultHost
|
|
16
|
-
/**
|
|
17
|
-
* Override the event subscription API. Set to `null` to disable the
|
|
18
|
-
* `@history` subscription entirely. Defaults to `on` from `@wippy-fe/proxy`.
|
|
19
|
-
*/
|
|
20
|
-
on?: typeof defaultOn | null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Canonical memory-router factory for Wippy subapps.
|
|
25
|
-
*
|
|
26
|
-
* Every subapp currently duplicates the same boilerplate:
|
|
27
|
-
* - create a memory history
|
|
28
|
-
* - prime it with an initial path from context
|
|
29
|
-
* - call `host.onRouteChanged` in `afterEach`
|
|
30
|
-
* - listen for `@history` events from the host and mirror them to the router
|
|
31
|
-
*
|
|
32
|
-
* This factory encapsulates that boilerplate so subapps become one-liners:
|
|
33
|
-
*
|
|
34
|
-
* ```ts
|
|
35
|
-
* import { createAppRouter } from '@wippy-fe/router'
|
|
36
|
-
* import { routes } from './routes'
|
|
37
|
-
*
|
|
38
|
-
* const router = createAppRouter(routes, {
|
|
39
|
-
* initialPath: appConfig.context?.route,
|
|
40
|
-
* })
|
|
41
|
-
* app.use(router)
|
|
42
|
-
* ```
|
|
43
|
-
*
|
|
44
|
-
* Memory history is hardcoded — srcdoc iframes do not support `createWebHistory`
|
|
45
|
-
* (confirmed dead end — `window.location` is non-configurable in srcdoc, and
|
|
46
|
-
* framework routers read `location.pathname` directly). Only memory-based
|
|
47
|
-
* routing works inside child iframes.
|
|
48
|
-
*/
|
|
49
|
-
export function createAppRouter(
|
|
50
|
-
routes: RouteRecordRaw[],
|
|
51
|
-
options: CreateAppRouterOptions = {},
|
|
52
|
-
): Router {
|
|
53
|
-
const host = options.host ?? defaultHost
|
|
54
|
-
const on = options.on === undefined ? defaultOn : options.on
|
|
55
|
-
|
|
56
|
-
const history = createMemoryHistory()
|
|
57
|
-
if (options.initialPath)
|
|
58
|
-
history.replace(options.initialPath)
|
|
59
|
-
|
|
60
|
-
const router = createRouter({ history, routes })
|
|
61
|
-
|
|
62
|
-
// Register this router with the link classifier so it can check own
|
|
63
|
-
// routes before falling back to mount route / system route matching.
|
|
64
|
-
setLocalRouter((path: string) => router.resolve(path))
|
|
65
|
-
|
|
66
|
-
// Track the last navId applied from a parent-initiated update.
|
|
67
|
-
// When the router navigates in response to a parent @history event,
|
|
68
|
-
// suppress the resulting afterEach from sending CmdRouteChanged back up
|
|
69
|
-
// (which would create an echo loop).
|
|
70
|
-
let lastAppliedNavId: number | undefined
|
|
71
|
-
|
|
72
|
-
router.afterEach((to) => {
|
|
73
|
-
// If this navigation was triggered by a parent @history update,
|
|
74
|
-
// pass the navId so the host can identify it as an echo and suppress.
|
|
75
|
-
const navId = lastAppliedNavId
|
|
76
|
-
lastAppliedNavId = undefined
|
|
77
|
-
host.onRouteChanged(to.fullPath, navId)
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
if (on) {
|
|
81
|
-
on('@history', ({ path, navId }: { path?: string, navId?: number }) => {
|
|
82
|
-
if (!path)
|
|
83
|
-
return
|
|
84
|
-
if (navId !== undefined)
|
|
85
|
-
lastAppliedNavId = navId
|
|
86
|
-
const normalized = path.startsWith('/') ? path : `/${path}`
|
|
87
|
-
if (router.currentRoute.value.fullPath !== normalized)
|
|
88
|
-
router.push(normalized)
|
|
89
|
-
})
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return router
|
|
93
|
-
}
|
package/src/host-router-link.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type { PropType } from 'vue'
|
|
2
|
-
import { host } from '@wippy-fe/proxy'
|
|
3
|
-
import { defineComponent, h } from 'vue'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Explicit host-level navigation link. Use when you want to navigate
|
|
7
|
-
* to a known host page regardless of the current nesting level.
|
|
8
|
-
*
|
|
9
|
-
* Unlike `AutoRouterLink` (which classifies links automatically),
|
|
10
|
-
* `HostRouterLink` ALWAYS sends navigation to the host via postMessage.
|
|
11
|
-
*
|
|
12
|
-
* Use cases:
|
|
13
|
-
* - Inside a nav-owner child linking to a different host page
|
|
14
|
-
* - Anywhere you explicitly want host-level navigation
|
|
15
|
-
*
|
|
16
|
-
* Renders a real `<a>` for accessibility / right-click / SEO.
|
|
17
|
-
* Left-click calls `host.navigate()`. Modifier keys (ctrl, meta, shift)
|
|
18
|
-
* fall through to the browser default (open in new tab).
|
|
19
|
-
*/
|
|
20
|
-
export const HostRouterLink = defineComponent({
|
|
21
|
-
name: 'WippyHostRouterLink',
|
|
22
|
-
props: {
|
|
23
|
-
to: {
|
|
24
|
-
type: String as PropType<string>,
|
|
25
|
-
required: true,
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
setup(props, { slots }) {
|
|
29
|
-
return () => h(
|
|
30
|
-
'a',
|
|
31
|
-
{
|
|
32
|
-
href: props.to,
|
|
33
|
-
onClick: (e: MouseEvent) => {
|
|
34
|
-
if (e.defaultPrevented || e.button !== 0)
|
|
35
|
-
return
|
|
36
|
-
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
|
|
37
|
-
return
|
|
38
|
-
e.preventDefault()
|
|
39
|
-
host.navigate(props.to)
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
slots.default?.(),
|
|
43
|
-
)
|
|
44
|
-
},
|
|
45
|
-
})
|
package/src/index.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export { classifyLink, setLocalRouter } from './classify'
|
|
2
|
-
export type { ClassifierContext, LinkClassification, LinkKind } from './classify'
|
|
3
|
-
export { createAppRouter, type CreateAppRouterOptions } from './create-app-router'
|
|
4
|
-
export { HostRouterLink } from './host-router-link'
|
|
5
|
-
export { AutoRouterLink, RouterLink } from './router-link'
|
|
6
|
-
export {
|
|
7
|
-
assertValidMountRoutes,
|
|
8
|
-
MOUNT_ROUTE_V1_REGEX,
|
|
9
|
-
type MountRouteIssue,
|
|
10
|
-
type MountRouteIssueKind,
|
|
11
|
-
MountRouteValidationError,
|
|
12
|
-
validateMountRoutes,
|
|
13
|
-
} from './validate-mount-routes'
|
package/src/router-link.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import type { PropType } from 'vue'
|
|
2
|
-
import type { RouteLocationRaw } from 'vue-router'
|
|
3
|
-
import { host } from '@wippy-fe/proxy'
|
|
4
|
-
import { defineComponent, h } from 'vue'
|
|
5
|
-
import { useRouter, RouterLink as VueRouterLink } from 'vue-router'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Drop-in replacement for Vue Router's `<RouterLink>` that classifies the
|
|
9
|
-
* target href and dispatches intelligently:
|
|
10
|
-
*
|
|
11
|
-
* - `host-nav` → preventDefault + `host.navigate(href)` via postMessage
|
|
12
|
-
* - `child-nav` → delegate to the real `<RouterLink>` (normal in-app nav)
|
|
13
|
-
* - `external` → render an `<a>` with `target="_blank" rel="noopener"`
|
|
14
|
-
* - `ignore` → render an `<a>` with a click-preventing handler
|
|
15
|
-
*
|
|
16
|
-
* Subapps migrate by changing a single import:
|
|
17
|
-
*
|
|
18
|
-
* ```ts
|
|
19
|
-
* // before
|
|
20
|
-
* import { RouterLink } from 'vue-router'
|
|
21
|
-
*
|
|
22
|
-
* // after
|
|
23
|
-
* import { RouterLink } from '@wippy-fe/router'
|
|
24
|
-
* ```
|
|
25
|
-
*
|
|
26
|
-
* Props are a compatible subset of vue-router's RouterLink. Slot API
|
|
27
|
-
* mirrors the default (non-custom) slot of the real RouterLink.
|
|
28
|
-
*/
|
|
29
|
-
export const AutoRouterLink = defineComponent({
|
|
30
|
-
name: 'WippyAutoRouterLink',
|
|
31
|
-
props: {
|
|
32
|
-
to: {
|
|
33
|
-
type: [String, Object] as PropType<RouteLocationRaw>,
|
|
34
|
-
required: true,
|
|
35
|
-
},
|
|
36
|
-
replace: {
|
|
37
|
-
type: Boolean,
|
|
38
|
-
default: false,
|
|
39
|
-
},
|
|
40
|
-
activeClass: {
|
|
41
|
-
type: String,
|
|
42
|
-
default: undefined,
|
|
43
|
-
},
|
|
44
|
-
exactActiveClass: {
|
|
45
|
-
type: String,
|
|
46
|
-
default: undefined,
|
|
47
|
-
},
|
|
48
|
-
ariaCurrentValue: {
|
|
49
|
-
type: String as PropType<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>,
|
|
50
|
-
default: 'page',
|
|
51
|
-
},
|
|
52
|
-
/**
|
|
53
|
-
* For `external` kind: sets the anchor's target attribute.
|
|
54
|
-
* Defaults to `_blank` with safe `rel`. Pass empty string for same-tab.
|
|
55
|
-
*/
|
|
56
|
-
externalTarget: {
|
|
57
|
-
type: String,
|
|
58
|
-
default: '_blank',
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
setup(props, { slots }) {
|
|
62
|
-
const router = useRouter()
|
|
63
|
-
|
|
64
|
-
return () => {
|
|
65
|
-
const resolved = router.resolve(props.to)
|
|
66
|
-
const classification = host.classifyLink(resolved.href)
|
|
67
|
-
|
|
68
|
-
if (classification.kind === 'host-nav') {
|
|
69
|
-
return h(
|
|
70
|
-
'a',
|
|
71
|
-
{
|
|
72
|
-
'href': resolved.href,
|
|
73
|
-
'onClick': (event: MouseEvent) => {
|
|
74
|
-
if (event.defaultPrevented)
|
|
75
|
-
return
|
|
76
|
-
if (event.button !== 0)
|
|
77
|
-
return
|
|
78
|
-
if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
|
|
79
|
-
return
|
|
80
|
-
event.preventDefault()
|
|
81
|
-
host.navigate(classification.normalizedPath ?? classification.href)
|
|
82
|
-
},
|
|
83
|
-
'aria-current': props.ariaCurrentValue,
|
|
84
|
-
},
|
|
85
|
-
slots.default?.(),
|
|
86
|
-
)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (classification.kind === 'external') {
|
|
90
|
-
const isBlank = props.externalTarget === '_blank'
|
|
91
|
-
return h(
|
|
92
|
-
'a',
|
|
93
|
-
{
|
|
94
|
-
href: resolved.href,
|
|
95
|
-
target: props.externalTarget || undefined,
|
|
96
|
-
rel: isBlank ? 'noopener noreferrer' : undefined,
|
|
97
|
-
},
|
|
98
|
-
slots.default?.(),
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (classification.kind === 'ignore') {
|
|
103
|
-
return h(
|
|
104
|
-
'a',
|
|
105
|
-
{
|
|
106
|
-
href: resolved.href || '#',
|
|
107
|
-
onClick: (event: MouseEvent) => event.preventDefault(),
|
|
108
|
-
},
|
|
109
|
-
slots.default?.(),
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// child-nav → delegate to real vue-router RouterLink
|
|
114
|
-
return h(
|
|
115
|
-
VueRouterLink,
|
|
116
|
-
{
|
|
117
|
-
to: props.to,
|
|
118
|
-
replace: props.replace,
|
|
119
|
-
activeClass: props.activeClass,
|
|
120
|
-
exactActiveClass: props.exactActiveClass,
|
|
121
|
-
ariaCurrentValue: props.ariaCurrentValue,
|
|
122
|
-
},
|
|
123
|
-
slots.default
|
|
124
|
-
? { default: (scope: unknown) => slots.default?.(scope) }
|
|
125
|
-
: undefined,
|
|
126
|
-
)
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
/** @deprecated Use AutoRouterLink instead */
|
|
132
|
-
export const RouterLink = AutoRouterLink
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side validator for mountRoute patterns.
|
|
3
|
-
*
|
|
4
|
-
* This mirrors the backend (wippy-framework/src/views/page_registry.lua)
|
|
5
|
-
* validator logic. The backend is the authoritative source — the frontend
|
|
6
|
-
* re-validates as a defensive safety net for two scenarios:
|
|
7
|
-
*
|
|
8
|
-
* 1. A subapp receives `mountRoutes` from `ChildAppConfig` and wants to
|
|
9
|
-
* sanity-check them before registering or relying on them (e.g. during
|
|
10
|
-
* nested-navigation mount, if a subapp acts as a mini-host).
|
|
11
|
-
* 2. The host's `syncMountRoutes` calls this to detect conflicts before
|
|
12
|
-
* mutating the live Vue Router (fail-fast with a clear error rather
|
|
13
|
-
* than leaving the router in a hybrid state).
|
|
14
|
-
*
|
|
15
|
-
* The validator is intentionally strict. v1 accepts ONLY the canonical form:
|
|
16
|
-
* /:part(.*)* root mount
|
|
17
|
-
* /<literal>/:part(.*)* prefix mount (one or more literal segments)
|
|
18
|
-
* /<lit1>/<lit2>/:part(.*)* nested prefix mount
|
|
19
|
-
*
|
|
20
|
-
* Literal segments must match `[a-z0-9-]+` (lowercase alphanumerics + hyphens).
|
|
21
|
-
* The param name `:part` (not `:pathMatch`) matches gen-2-chat's existing
|
|
22
|
-
* `/c/:id/:part*` convention, so `route.params.part` is a uniform shape
|
|
23
|
-
* regardless of whether a page was reached via a mount route or via `/c/<id>`.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
export const MOUNT_ROUTE_V1_REGEX = /^\/([a-z0-9-]+(\/[a-z0-9-]+)*\/)?:part\(\.\*\)\*$/
|
|
27
|
-
|
|
28
|
-
export type MountRouteIssueKind = 'syntax' | 'duplicate'
|
|
29
|
-
|
|
30
|
-
export interface MountRouteIssue {
|
|
31
|
-
kind: MountRouteIssueKind
|
|
32
|
-
mountRoute: string
|
|
33
|
-
pageId: string
|
|
34
|
-
conflictingPageId?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export class MountRouteValidationError extends Error {
|
|
38
|
-
constructor(public readonly issues: MountRouteIssue[]) {
|
|
39
|
-
super(formatMessage(issues))
|
|
40
|
-
this.name = 'MountRouteValidationError'
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function formatMessage(issues: MountRouteIssue[]): string {
|
|
45
|
-
const lines: string[] = ['[wippy-fe/router] mountRoute validation failed:', '']
|
|
46
|
-
for (const issue of issues) {
|
|
47
|
-
if (issue.kind === 'syntax') {
|
|
48
|
-
lines.push(
|
|
49
|
-
` • Page "${issue.pageId}" has invalid mountRoute "${issue.mountRoute}".`,
|
|
50
|
-
` v1 only supports "/<literal>/:part(.*)*" and "/:part(.*)*".`,
|
|
51
|
-
)
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
lines.push(
|
|
55
|
-
` • Pages "${issue.pageId}" and "${issue.conflictingPageId}" both claim`,
|
|
56
|
-
` mountRoute "${issue.mountRoute}". Mount routes must be unique.`,
|
|
57
|
-
)
|
|
58
|
-
}
|
|
59
|
-
lines.push('')
|
|
60
|
-
}
|
|
61
|
-
return lines.join('\n')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Validate an array of [mountRoute, pageId] tuples.
|
|
66
|
-
* Returns every issue found (does not stop at first). Empty result = clean.
|
|
67
|
-
*
|
|
68
|
-
* Accepts tuples (not a plain object) so duplicate mount routes surface as
|
|
69
|
-
* separate entries rather than being silently collapsed by object-key dedup.
|
|
70
|
-
*/
|
|
71
|
-
export function validateMountRoutes(
|
|
72
|
-
entries: Array<[mountRoute: string, pageId: string]>,
|
|
73
|
-
): MountRouteIssue[] {
|
|
74
|
-
const issues: MountRouteIssue[] = []
|
|
75
|
-
const seen = new Map<string, string>()
|
|
76
|
-
|
|
77
|
-
for (const [mountRoute, pageId] of entries) {
|
|
78
|
-
if (typeof mountRoute !== 'string' || !MOUNT_ROUTE_V1_REGEX.test(mountRoute)) {
|
|
79
|
-
issues.push({ kind: 'syntax', mountRoute: String(mountRoute), pageId })
|
|
80
|
-
continue
|
|
81
|
-
}
|
|
82
|
-
const previousOwner = seen.get(mountRoute)
|
|
83
|
-
if (previousOwner && previousOwner !== pageId) {
|
|
84
|
-
issues.push({
|
|
85
|
-
kind: 'duplicate',
|
|
86
|
-
mountRoute,
|
|
87
|
-
pageId,
|
|
88
|
-
conflictingPageId: previousOwner,
|
|
89
|
-
})
|
|
90
|
-
continue
|
|
91
|
-
}
|
|
92
|
-
seen.set(mountRoute, pageId)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return issues
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Assert that a map of mountRoutes is valid. Throws MountRouteValidationError
|
|
100
|
-
* with a detailed message on any issue. Convenience for subapps that want to
|
|
101
|
-
* fail-fast rather than iterate the issues themselves.
|
|
102
|
-
*/
|
|
103
|
-
export function assertValidMountRoutes(
|
|
104
|
-
mountRoutes: Record<string, string>,
|
|
105
|
-
): void {
|
|
106
|
-
const entries: Array<[string, string]> = Object.entries(mountRoutes)
|
|
107
|
-
const issues = validateMountRoutes(entries)
|
|
108
|
-
if (issues.length > 0)
|
|
109
|
-
throw new MountRouteValidationError(issues)
|
|
110
|
-
}
|