@vkma.dev/router 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/bridge-like.d.ts +25 -0
  3. package/dist/core/bridge-like.d.ts.map +1 -0
  4. package/dist/core/bridge-like.js +13 -0
  5. package/dist/core/bridge-like.js.map +1 -0
  6. package/dist/core/bridge-sync.d.ts +37 -0
  7. package/dist/core/bridge-sync.d.ts.map +1 -0
  8. package/dist/core/bridge-sync.js +58 -0
  9. package/dist/core/bridge-sync.js.map +1 -0
  10. package/dist/core/hash-params.d.ts +40 -0
  11. package/dist/core/hash-params.d.ts.map +1 -0
  12. package/dist/core/hash-params.js +60 -0
  13. package/dist/core/hash-params.js.map +1 -0
  14. package/dist/core/loose-route.js +9 -0
  15. package/dist/core/loose-route.js.map +1 -0
  16. package/dist/core/panels-stack.d.ts +28 -0
  17. package/dist/core/panels-stack.d.ts.map +1 -0
  18. package/dist/core/panels-stack.js +60 -0
  19. package/dist/core/panels-stack.js.map +1 -0
  20. package/dist/core/state-keys.d.ts +10 -0
  21. package/dist/core/state-keys.d.ts.map +1 -0
  22. package/dist/core/state-keys.js +21 -0
  23. package/dist/core/state-keys.js.map +1 -0
  24. package/dist/core/swipe-back.d.ts +25 -0
  25. package/dist/core/swipe-back.d.ts.map +1 -0
  26. package/dist/core/swipe-back.js +53 -0
  27. package/dist/core/swipe-back.js.map +1 -0
  28. package/dist/core/vk-history.d.ts +41 -0
  29. package/dist/core/vk-history.d.ts.map +1 -0
  30. package/dist/core/vk-history.js +115 -0
  31. package/dist/core/vk-history.js.map +1 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.js +8 -0
  34. package/dist/preset/app.d.ts +27 -0
  35. package/dist/preset/app.d.ts.map +1 -0
  36. package/dist/preset/app.js +110 -0
  37. package/dist/preset/app.js.map +1 -0
  38. package/dist/preset/builders.d.ts +209 -0
  39. package/dist/preset/builders.d.ts.map +1 -0
  40. package/dist/preset/builders.js +209 -0
  41. package/dist/preset/builders.js.map +1 -0
  42. package/dist/preset/define-panel.d.ts +77 -0
  43. package/dist/preset/define-panel.d.ts.map +1 -0
  44. package/dist/preset/define-panel.js +42 -0
  45. package/dist/preset/define-panel.js.map +1 -0
  46. package/dist/preset/define-view.d.ts +42 -0
  47. package/dist/preset/define-view.d.ts.map +1 -0
  48. package/dist/preset/define-view.js +43 -0
  49. package/dist/preset/define-view.js.map +1 -0
  50. package/dist/preset/inflate.d.ts +17 -0
  51. package/dist/preset/inflate.d.ts.map +1 -0
  52. package/dist/preset/inflate.js +40 -0
  53. package/dist/preset/inflate.js.map +1 -0
  54. package/dist/preset/navigator.d.ts +75 -0
  55. package/dist/preset/navigator.d.ts.map +1 -0
  56. package/dist/preset/navigator.js +116 -0
  57. package/dist/preset/navigator.js.map +1 -0
  58. package/dist/preset.d.ts +17 -0
  59. package/dist/preset.js +16 -0
  60. package/dist/react/hooks.d.ts +69 -0
  61. package/dist/react/hooks.d.ts.map +1 -0
  62. package/dist/react/hooks.js +101 -0
  63. package/dist/react/hooks.js.map +1 -0
  64. package/dist/react.d.ts +2 -0
  65. package/dist/react.js +1 -0
  66. package/dist/vkui/choreography.js +59 -0
  67. package/dist/vkui/choreography.js.map +1 -0
  68. package/dist/vkui/modals.d.ts +49 -0
  69. package/dist/vkui/modals.d.ts.map +1 -0
  70. package/dist/vkui/modals.js +116 -0
  71. package/dist/vkui/modals.js.map +1 -0
  72. package/dist/vkui/not-found.d.ts +9 -0
  73. package/dist/vkui/not-found.d.ts.map +1 -0
  74. package/dist/vkui/not-found.js +23 -0
  75. package/dist/vkui/not-found.js.map +1 -0
  76. package/dist/vkui/popouts.d.ts +34 -0
  77. package/dist/vkui/popouts.d.ts.map +1 -0
  78. package/dist/vkui/popouts.js +69 -0
  79. package/dist/vkui/popouts.js.map +1 -0
  80. package/dist/vkui/vk-view.d.ts +26 -0
  81. package/dist/vkui/vk-view.d.ts.map +1 -0
  82. package/dist/vkui/vk-view.js +71 -0
  83. package/dist/vkui/vk-view.js.map +1 -0
  84. package/dist/vkui.d.ts +5 -0
  85. package/dist/vkui.js +5 -0
  86. package/package.json +70 -3
  87. package/src/core/bridge-like.ts +25 -0
  88. package/src/core/bridge-sync.ts +95 -0
  89. package/src/core/hash-params.ts +73 -0
  90. package/src/core/loose-route.ts +17 -0
  91. package/src/core/panels-stack.ts +85 -0
  92. package/src/core/state-keys.ts +39 -0
  93. package/src/core/swipe-back.ts +80 -0
  94. package/src/core/vk-history.ts +146 -0
  95. package/src/index.ts +30 -0
  96. package/src/preset/app.tsx +158 -0
  97. package/src/preset/builders.tsx +566 -0
  98. package/src/preset/define-panel.ts +109 -0
  99. package/src/preset/define-view.ts +73 -0
  100. package/src/preset/inflate.ts +57 -0
  101. package/src/preset/navigator.ts +252 -0
  102. package/src/preset.ts +51 -0
  103. package/src/react/hooks.ts +168 -0
  104. package/src/react.ts +11 -0
  105. package/src/vkui/choreography.ts +105 -0
  106. package/src/vkui/modals.tsx +216 -0
  107. package/src/vkui/not-found.tsx +21 -0
  108. package/src/vkui/popouts.tsx +106 -0
  109. package/src/vkui/vk-view.tsx +136 -0
  110. package/src/vkui.ts +17 -0
  111. package/README.md +0 -3
