@siteable/core 0.1.1 → 0.1.2

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,121 @@
1
+ /**
2
+ * ISS-005 — prop normalization at the generation chokepoint.
3
+ *
4
+ * AI generators (Gemini, host /api/generate) return block props that violate the
5
+ * declared contracts in block-metadata (e.g. navbar `links` as [{label,href}]
6
+ * where the block declares string[]). Blocks render those props unguarded →
7
+ * React "Objects are not valid as a React child" crash. validateBlock was the
8
+ * hole: `{ ...defaultProps, ...raw.props }` never checked raw VALUES.
9
+ *
10
+ * This module coerces raw prop values against the SHAPE of the block's
11
+ * defaultProps — fully generic, zero block-name hardcoding.
12
+ *
13
+ * POLICY (review item 2): an empty raw array (e.g. `links: []`) is replaced by
14
+ * the DEFAULT array — DELIBERATE, not an oversight. At this layer the config
15
+ * is unvalidated AI/host output, so "empty" is indistinguishable from
16
+ * "generator emitted nothing meaningful"; rendering a navbar with zero links
17
+ * is a silent broken page, defaults are recoverable. Codified by:
18
+ * tests/prop-normalization.test.ts — 'empty raw array falls back to non-empty defaultProps'
19
+ * tests/validate-site-config.test.ts — footer columns 'links: []' entry case
20
+ * A user who genuinely wants zero links sets them via the editor AFTER this
21
+ * chokepoint (normalize only runs on generation validation, never on
22
+ * editor/store round-trips), so user intent is expressible elsewhere.
23
+ */
24
+
25
+ // For a string contract, an object value is best-effort rescued by taking the
26
+ // first string field among these display-ish keys (order = priority).
27
+ const STRING_FALLBACK_FIELDS = ['label', 'text', 'title', 'name'] as const
28
+
29
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
30
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
31
+ }
32
+
33
+ /** Coerce any value to a string for a `string`/`string[]` contract; null = unresolvable. */
34
+ function toStringOrNull(value: unknown): string | null {
35
+ if (typeof value === 'string') return value
36
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value)
37
+ if (typeof value === 'boolean') return String(value)
38
+ if (isPlainObject(value)) {
39
+ for (const field of STRING_FALLBACK_FIELDS) {
40
+ if (typeof value[field] === 'string') return value[field] as string
41
+ }
42
+ }
43
+ return null
44
+ }
45
+
46
+ function normalizeValue(raw: unknown, def: unknown): unknown {
47
+ // string contract
48
+ if (typeof def === 'string') {
49
+ return toStringOrNull(raw) ?? def
50
+ }
51
+
52
+ // number contract: keep finite numbers, accept numeric strings, else default
53
+ if (typeof def === 'number') {
54
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw
55
+ if (typeof raw === 'string' && raw.trim() !== '' && Number.isFinite(Number(raw))) return Number(raw)
56
+ return def
57
+ }
58
+
59
+ // boolean contract
60
+ if (typeof def === 'boolean') {
61
+ if (typeof raw === 'boolean') return raw
62
+ if (raw === 'true') return true
63
+ if (raw === 'false') return false
64
+ return def
65
+ }
66
+
67
+ // array contracts
68
+ if (Array.isArray(def)) {
69
+ if (!Array.isArray(raw)) return def
70
+ if (def.length === 0) return raw // no element shape declared → pass through
71
+
72
+ const elemDef = def[0]
73
+ const out: unknown[] = []
74
+ for (const entry of raw) {
75
+ if (typeof elemDef === 'string') {
76
+ const s = toStringOrNull(entry) // string entries kept, objects rescued, garbage dropped
77
+ if (s !== null) out.push(s)
78
+ } else if (isPlainObject(elemDef)) {
79
+ // array-of-objects: first default entry is the shape; non-object entries dropped
80
+ if (isPlainObject(entry)) out.push(normalizeBlockProps(entry, elemDef))
81
+ } else if (typeof entry === typeof elemDef) {
82
+ // primitive (number/boolean) elements: keep type-matching entries as-is
83
+ out.push(entry)
84
+ }
85
+ }
86
+ // all-garbage / emptied array → show defaults rather than render nothing
87
+ if (out.length === 0) return def
88
+ return out
89
+ }
90
+
91
+ // object contract: recurse per key
92
+ if (isPlainObject(def)) {
93
+ if (isPlainObject(raw)) return normalizeBlockProps(raw, def)
94
+ return def
95
+ }
96
+
97
+ // no contract declared for this default (null/undefined) → keep raw
98
+ return raw
99
+ }
100
+
101
+ /**
102
+ * Coerce `rawProps` values against the shape of `defaultProps`.
103
+ * - Keys missing from rawProps are NOT injected — the caller merges defaultProps.
104
+ * - Keys present in rawProps but absent from defaultProps are kept as-is
105
+ * (editor JSON patches may legitimately add keys; never discard data).
106
+ * - Non-object rawProps (null, string, array) normalize to {}.
107
+ */
108
+ export function normalizeBlockProps(
109
+ rawProps: unknown,
110
+ defaultProps: Record<string, unknown>,
111
+ ): Record<string, unknown> {
112
+ if (!isPlainObject(rawProps)) return {}
113
+
114
+ const out: Record<string, unknown> = { ...rawProps }
115
+ for (const [key, def] of Object.entries(defaultProps)) {
116
+ if (key in out) {
117
+ out[key] = normalizeValue(out[key], def)
118
+ }
119
+ }
120
+ return out
121
+ }