@cosmicdrift/kumiko-renderer-web 0.46.0 → 0.47.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.
|
|
3
|
+
"version": "0.47.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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
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";
|