@kahitsan/ksui 0.19.0 → 0.21.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 +1 -1
- package/src/components/base/BadgeSelect.test.tsx +88 -0
- package/src/components/base/BadgeSelect.tsx +229 -0
- package/src/components/composite/CustomRenderer.test.tsx +86 -0
- package/src/components/composite/CustomRenderer.tsx +114 -0
- package/src/components/composite/FileField.test.tsx +112 -0
- package/src/components/composite/FileField.tsx +301 -0
- package/src/components/composite/FlowRunner.test.tsx +127 -0
- package/src/components/composite/FlowRunner.tsx +348 -0
- package/src/index.ts +53 -0
- package/src/utils/flow.test.ts +41 -0
- package/src/utils/flow.ts +149 -0
- package/src/utils/renderers.test.ts +87 -0
- package/src/utils/renderers.ts +153 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// U8 — registry + consumes-validation unit tests (pure, no DOM).
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
clearRenderers,
|
|
5
|
+
getRenderer,
|
|
6
|
+
hasRenderer,
|
|
7
|
+
registerRenderer,
|
|
8
|
+
unregisterRenderer,
|
|
9
|
+
validateConsumes,
|
|
10
|
+
type RendererDefinition,
|
|
11
|
+
} from "./renderers";
|
|
12
|
+
|
|
13
|
+
const noopDef = (id: string): RendererDefinition => ({
|
|
14
|
+
id,
|
|
15
|
+
consumes: { name: "string" },
|
|
16
|
+
emits: ["edit"],
|
|
17
|
+
render: () => null as never,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => clearRenderers());
|
|
21
|
+
|
|
22
|
+
describe("renderer registry", () => {
|
|
23
|
+
it("registers and looks up by id", () => {
|
|
24
|
+
registerRenderer(noopDef("card"));
|
|
25
|
+
expect(hasRenderer("card")).toBe(true);
|
|
26
|
+
expect(getRenderer("card")?.id).toBe("card");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("re-registering the same id replaces (last write wins)", () => {
|
|
30
|
+
registerRenderer(noopDef("card"));
|
|
31
|
+
registerRenderer({ ...noopDef("card"), emits: ["pay"] });
|
|
32
|
+
expect(getRenderer("card")?.emits).toEqual(["pay"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("unknown id is undefined, not a throw", () => {
|
|
36
|
+
expect(getRenderer("missing")).toBeUndefined();
|
|
37
|
+
expect(hasRenderer("missing")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("unregister removes a renderer", () => {
|
|
41
|
+
registerRenderer(noopDef("card"));
|
|
42
|
+
unregisterRenderer("card");
|
|
43
|
+
expect(hasRenderer("card")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("validateConsumes", () => {
|
|
48
|
+
it("accepts a matching item", () => {
|
|
49
|
+
const r = validateConsumes(
|
|
50
|
+
{ customer: "string", progress: "number", packages: "array", balance: "currency?" },
|
|
51
|
+
{ customer: "Acme", progress: 3, packages: [], balance: 1200 },
|
|
52
|
+
);
|
|
53
|
+
expect(r.ok).toBe(true);
|
|
54
|
+
expect(r.errors).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("allows an optional field to be absent", () => {
|
|
58
|
+
const r = validateConsumes({ name: "string", balance: "currency?" }, { name: "x" });
|
|
59
|
+
expect(r.ok).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects a missing required field", () => {
|
|
63
|
+
const r = validateConsumes({ name: "string" }, {});
|
|
64
|
+
expect(r.ok).toBe(false);
|
|
65
|
+
expect(r.errors[0]).toContain("name");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects a type mismatch", () => {
|
|
69
|
+
const r = validateConsumes({ progress: "number" }, { progress: "nope" });
|
|
70
|
+
expect(r.ok).toBe(false);
|
|
71
|
+
expect(r.errors[0]).toContain("expected number");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects a non-object item", () => {
|
|
75
|
+
expect(validateConsumes({ a: "string" }, null).ok).toBe(false);
|
|
76
|
+
expect(validateConsumes({ a: "string" }, [] as unknown).ok).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ignores extra keys not in the schema", () => {
|
|
80
|
+
const r = validateConsumes({ name: "string" }, { name: "x", extra: 99 });
|
|
81
|
+
expect(r.ok).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("any matches anything", () => {
|
|
85
|
+
expect(validateConsumes({ blob: "any" }, { blob: { x: 1 } }).ok).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// U8 — schema-bound custom renderer registry (Vision §8).
|
|
2
|
+
//
|
|
3
|
+
// A custom renderer is a REAL, in-process SolidJS component built on ksui
|
|
4
|
+
// primitives — never an iframe/VM. The Vision is explicit that runtime isolation
|
|
5
|
+
// is deliberately omitted (theming fidelity + performance); what keeps a renderer
|
|
6
|
+
// safe is that it holds NO authority: it receives only its declared `consumes`
|
|
7
|
+
// data and can only fire its declared `emits` (flow triggers, §9). It cannot read
|
|
8
|
+
// or write data or call peers.
|
|
9
|
+
//
|
|
10
|
+
// WHY a build-time, in-process registry (no eval, no remote code): the security
|
|
11
|
+
// boundary for code-bearing UI is SUPPLY-CHAIN, not sandbox (Vision §8 / §14 —
|
|
12
|
+
// the VS Code model). The registry holds only components linked into the bundle
|
|
13
|
+
// at build time, so trust comes from the package supply chain (signing, scanning,
|
|
14
|
+
// verified publishers), not from caging a string of code at runtime. There is no
|
|
15
|
+
// `eval`, no dynamic `import(url)`, no Function constructor anywhere here.
|
|
16
|
+
|
|
17
|
+
import type { Component } from "solid-js";
|
|
18
|
+
|
|
19
|
+
/** The set of primitive shapes a renderer can declare it consumes per field. */
|
|
20
|
+
export type ConsumeKind =
|
|
21
|
+
| "string"
|
|
22
|
+
| "number"
|
|
23
|
+
| "boolean"
|
|
24
|
+
| "enum"
|
|
25
|
+
| "array"
|
|
26
|
+
| "object"
|
|
27
|
+
| "currency"
|
|
28
|
+
| "any";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The input-schema contract a renderer declares (§8 `consumes`): a map of
|
|
32
|
+
* prop name → expected kind. A trailing "?" on the kind marks the field optional
|
|
33
|
+
* (e.g. `balance: "currency?"` in the Vision example). The registry validates an
|
|
34
|
+
* `item` against this before rendering and falls back when it doesn't match.
|
|
35
|
+
*/
|
|
36
|
+
export type ConsumesSchema = Readonly<Record<string, `${ConsumeKind}` | `${ConsumeKind}?`>>;
|
|
37
|
+
|
|
38
|
+
/** The props a registered renderer receives. `emit` only fires declared triggers. */
|
|
39
|
+
export interface RendererProps {
|
|
40
|
+
/** The validated data object, shaped by the renderer's `consumes` schema. */
|
|
41
|
+
readonly item: Record<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Fire one of the renderer's declared `emits` interaction points. The host
|
|
44
|
+
* wires each emit name to a flow trigger (§9); ksui never executes authority.
|
|
45
|
+
* An emit NOT in the declared set is dropped with a warning (a renderer cannot
|
|
46
|
+
* forge an interaction it never declared).
|
|
47
|
+
*/
|
|
48
|
+
readonly emit: (event: string, payload?: unknown) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A registry entry: the schema-bound contract plus the in-process component. */
|
|
52
|
+
export interface RendererDefinition {
|
|
53
|
+
/** Stable id a spec binds to (e.g. "availment-card"). */
|
|
54
|
+
readonly id: string;
|
|
55
|
+
/** Input schema the component consumes (§8). */
|
|
56
|
+
readonly consumes: ConsumesSchema;
|
|
57
|
+
/** Interaction points the component may emit (§8) → flow triggers (§9). */
|
|
58
|
+
readonly emits: readonly string[];
|
|
59
|
+
/** The real, in-process SolidJS component. Built on ksui primitives. */
|
|
60
|
+
readonly render: Component<RendererProps>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Result of validating an item against a `consumes` schema. */
|
|
64
|
+
export interface ValidationResult {
|
|
65
|
+
readonly ok: boolean;
|
|
66
|
+
/** Human-readable reasons a prop failed (empty when ok). */
|
|
67
|
+
readonly errors: readonly string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// The registry (module-scoped, in-process). Populated at build time by the host
|
|
72
|
+
// calling `registerRenderer` during startup wiring — never from network input.
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const REGISTRY = new Map<string, RendererDefinition>();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register an in-process renderer. Re-registering the same id REPLACES the prior
|
|
79
|
+
* one (last write wins) so a host can override a kit default deterministically.
|
|
80
|
+
*/
|
|
81
|
+
export function registerRenderer(def: RendererDefinition): void {
|
|
82
|
+
REGISTRY.set(def.id, def);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Look up a registered renderer, or undefined when the id is unknown. */
|
|
86
|
+
export function getRenderer(id: string): RendererDefinition | undefined {
|
|
87
|
+
return REGISTRY.get(id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when an id is registered. */
|
|
91
|
+
export function hasRenderer(id: string): boolean {
|
|
92
|
+
return REGISTRY.has(id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Remove a renderer (used by tests + hot-reload). */
|
|
96
|
+
export function unregisterRenderer(id: string): void {
|
|
97
|
+
REGISTRY.delete(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Clear the whole registry (test isolation). */
|
|
101
|
+
export function clearRenderers(): void {
|
|
102
|
+
REGISTRY.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Validation — does an item satisfy a renderer's declared `consumes` schema?
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function kindMatches(kind: ConsumeKind, value: unknown): boolean {
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case "any":
|
|
112
|
+
return true;
|
|
113
|
+
case "string":
|
|
114
|
+
case "enum": // an enum value arrives as a string; the renderer maps it
|
|
115
|
+
return typeof value === "string";
|
|
116
|
+
case "number":
|
|
117
|
+
case "currency": // currency is a numeric amount in minor/major units
|
|
118
|
+
return typeof value === "number" && !Number.isNaN(value);
|
|
119
|
+
case "boolean":
|
|
120
|
+
return typeof value === "boolean";
|
|
121
|
+
case "array":
|
|
122
|
+
return Array.isArray(value);
|
|
123
|
+
case "object":
|
|
124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate `item` against a `consumes` schema. A field whose kind ends in "?" is
|
|
130
|
+
* optional — absent/undefined is allowed, but a present value still type-checks.
|
|
131
|
+
* Extra keys on `item` not named in the schema are IGNORED (the renderer simply
|
|
132
|
+
* won't read them); only declared fields are enforced.
|
|
133
|
+
*/
|
|
134
|
+
export function validateConsumes(consumes: ConsumesSchema, item: unknown): ValidationResult {
|
|
135
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
136
|
+
return { ok: false, errors: ["item must be a non-null object"] };
|
|
137
|
+
}
|
|
138
|
+
const record = item as Record<string, unknown>;
|
|
139
|
+
const errors: string[] = [];
|
|
140
|
+
for (const [key, spec] of Object.entries(consumes)) {
|
|
141
|
+
const optional = spec.endsWith("?");
|
|
142
|
+
const kind = (optional ? spec.slice(0, -1) : spec) as ConsumeKind;
|
|
143
|
+
const value = record[key];
|
|
144
|
+
if (value === undefined || value === null) {
|
|
145
|
+
if (!optional) errors.push(`missing required "${key}" (${kind})`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!kindMatches(kind, value)) {
|
|
149
|
+
errors.push(`"${key}" expected ${kind}, got ${typeof value}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { ok: errors.length === 0, errors };
|
|
153
|
+
}
|