krz/brand-bench
A dynamic brand documentation generator with Ollama integration.
clone: git clone https://gitbay.org/krz/brand-bench.git
main: src/lib/sanitize.ts · raw
1/**
2 * Defensive coercion helpers for AI-generated brand outputs.
3 * AI models sometimes return structured objects instead of plain strings/arrays;
4 * these helpers normalise any value to the expected type so React never receives
5 * an object where it expects a renderable child.
6 */
7import type { BrandOutputs } from '../types';
8import { TYPE_SCALE } from '../engine/generator';
9
10// ---------------------------------------------------------------------------
11// Primitive coercions
12// ---------------------------------------------------------------------------
13
14/** Coerce any value to a non-empty string. */
15export function str(v: unknown, fallback = ''): string {
16 if (typeof v === 'string') return v;
17 if (typeof v === 'number' || typeof v === 'boolean') return String(v);
18 if (v && typeof v === 'object') {
19 const o = v as Record<string, unknown>;
20 for (const k of ['text', 'content', 'value', 'description', 'name', 'label']) {
21 if (typeof o[k] === 'string' && o[k]) return o[k] as string;
22 }
23 const vals = Object.values(o).filter(x => typeof x === 'string') as string[];
24 if (vals.length) return vals.join(' — ');
25 }
26 return fallback;
27}
28
29/** Coerce any value to an array of non-empty strings. */
30export function strArr(v: unknown, fallback: string[] = []): string[] {
31 if (Array.isArray(v)) return v.map(x => str(x)).filter(Boolean);
32 if (typeof v === 'string' && v) return [v];
33 if (v && typeof v === 'object') return [str(v)].filter(Boolean);
34 return fallback;
35}
36
37function normalizeHex(hex: string): string {
38 const h = hex.trim().toLowerCase();
39 const clean = h.startsWith('#') ? h : `#${h}`;
40 return /^#[0-9a-f]{6}$/.test(clean) ? clean : '#888888';
41}
42
43// ---------------------------------------------------------------------------
44// Full output sanitiser — safe to call on any untrusted value
45// ---------------------------------------------------------------------------
46
47/**
48 * Recursively coerce every field of a brand output object to its expected type.
49 * Returns `null` if the input is clearly not a valid brand output at all.
50 */
51const DEFAULT_SWATCHES = [
52 { id: 's0', name: 'Background', hex: '#ffffff', role: 'background' },
53 { id: 's1', name: 'Surface', hex: '#f5f5f5', role: 'neutral' },
54 { id: 's2', name: 'Primary', hex: '#2563eb', role: 'primary' },
55 { id: 's3', name: 'Accent', hex: '#7c3aed', role: 'accent' },
56 { id: 's4', name: 'Text', hex: '#111111', role: 'text' },
57];
58
59export function sanitizeOutputs(raw: unknown): BrandOutputs | null {
60 if (!raw || typeof raw !== 'object') return null;
61 let r = raw as Record<string, unknown>;
62
63 // Unwrap single-key envelopes: {"brand_package": {...}} → {...}
64 const keys = Object.keys(r);
65 if (keys.length === 1 && r[keys[0]] && typeof r[keys[0]] === 'object') {
66 r = r[keys[0]] as Record<string, unknown>;
67 }
68
69 // Must have at least one recognisable content field
70 if (!r.overview && !r.positioning && !r.tone && !r.titles && !r.palette) return null;
71
72 const tone = (r.tone && typeof r.tone === 'object' ? r.tone : {}) as Record<string, unknown>;
73 const typo = (r.typography && typeof r.typography === 'object' ? r.typography : {}) as Record<string, unknown>;
74 const palette = (r.palette && typeof r.palette === 'object' ? r.palette : {}) as Record<string, unknown>;
75
76 // Accept swatches under palette.swatches or palette.colors
77 const rawSwatches = Array.isArray(palette.swatches) ? palette.swatches
78 : Array.isArray(palette.colors) ? palette.colors
79 : [];
80
81 const swatches = rawSwatches.length
82 ? rawSwatches.map((s: unknown, i: number) => {
83 const sw = (s && typeof s === 'object' ? s : {}) as Record<string, unknown>;
84 return {
85 id: str(sw.id, `s${i}`),
86 name: str(sw.name, 'Color'),
87 hex: normalizeHex(str(sw.hex ?? sw.color ?? sw.value, '#888888')),
88 role: str(sw.role ?? sw.type, 'accent'),
89 };
90 })
91 : DEFAULT_SWATCHES;
92
93 const visualDirections = Array.isArray(r.visualDirections)
94 ? r.visualDirections.map((v: unknown, i: number) => {
95 const d = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
96 return {
97 id: str(d.id, `v${i + 1}`),
98 name: str(d.name, `Direction ${i + 1}`),
99 description: str(d.description, ''),
100 palette: str(d.palette, ''),
101 typography: str(d.typography, ''),
102 references: str(d.references, ''),
103 };
104 })
105 : [];
106
107 const logoConcepts = Array.isArray(r.logoConcepts)
108 ? r.logoConcepts.map((v: unknown, i: number) => {
109 const c = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
110 return {
111 id: str(c.id, `l${i + 1}`),
112 title: str(c.title, `Concept ${i + 1}`),
113 concept: str(c.concept, ''),
114 mark: str(c.mark, ''),
115 execution: str(c.execution, ''),
116 };
117 })
118 : [];
119
120 const usageExamples = Array.isArray(r.usageExamples)
121 ? r.usageExamples.map((v: unknown) => {
122 const e = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>;
123 return {
124 context: str(e.context, 'Example'),
125 text: str(e.text, ''),
126 };
127 })
128 : [];
129
130 // Preserve an existing valid scale (already-saved data) or fall back to default
131 const existingScale = Array.isArray(typo.scale) ? typo.scale : TYPE_SCALE;
132
133 return {
134 overview: str(r.overview, ''),
135 positioning: str(r.positioning, ''),
136 tone: {
137 attributes: strArr(tone.attributes, []),
138 voiceNotes: str(tone.voiceNotes, ''),
139 avoidList: strArr(tone.avoidList, []),
140 examplePhrases: strArr(tone.examplePhrases, []),
141 },
142 titles: strArr(r.titles, []),
143 subtitles: strArr(r.subtitles, []),
144 taglines: strArr(r.taglines, []),
145 palette: { swatches },
146 typography: {
147 primary: str(typo.primary, 'Inter'),
148 secondary: str(typo.secondary, 'Inter'),
149 mono: str(typo.mono, 'JetBrains Mono'),
150 pairNote: str(typo.pairNote, ''),
151 scale: existingScale,
152 },
153 visualDirections,
154 logoConcepts,
155 usageExamples,
156 constraints: strArr(r.constraints, []),
157 };
158}