@cosmicdrift/kumiko-renderer-web 0.46.0 → 0.48.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.40.1",
20
- "@cosmicdrift/kumiko-headless": "0.40.1",
21
- "@cosmicdrift/kumiko-renderer": "0.40.1",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.45.0",
20
+ "@cosmicdrift/kumiko-headless": "0.45.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.45.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -0,0 +1,147 @@
1
+ import { afterAll, afterEach, describe, expect, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { act, screen } from "@testing-library/react";
4
+ import { createContext, type ReactNode, useContext } from "react";
5
+ import type { ClientFeatureDefinition } from "../app/client-plugin";
6
+ import { createPublicSurface } from "../app/create-public-surface";
7
+ import { createMockDispatcher } from "./test-utils";
8
+
9
+ function mountRoot(id = "root"): HTMLDivElement {
10
+ const existing = document.getElementById(id);
11
+ if (existing) existing.remove();
12
+ const root = document.createElement("div");
13
+ root.id = id;
14
+ document.body.appendChild(root);
15
+ return root as HTMLDivElement;
16
+ }
17
+
18
+ function setPath(path: string): void {
19
+ window.history.replaceState({}, "", path);
20
+ }
21
+
22
+ function dispatcher(): Dispatcher {
23
+ return createMockDispatcher({});
24
+ }
25
+
26
+ let appRoot: { unmount: () => void } | undefined;
27
+
28
+ async function mount(options: Parameters<typeof createPublicSurface>[0]): Promise<void> {
29
+ await act(async () => {
30
+ appRoot = createPublicSurface(options).root;
31
+ });
32
+ }
33
+
34
+ describe("createPublicSurface", () => {
35
+ afterEach(() => {
36
+ if (appRoot !== undefined) {
37
+ act(() => {
38
+ // biome-ignore lint/style/noNonNullAssertion: TS can't narrow inside act() callback
39
+ appRoot!.unmount();
40
+ });
41
+ appRoot = undefined;
42
+ }
43
+ while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
44
+ delete (window as unknown as { __KUMIKO_SCHEMA__?: unknown }).__KUMIKO_SCHEMA__;
45
+ setPath("/");
46
+ });
47
+
48
+ afterAll(() => {
49
+ delete (window as unknown as { __KUMIKO_SCHEMA__?: unknown }).__KUMIKO_SCHEMA__;
50
+ });
51
+
52
+ test("rendert die Route deren path auf den aktuellen Pfad matcht", async () => {
53
+ setPath("/login");
54
+ mountRoot();
55
+ await mount({
56
+ dispatcher: dispatcher(),
57
+ routes: [
58
+ { path: "/login", component: <div data-testid="login">login</div> },
59
+ { path: "/signup", component: <div data-testid="signup">signup</div> },
60
+ ],
61
+ });
62
+ expect(screen.getByTestId("login")).toBeTruthy();
63
+ expect(screen.queryByTestId("signup")).toBeNull();
64
+ });
65
+
66
+ test("ohne Match → fallback", async () => {
67
+ setPath("/nope");
68
+ mountRoot();
69
+ await mount({
70
+ dispatcher: dispatcher(),
71
+ routes: [{ path: "/login", component: <div data-testid="login">login</div> }],
72
+ fallback: <div data-testid="fallback">fallback</div>,
73
+ });
74
+ expect(screen.getByTestId("fallback")).toBeTruthy();
75
+ expect(screen.queryByTestId("login")).toBeNull();
76
+ });
77
+
78
+ test("injectSchema:false — ignoriert window.__KUMIKO_SCHEMA__ komplett (kein Topologie-Leak)", async () => {
79
+ // Ein Admin-Schema im Window darf NICHT zu gerenderter Admin-Nav/
80
+ // Topologie führen — die Surface liest das Global gar nicht, sie
81
+ // rendert ausschließlich die deklarierte Route.
82
+ setPath("/login");
83
+ (window as unknown as { __KUMIKO_SCHEMA__?: unknown }).__KUMIKO_SCHEMA__ = {
84
+ features: [
85
+ {
86
+ featureName: "secret-admin",
87
+ entities: {},
88
+ screens: [{ id: "secret-topology", type: "custom" }],
89
+ },
90
+ ],
91
+ };
92
+ mountRoot();
93
+ await mount({
94
+ dispatcher: dispatcher(),
95
+ routes: [{ path: "/login", component: <div data-testid="login">login</div> }],
96
+ });
97
+ expect(screen.getByTestId("login")).toBeTruthy();
98
+ expect(document.body.textContent).not.toContain("secret");
99
+ expect(document.body.textContent).not.toContain("topology");
100
+ });
101
+
102
+ test("clientFeatures: providers werden gestackt, gates aber NICHT (Surface bleibt öffentlich)", async () => {
103
+ const Ctx = createContext("absent");
104
+ function MarkerProvider({ children }: { readonly children: ReactNode }): ReactNode {
105
+ return <Ctx.Provider value="present">{children}</Ctx.Provider>;
106
+ }
107
+ function HideEverythingGate(): ReactNode {
108
+ return <div data-testid="gate-hijack">gate</div>;
109
+ }
110
+ function ProbeRoute(): ReactNode {
111
+ return <div data-testid="probe">{useContext(Ctx)}</div>;
112
+ }
113
+ const feature: ClientFeatureDefinition = {
114
+ name: "auth",
115
+ providers: [MarkerProvider],
116
+ gates: [HideEverythingGate],
117
+ };
118
+ setPath("/login");
119
+ mountRoot();
120
+ await mount({
121
+ dispatcher: dispatcher(),
122
+ clientFeatures: [feature],
123
+ routes: [{ path: "/login", component: <ProbeRoute /> }],
124
+ });
125
+ // Provider sichtbar (Context greift), Gate nicht angewandt.
126
+ expect(screen.getByTestId("probe").textContent).toBe("present");
127
+ expect(screen.queryByTestId("gate-hijack")).toBeNull();
128
+ });
129
+
130
+ test("shell wrappt den gematchten Content (Page-Chrome)", async () => {
131
+ setPath("/login");
132
+ mountRoot();
133
+ await mount({
134
+ dispatcher: dispatcher(),
135
+ routes: [{ path: "/login", component: <div data-testid="login">login</div> }],
136
+ shell: ({ children }) => <div data-testid="chrome">{children}</div>,
137
+ });
138
+ const chrome = screen.getByTestId("chrome");
139
+ expect(chrome.querySelector("[data-testid=login]")).not.toBeNull();
140
+ });
141
+
142
+ test("fehlendes #root → wirft mit hilfreicher Message", () => {
143
+ expect(() => createPublicSurface({ dispatcher: dispatcher(), routes: [] })).toThrow(
144
+ /#root not found/,
145
+ );
146
+ });
147
+ });
@@ -0,0 +1,108 @@
1
+ import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
2
+ import type { Dispatcher, LocaleResolver } from "@cosmicdrift/kumiko-headless";
3
+ import {
4
+ DispatcherProvider,
5
+ kumikoDefaultTranslations,
6
+ LocaleProvider,
7
+ PrimitivesProvider,
8
+ type PrimitivesRegistry,
9
+ } from "@cosmicdrift/kumiko-renderer";
10
+ import type { ReactNode } from "react";
11
+ import { createRoot, type Root } from "react-dom/client";
12
+ import { defaultPrimitives } from "../primitives";
13
+ import { ToastProvider } from "../primitives/toast";
14
+ import { createBrowserLocaleResolver } from "./browser-locale";
15
+ import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
16
+
17
+ // Apex-Surface — der öffentliche Gegenpart zu createKumikoApp. Mountet eine
18
+ // schlanke, schema-LOSE Provider-Chain (Locale + Primitives + Dispatcher +
19
+ // feature-providers) und rendert genau einen anhand des URL-Pfads gewählten
20
+ // Content. Bewusst KEIN Schema, KEINE Nav, KEIN KumikoScreen: die Surface ist
21
+ // anonym erreichbar, ein __KUMIKO_SCHEMA__-Inject würde Admin-Nav/Topologie an
22
+ // Besucher leaken (injectSchema:false ist hier struktureller Default, kein Flag).
23
+ //
24
+ // `routes` sind app-authored React-Elemente — Auth-Screens et al. tragen
25
+ // Callback-Props (loggedInHref usw.), die durch keine serialisierbare
26
+ // ScreenDefinition passen würden; deshalb laufen sie über diesen Mount und
27
+ // nicht über die Registry. Späterer Registry-Content (CMS/Landing) konvergiert
28
+ // auf denselben Mount.
29
+ //
30
+ // createPublicSurface({
31
+ // routes: [{ path: "/login", component: <LoginScreen ... /> }],
32
+ // fallback: <LoginScreen ... />,
33
+ // clientFeatures: [emailPasswordClient()],
34
+ // shell: ({ children }) => <MarketingChrome>{children}</MarketingChrome>,
35
+ // });
36
+
37
+ export type PublicRoute = {
38
+ /** Exakter window.location.pathname-Match (match-once beim Mount; alle
39
+ * Apex-Pages sind Full-Page-Reloads, kein SPA-Router). */
40
+ readonly path: string;
41
+ readonly component: ReactNode;
42
+ };
43
+
44
+ export type CreatePublicSurfaceOptions = {
45
+ readonly routes: readonly PublicRoute[];
46
+ /** Gerendert wenn kein route.path auf den aktuellen Pfad matcht. */
47
+ readonly fallback?: ReactNode;
48
+ readonly rootId?: string;
49
+ readonly locale?: LocaleResolver;
50
+ readonly primitives?: Partial<PrimitivesRegistry>;
51
+ /** Dispatcher für Handler-Calls (z.B. anonymer Deletion-Request). Auth-
52
+ * Screens brauchen ihn nicht (fetch via auth-client), aber er steht für
53
+ * dispatchende Public-Flows bereit. Default: createLiveDispatcher(). */
54
+ readonly dispatcher?: Dispatcher;
55
+ /** Feature-Client-Extensions — NUR `providers` + `translations` werden
56
+ * gestackt. `gates` werden bewusst ignoriert: ein AuthGate würde die
57
+ * öffentliche Surface hinter Login sperren. */
58
+ readonly clientFeatures?: readonly ClientFeatureDefinition[];
59
+ /** Page-Chrome um den gematchten Content (Apex-/Marketing-Layout). */
60
+ readonly shell?: (props: { readonly children: ReactNode }) => ReactNode;
61
+ };
62
+
63
+ function matchRoute(options: CreatePublicSurfaceOptions, pathname: string): ReactNode {
64
+ for (const route of options.routes) {
65
+ if (route.path === pathname) return route.component;
66
+ }
67
+ return options.fallback ?? null;
68
+ }
69
+
70
+ export function createPublicSurface(options: CreatePublicSurfaceOptions): { readonly root: Root } {
71
+ const rootId = options.rootId ?? "root";
72
+ const container = document.getElementById(rootId);
73
+ if (!container) {
74
+ throw new Error(
75
+ `createPublicSurface: DOM element #${rootId} not found. Make sure your HTML has a matching <div id="${rootId}"></div> before the bundle loads.`,
76
+ );
77
+ }
78
+
79
+ const dispatcher = options.dispatcher ?? createLiveDispatcher();
80
+ const primitives: PrimitivesRegistry = { ...defaultPrimitives, ...(options.primitives ?? {}) };
81
+ const localeResolver = options.locale ?? createBrowserLocaleResolver();
82
+
83
+ const clientFeatures = options.clientFeatures ?? [];
84
+ const providers = clientFeatures.flatMap((f) => f.providers ?? []);
85
+ const fallbackBundles = [
86
+ ...clientFeatures.flatMap((f) => (f.translations !== undefined ? [f.translations] : [])),
87
+ kumikoDefaultTranslations,
88
+ ];
89
+
90
+ const pathname = typeof window !== "undefined" ? window.location.pathname : "";
91
+ const matched = matchRoute(options, pathname);
92
+ const Shell = options.shell;
93
+ const content = Shell !== undefined ? <Shell>{matched}</Shell> : matched;
94
+
95
+ const tree = (
96
+ <LocaleProvider resolver={localeResolver} fallbackBundles={fallbackBundles}>
97
+ <PrimitivesProvider value={primitives}>
98
+ <DispatcherProvider dispatcher={dispatcher}>
99
+ <ToastProvider>{stackWrappers(providers, content)}</ToastProvider>
100
+ </DispatcherProvider>
101
+ </PrimitivesProvider>
102
+ </LocaleProvider>
103
+ );
104
+
105
+ const root = createRoot(container);
106
+ root.render(tree);
107
+ return { root };
108
+ }
package/src/index.ts CHANGED
@@ -87,6 +87,8 @@ export { createBrowserLocaleResolver } from "./app/browser-locale";
87
87
  export type { ClientFeatureDefinition } from "./app/client-plugin";
88
88
  export type { CreateKumikoAppOptions } from "./app/create-app";
89
89
  export { createKumikoApp } from "./app/create-app";
90
+ export type { CreatePublicSurfaceOptions, PublicRoute } from "./app/create-public-surface";
91
+ export { createPublicSurface } from "./app/create-public-surface";
90
92
  export type { KumikoLinkProps } from "./app/nav";
91
93
  export { KumikoLink, useBrowserNavApi } from "./app/nav";
92
94
  export type { AppLayoutProps } from "./layout/app-layout";