akanjs 3.0.0-beta.26 → 3.0.0-beta.27

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.
@@ -174,6 +174,8 @@ export interface Route {
174
174
  path: string;
175
175
  renderPage?: RouteRender;
176
176
  renderLayout?: RouteRender;
177
+ /** `renderLayout` is a generated `__root_layout`: a root boundary the page generator found. */
178
+ isRootLayout?: boolean;
177
179
  /** Synthetic layout render from a `_overrides.tsx` at this node; wraps the subtree in a UI-override provider. */
178
180
  renderOverrides?: RouteRender;
179
181
  pageIncludesOwnLayout?: boolean;
package/client/device.ts CHANGED
@@ -15,14 +15,13 @@ let debugSeq = 0;
15
15
 
16
16
  const isFrameDebugEnabled = () => {
17
17
  if (typeof window === "undefined") return false;
18
- const windowWithTarget = window as typeof window & { __AKAN_MOBILE_TARGET__?: unknown };
19
- const search = new URLSearchParams(window.location.search);
20
- return (
21
- Boolean(windowWithTarget.__AKAN_MOBILE_TARGET__) ||
22
- search.has("akanMobileTarget") ||
23
- search.get("akanFrameDebug") === "1" ||
24
- window.localStorage.getItem("akan:debug:frame") === "1"
25
- );
18
+ if (new URLSearchParams(window.location.search).get("akanFrameDebug") === "1") return true;
19
+ try {
20
+ return window.localStorage.getItem("akan:debug:frame") === "1";
21
+ } catch {
22
+
23
+ return false;
24
+ }
26
25
  };
27
26
 
28
27
  export function debugFrame(event: string, payload: DebugPayload = {}) {
@@ -33,7 +32,8 @@ export function debugFrame(event: string, payload: DebugPayload = {}) {
33
32
  now: Math.round(performance.now()),
34
33
  ...payload,
35
34
  };
36
- console.info(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, details, JSON.stringify(details));
35
+
36
+ console.debug(`[akan:frame:${debugSessionId}:${debugSeq}] ${event}`, details);
37
37
  }
38
38
 
39
39
  interface DeviceInitOption {
package/common/Logger.ts CHANGED
@@ -77,6 +77,15 @@ const colorizeMap: { [key in LogLevel]: (text: string) => string } = {
77
77
  error: clc.red,
78
78
  };
79
79
 
80
+ const consoleMethodMap = {
81
+ trace: "debug",
82
+ verbose: "debug",
83
+ debug: "debug",
84
+ info: "info",
85
+ warn: "warn",
86
+ error: "error",
87
+ } as const satisfies { [key in LogLevel]: "debug" | "info" | "warn" | "error" };
88
+
80
89
  const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
81
90
 
82
91
  const redactedAttrKey = /password|passwd|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key/i;
@@ -332,7 +341,22 @@ export class Logger {
332
341
  if (!Logger.consoleOutput) return;
333
342
  if (typeof window === "undefined")
334
343
  (process[entry.stream] as unknown as NodeJS.WriteStream | undefined)?.write(entry.message);
335
- else console.log(entry.message);
344
+ else Logger.#writeBrowserConsole(entry.record, entry.message);
345
+ }
346
+
347
+ static #writeBrowserConsole(record: LogRecord, rendered: string) {
348
+ const level = record.level ?? "info";
349
+ const context = record.context ? `[${record.context}] ` : "";
350
+ const attrs = record.attrs ? ` ${Logger.formatAttrs(record.attrs)}` : "";
351
+ const text = Logger.#inNativeShell()
352
+ ? `[${record.name}] ${context}${record.message}${attrs}`
353
+ : rendered.replace(/\n+$/, "");
354
+
355
+ console[consoleMethodMap[level]](text);
356
+ }
357
+ static #inNativeShell() {
358
+ const platform = (globalThis as { __AKAN_NATIVE__?: { platform?: string } }).__AKAN_NATIVE__?.platform;
359
+ return platform !== undefined && platform !== "web";
336
360
  }
337
361
  static #write(name: string | undefined, message: string, context: string, logLevel: LogLevel, attrs?: LogAttrs) {
338
362
  const stream = logLevel === "error" ? "stderr" : "stdout";
