@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.
@@ -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.34",
4
- "description": "Drop-in Vue Router helpers for Wippy subapps — RouterLink with host-nav detection, canonical memory-router factory, and link classifier re-export.",
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
- "main": "src/index.ts",
8
- "types": "src/index.ts",
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
- "src/",
11
- "package.json"
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.34"
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'
@@ -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
- }
@@ -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'
@@ -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
- }