@cosmicdrift/kumiko-renderer 0.182.0 → 0.182.1
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",
|
|
3
|
-
"version": "0.182.
|
|
3
|
+
"version": "0.182.1",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.182.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.182.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.182.1",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.182.1",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2"
|
|
22
22
|
},
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import type { ComponentType, ReactNode } from "react";
|
|
4
|
+
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
5
|
+
import { type ContentEditorProps, ContentEditorsProvider } from "../content-editors";
|
|
6
|
+
import { ContentPreview, substituteVariables } from "../content-preview";
|
|
7
|
+
|
|
8
|
+
const noop = (): ReactNode => null;
|
|
9
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
10
|
+
|
|
11
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
12
|
+
if (props.kind !== "textarea") return null;
|
|
13
|
+
return (
|
|
14
|
+
<textarea data-testid="cp-textarea" value={props.value} disabled={props.disabled} readOnly />
|
|
15
|
+
);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const testPrimitives: CorePrimitives = {
|
|
19
|
+
Button: noop,
|
|
20
|
+
Banner: passChildren,
|
|
21
|
+
Field: passChildren,
|
|
22
|
+
Input: captureInput,
|
|
23
|
+
DataTable: noop,
|
|
24
|
+
Form: passChildren,
|
|
25
|
+
Section: passChildren,
|
|
26
|
+
Card: passChildren,
|
|
27
|
+
Grid: passChildren,
|
|
28
|
+
GridCell: passChildren,
|
|
29
|
+
Text: passChildren,
|
|
30
|
+
Heading: noop,
|
|
31
|
+
Dialog: noop,
|
|
32
|
+
Modal: noop,
|
|
33
|
+
Lightbox: noop,
|
|
34
|
+
ConfigSourceBadge: noop,
|
|
35
|
+
ConfigCascadeView: noop,
|
|
36
|
+
Link: noop,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
40
|
+
return <PrimitivesProvider value={testPrimitives}>{children}</PrimitivesProvider>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("substituteVariables", () => {
|
|
44
|
+
test("replaces every {{name}} with its example value", () => {
|
|
45
|
+
expect(
|
|
46
|
+
substituteVariables("Hi {{customerName}}, order {{orderId}} shipped.", {
|
|
47
|
+
customerName: "Max Mustermann",
|
|
48
|
+
orderId: "A-1042",
|
|
49
|
+
}),
|
|
50
|
+
).toBe("Hi Max Mustermann, order A-1042 shipped.");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a name with no example value stays as the literal placeholder", () => {
|
|
54
|
+
expect(substituteVariables("Hi {{customerName}}", {})).toBe("Hi {{customerName}}");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("no variables in the content → content passes through unchanged", () => {
|
|
58
|
+
expect(substituteVariables("Just plain text.", { customerName: "Max" })).toBe(
|
|
59
|
+
"Just plain text.",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("ContentPreview", () => {
|
|
65
|
+
test("renders the format's registered editor read-only, with variables substituted", () => {
|
|
66
|
+
function RichEditor({ value, readOnly }: ContentEditorProps): ReactNode {
|
|
67
|
+
return (
|
|
68
|
+
<div data-testid="cp-rich" data-readonly={readOnly}>
|
|
69
|
+
{value}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
render(
|
|
75
|
+
<ContentEditorsProvider value={{ rich: RichEditor }}>
|
|
76
|
+
<ContentPreview
|
|
77
|
+
content="Hallo {{customerName}}"
|
|
78
|
+
variables={{ customerName: "Max Mustermann" }}
|
|
79
|
+
contentFormat="rich"
|
|
80
|
+
/>
|
|
81
|
+
</ContentEditorsProvider>,
|
|
82
|
+
{ wrapper: Wrapper },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const el = screen.getByTestId("cp-rich");
|
|
86
|
+
expect(el.textContent).toBe("Hallo Max Mustermann");
|
|
87
|
+
expect(el.dataset["readonly"]).toBe("true");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("no editor registered for the format → falls back to the textarea, still substituted", () => {
|
|
91
|
+
render(
|
|
92
|
+
<ContentPreview
|
|
93
|
+
content="Hi {{orderId}}"
|
|
94
|
+
variables={{ orderId: "A-1042" }}
|
|
95
|
+
contentFormat="plain"
|
|
96
|
+
/>,
|
|
97
|
+
{ wrapper: Wrapper },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const el = screen.getByTestId("cp-textarea") as HTMLTextAreaElement;
|
|
101
|
+
expect(el.value).toBe("Hi A-1042");
|
|
102
|
+
expect(el.disabled).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("rich format: an example value with markup characters is escaped, not injected as HTML", () => {
|
|
106
|
+
function RichEditor({ value }: ContentEditorProps): ReactNode {
|
|
107
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: proves the substituted value is escaped, not injected — the whole point of this test.
|
|
108
|
+
return <div data-testid="cp-rich" dangerouslySetInnerHTML={{ __html: value }} />;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(
|
|
112
|
+
<ContentEditorsProvider value={{ rich: RichEditor }}>
|
|
113
|
+
<ContentPreview
|
|
114
|
+
content="Preis: {{price}}"
|
|
115
|
+
variables={{ price: "<b>0</b> & up" }}
|
|
116
|
+
contentFormat="rich"
|
|
117
|
+
/>
|
|
118
|
+
</ContentEditorsProvider>,
|
|
119
|
+
{ wrapper: Wrapper },
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const el = screen.getByTestId("cp-rich");
|
|
123
|
+
expect(el.querySelector("b")).toBeNull();
|
|
124
|
+
expect(el.textContent).toBe("Preis: <b>0</b> & up");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("plain format: an example value with markup characters passes through unescaped", () => {
|
|
128
|
+
render(
|
|
129
|
+
<ContentPreview
|
|
130
|
+
content="Preis: {{price}}"
|
|
131
|
+
variables={{ price: "<b>0</b>" }}
|
|
132
|
+
contentFormat="plain"
|
|
133
|
+
/>,
|
|
134
|
+
{ wrapper: Wrapper },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const el = screen.getByTestId("cp-textarea") as HTMLTextAreaElement;
|
|
138
|
+
expect(el.value).toBe("Preis: <b>0</b>");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Read-only render of a content editor's format with the collection's
|
|
2
|
+
// example variableSchema values substituted in for `{{name}}` — reuses the
|
|
3
|
+
// same registered editor the collection edits with (readOnly), so "rich"
|
|
4
|
+
// renders formatted HTML and "plain"/"markdown" render as text through the
|
|
5
|
+
// exact same component, no separate render path per format.
|
|
6
|
+
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { useContentEditor } from "./content-editors";
|
|
9
|
+
|
|
10
|
+
const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
|
|
11
|
+
|
|
12
|
+
const noop = (): void => {};
|
|
13
|
+
|
|
14
|
+
/** A name with no example value (or not in the schema at all) stays as the
|
|
15
|
+
* literal `{{name}}` placeholder — there is nothing else to show for it. */
|
|
16
|
+
export function substituteVariables(
|
|
17
|
+
content: string,
|
|
18
|
+
variables: Readonly<Record<string, string>>,
|
|
19
|
+
): string {
|
|
20
|
+
return content.replace(VARIABLE_PATTERN, (match, name: string) => variables[name] ?? match);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeHtml(value: string): string {
|
|
24
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ContentPreviewProps = {
|
|
28
|
+
readonly content: string;
|
|
29
|
+
readonly variables: Readonly<Record<string, string>>;
|
|
30
|
+
readonly contentFormat?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function ContentPreview({
|
|
34
|
+
content,
|
|
35
|
+
variables,
|
|
36
|
+
contentFormat,
|
|
37
|
+
}: ContentPreviewProps): ReactNode {
|
|
38
|
+
const Editor = useContentEditor(contentFormat);
|
|
39
|
+
// "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
|
|
40
|
+
// an example value substituted in raw could break the markup (`<`) or
|
|
41
|
+
// render as an unescaped entity (`&`). "plain"/"markdown" content is text,
|
|
42
|
+
// no escaping wanted there.
|
|
43
|
+
const safeVariables =
|
|
44
|
+
contentFormat === "rich"
|
|
45
|
+
? Object.fromEntries(
|
|
46
|
+
Object.entries(variables).map(([name, value]) => [name, escapeHtml(value)]),
|
|
47
|
+
)
|
|
48
|
+
: variables;
|
|
49
|
+
return (
|
|
50
|
+
<Editor
|
|
51
|
+
value={substituteVariables(content, safeVariables)}
|
|
52
|
+
onChange={noop}
|
|
53
|
+
variables={[]}
|
|
54
|
+
readOnly
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -87,6 +87,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
87
87
|
|
|
88
88
|
// ContentEditor — Variablen-Chip-Leiste (VariableChips).
|
|
89
89
|
"kumiko.contentEditor.insertVariable": "{name} einfügen",
|
|
90
|
+
"kumiko.contentEditor.preview": "Vorschau",
|
|
91
|
+
"kumiko.contentEditor.editMode": "Bearbeiten",
|
|
90
92
|
|
|
91
93
|
// Config-Cascade — Source-Badges + Cascade-Panel (ConfigCascadeView).
|
|
92
94
|
"kumiko.config.source.user": "Mein Wert",
|
|
@@ -220,6 +222,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
220
222
|
"kumiko.rowAction.failed": "Action failed",
|
|
221
223
|
|
|
222
224
|
"kumiko.contentEditor.insertVariable": "Insert {name}",
|
|
225
|
+
"kumiko.contentEditor.preview": "Preview",
|
|
226
|
+
"kumiko.contentEditor.editMode": "Edit",
|
|
223
227
|
|
|
224
228
|
"kumiko.config.source.user": "My value",
|
|
225
229
|
"kumiko.config.source.tenant": "Tenant",
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,8 @@ export {
|
|
|
30
30
|
TextareaContentEditor,
|
|
31
31
|
useContentEditor,
|
|
32
32
|
} from "./app/content-editors";
|
|
33
|
+
export type { ContentPreviewProps } from "./app/content-preview";
|
|
34
|
+
export { ContentPreview, substituteVariables } from "./app/content-preview";
|
|
33
35
|
export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-screens";
|
|
34
36
|
export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens";
|
|
35
37
|
export type { DashboardBodyProps, DashboardBodyProviderProps } from "./app/dashboard-body";
|