@@ -0,0 +1,566 @@
1
+ import {
2
+ createRootRoute,
3
+ createRoute,
4
+ createRouteMask,
5
+ type AnyRoute,
6
+ type RouteMask,
7
+ } from "@tanstack/react-router";
8
+ import type { ComponentType, LazyExoticComponent, ReactNode } from "react";
9
+ import type { ModalEntry } from "../vkui/modals.tsx";
10
+ import type { PopoutEntry } from "../vkui/popouts.tsx";
11
+ import { AppRootShell } from "./app.tsx";
12
+ import { PANEL_DEFINITION, type PanelDefinition } from "./define-panel.ts";
13
+ import { VIEW_DEFINITION, type ViewDefinition } from "./define-view.ts";
14
+
15
+ /**
16
+ * Typed builders that generate the TanStack route tree in the /vkui
17
+ * convention: view = pathless layout route, panels = path children, modals
18
+ * = sibling child routes of their panel (chained modals are siblings — the
19
+ * host closes one before opening the next). Local route modals are
20
+ * URL-masked back to their panel (`app.routeMasks` → `createRouter`'s
21
+ * `routeMasks`): the address stays at the panel while any modal level is
22
+ * open, the raw modal path still deep-links (parsing is never masked), and
23
+ * the mask survives reload (history.state.__tempLocation). Global modals
24
+ * are rendered by the host from `?modal=<id>` on any panel (deep-linkable;
25
+ * the imperative open carries its own mask). Every node accepts raw
26
+ * TanStack options (`loader`, `beforeLoad`, `validateSearch`, `lazy`, …)
27
+ * passed through untouched, and the underlying routes stay reachable for
28
+ * escape hatches.
29
+ */
30
+
31
+ /**
32
+ * Route options with the builder-managed fields removed. `AnyRoute`'s
33
+ * options carry TanStack's generics pre-instanced, so this stays a plain
34
+ * structural type; the registered route tree provides the real typing for
35
+ * navigation, loaders and search.
36
+ */
37
+ export type LooseRouteOptions = Partial<
38
+ Omit<NonNullable<AnyRoute>["options"], "getParentRoute" | "path" | "id">
39
+ >;
40
+
41
+ /** A renderable modal entry component — plain or React.lazy. */
42
+ export type ModalEntryComponent = ComponentType | LazyExoticComponent<ComponentType>;
43
+
44
+ /**
45
+ * A declared modal: a renderable component, or a component with ITS OWN
46
+ * nested modals. Nested modals are child routes of their parent modal and
47
+ * stack ON TOP of it (back unwinds the stack instead of closing everything).
48
+ */
49
+ export interface ModalDeclaration {
50
+ component: ModalEntryComponent;
51
+ modals?: Record<string, ModalComponent>;
52
+ }
53
+
54
+ /** What a modals record accepts: either shape. */
55
+ export type ModalComponent = ModalEntryComponent | ModalDeclaration;
56
+
57
+ export interface PanelOptions extends LooseRouteOptions {
58
+ /**
59
+ * Chunk splitting without file-based routing: resolves to the panel's
60
+ * component. The preset bridges it into TanStack's `route.lazy()` (which
61
+ * expects a `{ options }`-shaped lazy route) and hands the caller the
62
+ * panel's route id. The payload is either `{ component }` or a dynamic
63
+ * import namespace with a `default` export (`lazy: () => import("./feed")`).
64
+ */
65
+ lazy?: PanelLazy;
66
+ /** Route-level modals: sibling child routes of this panel (recursible). */
67
+ modals?: Record<string, ModalComponent>;
68
+ /**
69
+ * Panel-local popouts: resolved BEFORE the global registry while this
70
+ * panel is the deepest matched one. The same id declared globally is a
71
+ * dev-time error.
72
+ */
73
+ popouts?: Record<string, PopoutEntry["Component"]>;
74
+ }
75
+
76
+ /**
77
+ * The lazy loader shape: an explicit `{ component }` payload or a dynamic
78
+ * import namespace whose `default` export is the component. The arrow keeps
79
+ * the chunk out of the main graph; the union keeps call sites terse
80
+ * (`lazy: () => import("./feed")`).
81
+ */
82
+ export type PanelLazy = (
83
+ routeId: string,
84
+ ) => Promise<{ component: ComponentType } | { default: ComponentType }>;
85
+
86
+ export interface ViewOptions extends LooseRouteOptions {
87
+ /**
88
+ * Panels of the view: raw inline options (small apps, wrap `lazy` in
89
+ * `panel()`) or a {@link PanelDefinition} from `definePanel` — the
90
+ * recommended shape, whose handle lives in a leaf module so panel chunks
91
+ * never import the app back.
92
+ */
93
+ panels: Record<string, PanelOptions | PanelDefinition>;
94
+ }
95
+
96
+ export interface PanelHandle<
97
+ TParams = Record<string, string>,
98
+ TSearch = Record<string, unknown>,
99
+ TLoaderData = unknown,
100
+ > {
101
+ /** Full TanStack route id — the `from` for the /react data hooks. */
102
+ readonly routeId: string;
103
+ /** URL path of the panel. */
104
+ path: string;
105
+ /** The view route id the panel belongs to. */
106
+ viewId: string;
107
+ /** Phantom carrier — the panel's data types for the /react hooks. */
108
+ readonly types?: { params: TParams; search: TSearch; loaderData: TLoaderData };
109
+ }
110
+
111
+ /** `view:feed` + panel `post/$id` → the runtime id `/view:feed/post/$id`. */
112
+ export type PanelRouteId<V extends string, P extends string> = P extends "index"
113
+ ? `/view:${V}/`
114
+ : `/view:${V}/${P}`;
115
+
116
+ /** `$id`-segments of a panel path → the params object type. */
117
+ export type ParamsOfKey<P extends string> = P extends `${infer Head}/${infer Rest}`
118
+ ? ParamsOfSegment<Head> & ParamsOfKey<Rest>
119
+ : ParamsOfSegment<P>;
120
+ type ParamsOfSegment<S extends string> = S extends `$${infer Name}` ? { [K in Name]: string } : {};
121
+ export type SearchOf<O extends PanelOptions> = O extends {
122
+ validateSearch: (search: never) => infer S;
123
+ }
124
+ ? S
125
+ : Record<string, never>;
126
+ export type LoaderDataOf<O extends PanelOptions> = O extends {
127
+ loader: (...args: never[]) => infer L;
128
+ }
129
+ ? Awaited<L>
130
+ : unknown;
131
+
132
+ /**
133
+ * The options a panels-map member declares — a {@link PanelDefinition}
134
+ * carries them in its third type parameter, raw inline options ARE the
135
+ * options.
136
+ */
137
+ export type OptionsOf<M extends PanelOptions | PanelDefinition> =
138
+ M extends PanelDefinition<any, any, infer O> ? O : M;
139
+
140
+ type UnionToIntersection<U> = (U extends unknown ? (arg: U) => void : never) extends (
141
+ arg: infer I,
142
+ ) => void
143
+ ? I
144
+ : never;
145
+
146
+ /**
147
+ * The declared panels map with LITERAL route ids and option-derived data
148
+ * types: `app.panels["feed/post/$id"]` autocompletes, is never undefined,
149
+ * its `routeId` is the literal runtime id, and `usePanelParams(handle)` &
150
+ * co. resolve params / search / loaderData from the declared options.
151
+ */
152
+ export type PanelHandlesOf<TViews extends Record<string, ViewOptions>> = UnionToIntersection<
153
+ {
154
+ [V in keyof TViews & string]: {
155
+ [P in keyof TViews[V]["panels"] & string as `${V}/${P}`]: PanelHandle<
156
+ ParamsOfKey<P>,
157
+ SearchOf<OptionsOf<TViews[V]["panels"][P]>>,
158
+ LoaderDataOf<OptionsOf<TViews[V]["panels"][P]>>
159
+ > & {
160
+ readonly routeId: PanelRouteId<V, P>;
161
+ };
162
+ };
163
+ }[keyof TViews & string]
164
+ >;
165
+
166
+ export interface AppHandle {
167
+ routeTree: AnyRoute;
168
+ /** Modal entries for the `<Modals>` host. */
169
+ modals: ModalEntry[];
170
+ /**
171
+ * Declarative URL masks for the local route modals — pass into
172
+ * `createRouter({ routeMasks })`, or a modal open puts its own path in
173
+ * the address bar.
174
+ */
175
+ routeMasks: RouteMask<any>[];
176
+ /** Popout registry for the `<Popouts>` host. */
177
+ popouts: PopoutEntry[];
178
+ /** Panel handles keyed by `${viewKey}/${panelKey}`. */
179
+ panels: Record<string, PanelHandle>;
180
+ views: Record<string, AnyRoute>;
181
+ /** Inflate flag (see {@link AppOptions.inflate}). */
182
+ inflate: boolean;
183
+ }
184
+
185
+ /**
186
+ * The app handle with the DECLARED panels shape: literal keys
187
+ * (`app.panels["feed/post/$id"]` — autocomplete, never undefined) and
188
+ * literal route ids that drive the /react hooks' typing. Structurally an
189
+ * AppHandle, so it flows into AppRootShell / useInflate / deriveInflateChain.
190
+ */
191
+ export interface TypedAppHandle<TViews extends Record<string, ViewOptions>> {
192
+ routeTree: AnyRoute;
193
+ modals: ModalEntry[];
194
+ routeMasks: RouteMask<any>[];
195
+ popouts: PopoutEntry[];
196
+ panels: PanelHandlesOf<TViews>;
197
+ views: Record<string, AnyRoute>;
198
+ inflate: boolean;
199
+ }
200
+
201
+ export interface AppOptionsBase {
202
+ /**
203
+ * Custom root shell (renders inside the router context). Defaults to
204
+ * AppRootShell: the active view + modal/popout hosts. Deliberately
205
+ * LOOSE (`AppHandle`, not `TypedAppHandle<TViews>`): a contextual type
206
+ * mentioning TViews makes the app const's own initializer circular
207
+ * (TS7022) — typed panel handles are read from the RETURN value.
208
+ */
209
+ root?: (props: { app: AppHandle }) => ReactNode;
210
+ /** Global modals: `?modal=<id>` opens them from any panel. */
211
+ modals?: Record<string, ModalEntryComponent>;
212
+ /** Global popout registry. */
213
+ popouts?: Record<string, PopoutEntry["Component"]>;
214
+ /**
215
+ * Inflate (default `true`): a cold start on a deep-linked panel replays
216
+ * the panel flow before it, so back walks the natural chain.
217
+ */
218
+ inflate?: boolean;
219
+ }
220
+
221
+ export interface AppOptions<
222
+ TViews extends Record<string, ViewOptions> = Record<string, ViewOptions>,
223
+ > extends AppOptionsBase {
224
+ views: TViews;
225
+ }
226
+
227
+ /** `createApp` with `defineView` hubs — handles live on the panel definitions. */
228
+ export interface AppDefinitionOptions extends AppOptionsBase {
229
+ views: readonly ViewDefinition[] | Record<string, ViewDefinition>;
230
+ }
231
+
232
+ /**
233
+ * Type-erasing wrapper for `lazy` panel chunks. The chunk module imports
234
+ * the app back for its typed panel handles — passing its component types
235
+ * straight into `views` makes the app's own inference circular (the
236
+ * component's inferred type depends on `app.panels`). `panel()` erases the
237
+ * chunk to `{ component: ComponentType }` at the boundary.
238
+ *
239
+ * ```tsx
240
+ * lazy: panel(() => import("./panels/feed").then(m => ({ component: m.FeedPanel })))
241
+ * lazy: panel(routeId => import("./panels/post.lazy").then(m => m.postLazy(routeId)))
242
+ * ```
243
+ */
244
+ /**
245
+ * Identity eraser for RAW inline `lazy` loaders (small apps declaring panels
246
+ * directly in `createApp`): the chunk modules import the app back for their
247
+ * handles, so the loader's return type must not carry the chunk's component
248
+ * type into the app's own inference. Apps using `definePanel` don't need it
249
+ * — the definition boundary already erases.
250
+ */
251
+ export function panel(load: PanelLazy): PanelLazy {
252
+ return load;
253
+ }
254
+
255
+ /** Normalized view entries the builder loop consumes. */
256
+ interface DeclaredView {
257
+ viewKey: string;
258
+ viewRest: LooseRouteOptions;
259
+ viewPanels: Record<string, PanelOptions | PanelDefinition>;
260
+ }
261
+
262
+ function normalizeViews(views: unknown): DeclaredView[] {
263
+ if (Array.isArray(views)) {
264
+ return (views as readonly ViewDefinition[]).map(definition => ({
265
+ viewKey: definition.view,
266
+ viewRest: definition.options ?? {},
267
+ viewPanels: { ...definition.panels },
268
+ }));
269
+ }
270
+ return Object.entries(views as Record<string, unknown>).map(([viewKey, declared]) => {
271
+ if (VIEW_DEFINITION in (declared as object)) {
272
+ const definition = declared as ViewDefinition;
273
+ if (definition.view !== viewKey) {
274
+ console.error(
275
+ `[vkma-router] view "${definition.view}" is nested under key "${viewKey}"; the hub's view string and the nesting key must match`,
276
+ );
277
+ }
278
+ return {
279
+ viewKey: definition.view,
280
+ viewRest: definition.options ?? {},
281
+ viewPanels: { ...definition.panels },
282
+ };
283
+ }
284
+ const { panels, ...viewRest } = declared as ViewOptions;
285
+ return { viewKey, viewRest: viewRest as LooseRouteOptions, viewPanels: panels ?? {} };
286
+ });
287
+ }
288
+
289
+ /** Normalize a declared modal into its declaration form. */
290
+ function asDeclaration(value: ModalComponent): ModalDeclaration {
291
+ // shape check, NOT typeof: lazy/memo/forwardRef components are objects
292
+ return typeof value === "object" && value !== null && "component" in value
293
+ ? value
294
+ : { component: value };
295
+ }
296
+
297
+ /**
298
+ * Build a modal subtree under `parentRoute` (a panel or a parent modal) and
299
+ * register every entry (flat) into the host registry. Nested modals are
300
+ * child routes of their parent modal — the stacking is expressed by the
301
+ * route tree itself.
302
+ */
303
+ function createModalTree(
304
+ parentRoute: AnyRoute,
305
+ modals: Record<string, ModalComponent>,
306
+ registry: ModalEntry[],
307
+ baseKey: string,
308
+ /** Full path of the OWNING panel — every level's mask displays it. */
309
+ panelFullPath: string,
310
+ /** Full path of the parent in the modal chain (panel or parent modal). */
311
+ parentFullPath: string,
312
+ /** Mask pairs collected for the whole app, mapped to RouteMask later. */
313
+ masks: Array<{ from: string; to: string }>,
314
+ ): AnyRoute[] {
315
+ const routes: AnyRoute[] = [];
316
+ for (const [modalKey, declared] of Object.entries(modals)) {
317
+ const { component, modals: nested } = asDeclaration(declared);
318
+ const modalRoute = createRoute({
319
+ getParentRoute: () => parentRoute,
320
+ path: modalKey,
321
+ staticData: { modal: `${baseKey}/${modalKey}` },
322
+ });
323
+ const modalFullPath =
324
+ parentFullPath === "/" ? `/${modalKey}` : `${parentFullPath}/${modalKey}`;
325
+ // while ANY level of the chain is open the URL keeps showing the
326
+ // OWNING panel's path — nested levels mask to the panel too, so the
327
+ // display never leaks a modal segment (the raw modal path still
328
+ // deep-links: parsing is never masked)
329
+ masks.push({ from: modalFullPath, to: panelFullPath });
330
+ registry.push({
331
+ get routeId() {
332
+ return modalRoute.id;
333
+ },
334
+ get id() {
335
+ return modalRoute.id;
336
+ },
337
+ Component: component,
338
+ });
339
+ routes.push(modalRoute);
340
+ if (nested !== undefined) {
341
+ const children = createModalTree(
342
+ modalRoute,
343
+ nested,
344
+ registry,
345
+ `${baseKey}/${modalKey}`,
346
+ panelFullPath,
347
+ modalFullPath,
348
+ masks,
349
+ );
350
+ if (children.length > 0) {
351
+ // addChildren REPLACES the children array — one call per route
352
+ modalRoute.addChildren(children);
353
+ }
354
+ }
355
+ }
356
+ return routes;
357
+ }
358
+
359
+ /** Definition-form views: the typed handles live on the panel definitions. */
360
+ export function createApp(options: AppDefinitionOptions): AppHandle;
361
+ /** Raw inline options: `app.panels` is fully typed from the declared tree. */
362
+ export function createApp<const TViews extends Record<string, ViewOptions>>(
363
+ options: AppOptions<TViews>,
364
+ ): TypedAppHandle<TViews>;
365
+ export function createApp(options: AppOptions | AppDefinitionOptions): any {
366
+ const modals: ModalEntry[] = [];
367
+ const pendingMasks: Array<{ from: string; to: string }> = [];
368
+ const popouts: PopoutEntry[] = Object.entries(options.popouts ?? {}).map(([id, Component]) => ({
369
+ id,
370
+ Component,
371
+ }));
372
+ const globalPopoutIds = new Set(Object.keys(options.popouts ?? {}));
373
+
374
+ const views: Record<string, AnyRoute> = {};
375
+ const panels: Record<string, PanelHandle> = {};
376
+ /**
377
+ * TanStack finalizes route ids in `route.init()` during `createRouter` —
378
+ * they are not readable at build time. The handle therefore exposes
379
+ * GETTERS: consumers read `routeId` at render time, when the router
380
+ * (and the ids) already exist.
381
+ */
382
+ const deferredPanels: Array<{
383
+ key: string;
384
+ path: string;
385
+ route: AnyRoute;
386
+ }> = [];
387
+ /** full panel path → owning panel key, for the duplicate-path dev check */
388
+ const usedPaths = new Map<string, string>();
389
+ const usedViewKeys = new Set<string>();
390
+
391
+ const customRoot = options.root;
392
+ const rootRoute = createRootRoute({
393
+ // a custom root receives the app handle (plus the raw match props);
394
+ // the default is the auto-shell. Both close over the handle — the
395
+ // component only runs at render time, after the binding exists.
396
+ component: (customRoot
397
+ ? (routeProps: Record<string, unknown>) =>
398
+ customRoot({ ...routeProps, app: handle }) as never
399
+ : () => <AppRootShell app={handle} />) as never,
400
+ });
401
+
402
+ for (const { viewKey, viewRest, viewPanels } of normalizeViews(options.views)) {
403
+ if (usedViewKeys.has(viewKey)) {
404
+ console.error(
405
+ `[vkma-router] duplicate view "${viewKey}"; a view must be declared exactly once (one defineView hub)`,
406
+ );
407
+ continue;
408
+ }
409
+ usedViewKeys.add(viewKey);
410
+ const viewRoute = createRoute({
411
+ getParentRoute: () => rootRoute,
412
+ id: `view:${viewKey}`,
413
+ ...(viewRest as Partial<Record<string, unknown>>),
414
+ });
415
+
416
+ const panelRoutes: AnyRoute[] = [];
417
+ for (const [panelKey, declared] of Object.entries(viewPanels)) {
418
+ // a definition carries its options inside; raw options ARE the options
419
+ const panelOptions: PanelOptions =
420
+ PANEL_DEFINITION in declared ? declared.options : declared;
421
+ if (PANEL_DEFINITION in declared && declared.view !== viewKey) {
422
+ console.error(
423
+ `[vkma-router] panel "${panelKey}" is defined for view "${declared.view}" but nested under "views.${viewKey}"; its handle route id and the tree would disagree`,
424
+ );
425
+ }
426
+ const { modals: panelModals, lazy, popouts: panelPopouts, ...panelRest } = panelOptions;
427
+ // panel-local popouts: resolved before the global registry while
428
+ // this panel is the deepest matched one; a global id collision is
429
+ // a dev-time error (the local declaration would shadow it)
430
+ const panelRegistryKey = `${viewKey}/${panelKey}`;
431
+ const localPopoutIds = new Set<string>();
432
+ for (const [popoutId, Component] of Object.entries(panelPopouts ?? {})) {
433
+ if (globalPopoutIds.has(popoutId)) {
434
+ console.error(
435
+ `[vkma-router] popout "${popoutId}" is declared both globally and on panel "${panelRegistryKey}"; the local one shadows the global — rename one of them`,
436
+ );
437
+ }
438
+ if (localPopoutIds.has(popoutId)) {
439
+ console.error(
440
+ `[vkma-router] duplicate popout "${popoutId}" on panel "${panelRegistryKey}"`,
441
+ );
442
+ continue;
443
+ }
444
+ localPopoutIds.add(popoutId);
445
+ popouts.push({ id: popoutId, Component, panel: panelRegistryKey });
446
+ }
447
+ const panelPath = panelKey === "index" ? "/" : panelKey;
448
+ // dev check: two panels with the same full path silently break
449
+ // matching (the router resolves the conflict arbitrarily) — a
450
+ // multi-view app must give every home panel its own segment
451
+ const pathOwner = usedPaths.get(panelPath);
452
+ if (pathOwner !== undefined) {
453
+ console.error(
454
+ `[vkma-router] duplicate panel path "${panelPath}" (${pathOwner} and ${viewKey}/${panelKey}); only one view may own "/" — give the other home panels distinct path segments (e.g. "feed")`,
455
+ );
456
+ } else {
457
+ usedPaths.set(panelPath, `${viewKey}/${panelKey}`);
458
+ }
459
+ const panelRoute = createRoute({
460
+ getParentRoute: () => viewRoute,
461
+ path: panelPath,
462
+ staticData: { panel: `${viewKey}/${panelKey}` },
463
+ ...(panelRest as Partial<Record<string, unknown>>),
464
+ });
465
+ if (lazy !== undefined) {
466
+ // runs on match load — the router (and ids) exist by then;
467
+ // TanStack's route.lazy() merges `lazyRoute.options`, the
468
+ // builder bridges the plain-options contract (both payload
469
+ // shapes) into that shape
470
+ panelRoute.lazy(
471
+ () =>
472
+ Promise.resolve(lazy(panelRoute.id)).then(payload => ({
473
+ options:
474
+ "component" in payload ? payload : { component: payload.default },
475
+ })) as never,
476
+ );
477
+ }
478
+ // the modal subtree (nested modals are child routes of their
479
+ // parent modal); entries register flat into the host registry.
480
+ // The view is a pathless layout route — the panel's full path is
481
+ // just its declared path rooted at "/"
482
+ const panelFullPath = panelPath === "/" ? "/" : `/${panelPath.replace(/^\//, "")}`;
483
+ const modalRoutes = createModalTree(
484
+ panelRoute,
485
+ panelModals ?? {},
486
+ modals,
487
+ `${viewKey}/${panelKey}`,
488
+ panelFullPath,
489
+ panelFullPath,
490
+ pendingMasks,
491
+ );
492
+ // addChildren REPLACES the children array — one call after the loop
493
+ // (per-modal calls would orphan every modal but the last)
494
+ panelRoute.addChildren(modalRoutes);
495
+ panelRoutes.push(panelRoute);
496
+ deferredPanels.push({
497
+ key: `${viewKey}/${panelKey}`,
498
+ path: panelPath,
499
+ route: panelRoute,
500
+ });
501
+ }
502
+
503
+ viewRoute.addChildren(panelRoutes);
504
+ views[viewKey] = viewRoute;
505
+ }
506
+
507
+ rootRoute.addChildren(Object.values(views));
508
+
509
+ // Declarative masks for the local route modals: while a modal is open
510
+ // the router displays the owning panel's path (createRouteMask is
511
+ // identity — the router matches `from` at navigation time and stores
512
+ // the real location in history.state.__tempLocation, so the raw modal
513
+ // path still deep-links and the mask survives reload)
514
+ const routeMasks = pendingMasks.map(
515
+ mask =>
516
+ createRouteMask({
517
+ routeTree: rootRoute as never,
518
+ from: mask.from,
519
+ to: mask.to,
520
+ // modal routes are children of their panel — the params ($id, …)
521
+ // are shared, forward them to the displayed location wholesale
522
+ params: true,
523
+ } as never) as RouteMask<any>,
524
+ );
525
+
526
+ for (const { key, path, route } of deferredPanels) {
527
+ panels[key] = {
528
+ get routeId() {
529
+ return route.id;
530
+ },
531
+ path,
532
+ get viewId() {
533
+ // runtime ids carry a leading slash: "/view:feed" — split("/")[0]
534
+ // is ALWAYS "" (that silently broke the inflate view filter: every
535
+ // panel matched every view, so a cold home deep link replayed the
536
+ // first other view's panels before the target)
537
+ const match = String(route.id).match(/^\/?view:([^/]+)/);
538
+ return match?.[1] ?? String(route.id);
539
+ },
540
+ };
541
+ }
542
+
543
+ // global modals: no route — the Modals host opens them from the
544
+ // `?modal=<id>` search param on any panel (deep-linkable; the
545
+ // imperative open in useNavigator masks the URL back to the panel)
546
+ for (const [modalKey, Modal] of Object.entries(options.modals ?? {})) {
547
+ modals.push({
548
+ routeId: `?modal=${modalKey}`,
549
+ id: modalKey,
550
+ Component: Modal,
551
+ });
552
+ }
553
+
554
+ const handle: AppHandle = {
555
+ routeTree: rootRoute,
556
+ modals,
557
+ routeMasks,
558
+ popouts,
559
+ panels,
560
+ views,
561
+ inflate: options.inflate ?? true,
562
+ };
563
+ // the TypedAppHandle overload's literal route-id types are only
564
+ // computable through the caller's generic
565
+ return handle;
566
+ }
@@ -0,0 +1,109 @@
1
+ import type {
2
+ LoaderDataOf,
3
+ PanelOptions,
4
+ PanelLazy,
5
+ PanelRouteId,
6
+ ParamsOfKey,
7
+ SearchOf,
8
+ } from "./builders.ts";
9
+
10
+ /** Brand distinguishing a {@link PanelDefinition} from raw inline options. */
11
+ export const PANEL_DEFINITION = "__vkmaPanel" as const;
12
+
13
+ /**
14
+ * A self-contained panel declaration: the definition IS the typed handle.
15
+ *
16
+ * Panel chunk modules import the DEFINITION (never the app) for their data
17
+ * hooks — the definition's stored type is CLOSED (its `options` are widened
18
+ * and the concrete option-derived types travel in the `types` phantom), so
19
+ * the type graph stays acyclic and the app import disappears from chunks
20
+ * entirely. `createApp` nests definitions under their view key and builds
21
+ * the routes from `options`.
22
+ */
23
+ export interface PanelDefinition<
24
+ V extends string = string,
25
+ P extends string = string,
26
+ O extends PanelOptions = PanelOptions,
27
+ > {
28
+ readonly [PANEL_DEFINITION]: true;
29
+ /** The view (story) namespace — must match the key under `views`. */
30
+ readonly view: V;
31
+ /** The panel path segment (`"index"` = the view's home panel). */
32
+ readonly path: P;
33
+ /**
34
+ * The declared options, WIDENED on purpose: a widened stored type cannot
35
+ * reference chunk modules, so importing the definition from a chunk can
36
+ * never close a type cycle. The concrete option-derived data types
37
+ * travel in the phantom below.
38
+ */
39
+ readonly options: PanelOptions;
40
+ /** Runtime route id by convention (`/view:${view}/${path}`). */
41
+ readonly routeId: PanelRouteId<V, P>;
42
+ /**
43
+ * Phantom carrying the option-derived data types for the /react hooks.
44
+ * `O` is the options WITHOUT `lazy` (the loader is a separate factory
45
+ * parameter) — its arrow would otherwise embed the chunk module's
46
+ * (deferred) member types into the definition, the exact cycle this
47
+ * module exists to break.
48
+ */
49
+ readonly types?: {
50
+ params: ParamsOfKey<P>;
51
+ search: SearchOf<O>;
52
+ loaderData: LoaderDataOf<O>;
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Declare a panel:
58
+ *
59
+ * ```ts
60
+ * export const feedPanel = definePanel("feed", "feed", {
61
+ * loader: async () => ({ posts }),
62
+ * }, () => import("./feed"));
63
+ * ```
64
+ *
65
+ * The returned definition nests directly into `createApp`'s
66
+ * `views[view].panels[path]` and doubles as the typed handle for the
67
+ * `/react` hooks — chunk modules import it instead of the app handle, which
68
+ * is what keeps the app's inference and the chunk graph acyclic. The view
69
+ * key MUST match the nesting key in `createApp` (a mismatch is a dev-time
70
+ * console error, not a silent hook break).
71
+ */
72
+ export function definePanel<
73
+ const V extends string,
74
+ const P extends string,
75
+ O extends PanelOptions = PanelOptions,
76
+ >(
77
+ view: V,
78
+ path: P,
79
+ options: O,
80
+ /**
81
+ * The lazy loader as a SEPARATE parameter — not a member of `options`.
82
+ * Its arrow references the UI chunk's module, whose member types include
83
+ * the (deferred) component signatures; if that arrow were part of the
84
+ * inferred `O`, a chunk component resolving hooks against this very
85
+ * definition would close the inference cycle (TS7022). A separate
86
+ * parameter is contextually typed by `PanelLazy` directly — the module
87
+ * type never enters `O`. Mirrors TanStack's own options/`route.lazy()`
88
+ * split.
89
+ */
90
+ lazy?: PanelLazy,
91
+ ): PanelDefinition<V, P, O> {
92
+ return {
93
+ [PANEL_DEFINITION]: true,
94
+ view,
95
+ path,
96
+ options: lazy === undefined ? options : { ...options, lazy },
97
+ // same formula as panelRouteId — TS cannot see the literal through the
98
+ // template, the cast records the guarantee
99
+ routeId: panelRouteId(view, path) as PanelRouteId<V, P>,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * The deterministic route-id convention shared with `createApp`'s tree
105
+ * assembly — one formula, two consumers, no drift.
106
+ */
107
+ export function panelRouteId(view: string, path: string): string {
108
+ return path === "index" ? `/view:${view}/` : `/view:${view}/${path}`;
109
+ }