akanjs 3.1.0-rc.1 → 3.1.0-rc.3

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 (285) hide show
  1. package/base/primitiveRegistry.ts +75 -6
  2. package/client/cookie.ts +2 -1
  3. package/client/csrTypes.ts +4 -8
  4. package/client/frameConfig.ts +48 -46
  5. package/client/index.ts +2 -0
  6. package/client/mcpView.ts +2 -0
  7. package/client/route/RouteHead.ts +66 -12
  8. package/client/router.ts +35 -19
  9. package/client/transitionTypes.ts +6 -0
  10. package/client/viewTransition.ts +10 -0
  11. package/client/viewportMeta.ts +5 -5
  12. package/client/webManifest.ts +16 -0
  13. package/common/Logger.ts +7 -3
  14. package/common/PipelineNames.ts +9 -10
  15. package/common/PipelinePattern.ts +65 -19
  16. package/common/PipelinePlanSchema.ts +147 -155
  17. package/common/PipelineValueReader.ts +76 -0
  18. package/common/bearerTokenOf.ts +4 -0
  19. package/common/index.ts +3 -3
  20. package/common/jwtDecode.ts +12 -8
  21. package/common/mcpExposure.ts +5 -3
  22. package/common/parseSearchParams.ts +11 -0
  23. package/common/pipelinePlan.ts +6 -14
  24. package/common/types.ts +4 -5
  25. package/constant/via.ts +6 -2
  26. package/fetch/client/fetchClient.ts +55 -30
  27. package/fetch/client/httpClient.ts +47 -12
  28. package/fetch/client/wsClient.ts +70 -30
  29. package/fetch/requestStorage.ts +1 -14
  30. package/index.ts +12 -176
  31. package/package.json +6 -1
  32. package/pipelineConfig.ts +167 -0
  33. package/server/akanApp.ts +3 -11
  34. package/server/akanServer.ts +6 -5
  35. package/server/assetHeaders.ts +19 -0
  36. package/server/di/diLifecycle.ts +6 -2
  37. package/server/di/utils.ts +3 -9
  38. package/server/edDsaJwt.ts +57 -0
  39. package/server/logging/logStreamRoute.ts +13 -6
  40. package/server/machine/machineToken.ts +9 -38
  41. package/server/mcp/McpAppShell.ts +51 -0
  42. package/server/mcp/McpApps.ts +14 -122
  43. package/server/mcp/McpAuth.ts +4 -4
  44. package/server/mcp/McpDispatcher.ts +27 -10
  45. package/server/mcp/McpPages.ts +243 -0
  46. package/server/mcp/McpRouter.ts +65 -207
  47. package/server/mcp/McpUiSession.ts +67 -0
  48. package/server/mcp/mcpAppViewRuntime.ts +87 -0
  49. package/server/ops/opsToken.ts +9 -30
  50. package/server/resolver/PendingSubscribes.ts +34 -0
  51. package/server/resolver/signal.resolver.ts +105 -66
  52. package/server/routeTreeBuilder.ts +15 -28
  53. package/server/routing/roomReplay.ts +79 -14
  54. package/server/rscClient.tsx +10 -8
  55. package/server/rscHeadPatch.ts +6 -4
  56. package/server/rscPagePrompts.ts +2 -3
  57. package/server/rscWorker.tsx +72 -48
  58. package/server/sitemap.ts +0 -14
  59. package/server/ssrFromRscRenderer.tsx +13 -16
  60. package/server/webRouter.ts +55 -57
  61. package/service/predefinedAdaptor/queue.adaptor.ts +14 -8
  62. package/service/predefinedAdaptor/solidQueue.adaptor.ts +7 -5
  63. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +14 -13
  64. package/service/serve.ts +2 -2
  65. package/signal/TaggedJson.ts +79 -0
  66. package/signal/endpointCache.ts +6 -4
  67. package/signal/endpointIdempotency.ts +175 -27
  68. package/signal/mcp/McpDocument.ts +11 -7
  69. package/signal/mcp/mcpProtocol.ts +30 -1
  70. package/signal/signalContext.ts +27 -14
  71. package/store/action.ts +5 -8
  72. package/store/servedEndpointOf.ts +8 -0
  73. package/types/base/primitiveRegistry.d.ts +24 -2
  74. package/types/client/csrTypes.d.ts +6 -8
  75. package/types/client/frameConfig.d.ts +14 -11
  76. package/types/client/index.d.ts +2 -0
  77. package/types/client/mcpView.d.ts +2 -0
  78. package/types/client/route/RouteHead.d.ts +2 -0
  79. package/types/client/router.d.ts +10 -0
  80. package/types/client/transitionTypes.d.ts +4 -0
  81. package/types/client/viewTransition.d.ts +3 -0
  82. package/types/client/webManifest.d.ts +2 -0
  83. package/types/common/Logger.d.ts +1 -0
  84. package/types/common/PipelineNames.d.ts +1 -0
  85. package/types/common/PipelineValueReader.d.ts +36 -0
  86. package/types/common/bearerTokenOf.d.ts +1 -0
  87. package/types/common/index.d.ts +4 -1
  88. package/types/common/jwtDecode.d.ts +3 -1
  89. package/types/common/mcpExposure.d.ts +3 -1
  90. package/types/common/parseSearchParams.d.ts +3 -0
  91. package/types/common/pipelinePlan.d.ts +6 -8
  92. package/types/common/types.d.ts +3 -5
  93. package/types/dictionary/base.dictionary.d.ts +1 -1
  94. package/types/dictionary/dictionary.d.ts +8 -8
  95. package/types/fetch/client/fetchClient.d.ts +4 -2
  96. package/types/fetch/client/httpClient.d.ts +2 -0
  97. package/types/fetch/client/wsClient.d.ts +3 -8
  98. package/types/fetch/requestStorage.d.ts +0 -2
  99. package/types/index.d.ts +12 -158
  100. package/types/pipelineConfig.d.ts +148 -0
  101. package/types/server/assetHeaders.d.ts +9 -0
  102. package/types/server/edDsaJwt.d.ts +23 -0
  103. package/types/server/mcp/McpAppShell.d.ts +5 -0
  104. package/types/server/mcp/McpApps.d.ts +1 -9
  105. package/types/server/mcp/McpPages.d.ts +57 -0
  106. package/types/server/mcp/McpUiSession.d.ts +11 -0
  107. package/types/server/mcp/mcpAppViewRuntime.d.ts +1 -0
  108. package/types/server/ops/opsToken.d.ts +1 -1
  109. package/types/server/resolver/PendingSubscribes.d.ts +7 -0
  110. package/types/server/routeTreeBuilder.d.ts +0 -1
  111. package/types/server/routing/roomReplay.d.ts +14 -3
  112. package/types/server/sitemap.d.ts +0 -2
  113. package/types/service/predefinedAdaptor/queue.adaptor.d.ts +7 -3
  114. package/types/service/predefinedAdaptor/solidQueue.adaptor.d.ts +2 -0
  115. package/types/service/serve.d.ts +2 -2
  116. package/types/signal/TaggedJson.d.ts +12 -0
  117. package/types/signal/endpointIdempotency.d.ts +2 -1
  118. package/types/signal/mcp/McpDocument.d.ts +5 -2
  119. package/types/signal/mcp/mcpProtocol.d.ts +29 -1
  120. package/types/signal/signalContext.d.ts +1 -0
  121. package/types/store/action.d.ts +1 -1
  122. package/types/store/servedEndpointOf.d.ts +1 -0
  123. package/types/ui/Agent/Launcher.d.ts +4 -2
  124. package/types/ui/Agent/attachment.d.ts +0 -1
  125. package/types/ui/Dialog/Modal.d.ts +2 -6
  126. package/types/ui/Dialog/modalSurface.d.ts +7 -1
  127. package/types/ui/Layout/BottomInset.d.ts +1 -1
  128. package/types/ui/Layout/FrameSlotPortal.d.ts +8 -0
  129. package/types/ui/Layout/Navbar.d.ts +1 -1
  130. package/types/ui/Layout/TopInset.d.ts +2 -2
  131. package/types/ui/Layout/frameSlot.d.ts +11 -0
  132. package/types/ui/Layout/index.d.ts +4 -4
  133. package/types/ui/Link/activePath.d.ts +1 -0
  134. package/types/ui/Modal.d.ts +2 -6
  135. package/types/ui/Model/EditModal.d.ts +2 -6
  136. package/types/ui/Model/New.d.ts +2 -6
  137. package/types/ui/Model/ViewEditModal.d.ts +2 -6
  138. package/types/ui/Model/ViewModal.d.ts +2 -6
  139. package/types/ui/Model/newFormOrigin.d.ts +10 -1
  140. package/types/ui/Portal.d.ts +1 -3
  141. package/types/ui/SheetHandle.d.ts +8 -0
  142. package/types/ui/System/Common.d.ts +1 -7
  143. package/types/ui/System/SSR.d.ts +4 -5
  144. package/types/ui/index.d.ts +1 -1
  145. package/types/ui/overlayLayer.d.ts +5 -0
  146. package/types/ui/useDismissDrag.d.ts +25 -0
  147. package/types/vendor/@akanjs/native/native/desktop/src/kiosk.d.ts +1 -0
  148. package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +24 -5
  149. package/types/vendor/@akanjs/native/packages/cli/src/commands/update.d.ts +1 -1
  150. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +6 -0
  151. package/types/vendor/@akanjs/native/packages/cli/src/lib/compat.d.ts +6 -0
  152. package/types/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.d.ts +2 -6
  153. package/types/vendor/@akanjs/native/packages/cli/src/lib/publish.d.ts +29 -1
  154. package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +29 -6
  155. package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +9 -6
  156. package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows.d.ts +1 -0
  157. package/types/vendor/@akanjs/native/packages/core/src/kernel.d.ts +83 -1
  158. package/types/vendor/@akanjs/native/packages/desktop/src/host.d.ts +9 -0
  159. package/types/vendor/@akanjs/native/packages/desktop/src/main.d.ts +5 -0
  160. package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +18 -6
  161. package/types/vendor/@akanjs/native/plugins/external-view/src/index.d.ts +26 -8
  162. package/types/vendor/@akanjs/native/plugins/single-instance/src/socket.d.ts +5 -1
  163. package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +5 -0
  164. package/types/vendor/@akanjs/native/plugins/updates/src/index.d.ts +4 -3
  165. package/types/vendor/@akanjs/native/plugins/window/src/index.d.ts +3 -3
  166. package/types/vendor/use-agentic/AgenticSurface.d.ts +2 -0
  167. package/types/webkit/FrameRootVars.d.ts +6 -0
  168. package/types/webkit/McpAppAgent.d.ts +4 -3
  169. package/types/webkit/McpAppBridge.d.ts +8 -29
  170. package/types/webkit/McpAppDownloads.d.ts +7 -0
  171. package/types/webkit/McpAppFetchBridge.d.ts +9 -0
  172. package/types/webkit/McpAppSize.d.ts +9 -0
  173. package/types/webkit/McpAppView.d.ts +0 -1
  174. package/ui/Agent/Chat.tsx +9 -6
  175. package/ui/Agent/EmbedApproval.tsx +6 -1
  176. package/ui/Agent/Launcher.tsx +5 -2
  177. package/ui/Agent/attachment.ts +3 -9
  178. package/ui/Agent/useKeyboardFrame.ts +5 -3
  179. package/ui/Agent/useScreenActions.ts +8 -4
  180. package/ui/BottomSheet.tsx +70 -51
  181. package/ui/Dialog/LegacyModal.tsx +15 -15
  182. package/ui/Dialog/Modal.tsx +14 -20
  183. package/ui/Dialog/modalSurface.ts +18 -8
  184. package/ui/Dropdown.tsx +8 -1
  185. package/ui/Layout/BottomInset.tsx +11 -21
  186. package/ui/Layout/BottomTab.tsx +3 -7
  187. package/ui/Layout/FrameSlotPortal.tsx +87 -0
  188. package/ui/Layout/Navbar.tsx +5 -17
  189. package/ui/Layout/TopInset.tsx +9 -26
  190. package/ui/Layout/frameSlot.ts +42 -0
  191. package/ui/Link/Action.tsx +9 -10
  192. package/ui/Link/Anchor.tsx +3 -6
  193. package/ui/Link/activePath.ts +8 -1
  194. package/ui/Load/Pagination.tsx +2 -2
  195. package/ui/Load/Units.tsx +2 -2
  196. package/ui/Modal.tsx +2 -6
  197. package/ui/Model/EditModal.tsx +4 -9
  198. package/ui/Model/New.tsx +2 -6
  199. package/ui/Model/ViewEditModal.tsx +18 -11
  200. package/ui/Model/ViewModal.tsx +2 -6
  201. package/ui/Model/newFormOrigin.ts +52 -15
  202. package/ui/Portal.tsx +2 -4
  203. package/ui/SheetHandle.tsx +13 -0
  204. package/ui/System/CSR.tsx +17 -13
  205. package/ui/System/Client.tsx +7 -15
  206. package/ui/System/Common.tsx +2 -36
  207. package/ui/System/Reconnect.tsx +5 -1
  208. package/ui/System/SSR.tsx +23 -37
  209. package/ui/Toast.tsx +3 -2
  210. package/ui/Tooltip.tsx +4 -2
  211. package/ui/index.ts +1 -1
  212. package/ui/overlayLayer.ts +5 -0
  213. package/ui/overlayPosition.ts +20 -26
  214. package/ui/recipe/inputRecipe.ts +4 -4
  215. package/ui/useDismissDrag.ts +48 -0
  216. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +19 -6
  217. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeKernel.kt +104 -0
  218. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeUpdateReports.kt +61 -70
  219. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeUpdates.kt +37 -41
  220. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeVectors.kt +39 -2
  221. package/vendor/@akanjs/native/native/desktop/src/dialog_args.rs +1 -1
  222. package/vendor/@akanjs/native/native/desktop/src/external.rs +693 -277
  223. package/vendor/@akanjs/native/native/desktop/src/kiosk.js +85 -0
  224. package/vendor/@akanjs/native/native/desktop/src/kiosk.rs +41 -0
  225. package/vendor/@akanjs/native/native/desktop/src/lib.rs +104 -126
  226. package/vendor/@akanjs/native/native/desktop/src/linux/kiosk.rs +18 -0
  227. package/vendor/@akanjs/native/native/desktop/src/linux/mod.rs +1 -0
  228. package/vendor/@akanjs/native/native/desktop/src/navigation.rs +79 -1
  229. package/vendor/@akanjs/native/native/desktop/src/page_cursor.rs +72 -0
  230. package/vendor/@akanjs/native/native/desktop/src/page_menu.rs +31 -1
  231. package/vendor/@akanjs/native/native/desktop/src/placement.rs +208 -1
  232. package/vendor/@akanjs/native/native/desktop/src/win/external.rs +307 -0
  233. package/vendor/@akanjs/native/native/desktop/src/win/kiosk.rs +42 -0
  234. package/vendor/@akanjs/native/native/desktop/src/win/mod.rs +2 -0
  235. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeApp.swift +15 -10
  236. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeKernel.swift +88 -0
  237. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeUpdateReports.swift +65 -58
  238. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeUpdates.swift +42 -31
  239. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeVectors.swift +43 -0
  240. package/vendor/@akanjs/native/native/ios/Sources/AkanNativeViewController.swift +19 -10
  241. package/vendor/@akanjs/native/packages/cli/src/api.ts +57 -15
  242. package/vendor/@akanjs/native/packages/cli/src/commands/build.ts +4 -2
  243. package/vendor/@akanjs/native/packages/cli/src/commands/update.ts +53 -10
  244. package/vendor/@akanjs/native/packages/cli/src/config.ts +6 -0
  245. package/vendor/@akanjs/native/packages/cli/src/lib/compat.ts +17 -2
  246. package/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.ts +5 -13
  247. package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +11 -0
  248. package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +164 -4
  249. package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +114 -48
  250. package/vendor/@akanjs/native/packages/cli/src/lib/vectors.ts +11 -1
  251. package/vendor/@akanjs/native/packages/cli/src/platforms/android.ts +9 -9
  252. package/vendor/@akanjs/native/packages/cli/src/platforms/ios.ts +9 -9
  253. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +2 -2
  254. package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +50 -19
  255. package/vendor/@akanjs/native/packages/cli/src/platforms/windows.ts +10 -3
  256. package/vendor/@akanjs/native/packages/core/src/kernel.ts +138 -1
  257. package/vendor/@akanjs/native/packages/core/vectors/updates.json +92 -3
  258. package/vendor/@akanjs/native/packages/desktop/src/host.ts +49 -13
  259. package/vendor/@akanjs/native/packages/desktop/src/main.ts +6 -2
  260. package/vendor/@akanjs/native/packages/desktop/src/server.ts +33 -18
  261. package/vendor/@akanjs/native/plugins/external-view/src/desktop.ts +30 -11
  262. package/vendor/@akanjs/native/plugins/external-view/src/index.ts +26 -8
  263. package/vendor/@akanjs/native/plugins/single-instance/src/socket.ts +39 -3
  264. package/vendor/@akanjs/native/plugins/updates/android/UpdatesPlugin.kt +58 -54
  265. package/vendor/@akanjs/native/plugins/updates/ios/UpdatesPlugin.swift +80 -45
  266. package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +95 -7
  267. package/vendor/@akanjs/native/plugins/updates/src/index.ts +4 -3
  268. package/vendor/@akanjs/native/plugins/window/src/index.ts +8 -4
  269. package/vendor/use-agentic/AgenticSurface.ts +7 -0
  270. package/vendor/use-agentic/ToolRunner.ts +29 -7
  271. package/webkit/CsrRouteTable.ts +54 -13
  272. package/webkit/EmbeddedLayout.tsx +5 -0
  273. package/webkit/FrameRootVars.ts +15 -0
  274. package/webkit/McpAppAgent.ts +32 -10
  275. package/webkit/McpAppBridge.ts +44 -87
  276. package/webkit/McpAppDownloads.ts +63 -0
  277. package/webkit/McpAppFetchBridge.ts +63 -0
  278. package/webkit/McpAppSize.ts +101 -0
  279. package/webkit/McpAppView.ts +19 -134
  280. package/webkit/bootCsr.tsx +4 -13
  281. package/webkit/useCamera.tsx +2 -7
  282. package/webkit/useCsrValues.ts +14 -5
  283. package/webkit/useLocation.ts +2 -12
  284. package/types/ui/Layout/FrameSlotFallback.d.ts +0 -9
  285. package/ui/Layout/FrameSlotFallback.tsx +0 -59
