@cosmicdrift/kumiko-renderer 0.182.0 → 0.183.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",
3
- "version": "0.182.0",
3
+ "version": "0.183.0",
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.0",
19
- "@cosmicdrift/kumiko-headless": "0.182.0",
18
+ "@cosmicdrift/kumiko-framework": "0.183.0",
19
+ "@cosmicdrift/kumiko-headless": "0.183.0",
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ }
@@ -87,6 +87,14 @@ 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",
92
+ "kumiko.contentEditor.bold": "Fett",
93
+ "kumiko.contentEditor.italic": "Kursiv",
94
+ "kumiko.contentEditor.heading1": "Überschrift 1",
95
+ "kumiko.contentEditor.heading2": "Überschrift 2",
96
+ "kumiko.contentEditor.bulletList": "Aufzählungsliste",
97
+ "kumiko.contentEditor.orderedList": "Nummerierte Liste",
90
98
 
91
99
  // Config-Cascade — Source-Badges + Cascade-Panel (ConfigCascadeView).
92
100
  "kumiko.config.source.user": "Mein Wert",
@@ -220,6 +228,14 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
220
228
  "kumiko.rowAction.failed": "Action failed",
221
229
 
222
230
  "kumiko.contentEditor.insertVariable": "Insert {name}",
231
+ "kumiko.contentEditor.preview": "Preview",
232
+ "kumiko.contentEditor.editMode": "Edit",
233
+ "kumiko.contentEditor.bold": "Bold",
234
+ "kumiko.contentEditor.italic": "Italic",
235
+ "kumiko.contentEditor.heading1": "Heading 1",
236
+ "kumiko.contentEditor.heading2": "Heading 2",
237
+ "kumiko.contentEditor.bulletList": "Bullet list",
238
+ "kumiko.contentEditor.orderedList": "Numbered list",
223
239
 
224
240
  "kumiko.config.source.user": "My value",
225
241
  "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";