@@ -367,7 +391,7 @@ export class Logger {
367
391
  if (!Logger.consoleOutput) return;
368
392
  if (typeof window === "undefined" && method !== "console" && (process as unknown as NodeJS.Process | undefined))
369
393
  process[stream].write(msg);
370
- else console[outputStream === "error" ? "error" : "log"](msg.trim());
394
+ else console[outputStream === "error" ? "error" : Logger.#inNativeShell() ? "debug" : "log"](msg.trim());
371
395
  }
372
396
  static {
373
397
  if (envValue("AKAN_PUBLIC_LOG_LEVEL") === "log" && typeof window === "undefined")
@@ -0,0 +1,52 @@
1
+ interface LayeredRoute<Render> {
2
+ /** The tree segment: `/`, `/:lang`, `/(group)`, `/foo`. */
3
+ path: string;
4
+ renderLayout?: Render;
5
+ /** A generated `__root_layout`: one of the root boundaries the page generator found. */
6
+ isRootLayout?: boolean;
7
+ renderOverrides?: Render;
8
+ /** `false` for a named page file, which sits beside its directory's layout rather than under it. */
9
+ pageIncludesOwnLayout?: boolean;
10
+ }
11
+
12
+ export interface RouteLayer<Render> {
13
+ /** The URL pattern down to this node; a route group adds nothing to it. */
14
+ path: string;
15
+ pathSegments: string[];
16
+ isRoot: boolean;
17
+ /** The root layout above or at this node, overrides left out: once there is one, nothing below is a root. */
18
+ rootLayouts: Render[];
19
+ renderRootLayouts: Render[];
20
+ renderLayouts: Render[];
21
+ /** What a page file at this node renders inside: without this node's layout for a named page file. */
22
+ pageRenderRootLayouts: Render[];
23
+ pageRenderLayouts: Render[];
24
+ }
25
+
26
+ export class RouteLayering {
27
+ static readonly #group = /^\/\(.*\)$/;
28
+
29
+ static of<Render>(route: LayeredRoute<Render>, parent: RouteLayer<Render> | null): RouteLayer<Render> {
30
+ const parentSegments = parent?.pathSegments ?? [];
31
+ const parentRootLayouts = parent?.rootLayouts ?? [];
32
+ const parentRootRenders = parent?.renderRootLayouts ?? [];
33
+ const parentLayouts = parent?.renderLayouts ?? [];
34
+ const segment = RouteLayering.#group.test(route.path) ? "" : route.path;
35
+ const path = parentSegments.filter((part) => part !== "/").join("") + segment;
36
+ const isRoot = parentRootLayouts.length === 0 && (route.isRootLayout === true || !route.renderLayout);
37
+ const overrideRenders = route.renderOverrides ? [route.renderOverrides] : [];
38
+
39
+ const nodeRenders = [...overrideRenders, ...(route.renderLayout ? [route.renderLayout] : [])];
40
+ const pageNodeRenders = route.pageIncludesOwnLayout === false ? overrideRenders : nodeRenders;
41
+ return {
42
+ path,
43
+ pathSegments: [...parentSegments, ...(segment ? [segment] : [])],
44
+ isRoot,
45
+ rootLayouts: isRoot && route.renderLayout ? [...parentRootLayouts, route.renderLayout] : parentRootLayouts,
46
+ renderRootLayouts: isRoot ? [...parentRootRenders, ...nodeRenders] : parentRootRenders,
47
+ renderLayouts: isRoot ? parentLayouts : [...parentLayouts, ...nodeRenders],
48
+ pageRenderRootLayouts: isRoot ? [...parentRootRenders, ...pageNodeRenders] : parentRootRenders,
49
+ pageRenderLayouts: isRoot ? parentLayouts : [...parentLayouts, ...pageNodeRenders],
50
+ };
51
+ }
52
+ }
package/common/index.ts CHANGED
@@ -128,6 +128,7 @@ export {
128
128
  export { deepObjectify, objectify, plainFieldsOf } from "./objectify";
129
129
  export { pathGet, pathGetLoose, pathSet, toPathSegments } from "./objectPath";
130
130
  export { formatPhone, isPhoneNumber } from "./phone";
131
+ export { type RouteLayer, RouteLayering } from "./RouteLayering";
131
132
  export { randomPick, randomPicks } from "./randomPick";
132
133
  export { hostFromRequest, isJsonContentType, originFromRequest } from "./requestOrigin";
133
134
  export { RestClient, type RestClientOptions, type RestRequestOptions } from "./restClient";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-beta.26",
3
+ "version": "3.0.0-beta.27",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -15,8 +15,9 @@ import {
15
15
  getRouteExports,
16
16
  Logger,
17
17
  matchRoutePattern,
18
- parseBasePaths,
19
18
  parseRouteModuleKey,
19
+ type RouteLayer,
20
+ RouteLayering,
20
21
  routeSegmentToTreePath,
21
22
  } from "akanjs/common";
22
23
  import { createElement } from "react";
@@ -54,15 +55,12 @@ export class RouteTreeBuilder {
54
55
  };
55
56
 
56
57
  readonly #context: PagesContext;
57
- readonly #baseLayoutPaths: string[];
58
58
  readonly #routeMap = new Map<string, Route>();
59
59
  readonly #pagePatterns: { key: string; pattern: string }[] = [];
60
60
  readonly #fallbackRoutes: LayoutFallbackRoute[] = [];
61
61
 
62
62
  constructor(context: PagesContext) {
63
63
  this.#context = context;
64
- const basePaths = process.env.AKAN_PUBLIC_BASE_PATHS ? parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS) : null;
65
- this.#baseLayoutPaths = ["/", "/:lang", ...(basePaths?.map((bp) => `/:lang/${bp}`) ?? [])];
66
64
  this.#routeMap.set("/", { path: "/", children: new Map() });
67
65
  }
68
66
 
@@ -169,7 +167,7 @@ export class RouteTreeBuilder {
169
167
  targetRouteMap.set(targetPath, {
170
168
  ...(targetRouteMap.get(targetPath) ?? { path: targetPath, children: new Map<string, Route>() }),
171
169
  ...(parsed.kind === "layout"
172
- ? { renderLayout: routeRender }
170
+ ? { renderLayout: routeRender, isRootLayout: parsed.isInternalRootLayout }
173
171
  : {
174
172
  renderPage: routeRender,
175
173
  pageIncludesOwnLayout: parsed.leaf === "_index",
@@ -178,48 +176,27 @@ export class RouteTreeBuilder {
178
176
  } as Route);
179
177
  }
180
178
 
181
- #getPathRoutes(
182
- route: Route,
183
- parentRootLayouts: RouteRender[] = [],
184
- parentLayouts: RouteRender[] = [],
185
- parentPaths: string[] = [],
186
- parentHead?: ResolveHead,
187
- parentRootRenders: RouteRender[] = [],
188
- ): PathRoute[] {
189
- const parentPath = parentPaths.filter((p) => p !== "/").join("");
190
- const currentPathSegment = /^\/\(.*\)$/.test(route.path) ? "" : route.path;
191
- const isRoot = this.#baseLayoutPaths.includes(parentPath + currentPathSegment) && parentRootLayouts.length < 2;
192
- const routePath = parentPath + currentPathSegment;
193
- const pathSegments = [...parentPaths, ...(currentPathSegment ? [currentPathSegment] : [])];
194
- const currentRootLayout = isRoot && route.renderLayout ? route.renderLayout : null;
195
-
196
- const currentOverrideRenders = route.renderOverrides ? [route.renderOverrides] : [];
197
- const nodeRenders = [...currentOverrideRenders, ...(route.renderLayout ? [route.renderLayout] : [])];
198
- const rootLayoutStack = [...parentRootLayouts, ...(currentRootLayout ? [currentRootLayout] : [])];
199
- const renderRootLayouts = isRoot ? [...parentRootRenders, ...nodeRenders] : parentRootRenders;
200
- const renderLayouts = isRoot ? parentLayouts : [...parentLayouts, ...nodeRenders];
179
+ #getPathRoutes(route: Route, parent: RouteLayer<RouteRender> | null = null, parentHead?: ResolveHead): PathRoute[] {
180
+ const layer = RouteLayering.of(route, parent);
201
181
  if (route.renderLayout) {
202
182
  this.#fallbackRoutes.push({
203
- path: routePath,
204
- pathSegments,
205
- renderRootLayouts,
206
- renderLayouts,
183
+ path: layer.path,
184
+ pathSegments: layer.pathSegments,
185
+ renderRootLayouts: layer.renderRootLayouts,
186
+ renderLayouts: layer.renderLayouts,
207
187
  });
208
188
  }
209
189
  const routeHead = RouteTreeBuilder.#composeHeadResolvers(route.renderLayout?.resolveHead, parentHead);
210
- const pageNodeRenders = route.pageIncludesOwnLayout === false ? currentOverrideRenders : nodeRenders;
211
- const pageRenderRootLayouts = isRoot ? [...parentRootRenders, ...pageNodeRenders] : parentRootRenders;
212
- const pageRenderLayouts = isRoot ? parentLayouts : [...parentLayouts, ...pageNodeRenders];
213
190
  const pageHead = route.pageIncludesOwnLayout === false ? parentHead : routeHead;
214
191
  return [
215
192
  ...(route.renderPage
216
193
  ? [
217
194
  {
218
- path: routePath,
219
- pathSegments,
195
+ path: layer.path,
196
+ pathSegments: layer.pathSegments,
220
197
  renderPage: route.renderPage,
221
- renderRootLayouts: pageRenderRootLayouts,
222
- renderLayouts: pageRenderLayouts,
198
+ renderRootLayouts: layer.pageRenderRootLayouts,
199
+ renderLayouts: layer.pageRenderLayouts,
223
200
  resolveHead: RouteTreeBuilder.#composeHeadResolvers(route.renderPage.resolveHead, pageHead),
224
201
  isSpecialRoute: route.isSpecialRoute,
225
202
  pageState: route.pageState ?? defaultPageState,
@@ -227,9 +204,7 @@ export class RouteTreeBuilder {
227
204
  ]
228
205
  : []),
229
206
  ...(route.children.size
230
- ? [...route.children.values()].flatMap((child) =>
231
- this.#getPathRoutes(child, rootLayoutStack, renderLayouts, pathSegments, routeHead, renderRootLayouts),
232
- )
207
+ ? [...route.children.values()].flatMap((child) => this.#getPathRoutes(child, layer, routeHead))
233
208
  : []),
234
209
  ];
235
210
  }
@@ -171,6 +171,8 @@ export interface Route {
171
171
  path: string;
172
172
  renderPage?: RouteRender;
173
173
  renderLayout?: RouteRender;
174
+ /** `renderLayout` is a generated `__root_layout`: a root boundary the page generator found. */
175
+ isRootLayout?: boolean;
174
176
  /** Synthetic layout render from a `_overrides.tsx` at this node; wraps the subtree in a UI-override provider. */
175
177
  renderOverrides?: RouteRender;
176
178
  pageIncludesOwnLayout?: boolean;
@@ -0,0 +1,28 @@
1
+ interface LayeredRoute<Render> {
2
+ /** The tree segment: `/`, `/:lang`, `/(group)`, `/foo`. */
3
+ path: string;
4
+ renderLayout?: Render;
5
+ /** A generated `__root_layout`: one of the root boundaries the page generator found. */
6
+ isRootLayout?: boolean;
7
+ renderOverrides?: Render;
8
+ /** `false` for a named page file, which sits beside its directory's layout rather than under it. */
9
+ pageIncludesOwnLayout?: boolean;
10
+ }
11
+ export interface RouteLayer<Render> {
12
+ /** The URL pattern down to this node; a route group adds nothing to it. */
13
+ path: string;
14
+ pathSegments: string[];
15
+ isRoot: boolean;
16
+ /** The root layout above or at this node, overrides left out: once there is one, nothing below is a root. */
17
+ rootLayouts: Render[];
18
+ renderRootLayouts: Render[];
19
+ renderLayouts: Render[];
20
+ /** What a page file at this node renders inside: without this node's layout for a named page file. */
21
+ pageRenderRootLayouts: Render[];
22
+ pageRenderLayouts: Render[];
23
+ }
24
+ export declare class RouteLayering {
25
+ #private;
26
+ static of<Render>(route: LayeredRoute<Render>, parent: RouteLayer<Render> | null): RouteLayer<Render>;
27
+ }
28
+ export {};
@@ -29,6 +29,7 @@ export { isMcpDescribableArg, type McpExposureEndpoint, type McpExposureOption,
29
29
  export { deepObjectify, objectify, plainFieldsOf } from "./objectify.d.ts";
30
30
  export { pathGet, pathGetLoose, pathSet, toPathSegments } from "./objectPath.d.ts";
31
31
  export { formatPhone, isPhoneNumber } from "./phone.d.ts";
32
+ export { type RouteLayer, RouteLayering } from "./RouteLayering.d.ts";
32
33
  export { randomPick, randomPicks } from "./randomPick.d.ts";
33
34
  export { hostFromRequest, isJsonContentType, originFromRequest } from "./requestOrigin.d.ts";
34
35
  export { RestClient, type RestClientOptions, type RestRequestOptions } from "./restClient.d.ts";
@@ -396,13 +396,15 @@ class AkanNativeActivity : Activity(), AkanNativePluginContext.Host {
396
396
 
397
397
  private inner class ChromeClient : WebChromeClient() {
398
398
  override fun onConsoleMessage(message: ConsoleMessage): Boolean { // WV-3
399
+ // A dev page forwards its console through the bridge once its runtime is up; this copy covers the rest.
400
+ if (bridge.consoleForwarded) return true
399
401
  val priority = when (message.messageLevel()) {
400
402
  ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
401
403
  ConsoleMessage.MessageLevel.WARNING -> Log.WARN
402
404
  ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG
403
405
  else -> Log.INFO
404
406
  }
405
- Log.println(priority, "AkanNativeConsole", message.message())
407
+ Log.println(priority, "AkanNativeConsole", message.message().trimEnd('\n'))
406
408
  return true
407
409
  }
408
410
 
@@ -53,6 +53,8 @@ class AkanNativeBridge(private val webView: WebView) {
53
53
 
54
54
  /** Dev builds: the self-test's \$host methods. */
55
55
  var dev = false
56
+ /** The page's runtime forwards its console here (a dev build's `$console` attach), so the WebView's copy is spare. */
57
+ @Volatile var consoleForwarded = false
56
58
 
57
59
  /** Dev builds: \$host.hang calls still open, and how many were cancelled. */
58
60
  private val hanging = ArrayList<AkanNativeCall>()
@@ -207,6 +209,7 @@ class AkanNativeBridge(private val webView: WebView) {
207
209
  if (pluginId == "\$bridge") return bridgeOp(call, doc)
208
210
  doc.running[id] = java.lang.ref.WeakReference(call)
209
211
  call.document = doc
212
+ if (pluginId == "\$console") return console(call)
210
213
  if (pluginId == "\$host") return host(call)
211
214
  val plugin = plugins[pluginId] ?: return call.reject(AkanNativeErrorCode.NOT_FOUND, "plugin $pluginId is not registered")
212
215
  // L2: an undeclared method or event never reaches the plugin, whatever its code handles.
@@ -333,6 +336,22 @@ class AkanNativeBridge(private val webView: WebView) {
333
336
  JSONObject().put("v", 1).put("id", id).put("ok", false).put("error", JSONObject().put("code", code).put("message", message))
334
337
 
335
338
  /** Shell built-ins the page runtime calls. Dev builds only: the self-test's bridge checks. */
339
+ /** dev builds: the page console at its own priority (WV-3), through the bridge as on iOS and the desktop. */
340
+ private fun console(call: AkanNativeCall) {
341
+ if (!dev) return call.reject(AkanNativeErrorCode.NOT_FOUND, "plugin \$console is not registered")
342
+ consoleForwarded = true
343
+ if (call.method != "attach") {
344
+ val priority = when (call.method) {
345
+ "error" -> Log.ERROR
346
+ "warn" -> Log.WARN
347
+ "trace", "verbose", "debug" -> Log.DEBUG
348
+ else -> Log.INFO
349
+ }
350
+ Log.println(priority, "AkanNativeConsole", (call.string("message") ?: "").trimEnd('\n'))
351
+ }
352
+ call.resolve()
353
+ }
354
+
336
355
  private fun host(call: AkanNativeCall) {
337
356
  // AkanNativeFeatures.DEV is a constant: release builds (false) lose these methods and what only they
338
357
  // reach (AkanNativeVectors) in R8 (architecture review stage 6).
@@ -13,10 +13,20 @@ enum AkanNativeLog {
13
13
  logger.info("\(text, privacy: .public)")
14
14
  }
15
15
 
16
+ /// One `[page<+> <level>]` tag per line, the desktop host's grammar, which the akan CLI reads the level back from;
17
+ /// os_log takes the page's level too, so `log stream` and Console.app filter on it.
16
18
  static func page(_ level: String, _ text: String) {
17
- print("[page \(level)] \(text)")
19
+ let lines = text.replacingOccurrences(of: "\\n+$", with: "", options: .regularExpression)
20
+ .components(separatedBy: "\n")
21
+ print(lines.enumerated().map { "[page\($0.offset == 0 ? "" : "+") \(level)] \($0.element)" }.joined(separator: "\n"))
18
22
  fflush(stdout)
19
- logger.info("[page \(level, privacy: .public)] \(text, privacy: .public)")
23
+ let message = lines.joined(separator: "\n")
24
+ switch level {
25
+ case "error": logger.error("\(message, privacy: .public)")
26
+ case "warn": logger.warning("\(message, privacy: .public)")
27
+ case "trace", "verbose", "debug": logger.debug("\(message, privacy: .public)")
28
+ default: logger.info("\(message, privacy: .public)")
29
+ }
20
30
  }
21
31
  }
22
32
 
@@ -93,7 +93,7 @@ export function runtime(): Runtime {
93
93
  };
94
94
  if (rt.transport) w.addEventListener?.("pageshow", (e) => e.persisted && w.location?.reload());
95
95
 
96
- if (boot.dev && rt.transport && boot.platform !== "android") forwardConsole(rt);
96
+ if (boot.dev && rt.transport) forwardConsole(boot.platform);
97
97
  }
98
98
  return g.__runtime as Runtime;
99
99
  }
@@ -102,7 +102,7 @@ export function runtime(): Runtime {
102
102
  * Dev builds on native hosts: mirror console output and uncaught errors to the host,
103
103
  * which prints them next to its own logs (WV-3).
104
104
  */
105
- function forwardConsole(_rt: Runtime): void {
105
+ function forwardConsole(platform: AkanNativeBoot["platform"]): void {
106
106
 
107
107
  const g = akanNativeGlobal() as { __consolePatched?: boolean };
108
108
  if (g.__consolePatched) return;
@@ -110,13 +110,32 @@ function forwardConsole(_rt: Runtime): void {
110
110
  const c = globalThis.console as unknown as Record<string, (...args: unknown[]) => void>;
111
111
  const format = (value: unknown): string => {
112
112
  if (typeof value === "string") return value;
113
- if (value instanceof Error) return value.stack ?? `${value.name}: ${value.message}`;
113
+
114
+ if (value instanceof Error) {
115
+ const head = `${value.name}: ${value.message}`;
116
+ return value.stack?.startsWith(head) ? value.stack : `${head}${value.stack ? `\n${value.stack}` : ""}`;
117
+ }
114
118
  try {
115
119
  return JSON.stringify(value) ?? String(value);
116
120
  } catch {
117
121
  return String(value);
118
122
  }
119
123
  };
124
+
125
+ const render = (args: unknown[]): string => {
126
+ const [first, ...rest] = args;
127
+ if (typeof first !== "string" || !/%[sdifoOc]/.test(first)) return args.map(format).join(" ");
128
+ const text = first.replace(/%([sdifoOc%])/g, (match, spec: string) => {
129
+ if (spec === "%") return "%";
130
+ if (rest.length === 0) return match;
131
+ const value = rest.shift();
132
+ if (spec === "c") return "";
133
+ if (spec === "d" || spec === "i") return String(Number.parseInt(String(value), 10));
134
+ if (spec === "f") return String(Number(value));
135
+ return format(value);
136
+ });
137
+ return [text, ...rest.map(format)].join(" ");
138
+ };
120
139
  const send = (level: string, args: unknown[]) => {
121
140
  const rt = akanNativeGlobal().__runtime as Runtime | undefined;
122
141
  if (!rt?.transport || !rt.boot.dev) return;
@@ -125,10 +144,12 @@ function forwardConsole(_rt: Runtime): void {
125
144
  id: ++rt.nextId,
126
145
  plugin: CONSOLE_PLUGIN,
127
146
  method: level,
128
- args: { message: args.map(format).join(" ") },
147
+ args: { message: render(args).replace(/\n+$/, "") },
129
148
  };
130
149
  rt.transport.send(request).catch(() => {});
131
150
  };
151
+
152
+ if (platform === "android") send("attach", []);
132
153
  for (const level of ["log", "info", "warn", "error", "debug"]) {
133
154
  const original = c[level]!.bind(globalThis.console);
134
155
  c[level] = (...args: unknown[]) => {
@@ -137,7 +158,7 @@ function forwardConsole(_rt: Runtime): void {
137
158
  };
138
159
  }
139
160
  const w = globalThis as { addEventListener?: (type: string, fn: (e: any) => void) => void };
140
- w.addEventListener?.("error", (e) => send("error", [`uncaught: ${e.error?.stack ?? e.message}`]));
161
+ w.addEventListener?.("error", (e) => send("error", ["uncaught:", e.error ?? e.message]));
141
162
  w.addEventListener?.("unhandledrejection", (e) => send("error", ["unhandled rejection:", e.reason]));
142
163
  }
143
164
 
@@ -132,10 +132,12 @@ export function startHost(plugins: DesktopPlugin[]): void {
132
132
  emitJs(window, `window.__AKAN_NATIVE__&&window.__AKAN_NATIVE__.receive(${JSON.stringify(message)})`);
133
133
  },
134
134
  builtins: {
135
-
135
+
136
136
  [CONSOLE_PLUGIN](req, window) {
137
- const line = `[page${window === 1 ? "" : ` ${window}`} ${req.method}] ${(req.args as { message?: string } | undefined)?.message ?? ""}`;
138
- (req.method === "error" || req.method === "warn" ? console.error : console.info)(line);
137
+ const message = (req.args as { message?: string } | undefined)?.message ?? "";
138
+ const scope = window === 1 ? "" : `#${window}`;
139
+ const lines = message.replace(/\n+$/, "").split("\n");
140
+ console.info(lines.map((line, i) => `[page${i === 0 ? "" : "+"}${scope} ${req.method}] ${line}`).join("\n"));
139
141
  },
140
142
 
141
143
  async [HOST_PLUGIN](req, window, signal) {
@@ -19,6 +19,8 @@ import {
19
19
  getRouteExports,
20
20
  Logger,
21
21
  parseRouteModuleKey,
22
+ type RouteLayer,
23
+ RouteLayering,
22
24
  routeSegmentToTreePath,
23
25
  } from "akanjs/common";
24
26
 
@@ -94,7 +96,6 @@ export class CsrRouteTable {
94
96
 
95
97
  async #build(context: CsrRouteContext): Promise<CsrRoutes> {
96
98
  const { device, basePaths, currentBasePath } = this.#options;
97
- const baseLayoutPaths = ["/", "/:lang", ...(currentBasePath ? [`/:lang/${currentBasePath}`] : [])];
98
99
  const otherBasePaths = basePaths?.filter((path) => path !== currentBasePath) ?? [];
99
100
  const pages: { [key: string]: RouteModule } = {};
100
101
  const asyncDefaultMap: { [key: string]: boolean | undefined } = {};
@@ -168,7 +169,11 @@ export class CsrRouteTable {
168
169
  targetRouteMap.set(targetPath, {
169
170
  ...(targetRouteMap.get(targetPath) ?? { path: targetPath, children: new Map<string, Route>() }),
170
171
  ...(parsed.kind === "layout"
171
- ? { renderLayout: routeRender, layoutPageConfig: (page as RouteModuleWithConfig).pageConfig }
172
+ ? {
173
+ renderLayout: routeRender,
174
+ isRootLayout: parsed.isInternalRootLayout,
175
+ layoutPageConfig: (page as RouteModuleWithConfig).pageConfig,
176
+ }
172
177
  : {
173
178
  renderPage: routeRender,
174
179
  pageIncludesOwnLayout: parsed.leaf === "_index",
@@ -181,38 +186,15 @@ export class CsrRouteTable {
181
186
  assertUniqueRoutePatterns(pagePatterns);
182
187
  const getPathRoutes = (
183
188
  route: Route,
184
- parentRootLayouts: RouteRender[] = [],
185
- parentLayouts: RouteRender[] = [],
186
- parentPaths: string[] = [],
189
+ parent: RouteLayer<RouteRender> | null = null,
187
190
  parentPageConfigChain: PageConfig[] = [],
188
- parentRootRenders: RouteRender[] = [],
189
191
  ): PathRoute[] => {
190
- const parentPath = parentPaths.filter((path) => path !== "/").join("");
191
- const isRouteGroup = /^\/\(.*\)$/.test(route.path);
192
- const currentPathSegment = isRouteGroup ? "" : route.path;
193
- const path = parentPath + currentPathSegment;
194
- const isRoot = !isRouteGroup && baseLayoutPaths.includes(path);
195
- const pathSegments = [...parentPaths, ...(currentPathSegment ? [currentPathSegment] : [])];
196
- const currentRootLayout = isRoot && route.renderLayout ? route.renderLayout : null;
197
- const currentLayout = !isRoot && route.renderLayout ? route.renderLayout : null;
192
+ const layer = RouteLayering.of(route, parent);
193
+ const { path, pathSegments } = layer;
198
194
  const currentLayoutConfig = route.renderLayout && route.layoutPageConfig ? route.layoutPageConfig : null;
199
-
200
- const currentOverrideRenders = route.renderOverrides ? [route.renderOverrides] : [];
201
- const nodeRenders = [...currentOverrideRenders, ...(route.renderLayout ? [route.renderLayout] : [])];
202
- const rootLayoutStack = [...parentRootLayouts, ...(currentRootLayout ? [currentRootLayout] : [])];
203
- const renderRootLayouts = isRoot ? [...parentRootRenders, ...nodeRenders] : parentRootRenders;
204
- const renderLayouts = isRoot ? parentLayouts : [...parentLayouts, ...nodeRenders];
205
- const pageConfigChain = [
206
- ...parentPageConfigChain,
207
- ...(currentRootLayout || currentLayout ? (currentLayoutConfig ? [currentLayoutConfig] : []) : []),
208
- ];
209
- const pageNodeRenders = route.pageIncludesOwnLayout === false ? currentOverrideRenders : nodeRenders;
210
- const pageRenderRootLayouts = isRoot ? [...parentRootRenders, ...pageNodeRenders] : parentRootRenders;
211
- const pageRenderLayouts = isRoot ? parentLayouts : [...parentLayouts, ...pageNodeRenders];
195
+ const pageConfigChain = [...parentPageConfigChain, ...(currentLayoutConfig ? [currentLayoutConfig] : [])];
212
196
  const pageRenderConfigChain =
213
- route.pageIncludesOwnLayout === false && (currentRootLayout || currentLayout)
214
- ? parentPageConfigChain
215
- : pageConfigChain;
197
+ route.pageIncludesOwnLayout === false && route.renderLayout ? parentPageConfigChain : pageConfigChain;
216
198
  const ownPageConfig = route.renderPage && route.pageConfig ? route.pageConfig : null;
217
199
  const finalPageConfigChain = [...pageRenderConfigChain, ...(ownPageConfig ? [ownPageConfig] : [])];
218
200
  const pageState = resolvePageState({
@@ -230,8 +212,8 @@ export class CsrRouteTable {
230
212
  path,
231
213
  pathSegments,
232
214
  renderPage: route.renderPage,
233
- renderRootLayouts: pageRenderRootLayouts,
234
- renderLayouts: pageRenderLayouts,
215
+ renderRootLayouts: layer.pageRenderRootLayouts,
216
+ renderLayouts: layer.pageRenderLayouts,
235
217
  isSpecialRoute: route.isSpecialRoute,
236
218
  pageState: route.pageState ?? pageState,
237
219
  pageConfigChain: finalPageConfigChain,
@@ -240,9 +222,7 @@ export class CsrRouteTable {
240
222
  ]
241
223
  : []),
242
224
  ...(route.children.size
243
- ? [...route.children.values()].flatMap((child) =>
244
- getPathRoutes(child, rootLayoutStack, renderLayouts, pathSegments, pageConfigChain, renderRootLayouts),
245
- )
225
+ ? [...route.children.values()].flatMap((child) => getPathRoutes(child, layer, pageConfigChain))
246
226
  : []),
247
227
  ];
248
228
  };
@@ -45,16 +45,19 @@ export const bootCsr = async (context: CsrRouteContext) => {
45
45
  const { pathRoutes, routeGuide } = useSyncExternalStore(table.subscribe, table.snapshot);
46
46
  const csrValues = useCsrValues(routeGuide, pathRoutes);
47
47
  const { location } = csrValues;
48
+
49
+ if (location.pathRoute.renderRootLayouts.length === 0)
50
+ throw new Error(
51
+ `[csr] no root layout for ${location.pathRoute.path}: the route table put none of its layouts at the root`,
52
+ );
48
53
  return (
49
54
  <csrContext.Provider value={csrValues}>
50
- {location.pathRoute.renderRootLayouts.length > 0 ? (
51
- <RenderLayer
52
- renders={location.pathRoute.renderRootLayouts}
53
- index={0}
54
- params={location.params}
55
- searchParams={location.searchParams}
56
- />
57
- ) : null}
55
+ <RenderLayer
56
+ renders={location.pathRoute.renderRootLayouts}
57
+ index={0}
58
+ params={location.params}
59
+ searchParams={location.searchParams}
60
+ />
58
61
  </csrContext.Provider>
59
62
  );
60
63
  };