@@ -192,7 +192,7 @@ export class Any extends PrimitiveScalar {
192
192
  }
193
193
  PrimitiveRegistry.register(Any);
194
194
 
195
- type NodeBuffer = Uint8Array & { toString(encoding: string): string };
195
+ type NodeBuffer = Uint8Array<ArrayBuffer> & { toString(encoding: string): string };
196
196
  interface BufferCtor {
197
197
  from(buffer: ArrayBufferLike, byteOffset: number, length: number): NodeBuffer;
198
198
  from(text: string, encoding: string): NodeBuffer;
@@ -210,13 +210,13 @@ export class Binary extends PrimitiveScalar {
210
210
  return value instanceof Uint8Array || typeof value === "string";
211
211
  }
212
212
  static override parseValue(input: Uint8Array | string): Uint8Array {
213
- return typeof input === "string" ? Binary.#fromBase64(input) : input;
213
+ return typeof input === "string" ? Binary.fromBase64(input) : input;
214
214
  }
215
215
  static override serializeValue(value: Uint8Array | string): string {
216
- return typeof value === "string" ? value : Binary.#toBase64(value);
216
+ return typeof value === "string" ? value : Binary.toBase64(value);
217
217
  }
218
218
 
219
- static #toBase64(bytes: Uint8Array): string {
219
+ static toBase64(bytes: Uint8Array): string {
220
220
  const buffer = (globalThis as { Buffer?: BufferCtor }).Buffer;
221
221
  if (buffer) return buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
222
222
  let binary = "";
@@ -224,7 +224,7 @@ export class Binary extends PrimitiveScalar {
224
224
  binary += String.fromCharCode(...bytes.subarray(idx, idx + 0x8000));
225
225
  return btoa(binary);
226
226
  }
227
- static #fromBase64(text: string): Uint8Array {
227
+ static fromBase64(text: string): Uint8Array<ArrayBuffer> {
228
228
  const buffer = (globalThis as { Buffer?: BufferCtor }).Buffer;
229
229
  if (buffer) return buffer.from(text, "base64");
230
230
  const binary = atob(text);
@@ -246,6 +246,65 @@ export class Upload extends PrimitiveScalar {
246
246
  }
247
247
  PrimitiveRegistry.register(Upload);
248
248
 
249
+ export type ImageResultMimeType = (typeof ImageResult.mimeTypes)[number];
250
+ export interface ImageResultValue {
251
+ /** Standard padded base64, without a `data:` prefix. */
252
+ data: string;
253
+ mimeType: ImageResultMimeType;
254
+ caption?: string | null;
255
+ }
256
+
257
+ /** A picture an endpoint returns: JSON over HTTP, an image content block over MCP. Not storable. */
258
+ export class ImageResult extends PrimitiveScalar {
259
+ static override refName: "ImageResult" = "ImageResult";
260
+ static override [SERVER_VALUE]: ImageResultValue;
261
+ static override [CLIENT_VALUE]: ImageResultValue;
262
+ static override [DEFAULT_VALUE]: ImageResultValue | null = null;
263
+ static override [PURIFIED_VALUE]: ImageResultValue | null = null;
264
+ static override [EXAMPLE_VALUE]: ImageResultValue = {
265
+ data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
266
+ mimeType: "image/png",
267
+ caption: "A 1x1 pixel.",
268
+ };
269
+
270
+ static readonly mimeTypes = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
271
+ static override jsonSchema: PrimitiveJsonSchema = {
272
+ type: "object",
273
+ properties: {
274
+ data: { type: "string", contentEncoding: "base64" },
275
+ mimeType: { type: "string", enum: [...ImageResult.mimeTypes] },
276
+ caption: { type: ["string", "null"] },
277
+ },
278
+ required: ["data", "mimeType"],
279
+ additionalProperties: false,
280
+ };
281
+
282
+ static override agent: PrimitiveAgentFace<ImageResultValue> = {
283
+ schema: {
284
+ type: "object",
285
+ properties: { mimeType: { type: "string" }, caption: { type: "string" } },
286
+ required: ["mimeType"],
287
+ },
288
+ read: ({ mimeType, caption }) => (caption ? { mimeType, caption } : { mimeType }),
289
+ };
290
+
291
+ static override validate(value: ImageResultValue): boolean {
292
+ if (!value || typeof value !== "object") return false;
293
+ const { data, mimeType, caption } = value;
294
+ return (
295
+ typeof data === "string" &&
296
+ ImageResult.#isBase64(data) &&
297
+ (ImageResult.mimeTypes as readonly string[]).includes(mimeType) &&
298
+ (caption === undefined || caption === null || typeof caption === "string")
299
+ );
300
+ }
301
+
302
+ static #isBase64(data: string) {
303
+ return data.length > 0 && data.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(data);
304
+ }
305
+ }
306
+ PrimitiveRegistry.register(ImageResult);
307
+
249
308
  declare global {
250
309
  interface StringConstructor {
251
310
  refName: "String";
@@ -388,4 +447,14 @@ Object.assign(Date, {
388
447
  });
389
448
  PrimitiveRegistry.register(Date);
390
449
 
391
- export type DefaultPrimitiveName = "String" | "Boolean" | "Date" | "Int" | "Float" | "ID" | "Any" | "Binary" | "Upload";
450
+ export type DefaultPrimitiveName =
451
+ | "String"
452
+ | "Boolean"
453
+ | "Date"
454
+ | "Int"
455
+ | "Float"
456
+ | "ID"
457
+ | "Any"
458
+ | "Binary"
459
+ | "Upload"
460
+ | "ImageResult";
package/client/cookie.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getEnv } from "akanjs/base";
2
2
  import {
3
3
  authTokenKey,
4
+ bearerTokenOf,
4
5
  decodeJwtPayload,
5
6
  isOwnAuthToken,
6
7
  Logger,
@@ -115,7 +116,7 @@ export const getAccount = <AddData = unknown>(): Account<AddData> => {
115
116
  const embedded = globalThis.__AKAN_EMBED__;
116
117
  if (embedded)
117
118
  return embedded.account ? ({ ...defaultAccount, ...embedded.account } as Account<AddData>) : defaultAccount;
118
- const jwt = getAuthToken() ?? getHeader("authorization")?.replace(/^Bearer\s+/i, "");
119
+ const jwt = getAuthToken() ?? bearerTokenOf(getHeader("authorization"));
119
120
  if (!jwt) return defaultAccount;
120
121
  const account = decodeJwtPayload<Account<AddData>>(jwt);
121
122
  if (account.appName !== getEnv().appName || account.environment !== getEnv().environment) return defaultAccount;
@@ -3,12 +3,13 @@ import type { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/sr
3
3
  import type { PromiseOrObject } from "akanjs/base";
4
4
  import { type ForwardRefExoticComponent, type ReactNode, type RefObject, useContext } from "react";
5
5
  import type { AnimatedComponent, AnimatedProps, Interpolation, SpringValue } from "react-spring";
6
+ import type { TailwindBreakpoint } from "./frameConfig";
6
7
  import type { RouteDefinition } from "./route/RouteDefinition";
7
8
  import type { RouterInstance } from "./router";
8
9
  import { sharedContext } from "./sharedContext";
10
+ import type { TransitionType } from "./transitionTypes";
9
11
  import type { ReactFont } from "./types";
10
12
 
11
- export type TransitionType = "none" | "fade" | "bottomUp" | "stack" | "scaleOut";
12
13
  export type PageSafeAreaConfig =
13
14
  | boolean
14
15
  | "top"
@@ -22,14 +23,9 @@ export type PageSafeAreaConfig =
22
23
  * `"block"` buffers the whole document, so an error in slow content can still render a clean error page. */
23
24
  export type SsrRenderMode = "stream" | "block";
24
25
 
25
- /** Px per Tailwind breakpoint (sm 40rem, md 48rem, lg 64rem, xl 80rem, 2xl 96rem), each from its own width up. */
26
- export interface ResponsiveInset {
26
+ /** Px per breakpoint of `insetBreakpoints`, each from its own width up, and `base` below the first. */
27
+ export interface ResponsiveInset extends Partial<{ [name in TailwindBreakpoint]: number }> {
27
28
  base: number;
28
- sm?: number;
29
- md?: number;
30
- lg?: number;
31
- xl?: number;
32
- "2xl"?: number;
33
29
  }
34
30
  export interface PageConfig {
35
31
  transition?: TransitionType;
@@ -1,12 +1,6 @@
1
1
  import { isRecord } from "akanjs/common";
2
- import type {
3
- PageConfig,
4
- PageSafeAreaConfig,
5
- PageState,
6
- ResponsiveInset,
7
- SsrRenderMode,
8
- TransitionType,
9
- } from "./csrTypes";
2
+ import type { PageConfig, PageSafeAreaConfig, PageState, ResponsiveInset, SsrRenderMode } from "./csrTypes";
3
+ import { transitionTypes } from "./transitionTypes";
10
4
 
11
5
  export type DevicePlatform = "ios" | "android" | "web" | (string & {});
12
6
  export type SafeAreaInsets = { top: number; bottom: number };
@@ -22,7 +16,7 @@ export interface ResolvePageStateOptions {
22
16
  width?: number;
23
17
  }
24
18
 
25
- /** Tailwind's default breakpoints in rem, the widths a `ResponsiveInset` keys on. */
19
+ /** Tailwind's default breakpoints in rem, the widths a `ResponsiveInset` keys on; `ui/styles.css` repeats them. */
26
20
  export const insetBreakpoints = [
27
21
  ["sm", 40],
28
22
  ["md", 48],
@@ -30,7 +24,14 @@ export const insetBreakpoints = [
30
24
  ["xl", 80],
31
25
  ["2xl", 96],
32
26
  ] as const;
33
- export type InsetBreakpoint = "base" | (typeof insetBreakpoints)[number][0];
27
+ export type TailwindBreakpoint = (typeof insetBreakpoints)[number][0];
28
+ export type InsetBreakpoint = "base" | TailwindBreakpoint;
29
+
30
+ const breakpointRem = Object.fromEntries(insetBreakpoints) as { [name in TailwindBreakpoint]: number };
31
+
32
+ /** The media query Tailwind's `<name>:` variant matches, or with `below` its `max-<name>:` one. */
33
+ export const breakpointQuery = (name: TailwindBreakpoint, { below = false }: { below?: boolean } = {}) =>
34
+ `(width ${below ? "<" : ">="} ${breakpointRem[name]}rem)`;
34
35
 
35
36
  const pageConfigKeys = new Set<keyof PageConfig>([
36
37
  "transition",
@@ -46,13 +47,12 @@ const pageConfigKeys = new Set<keyof PageConfig>([
46
47
  ]);
47
48
 
48
49
  const buildPageConfigKeys = new Set<keyof PageConfig>(["devOnly"]);
49
- const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
50
50
  const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
51
51
  const DEFAULT_BOOLEAN_INSET = 48;
52
52
 
53
53
  const hasOwn = <Key extends PropertyKey>(value: object, key: Key) => Object.hasOwn(value, key);
54
54
 
55
- export function validatePageConfig(routeKey: string, config?: PageConfig) {
55
+ export const validatePageConfig = (routeKey: string, config?: PageConfig) => {
56
56
  if (config === undefined) return;
57
57
  if (!isRecord(config)) throw new Error(`[route-convention] pageConfig in ${routeKey} must be an object.`);
58
58
  const pageConfig = config as PageConfig;
@@ -64,7 +64,7 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
64
64
  if (pageConfig.devOnly !== undefined && typeof pageConfig.devOnly !== "boolean") {
65
65
  throw new Error(`[route-convention] pageConfig.devOnly in ${routeKey} must be a boolean.`);
66
66
  }
67
- if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
67
+ if (pageConfig.transition !== undefined && !transitionTypes.includes(pageConfig.transition)) {
68
68
  throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
69
69
  }
70
70
  if (pageConfig.ssr !== undefined && !ssrRenderModes.has(pageConfig.ssr)) {
@@ -77,22 +77,22 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
77
77
  );
78
78
  }
79
79
  validateSafeAreaConfig(routeKey, pageConfig.safeArea);
80
- }
80
+ };
81
81
 
82
82
  const isInsetPx = (value: unknown): value is number =>
83
83
  typeof value === "number" && Number.isFinite(value) && value >= 0;
84
84
  const responsiveInsetKeys = new Set<string>(["base", ...insetBreakpoints.map(([name]) => name)]);
85
85
 
86
- function isValidInsetValue(value: unknown): value is NonNullable<PageConfig["topInset"]> {
86
+ const isValidInsetValue = (value: unknown): value is NonNullable<PageConfig["topInset"]> => {
87
87
  if (typeof value === "boolean" || isInsetPx(value)) return true;
88
88
  if (!isRecord(value) || !isInsetPx(value.base)) return false;
89
89
  return Object.entries(value).every(([key, px]) => responsiveInsetKeys.has(key) && isInsetPx(px));
90
- }
90
+ };
91
91
 
92
92
  export const isResponsiveInset = (inset: PageConfig["topInset"]): inset is ResponsiveInset => isRecord(inset);
93
93
 
94
94
  /** The inset at each breakpoint, each one the nearest declared value at or below it. */
95
- export function insetsByBreakpoint(inset: PageConfig["topInset"]): { [bp in InsetBreakpoint]: number } {
95
+ export const insetsByBreakpoint = (inset: PageConfig["topInset"]): { [bp in InsetBreakpoint]: number } => {
96
96
  const base = isResponsiveInset(inset) ? inset.base : resolveInset(inset);
97
97
  const values = { base } as { [bp in InsetBreakpoint]: number };
98
98
  let current = base;
@@ -101,17 +101,17 @@ export function insetsByBreakpoint(inset: PageConfig["topInset"]): { [bp in Inse
101
101
  values[name] = current;
102
102
  }
103
103
  return values;
104
- }
104
+ };
105
105
 
106
106
  /** The inset in force at `width` px, assuming a 16px rem. */
107
- export function insetAt(inset: PageConfig["topInset"], width?: number): number {
107
+ export const insetAt = (inset: PageConfig["topInset"], width?: number): number => {
108
108
  if (!isResponsiveInset(inset)) return resolveInset(inset);
109
109
  if (width === undefined) return inset.base;
110
110
  const values = insetsByBreakpoint(inset);
111
111
  return insetBreakpoints.reduce((value, [name, rem]) => (width >= rem * 16 ? values[name] : value), values.base);
112
- }
112
+ };
113
113
 
114
- function validateSafeAreaConfig(routeKey: string, safeArea?: PageSafeAreaConfig) {
114
+ const validateSafeAreaConfig = (routeKey: string, safeArea?: PageSafeAreaConfig) => {
115
115
  if (safeArea === undefined || typeof safeArea === "boolean" || safeArea === "top" || safeArea === "bottom") return;
116
116
  if (!isRecord(safeArea)) throw new Error(`[route-convention] pageConfig.safeArea in ${routeKey} is invalid.`);
117
117
  for (const key of Object.keys(safeArea)) {
@@ -131,9 +131,9 @@ function validateSafeAreaConfig(routeKey: string, safeArea?: PageSafeAreaConfig)
131
131
  ) {
132
132
  throw new Error(`[route-convention] pageConfig.safeArea.android in ${routeKey} is invalid.`);
133
133
  }
134
- }
134
+ };
135
135
 
136
- export function mergePageConfigs(configChain: PageConfig[] = []): PageConfig {
136
+ export const mergePageConfigs = (configChain: PageConfig[] = []): PageConfig => {
137
137
  const merged: PageConfig = {};
138
138
  for (const config of configChain) {
139
139
  for (const key of pageConfigKeys) {
@@ -147,9 +147,11 @@ export function mergePageConfigs(configChain: PageConfig[] = []): PageConfig {
147
147
  }
148
148
  }
149
149
  return merged;
150
- }
150
+ };
151
151
 
152
- export function getExplicitPageConfigKeys(configChain: PageConfig[] = []): Partial<Record<keyof PageConfig, boolean>> {
152
+ export const getExplicitPageConfigKeys = (
153
+ configChain: PageConfig[] = [],
154
+ ): Partial<Record<keyof PageConfig, boolean>> => {
153
155
  const explicitKeys: Partial<Record<keyof PageConfig, boolean>> = {};
154
156
  for (const config of configChain) {
155
157
  for (const key of pageConfigKeys) {
@@ -157,7 +159,7 @@ export function getExplicitPageConfigKeys(configChain: PageConfig[] = []): Parti
157
159
  }
158
160
  }
159
161
  return explicitKeys;
160
- }
162
+ };
161
163
 
162
164
  export const defaultPageState: PageState = {
163
165
  transition: "none",
@@ -172,7 +174,7 @@ export const defaultPageState: PageState = {
172
174
  bottomSafeAreaColor: "var(--color-background, Canvas)",
173
175
  };
174
176
 
175
- export function resolvePageState({
177
+ export const resolvePageState = ({
176
178
  configChain = [],
177
179
  path,
178
180
  basePath,
@@ -180,7 +182,7 @@ export function resolvePageState({
180
182
  deviceSafeArea,
181
183
  cssSafeArea,
182
184
  width,
183
- }: ResolvePageStateOptions): PageState {
185
+ }: ResolvePageStateOptions): PageState => {
184
186
  const profile = getPlatformFrameProfile(platform);
185
187
  const routeRole = getRouteRoleFrameDefaults(path, platform, basePath);
186
188
  const config = mergePageConfigs([profile, routeRole, ...configChain]);
@@ -221,29 +223,29 @@ export function resolvePageState({
221
223
  topSafeAreaColor: config.topSafeAreaColor ?? defaultPageState.topSafeAreaColor,
222
224
  bottomSafeAreaColor: config.bottomSafeAreaColor ?? defaultPageState.bottomSafeAreaColor,
223
225
  };
224
- }
226
+ };
225
227
 
226
- function resolveInset(inset: PageConfig["topInset"]): number {
228
+ const resolveInset = (inset: PageConfig["topInset"]): number => {
227
229
  if (inset === true) return DEFAULT_BOOLEAN_INSET;
228
230
  if (inset === false || inset === undefined) return 0;
229
231
  if (isResponsiveInset(inset)) return inset.base;
230
232
  return inset;
231
- }
233
+ };
232
234
 
233
- function resolveSafeAreaEnv(safeArea?: PageSafeAreaConfig): { top: boolean; bottom: boolean } | undefined {
235
+ const resolveSafeAreaEnv = (safeArea?: PageSafeAreaConfig): { top: boolean; bottom: boolean } | undefined => {
234
236
  if (safeArea === undefined || safeArea === false) return undefined;
235
237
  const top = safeArea === true || safeArea === "top" || (isRecord(safeArea) && safeArea.top !== false);
236
238
  const bottom = safeArea === true || safeArea === "bottom" || (isRecord(safeArea) && safeArea.bottom !== false);
237
239
  return top || bottom ? { top, bottom } : undefined;
238
- }
240
+ };
239
241
 
240
- function getPlatformFrameProfile(platform: DevicePlatform): PageConfig {
242
+ const getPlatformFrameProfile = (platform: DevicePlatform): PageConfig => {
241
243
  if (platform === "ios") return { safeArea: true, transition: "stack" };
242
244
  if (platform === "android") return { safeArea: { android: "auto" }, transition: "scaleOut", gesture: false };
243
245
  return { safeArea: false, transition: "none", gesture: false };
244
- }
246
+ };
245
247
 
246
- function getRouteRoleFrameDefaults(path: string, platform: DevicePlatform, basePath?: string): PageConfig {
248
+ const getRouteRoleFrameDefaults = (path: string, platform: DevicePlatform, basePath?: string): PageConfig => {
247
249
  const normalizedBasePath = basePath?.replace(/^\/+|\/+$/g, "");
248
250
  const depth = path
249
251
  .split("/")
@@ -255,9 +257,9 @@ function getRouteRoleFrameDefaults(path: string, platform: DevicePlatform, baseP
255
257
  transition: platform === "ios" ? "stack" : platform === "android" ? "scaleOut" : "none",
256
258
  gesture: platform === "ios",
257
259
  };
258
- }
260
+ };
259
261
 
260
- function resolveSafeArea({
262
+ const resolveSafeArea = ({
261
263
  safeArea,
262
264
  platform,
263
265
  deviceSafeArea,
@@ -267,7 +269,7 @@ function resolveSafeArea({
267
269
  platform: DevicePlatform;
268
270
  deviceSafeArea: SafeAreaInsets;
269
271
  cssSafeArea?: SafeAreaInsets;
270
- }): SafeAreaInsets {
272
+ }): SafeAreaInsets => {
271
273
  if (safeArea === false) return { top: 0, bottom: 0 };
272
274
  const enabledAt = (side: "top" | "bottom") =>
273
275
  safeArea === true ||
@@ -296,7 +298,7 @@ function resolveSafeArea({
296
298
  top: topEnabled ? source.top : 0,
297
299
  bottom: bottomEnabled ? source.bottom : 0,
298
300
  };
299
- }
301
+ };
300
302
 
301
303
  const hasInset = (insets: SafeAreaInsets) => insets.top > 0 || insets.bottom > 0;
302
304
 
@@ -305,16 +307,16 @@ const maxInsets = (a: SafeAreaInsets, b?: SafeAreaInsets): SafeAreaInsets => ({
305
307
  bottom: Math.max(a.bottom, b?.bottom ?? 0),
306
308
  });
307
309
 
308
- export function readCssSafeAreaInsets(): SafeAreaInsets {
310
+ export const readCssSafeAreaInsets = (): SafeAreaInsets => {
309
311
  if (typeof window === "undefined") return { top: 0, bottom: 0 };
310
312
  const style = window.getComputedStyle?.(document.documentElement);
311
313
  return {
312
314
  top: readCssPixel(style?.getPropertyValue("--akan-native-safe-area-top")) || readCssEnvProbe("top"),
313
315
  bottom: readCssPixel(style?.getPropertyValue("--akan-native-safe-area-bottom")) || readCssEnvProbe("bottom"),
314
316
  };
315
- }
317
+ };
316
318
 
317
- function readCssEnvProbe(side: "top" | "bottom") {
319
+ const readCssEnvProbe = (side: "top" | "bottom") => {
318
320
  if (typeof document === "undefined") return 0;
319
321
  const probe = document.createElement("div");
320
322
  probe.style.position = "fixed";
@@ -325,10 +327,10 @@ function readCssEnvProbe(side: "top" | "bottom") {
325
327
  const value = readCssPixel(window.getComputedStyle(probe)[side]);
326
328
  probe.remove();
327
329
  return value;
328
- }
330
+ };
329
331
 
330
- function readCssPixel(value?: string) {
332
+ const readCssPixel = (value?: string) => {
331
333
  if (!value) return 0;
332
334
  const parsed = Number.parseFloat(value);
333
335
  return Number.isFinite(parsed) ? parsed : 0;
334
- }
336
+ };
package/client/index.ts CHANGED
@@ -9,12 +9,14 @@ export * from "./device";
9
9
  export * from "./frameConfig";
10
10
  export * from "./locale";
11
11
  export * from "./makePageProto";
12
+ export * from "./mcpView";
12
13
  export * from "./resolveServerUrl";
13
14
  export * from "./route";
14
15
  export * from "./router";
15
16
  export * from "./rscNavigation";
16
17
  export * from "./sharedContext";
17
18
  export * from "./storage";
19
+ export * from "./transitionTypes";
18
20
  export * from "./translator";
19
21
  export * from "./types";
20
22
  export * from "./useClient";
@@ -0,0 +1,2 @@
1
+ /** Whether the page runs in a view an MCP host renders, where the host relays every call and owns the frame. */
2
+ export const isMcpView = () => !!globalThis.__AKAN_EMBED__;
@@ -14,14 +14,27 @@ export class RouteHead {
14
14
  "apple-touch-icon",
15
15
  "mask-icon",
16
16
  ]);
17
+
18
+ static readonly #pageIdentityKeys = new Set(["link:canonical", "meta:property:og:url"]);
17
19
 
18
20
  static merge(levels: ResolveHeadResult[]): ResolveHeadResult {
19
21
  const present = levels.filter((level) => RouteHead.#nodeOf(level) != null || RouteHead.#snapshotOf(level));
20
22
  if (present.length <= 1) return present[0];
21
23
  const node = RouteHead.mergeNodes(present.map((level) => RouteHead.#nodeOf(level)));
22
- const snapshots = present.map((level) => RouteHead.#snapshotOf(level)).filter((snapshot) => !!snapshot);
23
- if (!snapshots.length) return node;
24
- return { node, headSnapshot: RouteHead.#mergeSnapshots(snapshots) } satisfies ResolvedHead;
24
+ if (!present.some((level) => RouteHead.#snapshotOf(level))) return node;
25
+ const snapshotLevels: AkanHeadSnapshotNode[][] = [];
26
+ for (const level of present) {
27
+ const nodes = RouteHead.#snapshotOf(level)?.nodes ?? RouteHead.#snapshotNodesOf(RouteHead.#nodeOf(level));
28
+
29
+ if (!nodes) return node;
30
+ snapshotLevels.push(nodes);
31
+ }
32
+ return { node, headSnapshot: RouteHead.#mergeSnapshots(snapshotLevels) } satisfies ResolvedHead;
33
+ }
34
+
35
+ /** `merge` of a page's head over its layouts': a canonical or `og:url` comes from the page or not at all. */
36
+ static mergePage(layouts: ResolveHeadResult, page: ResolveHeadResult): ResolveHeadResult {
37
+ return RouteHead.merge([RouteHead.#withoutPageIdentity(layouts), page]);
25
38
  }
26
39
 
27
40
  /** What makes two tags the same tag; `null` for one that never replaces another. */
@@ -60,11 +73,7 @@ export class RouteHead {
60
73
  const present = nodes.filter((node) => node !== null && node !== undefined);
61
74
  if (present.length <= 1) return present[0];
62
75
  const levels = present.map((node) => RouteHead.#flatten(node));
63
- const kept = RouteHead.#keepDeepest(levels, (child) =>
64
- isValidElement(child) && typeof child.type === "string"
65
- ? RouteHead.keyOf(child.type, child.props as TagProps)
66
- : null,
67
- );
76
+ const kept = RouteHead.#keepDeepest(levels, (child) => RouteHead.#keyOfNode(child));
68
77
 
69
78
  const children = kept.map(({ item, level, idx }) =>
70
79
  isValidElement(item) ? cloneElement(item as ReactElement, { key: `head:${level}:${idx}` }) : item,
@@ -72,14 +81,59 @@ export class RouteHead {
72
81
  return createElement(Fragment, null, ...children);
73
82
  }
74
83
 
75
- static #mergeSnapshots(snapshots: AkanHeadSnapshotV1[]): AkanHeadSnapshotV1 {
76
- const kept = RouteHead.#keepDeepest(
77
- snapshots.map((snapshot) => snapshot.nodes),
78
- (node: AkanHeadSnapshotNode) => RouteHead.keyOf(node.tag, node.attrs ?? {}),
84
+ static #keyOfNode(child: ReactNode): string | null {
85
+ return isValidElement(child) && typeof child.type === "string"
86
+ ? RouteHead.keyOf(child.type, child.props as TagProps)
87
+ : null;
88
+ }
89
+
90
+ static #withoutPageIdentity(level: ResolveHeadResult): ResolveHeadResult {
91
+ const isIdentity = (key: string | null) => key !== null && RouteHead.#pageIdentityKeys.has(key);
92
+ const node = RouteHead.#nodeOf(level);
93
+ const children = RouteHead.#flatten(node);
94
+ const kept = children.filter((child) => !isIdentity(RouteHead.#keyOfNode(child)));
95
+ const keptNode =
96
+ kept.length === children.length ? node : kept.length > 0 ? createElement(Fragment, null, ...kept) : null;
97
+ const snapshot = RouteHead.#snapshotOf(level);
98
+ if (!snapshot) return keptNode;
99
+ const nodes = snapshot.nodes.filter((item) => !isIdentity(RouteHead.keyOf(item.tag, item.attrs ?? {})));
100
+ return { node: keptNode, headSnapshot: { ...snapshot, nodes } };
101
+ }
102
+
103
+ static #mergeSnapshots(levels: AkanHeadSnapshotNode[][]): AkanHeadSnapshotV1 {
104
+ const kept = RouteHead.#keepDeepest(levels, (node: AkanHeadSnapshotNode) =>
105
+ RouteHead.keyOf(node.tag, node.attrs ?? {}),
79
106
  );
80
107
  return { version: 1, nodes: kept.map(({ item }) => item) };
81
108
  }
82
109
 
110
+ static #snapshotNodesOf(node: ReactNode): AkanHeadSnapshotNode[] | null {
111
+ const nodes: AkanHeadSnapshotNode[] = [];
112
+ for (const child of RouteHead.#flatten(node)) {
113
+ const snapshotNode = RouteHead.#snapshotNodeOf(child);
114
+ if (!snapshotNode) return null;
115
+ nodes.push(snapshotNode);
116
+ }
117
+ return nodes;
118
+ }
119
+
120
+ static #snapshotNodeOf(child: ReactNode): AkanHeadSnapshotNode | null {
121
+ if (!isValidElement(child)) return null;
122
+ const tag = child.type;
123
+ if (tag !== "title" && tag !== "meta" && tag !== "link") return null;
124
+ const { children, ...props } = child.props as TagProps;
125
+ if (tag === "title")
126
+ return typeof children === "string" || typeof children === "number" ? { tag, text: String(children) } : null;
127
+ if (children !== undefined && children !== null) return null;
128
+ const attrs: Record<string, string> = {};
129
+ for (const [name, value] of Object.entries(props)) {
130
+ if (value === undefined || value === null) continue;
131
+ if (typeof value !== "string" && typeof value !== "number") return null;
132
+ attrs[name] = String(value);
133
+ }
134
+ return { tag, attrs };
135
+ }
136
+
83
137
  static #keepDeepest<T>(levels: T[][], keyOf: (item: T) => string | null) {
84
138
  const owner = new Map<string, number>();
85
139
  levels.forEach((items, level) => {