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}