@finesoft/front 0.5.0 → 0.5.2

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 (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-DIU6Sxl3.mjs +1237 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-Bpf9VayB.d.mts +779 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -698
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-1SphZ7x7.mjs +436 -0
  39. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-Ftl_0rhu.mjs +28 -0
  49. package/dist/src-qwx7Vw8g.mjs +3807 -0
  50. package/dist/ssr-BEUNDvbj.d.mts +210 -0
  51. package/dist/ssr-C8xnYXoY.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-BuaZHRG7.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +6 -333
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +6 -333
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BYZq9Jp7.mjs +0 -2
  128. package/dist/browser-JTs2jqVY.d.mts +0 -2811
@@ -1,355 +1,195 @@
1
- # 11. 导航
1
+ # 结构化导航
2
2
 
3
- 2–4 章讲的是**扁平单页**生命周期:一个 URL → 一个 intent → 一个页面。本章补上**结构化导航** —— 一棵递归的、与 UI 无关的导航树,对标 SwiftUI 的 `NavigationStack`、`TabView`、`NavigationSplitView`。
3
+ Tabs、Stack、Split 是不可变导航声明。页面引用生成操作目标;分支、列名称仍是明确的布局标识。
4
4
 
5
- 框架持有导航的**状态**、URL/history 接线、以及对每个目标的 intent 派发。它**不含任何 UI**。你的 `Page` 模型与之前一样保持内容无关 —— tabs、stack、split 怎么画,由你用 Svelte / React / Vue 自行决定。
5
+ ## 路由定义驱动参数类型
6
6
 
7
- 单个叶子树**逐位等价**于扁平单页,因此本特性完全可选:从不调用 `defineNavigation` 的应用行为不变。
8
-
9
- ## 心智模型
10
-
11
- 导航状态是一棵由四种节点构成的树:
12
-
13
- ```
14
- NavigationNode = LeafNode | StackNode | TabsNode | SplitNode
15
- ```
16
-
17
- | 节点 | 持有 | 语义 | SwiftUI |
18
- | ----------- | ------------------------------- | ---------------------------------------------------------------------- | --------------------- |
19
- | `LeafNode` | `intent` + `params` | 一个目标(一次 intent 派发) | 一个 destination view |
20
- | `StackNode` | 有序 `entries[]` | 一条路径:`entries[0]` 是根,末尾是可见的栈顶 | `NavigationStack` |
21
- | `TabsNode` | `active` 键 + `branches` | 并列分支;**仅激活分支可见** | `TabView` |
22
- | `SplitNode` | `columns[]` + 可选 `visibility` | 多列并存;可见集**默认全部列**,可收窄为 `detailOnly` / `doubleColumn` | `NavigationSplitView` |
23
-
24
- 叶子持有 `intent` + `params`,**不是** `Page`。树是纯粹的、可序列化的数据,描述「要去哪」;「那里是什么」(`Page`)由 controller 在解析时产出,并随快照交回。这正是树能进 URL / history 的原因。
25
-
26
- 内部节点递归嵌套 —— 一个由 `NavigationStack` 组成的 `TabView`、detail 列是 stack 的 split,等等。
27
-
28
- ## 声明一棵树
29
-
30
- 构造器与其它一切一样从 `@finesoft/front` 导入:
7
+ 路径参数和 query 都在路由中声明类型,并分别读取为 `params.id`、`query.tab`。既有 `handler` 仍接收路径参数和执行上下文,第三个参数接收 query:
31
8
 
32
9
  ```ts
33
- import { leaf, stack, tabs, split } from "@finesoft/front";
34
-
35
- // 一个目标 —— 等价于今天的扁平单页
36
- leaf("home");
37
- leaf("product", { id: 42 });
38
-
39
- // 栈:只有根,或根 + 已 push 的 entry
40
- stack(leaf("feed"));
41
- stack([leaf("feed"), leaf("post", { id: 7 })]);
42
-
43
- // Tabs:每个分支自成一个栈
44
- tabs({
45
- active: "home",
46
- branches: {
47
- home: stack(leaf("home")),
48
- search: stack(leaf("search")),
49
- me: stack(leaf("me")),
10
+ import { definePage, defineWebApp, int, optional, oneOf } from "@finesoft/front";
11
+
12
+ export const product = definePage({
13
+ id: "product",
14
+ routes: [
15
+ {
16
+ path: "/products/:id",
17
+ params: { id: int() },
18
+ query: { tab: optional(oneOf(["details", "reviews"])) },
19
+ },
20
+ ],
21
+ handler(params, context, query) {
22
+ context.signal.throwIfAborted();
23
+ // params.id: number;query.tab?: "details" | "reviews"
24
+ return { id: params.id.toFixed(), pageType: "product" as const, title: "Product" };
50
25
  },
51
26
  });
27
+ export const definition = defineWebApp({
28
+ id: "shop",
29
+ pages: [product],
30
+ getErrorPage: (status, title) => ({ id: String(status), pageType: "error", title }),
31
+ });
52
32
 
53
- // Split:sidebar + detail,detail 是一个栈
54
- split([
55
- { id: "sidebar", content: leaf("folders") },
56
- { id: "detail", content: stack(leaf("folder", { id: "inbox" })) },
57
- ]);
33
+ product.leaf({ id: 42 });
34
+ // product.leaf({ id: "42" }); // 编译错误
58
35
  ```
59
36
 
60
- `tabs()` 缺省 `order` 时按 `branches` 的插入顺序推导稳定 tab 顺序。`stack()` 接受单个根节点或一个 entries 数组。
37
+ 需要错误恢复时使用独立的 `BaseController` 子类;`execute` 可同步或异步返回,`fallback` 可省略,取消执行不会进入 `fallback`。已有 `perform` 工厂继续支持。`create` 不接受只有 `execute/fallback` 的对象。
61
38
 
62
- ## NavigationStack 组成的 TabView
39
+ ### 独立 Controller
63
40
 
64
- 最常见的形态:底部标签栏,每个 tab 保留自己的导航深度。
41
+ 路由与类可以分文件。只在路由中维护输入类型,类统一使用 `execute({ params, query, context })` / `fallback({ params, query, context, error })`:
65
42
 
66
43
  ```ts
67
- // src/bootstrap.ts
68
- import { type Framework, defineRoutes, defineNavigation, leaf, stack, tabs } from "@finesoft/front";
69
- import { HomeController } from "./lib/controllers/home";
70
- import { SearchController } from "./lib/controllers/search";
71
- import { ProfileController } from "./lib/controllers/profile";
72
- import { PostController } from "./lib/controllers/post";
73
-
74
- export function bootstrap(framework: Framework): void {
75
- defineRoutes(framework, [
76
- { path: "/", intentId: "home", controller: new HomeController() },
77
- { path: "/search", intentId: "search", controller: new SearchController() },
78
- { path: "/me", intentId: "me", controller: new ProfileController() },
79
- { path: "/posts/:id", intentId: "post", controller: new PostController() },
80
- ]);
44
+ // controllers/product.ts — 编写时不用重复参数类型
45
+ import { BaseController } from "@finesoft/front";
46
+ import type { ProductPage } from "../models/product";
47
+
48
+ export class ProductController extends BaseController {
49
+ execute({ params, query, context }): ProductPage {
50
+ return { id: params.id.toFixed(), pageType: "product", title: "Product" };
51
+ }
81
52
  }
82
-
83
- // 导航结构,只声明一次
84
- export const navigation = defineNavigation({
85
- initial: tabs({
86
- active: "home",
87
- branches: {
88
- home: stack(leaf("home")),
89
- search: stack(leaf("search")),
90
- me: stack(leaf("me")),
91
- },
92
- }),
93
- });
94
53
  ```
95
54
 
96
- `defineNavigation` 返回一个规范化的定义,附带两个适配器 —— `toBrowserConfig()` 给 CSR、`toSSRDefinition()` 给 SSR —— 因此你只声明**一次**树,就能把各自需要的形态交给对应 runner。
97
-
98
- ### 接入浏览器
99
-
100
- `startBrowserApp` 新增一个可选 `navigation` 字段;存在时,`NavigationHandle`(以及统一的 `app` 句柄)会在 `mount` 回调的 context 里交给你,挂载时即可直接使用:
101
-
102
55
  ```ts
103
- // src/main.ts
104
- import { startBrowserApp } from "@finesoft/front";
105
- import { bootstrap, navigation } from "./bootstrap";
106
-
107
- startBrowserApp({
108
- bootstrap,
109
- callbacks,
110
- navigation: navigation.toBrowserConfig(),
111
- mount(target, { navigation: nav, app }) {
112
- // nav/app 在 mount 时已就绪,无需等待回调。
113
- // 快照变更时重渲染:
114
- nav?.subscribe((snapshot) => mountNavigation(snapshot));
115
- if (nav) mountNavigation(nav.getSnapshot());
116
- // ... 把 UI 挂载到 target,将 app 传给组件 ...
117
- return () => undefined;
118
- },
56
+ // app-definition.ts
57
+ import { definePage, int } from "@finesoft/front";
58
+ import { ProductController } from "./controllers/product";
59
+
60
+ export const product = definePage({
61
+ id: "product",
62
+ routes: [{ path: "/products/:id", params: { id: int() } }],
63
+ create: () => new ProductController(),
119
64
  });
120
65
  ```
121
66
 
122
- 提供 `navigation` 时,框架会构建 `NavigationController` history 桥、解析首屏,并在 mount context 中把 handle 交给你。缺省时 `startBrowserApp` 走原有扁平单页路径,行为不变。
123
-
124
- ## 驱动导航
67
+ 框架根据 `create` 返回的类定位 `BaseController` 子类,只维护 `import type`、参数注解与基类泛型。`context` 保留 DI、取消信号等执行服务;query 不混入 params。类型声明集中在 `.finesoft/controller-types.d.ts`,不会在 Controller 文件末尾追加声明区块。保存的类源码会出现这些引用,业务无需手写或同步。手写参数类型保留;移除参数注解与基类输入泛型即可交给生成器管理。建议保留方法的返回页面类型。
125
68
 
126
- `NavigationHandle` 暴露各操作。每个都返回 `Promise<NavigationSnapshot>`(提交后的树 + 每个可见目标解析出的 `Page`),并在浏览器侧把新状态写入 history/URL。
69
+ 生成后使用短类型名,基类直接接收完整输入类型:
127
70
 
128
71
  ```ts
129
- // 在激活栈压入一个目标
130
- await handle.push("post", { id: 7 });
131
-
132
- // 弹回
133
- await handle.pop(); // 一层
134
- await handle.pop(2); // 两层 —— 绝不越过栈根
135
- await handle.popToRoot();
136
-
137
- // 替换当前栈顶(如 登录 → dashboard 且不留返回步)
138
- await handle.replaceTop("dashboard");
139
-
140
- // 切换激活 tab —— 其它 tab 保留各自栈深
141
- await handle.selectTab("search");
72
+ import type {
73
+ ProductControllerInput as Input,
74
+ ProductControllerFailure as Failure,
75
+ } from "../../../.finesoft/controller-types";
76
+
77
+ export class ProductController extends BaseController<Input, ProductPage> {
78
+ async execute({ params, query, context }: Input): Promise<ProductPage> {
79
+ /* 业务逻辑 */
80
+ }
81
+ fallback({ params, error }: Failure): ProductPage {
82
+ /* 回退逻辑 */
83
+ }
84
+ }
142
85
  ```
143
86
 
144
- `pop` 绝不弹到栈根之下。不显式给 target 时,栈操作作用于**最深的激活栈**(当前可见的那个),`selectTab` 作用于**最外层**的 tabs 节点 —— 这正是「标签栏驱动聚焦栈」想要的。
145
-
146
- ### 读取结果
87
+ `Input` 已包含 `params`、`query` `context`,无需在基类中再次拆开;`Failure` 额外包含 `error`。同文件有多个控制器或名称冲突时使用带类名的别名。基类统一使用 `BaseController<Input, Result>`,手写输入可使用 `ControllerInput<Params, Query>`。直接调用 `perform` 的参数与结果保持类型检查。
147
88
 
148
- `NavigationSnapshot` 就是你拿来渲染的东西:
89
+ 旧的多个位置参数写法需要先迁移为单对象参数。生成器只更新类型引用,不改写方法业务逻辑;遇到旧的多参数 `execute` / `fallback` 签名时会报告迁移提示。
149
90
 
150
- ```ts
151
- const snapshot = handle.getSnapshot();
152
- snapshot.tree; // 当前 NavigationNode 树
153
- snapshot.destinations; // ResolvedDestination[]:{ intent, params, page, status? }
154
- ```
155
-
156
- `destinations` 的顺序与 `collectVisibleDestinations(tree)` 一致:tabs 节点**只**贡献激活分支,split **每个**非空列都贡献。这个顺序也正是服务端预取的内容。
91
+ 这使用标准 TypeScript 声明,因此编辑器补全与命令行检查一致。它不会执行路由模块、实例化 Controller,也不会增加运行时代码。同一类用于多个路由时,params/query 分别保留对应路由的输入类型,需要正常收窄。
157
92
 
158
- 你的视图层遍历 `snapshot.tree` 排布外壳(有哪些 tab、每个栈多深),从 `snapshot.destinations` 读页面内容。框架从不告诉你**怎么**画。
93
+ 取消路由注册但保留类时,框架保留该类最后一次生成的输入契约,避免破坏它的独立使用;重新注册后按新路由更新。引用式 `tsconfig.json` 会自动选择包含 `src` 的应用项目;有多个候选项目时,通过 `controllerTypes.tsconfig` 指定应用配置。
159
94
 
160
- ## NavigationSplitView
161
-
162
- split 视图同时展示多列 —— 经典的 sidebar + detail(+ sub-detail)布局。一列的选择驱动下一列。
95
+ 模板已配置自动生成。现有项目安装开发依赖 `typescript`,并把框架插件的创建放在 `lazyPlugins` 外,确保 `vp check` 读取配置时也会生成类型:
163
96
 
164
97
  ```ts
165
- export const navigation = defineNavigation({
166
- initial: split([
167
- { id: "sidebar", content: leaf("mailboxes") },
168
- { id: "list", content: undefined }, // 稍后选择
169
- { id: "detail", content: undefined },
170
- ]),
98
+ const front = finesoftFrontViteConfig({
99
+ controllerTypes: { root: import.meta.dirname },
100
+ });
101
+ export default defineConfig({
102
+ lint: { options: { typeAware: true, typeCheck: true } },
103
+ plugins: lazyPlugins(() => [front, react()]),
171
104
  });
172
105
  ```
173
106
 
174
- `selectColumn(columnId, intent, params?)` 设置某列内容:
175
-
176
- ```ts
177
- // 选一个邮箱 → 填充 "list" 列
178
- await handle.selectColumn("list", "messages", { mailbox: "inbox" });
179
-
180
- // 选一封邮件 → 填充 "detail" 列
181
- await handle.selectColumn("detail", "message", { id: 1024 });
182
-
183
- // 重选邮箱 → 清空 "list" 与 "detail"(它之后的所有列)
184
- await handle.selectColumn("list", "messages", { mailbox: "archive" });
185
-
186
- // 给 intent 传 undefined 显式清空某列
187
- await handle.selectColumn("detail", undefined);
188
- ```
189
-
190
- 设置某列会**清空它之后的所有列**。重选 sidebar 会正确作废已打开的 detail,于是你绝不会渲染出「旧 detail 配新 sidebar」的错配。
191
-
192
- 默认所有列都可见,快照的 `destinations` 里**每个非空列**各一条 —— 框架会派发(服务端则预取)它们每一个。
193
-
194
- ### 列可见性
195
-
196
- 对标 SwiftUI 的 `NavigationSplitViewVisibility`,split 带一个可选的 **visibility** —— 这是**可绑定、可序列化的导航状态**(不是样式),它决定哪些列算可见,进而决定服务端预取什么:
107
+ `vp dev` 启动后监听源码变更;`vp check` 与构建也会生成。初次打开项目先运行其中一个命令。将 `.finesoft/` 加入 `.gitignore`,提交类中的框架管理引用。直接使用 `tsc` 或自定义工具链时,先调用 `@finesoft/front` 导出的 `generateControllerTypes({ root })`。`controllerTypes: false` 关闭自动维护。
197
108
 
198
- | `visibility` | 可见列 |
199
- | -------------------------- | ------------------------- |
200
- | `automatic`(缺省)/ `all` | 全部列 |
201
- | `doubleColumn` | 首列 + 末列(隐藏中间列) |
202
- | `detailOnly` | 仅末列(detail) |
109
+ `createBrowserApp({ definition, target })` 返回的 `app`、`createWebSession` 和 SSR 的 `render(app)` 都保留该定义的参数关联:
203
110
 
204
111
  ```ts
205
- import { SPLIT_VISIBILITIES, visibleSplitColumns } from "@finesoft/front";
206
-
207
- // 声明时即指定(例如深链直达 detail)
208
- split(
209
- [
210
- { id: "sidebar", content: leaf("mailboxes") },
211
- { id: "detail", content: leaf("message", { id: 7 }) },
212
- ],
213
- SPLIT_VISIBILITIES.DETAIL_ONLY,
214
- );
215
-
216
- // 或运行时切换 —— 新变可见的列会被派发,隐藏的列从快照中移除
217
- await handle.setVisibility(SPLIT_VISIBILITIES.DETAIL_ONLY); // 只剩 detail 目标
218
- await handle.setVisibility(SPLIT_VISIBILITIES.ALL); // 重新预取 sidebar + list
219
-
220
- // 无需自己重实现映射,直接拿可见列渲染
221
- for (const col of visibleSplitColumns(splitNode)) renderColumn(col);
112
+ await app.perform({ kind: "push", intent: "product", params: { id: 42 } });
113
+ // id: "42"、缺少 id 或未知 intent 都会产生编译错误。
222
114
  ```
223
115
 
224
- `detailOnly` 深链在服务端**只**解析并预取 detail —— 隐藏列在显示前不耗成本。compact 窗口塌缩成单栈(SwiftUI `preferredCompactColumn`)是视口反应式的纯渲染,框架不碰,完全交给你:读 `getPlatform()` / 视口,自行把 split 塌成栈视图。
225
-
226
- ## 定位嵌套容器
116
+ `route()` 和页面引用的 `.route()` 返回值也保留已声明的 codec 类型。保留工厂返回值的推导;不要用宽类型 `WebAppDefinition` 注解覆盖 `definition`。显式标为 `PageRoute` 的变量允许省略 codec,接收方类型也必须考虑未配置 codec 的情况;需要校验声明形状时可用 `satisfies PageRoute` 保留具体类型。组件需要显式声明应用类型时使用 `WebAppView<typeof definition>`(或 `ViewProps<ProductPage, typeof definition>`),无需再写参数接口。通用 `WebAppView` 仍用于与任意应用兼容的布局和 Outlet。
227
117
 
228
- 当一棵树里有不止一个 stack/tabs/split 时,传一个显式的 `target` 路径来操作更深的那个。路径是从根出发的步骤序列:
118
+ 字符串数组如 `routes: ["/items/:id", "/products/:id"]` 继续支持多个别名,未声明 codec 的路径参数为 `string`,`:tab?` 为可选字符串。不同别名的参数结构不同时,控制器接收联合类型,需要先判断对应属性。相同参数匹配多个别名时,结构化目标需提供 `url` 指明路径,例如 `product.leaf({ id: 42 }, { url: "/products/42" })`。
229
119
 
230
- ```ts
231
- import type { NavigationPath } from "@finesoft/front";
232
-
233
- // split 的 detail 列里那个栈
234
- const detailStack: NavigationPath = [
235
- { kind: "column", id: "detail" },
236
- { kind: "stack-entry", index: 0 },
237
- ];
238
-
239
- await handle.push("attachment", { id: 3 }, { target: detailStack });
240
- await handle.selectTab("photos", someTabsPath);
241
- ```
120
+ `optional`、`withDefault`、`list` 与第三方 Standard Schema 的输出决定接收方类型。`leaf` 和结构化 Action 使用同一输出形状;默认值字段在该形状中为必填,URL 导航省略它时仍由 Router 填入默认值。运行时仍由原有 Router 校验和转换参数,不增加第二次 Schema 校验。
242
121
 
243
- 不给 `target` 时,操作默认作用于激活路径 —— 绝大多数情况下这都是对的。
122
+ ### Query 与路径参数
244
123
 
245
- ## 纯操作(不需要 controller)
246
-
247
- 上面这一切都由纯粹、不可变的树函数支撑,你可以直接用 —— 写测试、做乐观计算、或自建 controller:
124
+ 两者共用 codec 与推导规则,在 Controller 的对象入参中分别提供,不需要手动解析 URL:
248
125
 
249
126
  ```ts
250
- import {
251
- push,
252
- pop,
253
- selectTab,
254
- collectVisibleDestinations,
255
- resolveActivePath,
256
- } from "@finesoft/front";
257
-
258
- const next = push(tree, leaf("post", { id: 7 })); // 返回一棵新树
259
- const visible = collectVisibleDestinations(next); // readonly LeafNode[]
260
- const activePath = resolveActivePath(next);
261
- ```
262
-
263
- 它们绝不修改输入 —— 只有被改动路径上的节点会重建,树的其余部分按引用复用。非法 target(如对非 tabs 节点 `selectTab`、对空栈 target 执行 pop)会抛 `NavigationError`。
264
-
265
- ## 服务端渲染
266
-
267
- SSR 预取**所有**可见目标并把它们 —— 连同树本身 —— 序列化进 HTML,于是浏览器首屏直接复用服务端结果、不再取数。多列 split 视图天然预取多个 intent。
268
-
269
- 用 `createSSRNavigationRender` 配合 SSR 适配器:
270
-
271
- ```ts
272
- // src/ssr.ts
273
- import { createSSRNavigationRender } from "@finesoft/front";
274
- import { bootstrap, navigation } from "./bootstrap";
275
- import { renderApp } from "./lib/render";
276
-
277
- export const render = createSSRNavigationRender({
278
- bootstrap,
279
- getErrorPage: (status, message) => ({
280
- id: `error-${status}`,
281
- pageType: "error",
282
- title: message,
283
- }),
284
- renderApp, // (page, framework, snapshot) => { html, head, css }
285
- navigation: navigation.toSSRDefinition(),
127
+ routes: [
128
+ {
129
+ path: "/products/:id",
130
+ params: { id: int() },
131
+ query: {
132
+ q: withDefault(str(), ""),
133
+ tags: optional(list(str())),
134
+ },
135
+ },
136
+ ];
137
+ // execute({ params, query, context }) 内:
138
+ // params.id 是 number,query.q 是 string,query.tags 是 string[] | undefined。
139
+ await app.perform({
140
+ kind: "push",
141
+ intent: "product",
142
+ params: { id: 42 },
143
+ query: { q: "a & b", tags: ["new", "sale"] },
286
144
  });
287
145
  ```
288
146
 
289
- `renderApp` 收三个参数:**主目标**页面(激活叶子的结果 —— 与扁平 SSR `renderApp` 签名兼容)、framework、以及完整的多区域 `snapshot`,让你渲染 tabs/split 布局:
290
-
291
- ```ts
292
- function renderApp(page, framework, snapshot) {
293
- // page → 聚焦目标(如用于 <title>、status)
294
- // snapshot.tree → 要画哪些 tab / 列
295
- // snapshot.destinations → 每个可见区域的 Page
296
- return renderYourFramework(snapshot);
297
- }
298
- ```
147
+ 框架把 params 编入路径、query 编入查询串;数组保留顺序并编码成重复键。普通 URL 导航仍可直接使用 `/products/42?q=a%20%26%20b&tags=new&tags=sale`。
299
148
 
300
- `renderApp` 具体要搭的 islands 外壳 —— chrome + 按目标的 islands 作为独立水合 root,以及客户端 `mountEntry` / `resolveIslandsShell` 如何收养并水合它们 —— [Islands SSR](./04-rendering-and-hydration.md#islands-ssr结构化架构方案-c)。
149
+ Query 的字段名不限于路径占位符;`optional` 允许缺失,`withDefault` 仅在缺失时补值,显式空字符串仍参与校验。`list` 收集同名键,缺失时为 `[]`;`optional(list(...))` 缺失时为 `undefined`,`withDefault(list(...), [...])` 使用默认数组。普通单值字段遇到重复键仍取最后一个值。未声明的 query 保持字符串兼容行为,不会获得声明字段的类型保证;同名字段各自保留,例如 `/products/42?id=other` params.id 42,query.id "other"(声明为 str 时)。
301
150
 
302
- 底层原理:每个可见目标经**既有的** `PrefetchedIntents` 通道序列化为一条普通的 `{ intent, data: page }`,再额外挂一条承载序列化树的哨兵条目。`@finesoft/server` **零改动** —— 它经同一个 `#serialized-server-data` 脚本透传哨兵。hydration 时浏览器桥从 history state(或哨兵)读回树,并复用预取的页面。
303
-
304
- 若某请求没有结构化深链、应用也没提供骨架,SSR 回退到 `Router.resolve(url)` → 单个叶子 —— 即今天的扁平单页(含其 `renderMode`)。404 路径不变。
305
-
306
- ## 用 `createFullStateCodec` 做深链
307
-
308
- 默认情况下,**激活叶子**驱动 URL(`/posts/7`),完整的树通过 history state 旁路传输 —— 聚焦目标拥有干净、可分享的 URL。若要把**整棵**树编码进 URL 以支持完整深链(分享一个能还原 tab、栈深、split 选择的链接),改用 `createFullStateCodec`:
151
+ ## Tree / 导航树
309
152
 
310
153
  ```ts
311
- import { createFullStateCodec } from "@finesoft/front";
312
-
313
- export const navigation = defineNavigation({
314
- initial: tabs({
315
- active: "home",
316
- branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
317
- }),
318
- codec: createFullStateCodec(), // 整树 → "?__nav=..." query 参数
154
+ import { stack, tabs, split } from "@finesoft/front";
155
+ import { home, product } from "./pages";
156
+ export const navigation = tabs({
157
+ active: "catalog",
158
+ branches: {
159
+ catalog: stack([home.leaf(), product.leaf({ id: 42 })]),
160
+ compare: split([{ id: "left", content: product.leaf({ id: 42 }) }, { id: "right" }]),
161
+ },
319
162
  });
163
+ // defineWebApp({ ..., navigation })
320
164
  ```
321
165
 
322
- 此时 URL 形如 `/me?__nav=<编码后的树>`,粘贴它即可在 SSR 与浏览器两侧还原完整导航状态。编码紧凑(base64url)、稳定(key 排序,相同树永远产出相同串)、无损。传 `createFullStateCodec({ param: "nav" })` 可重命名保留 query 参数。
166
+ 相同目标仍有独立 EntryId 与草稿;ResourceKey 可共享显式缓存的查询数据,但不会共享视图状态。标准浏览器启动器拥有 URL 动作、重定向、popstate,过期 URL 结果不能覆盖后来的操作。显式树操作通过单个串行队列执行。Tabs 保留分支,Stack 保留在树条目,Split 检查各个目标。原生视图生命周期需跟随树时选择 `entries`。
323
167
 
324
- 如需自定义 URL 方案,你也可以实现自己的 `NavigationCodec` —— 两个内置实现仅依赖 router 的 `getRoutes()`(和可选的 `reverse()`),别无其它。
168
+ ## Action 导航
325
169
 
326
- ## 守卫照常生效
170
+ 所有导航通过 `app.perform(action)` 执行。URL 使用 `{ kind: "flow", url }`;结构化导航使用下列 Action:
327
171
 
328
- 导航级 `beforeLoad` / `afterLoad` 守卫在每次导航时对**主目标**(激活叶子)执行,`redirect` / `rewrite` / `deny` 语义与[第 3 章](./03-middleware.md)一致:
329
-
330
- ```ts
331
- export const navigation = defineNavigation({
332
- initial: tabs({
333
- active: "home",
334
- branches: { home: stack(leaf("home")), me: stack(leaf("me")) },
335
- }),
336
- beforeLoad: [authGuard],
337
- });
338
- ```
172
+ | kind | Fields |
173
+ | ----------------- | ------------------------------------------------------------- |
174
+ | push / replaceTop | intent, params?, query?, target?, url? |
175
+ | pop | count?, target? |
176
+ | popToRoot | target? |
177
+ | popTo | index, target? |
178
+ | selectTab | key, target? |
179
+ | selectColumn | columnId, intent (undefined clears), params?, query?, target? |
180
+ | setVisibility | visibility, target? |
181
+ | reuseEntry | entryId |
182
+ | refresh | — |
183
+ | hydrate | tree |
339
184
 
340
- - `redirect` → 当作 SPA 内跳处理(浏览器复用 FlowAction 管线);该目标不派发。
341
- - `rewrite` → 用新 URL 重新解析出该目标的 intent/params。
342
- - `deny` → 给目标打上 deny status,不派发其 intent。
185
+ ## 导航事务策略
343
186
 
344
- 单个目标的 dispatch 失败绝不会从操作里抛出 —— 它在该目标上记一个 `status` 和一张兜底页(与 controller 一样的 `fallback` 安全网),于是某一列失败不会让整屏空白。
187
+ `defineWebApp({ beforeNavigate, beforeCommit })` 可选地声明整棵树的策略。数组中的每个策略每次事务只运行一次,空树退出也会执行;先执行应用定义的策略,再执行控制器附加策略。`beforeLoad` / `afterLoad` 仍按每个可见页面运行。
345
188
 
346
- ## 向后兼容
189
+ `beforeNavigate` 接收原快照 `from`、候选树 `tree`、稳定的 `transitionId`、当前 `execution` 及其 `signal`、`isServer`,返回 `next`、`deny` 或 `redirect`。页面重定向沿用同一事务标识,不重复运行准入策略;各跳转仍由原所有者释放执行资源。`beforeCommit` 另接收加载完成的 `candidate`,仅允许 `next` 或 `deny`,在消费预取缓存、写入快照、历史、事件和视图之前执行。普通应用无需配置策略或手动管理作用域。
347
190
 
348
- - 不向 `startBrowserApp` / `createSSRRender` `navigation` 的应用走**原有扁平路径**,行为零变化。
349
- - 单叶子树等价于扁平单页:一个可见目标、一次 resolve/dispatch、一对 before/after。SSR 仅在 `serverData` 多挂一条树哨兵(在抵达 `PrefetchedIntents` 前被剔除)。
350
- - `Page` 保持内容无关。导航在你的页面**周围**加结构,从不规定页面形状或你怎么渲染它。
191
+ 拒绝返回带 `rejection` 的未提交快照,空树也能表达拒绝。会话恢复会在替换 scope 和业务 slice 前检查是否提交。浏览器首屏拒绝显示错误页,不提交导航或记录页面访问;后续拒绝保留当前草稿。扁平和导航 SSR 都执行相同策略,不输出被拒绝页面的数据或公开缓存许可。CSR 空壳继续由浏览器执行导航。
351
192
 
352
- ## 下一步
193
+ 自有历史条目的后退或前进被拒绝时,History 补偿回已提交条目并保持滚动身份;附加元数据让刷新后仍可识别所有权与位置。缺少兼容元数据的外部条目会给出诊断,不猜测应跨越几个历史位置。异步策略应把 `signal` 传给自身 I/O;取消不会回滚已经完成的业务写入。
353
194
 
354
- - [中间件](./03-middleware.md) —— 导航复用的守卫语义
355
- - [渲染与 Hydration](./04-rendering-and-hydration.md) —— 渲染模式 × 架构矩阵、islands SSR 外壳,以及预取结果如何跨越 SSR → CSR 边界
195
+ 底层宿主配置 `createWebSession({ createContext })` 时,回调直接返回 `NavigationContext`。执行作用域提供 DI 容器和取消信号,请求 cookie 与 header 由宿主上下文提供。会话默认使用应用的 `getErrorPage`,也可显式覆盖。