@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.
@@ -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
+ }