akanjs 2.1.1 → 2.1.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,8 @@ export interface ProviderProps {
28
28
  layoutStyle?: "mobile" | "web";
29
29
  /** Enable reconnect helper. Defaults to local operation mode in CSR. */
30
30
  reconnect?: boolean;
31
+ /** Active-locale dictionary injected by the server (SSR only) to seed the client Translator. */
32
+ dictionary?: Record<string, Record<string, unknown>>;
31
33
  /** Root route component used by CSR page loading. */
32
34
  of: (props: unknown) => ReactNode | null;
33
35
  }
@@ -3,13 +3,13 @@ import { type ReactNode } from "react";
3
3
  import { type ProviderProps } from "./Common.d.ts";
4
4
  export declare const SSR: {
5
5
  (): import("react/jsx-runtime").JSX.Element;
6
- Provider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
6
+ Provider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, dictionary, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
7
7
  Wrapper: ({ children, head, manifest, fonts, className, prefix, layoutStyle, }: SSRWrapperProps) => import("react/jsx-runtime").JSX.Element;
8
8
  };
9
9
  export type SSRProviderProps = ProviderProps & {
10
10
  fonts?: ReactFont[];
11
11
  };
12
- declare const SSRProvider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
12
+ declare const SSRProvider: ({ className, appName, params, head, manifest, env, gaTrackingId, children, theme, prefix, fonts, layoutStyle, reconnect, dictionary, of, }: SSRProviderProps) => import("react/jsx-runtime").JSX.Element;
13
13
  interface SSRWrapperProps {
14
14
  className?: string;
15
15
  appName: string;
@@ -43,7 +43,6 @@ export const InfiniteScroll = ({
43
43
  if (nextPage > totalPages) return;
44
44
  setIsFetching(true);
45
45
  await onAddPage(nextPage);
46
- void onAddPage(nextPage);
47
46
  onPageSelect(nextPage);
48
47
  setIsFetching(false);
49
48
  page.current = nextPage;
@@ -14,6 +14,7 @@ import {
14
14
  router,
15
15
  setCookie,
16
16
  type TransitionStyle,
17
+ Translator,
17
18
  } from "akanjs/client";
18
19
  import { Logger } from "akanjs/common";
19
20
  import type { AkanTheme } from "akanjs/fetch";
@@ -41,7 +42,7 @@ interface ClientWrapperProps {
41
42
  children: ReactNode;
42
43
  theme?: AkanTheme;
43
44
  lang?: string;
44
- dictionary?: { [key: string]: { [key: string]: string } };
45
+ dictionary?: Record<string, Record<string, unknown>>;
45
46
  signals?: SerializedSignal[];
46
47
  reconnect?: boolean;
47
48
  }
@@ -49,10 +50,12 @@ export const ClientWrapper = ({
49
50
  children,
50
51
  theme,
51
52
  lang = "en",
52
- dictionary = {},
53
+ dictionary,
53
54
  signals = [],
54
55
  reconnect = true,
55
56
  }: ClientWrapperProps) => {
57
+
58
+ if (dictionary) Translator.seed(lang, dictionary);
56
59
  if (getEnv().renderMode === "ssr") {
57
60
  }
58
61
  useLayoutEffect(() => {
@@ -68,7 +71,8 @@ export const ClientWrapper = ({
68
71
  };
69
72
  Client.Wrapper = ClientWrapper;
70
73
 
71
- interface ClientPathWrapperProps extends Omit<HTMLAttributes<HTMLDivElement>, "style"> {
74
+ interface ClientPathWrapperProps
75
+ extends Omit<HTMLAttributes<HTMLDivElement>, "style"> {
72
76
  bind?: () => HTMLAttributes<HTMLDivElement>;
73
77
  wrapperRef?: RefObject<HTMLDivElement | null> | null;
74
78
  pageType?: "current" | "prev" | "cached";
@@ -89,12 +93,18 @@ export const ClientPathWrapper = ({
89
93
  layoutStyle = "web",
90
94
  ...props
91
95
  }: ClientPathWrapperProps) => {
92
- const href = location?.href ?? (typeof window !== "undefined" ? window.location.href : "");
93
- const hash = location?.hash ?? (typeof window !== "undefined" ? window.location.hash : "");
96
+ const href =
97
+ location?.href ??
98
+ (typeof window !== "undefined" ? window.location.href : "");
99
+ const hash =
100
+ location?.hash ??
101
+ (typeof window !== "undefined" ? window.location.hash : "");
94
102
  const pathname = location?.pathname ?? "/";
95
103
  const params = location?.params ?? {};
96
104
  const searchParams = location?.searchParams ?? {};
97
- const search = location?.search ?? (typeof window !== "undefined" ? window.location.search : "");
105
+ const search =
106
+ location?.search ??
107
+ (typeof window !== "undefined" ? window.location.search : "");
98
108
  const lang = params.lang;
99
109
  const firstPath = pathname.split("/")[2];
100
110
  const pathRoute: PathRoute = location?.pathRoute ?? {
@@ -112,14 +122,24 @@ export const ClientPathWrapper = ({
112
122
  <pathContext.Provider
113
123
  value={{
114
124
  pageType,
115
- location: { href, hash, pathname, params, searchParams, search, pathRoute },
125
+ location: {
126
+ href,
127
+ hash,
128
+ pathname,
129
+ params,
130
+ searchParams,
131
+ search,
132
+ pathRoute,
133
+ },
116
134
  prefix,
117
135
  gestureEnabled,
118
136
  setGestureEnabled,
119
137
  }}
120
138
  >
121
139
  <animated.div
122
- {...(bind && pathRoute.pageState.gesture && gestureEnabled ? bind() : {})}
140
+ {...(bind && pathRoute.pageState.gesture && gestureEnabled
141
+ ? bind()
142
+ : {})}
123
143
  className={clsx("group/path", className)}
124
144
  ref={wrapperRef}
125
145
  {...props}
@@ -141,7 +161,13 @@ interface ClientBridgeProps {
141
161
  gaTrackingId?: string;
142
162
  }
143
163
 
144
- export const ClientBridge = ({ env, lang, theme, prefix, gaTrackingId }: ClientBridgeProps) => {
164
+ export const ClientBridge = ({
165
+ env,
166
+ lang,
167
+ theme,
168
+ prefix,
169
+ gaTrackingId,
170
+ }: ClientBridgeProps) => {
145
171
  const uiOperation = st.use.uiOperation();
146
172
  const pathname = st.use.pathname();
147
173
  const params = st.use.params();
@@ -208,18 +234,26 @@ function applyThemePolicy(theme: AkanTheme): void {
208
234
  }
209
235
  if (theme === "system") {
210
236
  const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
211
- document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
237
+ document.documentElement.setAttribute(
238
+ "data-theme",
239
+ dark ? "dark" : "light",
240
+ );
212
241
  return;
213
242
  }
214
243
  document.documentElement.setAttribute("data-theme", theme);
215
244
  }
216
245
 
217
- function buildSearchParams(entries: Iterable<[string, string]>): Record<string, string | string[]> {
246
+ function buildSearchParams(
247
+ entries: Iterable<[string, string]>,
248
+ ): Record<string, string | string[]> {
218
249
  const params: Record<string, string | string[]> = {};
219
250
  for (const [key, value] of entries) {
220
251
  const current = params[key];
221
252
  if (current === undefined) params[key] = value;
222
- else params[key] = Array.isArray(current) ? [...current, value] : [current, value];
253
+ else
254
+ params[key] = Array.isArray(current)
255
+ ? [...current, value]
256
+ : [current, value];
223
257
  }
224
258
  return params;
225
259
  }
@@ -239,7 +273,10 @@ interface ClientSsrBridgeProps {
239
273
  lang: string;
240
274
  prefix?: string;
241
275
  }
242
- export const ClientSsrBridge = ({ lang, prefix = "" }: ClientSsrBridgeProps) => {
276
+ export const ClientSsrBridge = ({
277
+ lang,
278
+ prefix = "",
279
+ }: ClientSsrBridgeProps) => {
243
280
  useEffect(() => {
244
281
  const visiblePrefix = getEnv().operationMode === "local" ? prefix : "";
245
282
  const navigateRscWithFallback = (
@@ -253,13 +290,19 @@ export const ClientSsrBridge = ({ lang, prefix = "" }: ClientSsrBridgeProps) =>
253
290
  return;
254
291
  }
255
292
  void navigation.catch((error) => {
256
- Logger.warn(`RSC navigation failed, falling back to document navigation: ${String(error)}`);
293
+ Logger.warn(
294
+ `RSC navigation failed, falling back to document navigation: ${String(error)}`,
295
+ );
257
296
  fallback();
258
297
  });
259
298
  };
260
299
  const syncHref = (href: string) => {
261
300
  const url = new URL(href, window.location.origin);
262
- const { path } = getPathInfo(`${url.pathname}${url.search}${url.hash}`, lang, visiblePrefix);
301
+ const { path } = getPathInfo(
302
+ `${url.pathname}${url.search}${url.hash}`,
303
+ lang,
304
+ visiblePrefix,
305
+ );
263
306
  const searchParams = buildSearchParams(url.searchParams.entries());
264
307
  st.set({ pathname: url.pathname, path, searchParams });
265
308
  };
@@ -271,11 +314,17 @@ export const ClientSsrBridge = ({ lang, prefix = "" }: ClientSsrBridgeProps) =>
271
314
  router: {
272
315
  push: (href, routeOptions) => {
273
316
  syncHref(href);
274
- navigateRscWithFallback(href, routeOptions, () => window.location.assign(href));
317
+ navigateRscWithFallback(href, routeOptions, () =>
318
+ window.location.assign(href),
319
+ );
275
320
  },
276
321
  replace: (href, routeOptions) => {
277
322
  syncHref(href);
278
- navigateRscWithFallback(href, { ...routeOptions, replace: true }, () => window.location.replace(href));
323
+ navigateRscWithFallback(
324
+ href,
325
+ { ...routeOptions, replace: true },
326
+ () => window.location.replace(href),
327
+ );
279
328
  },
280
329
  back: () => {
281
330
  window.history.back();
@@ -283,7 +332,10 @@ export const ClientSsrBridge = ({ lang, prefix = "" }: ClientSsrBridgeProps) =>
283
332
  refresh: () => {
284
333
  clearRscNavigationCache();
285
334
  syncHref(window.location.href);
286
- void navigateRsc(window.location.href, { replace: true, scrollToTop: false });
335
+ void navigateRsc(window.location.href, {
336
+ replace: true,
337
+ scrollToTop: false,
338
+ });
287
339
  },
288
340
  },
289
341
  });
@@ -294,8 +346,14 @@ export const ClientSsrBridge = ({ lang, prefix = "" }: ClientSsrBridgeProps) =>
294
346
  const visiblePrefix = getEnv().operationMode === "local" ? prefix : "";
295
347
  const sync = () => {
296
348
  const { pathname, search, hash } = window.location;
297
- const { path } = getPathInfo(`${pathname}${search}${hash}`, lang, visiblePrefix);
298
- const searchParams = buildSearchParams(new URLSearchParams(search).entries());
349
+ const { path } = getPathInfo(
350
+ `${pathname}${search}${hash}`,
351
+ lang,
352
+ visiblePrefix,
353
+ );
354
+ const searchParams = buildSearchParams(
355
+ new URLSearchParams(search).entries(),
356
+ );
299
357
  st.set({ pathname: window.location.pathname, path, searchParams });
300
358
  };
301
359
  sync();
@@ -30,6 +30,8 @@ export interface ProviderProps {
30
30
  layoutStyle?: "mobile" | "web";
31
31
  /** Enable reconnect helper. Defaults to local operation mode in CSR. */
32
32
  reconnect?: boolean;
33
+ /** Active-locale dictionary injected by the server (SSR only) to seed the client Translator. */
34
+ dictionary?: Record<string, Record<string, unknown>>;
33
35
  /** Root route component used by CSR page loading. */
34
36
  of: (props: unknown) => ReactNode | null;
35
37
  }
@@ -55,7 +57,10 @@ export function toManifestJson(value: unknown): unknown {
55
57
  return Object.fromEntries(
56
58
  Object.entries(value)
57
59
  .filter(([, entryValue]) => entryValue !== undefined)
58
- .map(([key, entryValue]) => [camelToSnake(key), toManifestJson(entryValue)]),
60
+ .map(([key, entryValue]) => [
61
+ camelToSnake(key),
62
+ toManifestJson(entryValue),
63
+ ]),
59
64
  );
60
65
  }
61
66
 
@@ -75,7 +80,11 @@ function encodeBase64Utf8(value: string): string {
75
80
 
76
81
  const buffer = (
77
82
  globalThis as typeof globalThis & {
78
- Buffer?: { from: (bytes: Uint8Array) => { toString: (encoding: "base64") => string } };
83
+ Buffer?: {
84
+ from: (bytes: Uint8Array) => {
85
+ toString: (encoding: "base64") => string;
86
+ };
87
+ };
79
88
  }
80
89
  ).Buffer;
81
90
  if (buffer) return buffer.from(bytes).toString("base64");
package/ui/System/SSR.tsx CHANGED
@@ -1,10 +1,21 @@
1
1
  import { getEnv } from "akanjs/base";
2
- import { clsx, type ReactFont, router, type WebAppManifest } from "akanjs/client";
2
+ import {
3
+ clsx,
4
+ type ReactFont,
5
+ router,
6
+ type WebAppManifest,
7
+ } from "akanjs/client";
3
8
  import { setRequestTheme } from "akanjs/fetch";
4
9
  import { Children, Fragment, type ReactNode, Suspense } from "react";
5
10
  import { FontCss } from "../fontCss";
6
11
  import { Load } from "../Load";
7
- import { ClientBridge, ClientInner, ClientPathWrapper, ClientSsrBridge, ClientWrapper } from "./Client";
12
+ import {
13
+ ClientBridge,
14
+ ClientInner,
15
+ ClientPathWrapper,
16
+ ClientSsrBridge,
17
+ ClientWrapper,
18
+ } from "./Client";
8
19
  import { ManifestLink, type ProviderProps } from "./Common";
9
20
 
10
21
  export const SSR = () => {
@@ -29,6 +40,7 @@ const SSRProvider = ({
29
40
  fonts,
30
41
  layoutStyle = "web",
31
42
  reconnect = getEnv().operationMode === "local",
43
+ dictionary,
32
44
  of,
33
45
  }: SSRProviderProps) => {
34
46
  setRequestTheme(theme);
@@ -38,7 +50,8 @@ const SSRProvider = ({
38
50
  of={of}
39
51
  loader={async () => {
40
52
  const { lang } = params;
41
- if (!router.isInitialized) router.init({ type: "ssr", side: "server", lang, prefix });
53
+ if (!router.isInitialized)
54
+ router.init({ type: "ssr", side: "server", lang, prefix });
42
55
  return { lang } as const;
43
56
  }}
44
57
  render={({ lang }) => (
@@ -52,13 +65,24 @@ const SSRProvider = ({
52
65
  prefix={prefix}
53
66
  layoutStyle={layoutStyle}
54
67
  >
55
- <ClientWrapper theme={theme} lang={lang} reconnect={reconnect}>
68
+ <ClientWrapper
69
+ theme={theme}
70
+ lang={lang}
71
+ reconnect={reconnect}
72
+ dictionary={dictionary}
73
+ >
56
74
  <Fragment key="children">{Children.toArray(children)}</Fragment>
57
75
  <Suspense key="client-inner" fallback={null}>
58
76
  <ClientInner />
59
77
  </Suspense>
60
78
  <Suspense key="client-bridge" fallback={null}>
61
- <ClientBridge key="bridge" env={env} theme={theme} prefix={prefix} gaTrackingId={gaTrackingId} />
79
+ <ClientBridge
80
+ key="bridge"
81
+ env={env}
82
+ theme={theme}
83
+ prefix={prefix}
84
+ gaTrackingId={gaTrackingId}
85
+ />
62
86
  <ClientSsrBridge key="ssr-bridge" lang={lang} prefix={prefix} />
63
87
  </Suspense>
64
88
  </ClientWrapper>
@@ -77,7 +101,14 @@ const ServerFontFace = ({ fonts }: { fonts: ReactFont[] }) => {
77
101
  return (
78
102
  <>
79
103
  {preloads.map((preload) => (
80
- <link key={preload.href} rel="preload" href={preload.href} as="font" type={preload.type} crossOrigin="" />
104
+ <link
105
+ key={preload.href}
106
+ rel="preload"
107
+ href={preload.href}
108
+ as="font"
109
+ type={preload.type}
110
+ crossOrigin=""
111
+ />
81
112
  ))}
82
113
  {css ? (
83
114
 
@@ -114,14 +145,23 @@ const SSRWrapper = ({
114
145
  {head ? <Fragment key="head">{head}</Fragment> : null}
115
146
  <div key="frame-root" id="frameRoot" className={className}>
116
147
  <ClientPathWrapper layoutStyle={layoutStyle} prefix={prefix}>
117
- <div key="top-safe-area" id="topSafeArea" className={clsx("fixed inset-x-0 top-0 bg-base-100")} />
118
- <div key="page-containers" id="pageContainers" className={clsx("isolate")}>
148
+ <div
149
+ key="top-safe-area"
150
+ id="topSafeArea"
151
+ className={clsx("fixed inset-x-0 top-0 bg-base-100")}
152
+ />
153
+ <div
154
+ key="page-containers"
155
+ id="pageContainers"
156
+ className={clsx("isolate")}
157
+ >
119
158
  <div id="pageContainer">
120
159
  <div
121
160
  id="pageContent"
122
161
  className={clsx("relative isolate", {
123
162
  "w-full": layoutStyle === "web",
124
- "left-1/2 h-screen w-[600px] -translate-x-1/2": layoutStyle === "mobile",
163
+ "left-1/2 h-screen w-[600px] -translate-x-1/2":
164
+ layoutStyle === "mobile",
125
165
  })}
126
166
  >
127
167
  {Children.toArray(children)}
@@ -136,7 +176,10 @@ const SSRWrapper = ({
136
176
  "w-full": layoutStyle === "web",
137
177
  })}
138
178
  >
139
- <div id="topInsetContent" className={clsx("relative isolate size-full")} />
179
+ <div
180
+ id="topInsetContent"
181
+ className={clsx("relative isolate size-full")}
182
+ />
140
183
  </div>
141
184
  <div
142
185
  key="top-left-action"
@@ -153,7 +196,11 @@ const SSRWrapper = ({
153
196
  >
154
197
  <div id="bottomInsetContent" className="isolate size-full" />
155
198
  </div>
156
- <div key="bottom-safe-area" id="bottomSafeArea" className="fixed inset-x-0 bg-base-100" />
199
+ <div
200
+ key="bottom-safe-area"
201
+ id="bottomSafeArea"
202
+ className="fixed inset-x-0 bg-base-100"
203
+ />
157
204
  </ClientPathWrapper>
158
205
  </div>
159
206
  </>
@@ -6,6 +6,7 @@ import {
6
6
  Device,
7
7
  defaultPageState,
8
8
  initAuth,
9
+ type LayoutModule,
9
10
  type PageConfig,
10
11
  type PageState,
11
12
  type PathRoute,
@@ -151,7 +152,15 @@ export const bootCsr = async (context: Record<string, () => Promise<RouteModule>
151
152
  if (!targetPath) continue;
152
153
  const page = pages[filePath];
153
154
  if (!page) continue;
154
- const routeRender: RouteRender = { render: page.default as never, Loading: page.Loading as never };
155
+ const layoutPage = parsed.kind === "layout" ? (page as LayoutModule) : null;
156
+ const routeRender: RouteRender = {
157
+ render: page.default as never,
158
+ Loading: page.Loading as never,
159
+ NotFound: layoutPage?.NotFound,
160
+ Error: layoutPage?.Error,
161
+ resolveNotFound: layoutPage ? () => layoutPage.NotFound : undefined,
162
+ resolveError: layoutPage ? () => layoutPage.Error : undefined,
163
+ };
155
164
  targetRouteMap.set(targetPath, {
156
165
 
157
166
  ...(targetRouteMap.get(targetPath) ?? { path: targetPath, children: new Map<string, Route>() }),
@@ -265,8 +274,10 @@ function validateRouteModuleExports(key: string, mod: RouteModule) {
265
274
  "layoutStyle",
266
275
  "gaTrackingId",
267
276
  "Loading",
277
+ "NotFound",
278
+ "Error",
268
279
  ])
269
- : new Set(["default", "head", "generateHead", "Loading"]);
280
+ : new Set(["default", "head", "generateHead", "Loading", "NotFound", "Error"]);
270
281
  for (const exportName of Object.keys(mod)) {
271
282
  if (!allowed.has(exportName)) {
272
283
  throw new Error(`[route-convention] unsupported export "${exportName}" in ${key}`);