@wippy-fe/router 0.0.22

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/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@wippy-fe/router",
3
+ "version": "0.0.22",
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
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "files": [
10
+ "src/",
11
+ "package.json"
12
+ ],
13
+ "peerDependencies": {
14
+ "vue": "^3.5.0",
15
+ "vue-router": "^4.5.0",
16
+ "@wippy-fe/proxy": "0.0.22"
17
+ }
18
+ }
@@ -0,0 +1,11 @@
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'
@@ -0,0 +1,93 @@
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
+ }
@@ -0,0 +1,45 @@
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 ADDED
@@ -0,0 +1,13 @@
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'
@@ -0,0 +1,132 @@
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
@@ -0,0 +1,110 @@
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
+ }