brand-intent 0.1.8 → 0.3.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/README.md +0 -1
- package/cli/index.mjs +3 -20
- package/package.json +15 -5
- package/parser/README.md +36 -0
- package/parser/brandParser.ts +551 -0
- package/parser/compositionParser.ts +556 -0
- package/parser/formatParser.ts +283 -0
- package/parser/identityParser.ts +438 -0
- package/parser/index.ts +20 -0
- package/parser/purposeParser.ts +596 -0
- package/parser/types.ts +17 -0
- package/skills/brand-intent.md +25 -12
- /package/{compositions → examples/krume/compositions}/editorial.composition +0 -0
- /package/{compositions → examples/krume/compositions}/simple-stack.composition +0 -0
- /package/{formats → examples/krume/formats}/instagram-4-5-feed-portrait.format +0 -0
- /package/{brands → examples}/krume/krume.brand +0 -0
- /package/{brands → examples}/krume/krume.identity +0 -0
- /package/{purposes → examples/krume/purposes}/daily-bread.purpose +0 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for .purpose definition files.
|
|
3
|
+
*
|
|
4
|
+
* Strict, line-based format. Every line must match a known pattern or be blank.
|
|
5
|
+
* Throws on parse errors with file name and line number.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SlotId } from './types';
|
|
9
|
+
|
|
10
|
+
// ── Parsed types ──────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type Density = 'light' | 'medium' | 'full';
|
|
13
|
+
|
|
14
|
+
export interface ParsedPurpose {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
preferredCompositions: string[];
|
|
19
|
+
slots: ParsedPurposeSlot[];
|
|
20
|
+
/** Content density — how much content this purpose typically carries. */
|
|
21
|
+
density?: Density;
|
|
22
|
+
/** Scope — narrows the composed upstream (audience, pillars). */
|
|
23
|
+
scope?: { audience?: string[]; pillars?: string };
|
|
24
|
+
/** Context — purpose-specific slot guidance (free-form text). */
|
|
25
|
+
context?: string;
|
|
26
|
+
/** Voice hints — purpose-specific writing rules for AI text generation. */
|
|
27
|
+
voice?: Record<string, string>;
|
|
28
|
+
/** Palette strategy: 'dynamic' (default), a theme name, or 'rotate T1, T2'. */
|
|
29
|
+
palette?: string;
|
|
30
|
+
/** Default logo asset ID — pre-selects this logo on page creation. */
|
|
31
|
+
defaultLogo?: string;
|
|
32
|
+
/** Default photo asset ID — pre-selects this photo on page creation. */
|
|
33
|
+
defaultPhoto?: string;
|
|
34
|
+
/** Default video asset ID — pre-selects this video on page creation. */
|
|
35
|
+
defaultVideo?: string;
|
|
36
|
+
/** When false, hides Take Photo / Upload buttons for this purpose. Default: true. */
|
|
37
|
+
camera?: boolean;
|
|
38
|
+
/** Default icon (Phosphor PascalCase name, e.g. "ArrowRight"). */
|
|
39
|
+
defaultIcon?: string;
|
|
40
|
+
/** Default icon weight. */
|
|
41
|
+
defaultIconWeight?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ParsedPurposeSlot {
|
|
45
|
+
id: SlotId;
|
|
46
|
+
label: string;
|
|
47
|
+
/** Sample text variants for this slot. Picked randomly on page creation. */
|
|
48
|
+
samples: string[];
|
|
49
|
+
maxLength: number;
|
|
50
|
+
/** 'text' (default) or 'list'. */
|
|
51
|
+
slotType?: 'text' | 'list';
|
|
52
|
+
/** Max number of items (list slots only). */
|
|
53
|
+
maxItems?: number;
|
|
54
|
+
/** Whether per-item icons are supported (list slots only). */
|
|
55
|
+
icons?: boolean;
|
|
56
|
+
/** Icon-only list — icons fill available space, text is suppressed. */
|
|
57
|
+
iconOnly?: boolean;
|
|
58
|
+
/** Space between icon and text, in em (list slots only). Default: 0.4 */
|
|
59
|
+
iconGap?: number;
|
|
60
|
+
/** Gap between list items in em. Default: 0.5 */
|
|
61
|
+
itemGap?: number;
|
|
62
|
+
/** List layout direction: 'vertical' (default) or 'horizontal'. */
|
|
63
|
+
listDirection?: 'vertical' | 'horizontal';
|
|
64
|
+
typography: {
|
|
65
|
+
fontFamily: string;
|
|
66
|
+
fontWeight: number;
|
|
67
|
+
fontSizeBase: number;
|
|
68
|
+
lineHeight: number;
|
|
69
|
+
letterSpacing?: number;
|
|
70
|
+
opticalSize?: number;
|
|
71
|
+
};
|
|
72
|
+
/** Name of the brand typography style this slot references (e.g. 'headline'). */
|
|
73
|
+
typographyRef?: string;
|
|
74
|
+
/** Brand color token this slot uses (e.g. 'text-primary', 'text-tertiary'). */
|
|
75
|
+
colorRef?: string;
|
|
76
|
+
/** Explicit per-slot overrides on top of typographyRef. */
|
|
77
|
+
typographyOverrides?: Partial<{
|
|
78
|
+
weight: number;
|
|
79
|
+
size: number;
|
|
80
|
+
opsz: number;
|
|
81
|
+
lineHeight: number;
|
|
82
|
+
letterSpacing: number;
|
|
83
|
+
italic: boolean;
|
|
84
|
+
uppercase: boolean;
|
|
85
|
+
autofitMin: number;
|
|
86
|
+
autofitMax: number;
|
|
87
|
+
textAlign: 'left' | 'center' | 'right' | 'justify';
|
|
88
|
+
hyphenate: boolean;
|
|
89
|
+
}>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const VALID_SLOTS = new Set<string>(['primary', 'secondary', 'detail', 'meta', 'cta', 'label']);
|
|
95
|
+
|
|
96
|
+
/** Map short font family names to CSS font-family strings. */
|
|
97
|
+
function resolveFontFamily(name: string): string {
|
|
98
|
+
return `'${name}', sans-serif`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Main parser ───────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export function parsePurposeFile(content: string, fileName: string): ParsedPurpose {
|
|
104
|
+
const lines = content.split('\n');
|
|
105
|
+
|
|
106
|
+
let id = '';
|
|
107
|
+
let name = '';
|
|
108
|
+
let description = '';
|
|
109
|
+
let compositions: string[] | null = null;
|
|
110
|
+
let scope: { audience?: string[]; pillars?: string } | undefined;
|
|
111
|
+
let context: string | undefined;
|
|
112
|
+
let inScopeBlock = false;
|
|
113
|
+
let inContextBlock = false;
|
|
114
|
+
let inVoiceBlock = false;
|
|
115
|
+
const contextLines: string[] = [];
|
|
116
|
+
let voice: Record<string, string> | undefined;
|
|
117
|
+
let palette: string | undefined;
|
|
118
|
+
let density: Density | undefined;
|
|
119
|
+
let defaultLogo: string | undefined;
|
|
120
|
+
let defaultPhoto: string | undefined;
|
|
121
|
+
let defaultVideo: string | undefined;
|
|
122
|
+
let camera: boolean | undefined;
|
|
123
|
+
let defaultIcon: string | undefined;
|
|
124
|
+
let defaultIconWeight: string | undefined;
|
|
125
|
+
const slots: ParsedPurposeSlot[] = [];
|
|
126
|
+
// For backward compat: track first # line text for fallback id
|
|
127
|
+
let firstHashText = '';
|
|
128
|
+
|
|
129
|
+
// Current slot being built (null when not inside a slot block)
|
|
130
|
+
let currentSlot: Partial<ParsedPurposeSlot> & { id?: SlotId } | null = null;
|
|
131
|
+
// Whether we're currently inside an override: block (4-space indent)
|
|
132
|
+
let inOverrideBlock = false;
|
|
133
|
+
// Whether we're currently inside a type: list sub-block (4-space indent)
|
|
134
|
+
let inListBlock = false;
|
|
135
|
+
// Multi-line samples state: 'slot' (2-space context) or 'list' (4-space context)
|
|
136
|
+
let inMultiLineSamples: 'slot' | 'list' | false = false;
|
|
137
|
+
|
|
138
|
+
function finalizeSlot(lineNum: number) {
|
|
139
|
+
if (!currentSlot) return;
|
|
140
|
+
const s = currentSlot;
|
|
141
|
+
if (!s.id) throw new Error(`${fileName}:${lineNum}: slot missing id`);
|
|
142
|
+
if (!s.label) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing label`);
|
|
143
|
+
if ((!s.samples || s.samples.length === 0) && !s.iconOnly) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing samples`);
|
|
144
|
+
if (!s.maxLength) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing maxLength`);
|
|
145
|
+
const t = s.typography as ParsedPurposeSlot['typography'];
|
|
146
|
+
if (!s.typographyRef) {
|
|
147
|
+
// Legacy direct font: path — all fields required
|
|
148
|
+
if (!t?.fontFamily) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" must have either "font:" or "typography:" (neither found)`);
|
|
149
|
+
if (!t.fontSizeBase) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing size`);
|
|
150
|
+
if (!t.lineHeight) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing lineHeight`);
|
|
151
|
+
}
|
|
152
|
+
inOverrideBlock = false;
|
|
153
|
+
inListBlock = false;
|
|
154
|
+
inMultiLineSamples = false;
|
|
155
|
+
slots.push(s as ParsedPurposeSlot);
|
|
156
|
+
currentSlot = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < lines.length; i++) {
|
|
160
|
+
const lineNum = i + 1;
|
|
161
|
+
const raw = lines[i];
|
|
162
|
+
const line = raw.trim();
|
|
163
|
+
|
|
164
|
+
// Blank line
|
|
165
|
+
if (line === '') continue;
|
|
166
|
+
|
|
167
|
+
// Comment — ALL # lines go to description now
|
|
168
|
+
if (line.startsWith('#')) {
|
|
169
|
+
const text = line.slice(1).trim();
|
|
170
|
+
if (!firstHashText) firstHashText = text;
|
|
171
|
+
description = description ? `${description} ${text}` : text;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// id: field
|
|
176
|
+
if (line.startsWith('id:')) {
|
|
177
|
+
id = line.slice(3).trim();
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// name: field
|
|
182
|
+
if (line.startsWith('name:')) {
|
|
183
|
+
name = line.slice(5).trim();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// scope block header
|
|
188
|
+
if (line === 'scope' && !raw.startsWith(' ')) {
|
|
189
|
+
finalizeSlot(lineNum);
|
|
190
|
+
inScopeBlock = true;
|
|
191
|
+
inContextBlock = false;
|
|
192
|
+
inVoiceBlock = false;
|
|
193
|
+
scope = scope ?? {};
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// context block header
|
|
198
|
+
if (line === 'context' && !raw.startsWith(' ')) {
|
|
199
|
+
finalizeSlot(lineNum);
|
|
200
|
+
inContextBlock = true;
|
|
201
|
+
inScopeBlock = false;
|
|
202
|
+
inVoiceBlock = false;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// voice block header
|
|
207
|
+
if (line === 'voice' && !raw.startsWith(' ')) {
|
|
208
|
+
finalizeSlot(lineNum);
|
|
209
|
+
inVoiceBlock = true;
|
|
210
|
+
inScopeBlock = false;
|
|
211
|
+
inContextBlock = false;
|
|
212
|
+
voice = voice ?? {};
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// scope block properties (2-space indent)
|
|
217
|
+
if (inScopeBlock && raw.startsWith(' ') && !raw.startsWith(' ')) {
|
|
218
|
+
if (line.startsWith('audience:')) {
|
|
219
|
+
scope = scope ?? {};
|
|
220
|
+
scope.audience = line.slice(9).trim().split(/[\s,]+/).filter(s => s.length > 0);
|
|
221
|
+
} else if (line.startsWith('pillars:')) {
|
|
222
|
+
scope = scope ?? {};
|
|
223
|
+
scope.pillars = line.slice(8).trim();
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
} else if (inScopeBlock && !raw.startsWith(' ')) {
|
|
227
|
+
inScopeBlock = false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// voice block properties (2-space indent, key: value)
|
|
231
|
+
if (inVoiceBlock && raw.startsWith(' ') && !raw.startsWith(' ')) {
|
|
232
|
+
const colonIdx = line.indexOf(':');
|
|
233
|
+
if (colonIdx !== -1) {
|
|
234
|
+
voice = voice ?? {};
|
|
235
|
+
voice[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim();
|
|
236
|
+
}
|
|
237
|
+
continue;
|
|
238
|
+
} else if (inVoiceBlock && !raw.startsWith(' ')) {
|
|
239
|
+
inVoiceBlock = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// context block lines (2-space indent, free-form text)
|
|
243
|
+
if (inContextBlock && raw.startsWith(' ')) {
|
|
244
|
+
contextLines.push(line);
|
|
245
|
+
continue;
|
|
246
|
+
} else if (inContextBlock && !raw.startsWith(' ')) {
|
|
247
|
+
inContextBlock = false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (line.startsWith('compositions:')) {
|
|
251
|
+
const colonIdx = line.indexOf(':');
|
|
252
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
253
|
+
compositions = value.split(/[\s,]+/).map(s => s.trim()).filter(s => s.length > 0);
|
|
254
|
+
if (compositions.length === 0) throw new Error(`${fileName}:${lineNum}: empty compositions list`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Palette strategy
|
|
259
|
+
if (line.startsWith('palette:')) {
|
|
260
|
+
palette = line.slice(8).trim();
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Content density
|
|
265
|
+
if (line.startsWith('density:')) {
|
|
266
|
+
const value = line.slice(8).trim() as Density;
|
|
267
|
+
if (!['light', 'medium', 'full'].includes(value)) throw new Error(`${fileName}:${lineNum}: invalid density "${value}" — expected light, medium, or full`);
|
|
268
|
+
density = value;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Default logo asset ID
|
|
273
|
+
if (line.startsWith('logo:')) {
|
|
274
|
+
defaultLogo = line.slice(5).trim();
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Default photo asset ID
|
|
279
|
+
if (line.startsWith('photo:')) {
|
|
280
|
+
defaultPhoto = line.slice(6).trim();
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Default video asset ID
|
|
285
|
+
if (line.startsWith('video:')) {
|
|
286
|
+
defaultVideo = line.slice(6).trim();
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Camera control
|
|
291
|
+
if (line.startsWith('camera:')) {
|
|
292
|
+
camera = line.slice(7).trim() !== 'false';
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Default icon: "defaultIcon: warning-circle" or "defaultIcon: warning-circle duotone"
|
|
297
|
+
// Accepts kebab-case (natural for file format) and converts to PascalCase for Phosphor lookup.
|
|
298
|
+
if (line.startsWith('defaultIcon:')) {
|
|
299
|
+
const parts = line.slice(12).trim().split(/\s+/);
|
|
300
|
+
// Convert kebab-case to PascalCase: "warning-circle" → "WarningCircle"
|
|
301
|
+
defaultIcon = parts[0].split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
302
|
+
if (parts[1]) defaultIconWeight = parts[1];
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Backward compat: "icon: warning-circle" (deprecated — use defaultIcon:)
|
|
307
|
+
if (line.startsWith('icon:')) {
|
|
308
|
+
console.warn(`${fileName}:${lineNum}: deprecated "icon:" — use "defaultIcon:" instead`);
|
|
309
|
+
const parts = line.slice(5).trim().split(/\s+/);
|
|
310
|
+
// Convert kebab-case to PascalCase: "warning-circle" → "WarningCircle"
|
|
311
|
+
defaultIcon = parts[0].split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
312
|
+
if (parts[1]) defaultIconWeight = parts[1];
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Slot start
|
|
317
|
+
if (line.startsWith('slot ')) {
|
|
318
|
+
finalizeSlot(lineNum);
|
|
319
|
+
const slotId = line.slice(5).trim();
|
|
320
|
+
if (!VALID_SLOTS.has(slotId)) throw new Error(`${fileName}:${lineNum}: unknown slot "${slotId}"`);
|
|
321
|
+
currentSlot = { id: slotId as SlotId, samples: [], typography: {} as ParsedPurposeSlot['typography'] };
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Multi-line samples: continuation lines after a bare `samples:` line
|
|
326
|
+
if (inMultiLineSamples && currentSlot) {
|
|
327
|
+
const baseIndent = inMultiLineSamples === 'list' ? 6 : 4;
|
|
328
|
+
const contIndent = baseIndent + 2;
|
|
329
|
+
const isVariantLine = raw.length >= baseIndent && raw.slice(0, baseIndent) === ' '.repeat(baseIndent) && raw[baseIndent] === '-' && raw[baseIndent + 1] === ' ';
|
|
330
|
+
const isContLine = raw.length > contIndent && raw.slice(0, contIndent) === ' '.repeat(contIndent) && raw[contIndent] !== '-';
|
|
331
|
+
|
|
332
|
+
if (isVariantLine) {
|
|
333
|
+
// "- text" starts a new sample variant
|
|
334
|
+
const text = raw.slice(baseIndent + 2).trim();
|
|
335
|
+
if (!text) throw new Error(`${fileName}:${lineNum}: empty sample variant`);
|
|
336
|
+
currentSlot.samples!.push(text);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (isContLine && currentSlot.samples!.length > 0) {
|
|
340
|
+
// Continuation line — append to the last sample variant with \n
|
|
341
|
+
const text = raw.slice(contIndent).trim();
|
|
342
|
+
if (!text) throw new Error(`${fileName}:${lineNum}: empty sample continuation line`);
|
|
343
|
+
currentSlot.samples![currentSlot.samples!.length - 1] += '\n' + text;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// Not a sample line — end multi-line mode and fall through
|
|
347
|
+
inMultiLineSamples = false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 4-space indent: sub-block properties (override: or type: list)
|
|
351
|
+
if (raw.startsWith(' ') && currentSlot && (inOverrideBlock || inListBlock)) {
|
|
352
|
+
const prop = line;
|
|
353
|
+
if (inOverrideBlock) {
|
|
354
|
+
if (!currentSlot.typographyRef) throw new Error(`${fileName}:${lineNum}: "override:" block requires "typography:" on the slot`);
|
|
355
|
+
if (!currentSlot.typographyOverrides) currentSlot.typographyOverrides = {};
|
|
356
|
+
if (prop.startsWith('weight:')) {
|
|
357
|
+
const n = parseInt(prop.slice(7).trim(), 10);
|
|
358
|
+
if (isNaN(n) || n < 1 || n > 999) throw new Error(`${fileName}:${lineNum}: invalid weight "${prop.slice(7).trim()}"`);
|
|
359
|
+
currentSlot.typographyOverrides.weight = n;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (prop.startsWith('size:')) {
|
|
363
|
+
const n = parseFloat(prop.slice(5).trim());
|
|
364
|
+
if (isNaN(n) || n <= 0) throw new Error(`${fileName}:${lineNum}: invalid size "${prop.slice(5).trim()}"`);
|
|
365
|
+
currentSlot.typographyOverrides.size = n;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (prop.startsWith('lineHeight:')) {
|
|
369
|
+
const n = parseFloat(prop.slice(11).trim());
|
|
370
|
+
if (isNaN(n) || n <= 0) throw new Error(`${fileName}:${lineNum}: invalid lineHeight "${prop.slice(11).trim()}"`);
|
|
371
|
+
currentSlot.typographyOverrides.lineHeight = n;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (prop.startsWith('letterSpacing:')) {
|
|
375
|
+
const n = parseFloat(prop.slice(14).trim());
|
|
376
|
+
if (isNaN(n)) throw new Error(`${fileName}:${lineNum}: invalid letterSpacing "${prop.slice(14).trim()}"`);
|
|
377
|
+
currentSlot.typographyOverrides.letterSpacing = n;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (prop.startsWith('opsz:')) {
|
|
381
|
+
const n = parseFloat(prop.slice(5).trim());
|
|
382
|
+
if (isNaN(n)) throw new Error(`${fileName}:${lineNum}: invalid opsz "${prop.slice(5).trim()}"`);
|
|
383
|
+
currentSlot.typographyOverrides.opsz = n;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (prop === 'italic' || prop === 'italic: true') { currentSlot.typographyOverrides.italic = true; continue; }
|
|
387
|
+
if (prop === 'italic: false') { currentSlot.typographyOverrides.italic = false; continue; }
|
|
388
|
+
if (prop === 'uppercase' || prop === 'uppercase: true') { currentSlot.typographyOverrides.uppercase = true; continue; }
|
|
389
|
+
if (prop === 'uppercase: false') { currentSlot.typographyOverrides.uppercase = false; continue; }
|
|
390
|
+
if (prop === 'hyphenate' || prop === 'hyphenate: true') { currentSlot.typographyOverrides.hyphenate = true; continue; }
|
|
391
|
+
if (prop === 'hyphenate: false') { currentSlot.typographyOverrides.hyphenate = false; continue; }
|
|
392
|
+
if (prop === 'autofit: none' || prop === 'autofit:none') {
|
|
393
|
+
currentSlot.typographyOverrides.autofitMin = -1;
|
|
394
|
+
currentSlot.typographyOverrides.autofitMax = -1;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (prop.startsWith('autofit-min:')) {
|
|
398
|
+
const n = parseFloat(prop.slice(12).trim());
|
|
399
|
+
if (isNaN(n)) throw new Error(`${fileName}:${lineNum}: invalid autofit-min "${prop.slice(12).trim()}"`);
|
|
400
|
+
currentSlot.typographyOverrides.autofitMin = n;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (prop.startsWith('autofit-max:')) {
|
|
404
|
+
const n = parseFloat(prop.slice(12).trim());
|
|
405
|
+
if (isNaN(n)) throw new Error(`${fileName}:${lineNum}: invalid autofit-max "${prop.slice(12).trim()}"`);
|
|
406
|
+
currentSlot.typographyOverrides.autofitMax = n;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (prop.startsWith('textAlign:')) {
|
|
410
|
+
const v = prop.slice(10).trim();
|
|
411
|
+
if (v !== 'left' && v !== 'center' && v !== 'right' && v !== 'justify') {
|
|
412
|
+
throw new Error(`${fileName}:${lineNum}: invalid textAlign "${v}" — expected left, center, right, or justify`);
|
|
413
|
+
}
|
|
414
|
+
currentSlot.typographyOverrides.textAlign = v;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
throw new Error(`${fileName}:${lineNum}: unknown override property "${prop}"`);
|
|
418
|
+
}
|
|
419
|
+
if (inListBlock) {
|
|
420
|
+
if (prop.startsWith('maxItems:')) {
|
|
421
|
+
const n = parseInt(prop.slice(9).trim(), 10);
|
|
422
|
+
if (isNaN(n) || n <= 0) throw new Error(`${fileName}:${lineNum}: invalid maxItems "${prop.slice(9).trim()}"`);
|
|
423
|
+
currentSlot.maxItems = n;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (prop.startsWith('maxLength:')) {
|
|
427
|
+
const n = parseInt(prop.slice(10).trim(), 10);
|
|
428
|
+
if (isNaN(n) || n <= 0) throw new Error(`${fileName}:${lineNum}: invalid maxLength "${prop.slice(10).trim()}"`);
|
|
429
|
+
currentSlot.maxLength = n;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (prop.startsWith('samples:')) {
|
|
433
|
+
const raw = prop.slice(8).trim();
|
|
434
|
+
if (!raw) {
|
|
435
|
+
// Bare `samples:` — enter multi-line mode
|
|
436
|
+
currentSlot.samples = [];
|
|
437
|
+
inMultiLineSamples = 'list';
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
currentSlot.samples = raw.split('|').map(s => s.trim().replace(/\\n/g, '\n'));
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (prop === 'icons: true' || prop === 'icons:true') { currentSlot.icons = true; continue; }
|
|
444
|
+
if (prop === 'icons: false' || prop === 'icons:false') { currentSlot.icons = false; continue; }
|
|
445
|
+
if (prop === 'iconOnly: true' || prop === 'iconOnly:true') { currentSlot.iconOnly = true; currentSlot.icons = true; continue; }
|
|
446
|
+
if (prop.startsWith('iconGap:')) {
|
|
447
|
+
const n = parseFloat(prop.slice(8).trim());
|
|
448
|
+
if (isNaN(n) || n < 0) throw new Error(`${fileName}:${lineNum}: invalid iconGap "${prop.slice(8).trim()}"`);
|
|
449
|
+
currentSlot.iconGap = n;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (prop.startsWith('itemGap:')) {
|
|
453
|
+
const n = parseFloat(prop.slice(8).trim());
|
|
454
|
+
if (isNaN(n) || n < 0) throw new Error(`${fileName}:${lineNum}: invalid itemGap "${prop.slice(8).trim()}"`);
|
|
455
|
+
currentSlot.itemGap = n;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (prop.startsWith('direction:')) {
|
|
459
|
+
const d = prop.slice(10).trim();
|
|
460
|
+
if (d !== 'vertical' && d !== 'horizontal') throw new Error(`${fileName}:${lineNum}: invalid list direction "${d}" — must be "vertical" or "horizontal"`);
|
|
461
|
+
currentSlot.listDirection = d;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
throw new Error(`${fileName}:${lineNum}: unknown list property "${prop}"`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 2-space indent: slot properties (must be inside a slot block)
|
|
469
|
+
if (raw.startsWith(' ') && currentSlot) {
|
|
470
|
+
const prop = line;
|
|
471
|
+
|
|
472
|
+
if (prop === 'override:') {
|
|
473
|
+
if (!currentSlot.typographyRef) throw new Error(`${fileName}:${lineNum}: "override:" block requires "typography:" on the slot`);
|
|
474
|
+
inOverrideBlock = true;
|
|
475
|
+
inListBlock = false;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (prop.startsWith('type:')) {
|
|
479
|
+
const t = prop.slice(5).trim();
|
|
480
|
+
if (t !== 'text' && t !== 'list') throw new Error(`${fileName}:${lineNum}: invalid slot type "${t}" — must be "text" or "list"`);
|
|
481
|
+
currentSlot.slotType = t;
|
|
482
|
+
inListBlock = t === 'list';
|
|
483
|
+
inOverrideBlock = false;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// Any other 2-space line ends sub-blocks
|
|
487
|
+
inOverrideBlock = false;
|
|
488
|
+
inListBlock = false;
|
|
489
|
+
inMultiLineSamples = false;
|
|
490
|
+
|
|
491
|
+
if (prop.startsWith('label:')) {
|
|
492
|
+
currentSlot.label = prop.slice(6).trim();
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (prop.startsWith('samples:')) {
|
|
496
|
+
const raw = prop.slice(8).trim();
|
|
497
|
+
if (!raw) {
|
|
498
|
+
// Bare `samples:` — enter multi-line mode
|
|
499
|
+
currentSlot.samples = [];
|
|
500
|
+
inMultiLineSamples = 'slot';
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
currentSlot.samples = raw.split('|').map(s => s.trim().replace(/\\n/g, '\n'));
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (prop.startsWith('maxLength:')) {
|
|
507
|
+
const n = parseInt(prop.slice(10).trim(), 10);
|
|
508
|
+
if (isNaN(n) || n <= 0) throw new Error(`${fileName}:${lineNum}: invalid maxLength "${prop.slice(10).trim()}"`);
|
|
509
|
+
currentSlot.maxLength = n;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (prop.startsWith('typography:')) {
|
|
513
|
+
currentSlot.typographyRef = prop.slice(11).trim();
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (prop.startsWith('color:')) {
|
|
517
|
+
currentSlot.colorRef = prop.slice(6).trim();
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (prop.startsWith('font:')) {
|
|
521
|
+
// "Die Grotesk 800" → family="Die Grotesk", weight=800 (legacy direct path)
|
|
522
|
+
const value = prop.slice(5).trim();
|
|
523
|
+
const lastSpace = value.lastIndexOf(' ');
|
|
524
|
+
if (lastSpace === -1) throw new Error(`${fileName}:${lineNum}: font must be "FAMILY WEIGHT" (e.g. "Die Grotesk 800")`);
|
|
525
|
+
const familyName = value.slice(0, lastSpace);
|
|
526
|
+
const weight = parseInt(value.slice(lastSpace + 1), 10);
|
|
527
|
+
if (isNaN(weight) || weight < 1 || weight > 999) throw new Error(`${fileName}:${lineNum}: invalid font weight "${value.slice(lastSpace + 1)}"`);
|
|
528
|
+
currentSlot.typography!.fontFamily = resolveFontFamily(familyName);
|
|
529
|
+
currentSlot.typography!.fontWeight = weight;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (prop.startsWith('size:')) {
|
|
533
|
+
const n = parseFloat(prop.slice(5).trim());
|
|
534
|
+
if (isNaN(n) || n <= 0) throw new Error(`${fileName}:${lineNum}: invalid size "${prop.slice(5).trim()}"`);
|
|
535
|
+
currentSlot.typography!.fontSizeBase = n;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (prop.startsWith('align:')) {
|
|
539
|
+
// Ignored — textAlign is a layout concern, defined in .composition files
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (prop.startsWith('lineHeight:')) {
|
|
543
|
+
const n = parseFloat(prop.slice(11).trim());
|
|
544
|
+
if (isNaN(n) || n <= 0) throw new Error(`${fileName}:${lineNum}: invalid lineHeight "${prop.slice(11).trim()}"`);
|
|
545
|
+
currentSlot.typography!.lineHeight = n;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (prop.startsWith('letterSpacing:')) {
|
|
549
|
+
const n = parseFloat(prop.slice(14).trim());
|
|
550
|
+
if (isNaN(n)) throw new Error(`${fileName}:${lineNum}: invalid letterSpacing "${prop.slice(14).trim()}"`);
|
|
551
|
+
currentSlot.typography!.letterSpacing = n;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (prop.startsWith('opsz:')) {
|
|
555
|
+
const n = parseFloat(prop.slice(5).trim());
|
|
556
|
+
if (isNaN(n)) throw new Error(`${fileName}:${lineNum}: invalid opsz "${prop.slice(5).trim()}"`);
|
|
557
|
+
currentSlot.typography!.opticalSize = n;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
throw new Error(`${fileName}:${lineNum}: unknown slot property "${prop}"`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Unknown line
|
|
565
|
+
throw new Error(`${fileName}:${lineNum}: unexpected "${line}"`);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Finalize last slot
|
|
569
|
+
finalizeSlot(lines.length);
|
|
570
|
+
|
|
571
|
+
// Backward compat: if no id: line but we have a first # line, use it as id
|
|
572
|
+
if (!id) {
|
|
573
|
+
if (firstHashText) {
|
|
574
|
+
console.warn(`${fileName}: deprecated — add "id: ${firstHashText}" line (first # line used as fallback id)`);
|
|
575
|
+
id = firstHashText;
|
|
576
|
+
} else {
|
|
577
|
+
throw new Error(`${fileName}: missing purpose id (add "id:" line)`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Validate required fields
|
|
582
|
+
if (!compositions) throw new Error(`${fileName}: missing "compositions:" line`);
|
|
583
|
+
if (slots.length === 0) throw new Error(`${fileName}: no slots defined`);
|
|
584
|
+
|
|
585
|
+
// Derive display name from id if not provided: capitalize first letter of each word
|
|
586
|
+
if (!name) {
|
|
587
|
+
name = id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Finalize context from block lines (if any)
|
|
591
|
+
if (contextLines.length > 0 && !context) {
|
|
592
|
+
context = contextLines.join('\n').trim();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, defaultIcon, defaultIconWeight };
|
|
596
|
+
}
|
package/parser/types.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Brand Intent — shared types for parsers
|
|
2
|
+
// Extracted from Brand Atelier. These are the minimal types
|
|
3
|
+
// needed by the four parsers.
|
|
4
|
+
|
|
5
|
+
/** Semantic slot identifiers — fixed vocabulary. */
|
|
6
|
+
export type SlotId = 'primary' | 'secondary' | 'detail' | 'meta' | 'cta' | 'label';
|
|
7
|
+
|
|
8
|
+
/** Inset distances in px (screen) or mm (print). */
|
|
9
|
+
export interface ZoneInsets {
|
|
10
|
+
top: number;
|
|
11
|
+
bottom: number;
|
|
12
|
+
left: number;
|
|
13
|
+
right: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Text alignment options. */
|
|
17
|
+
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
|
package/skills/brand-intent.md
CHANGED
|
@@ -10,7 +10,7 @@ description: Generate and validate on-brand content using Brand Intent files
|
|
|
10
10
|
> If `$ARGUMENTS` is "example": scaffold the Krume bakery reference implementation into `examples/krume/` and explain what each file does.
|
|
11
11
|
> If `$ARGUMENTS` names a purpose (e.g. "daily-bread", "event", "quote"): compose context from the five layers and generate content for that purpose.
|
|
12
12
|
> If `$ARGUMENTS` is "validate": read the most recent generated or provided content and run the validation checklist against the brand files.
|
|
13
|
-
> If `$ARGUMENTS` is empty:
|
|
13
|
+
> If `$ARGUMENTS` is empty: find the active brand (see File Discovery below), summarize the brand voice and list available purposes.
|
|
14
14
|
|
|
15
15
|
You are working in a project that uses **Brand Intent**, an open file format for encoding brand identity, expression intent, and content composition rules across five composable layers. This skill teaches you how to read, compose, and validate Brand Intent files.
|
|
16
16
|
|
|
@@ -128,17 +128,28 @@ After generating or reviewing content, validate against these rules in order:
|
|
|
128
128
|
|
|
129
129
|
## File Discovery
|
|
130
130
|
|
|
131
|
-
Brand Intent
|
|
131
|
+
A Brand Intent brand lives in its own folder, named after the brand:
|
|
132
132
|
|
|
133
133
|
```
|
|
134
|
-
brandname
|
|
135
|
-
brandname.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
brandname/
|
|
135
|
+
├── brandname.identity
|
|
136
|
+
├── brandname.brand
|
|
137
|
+
├── formats/*.format
|
|
138
|
+
├── purposes/*.purpose
|
|
139
|
+
└── compositions/*.composition
|
|
139
140
|
```
|
|
140
141
|
|
|
141
|
-
|
|
142
|
+
### Finding the active brand
|
|
143
|
+
|
|
144
|
+
A **brand folder** is a directory that contains both a `*.identity` and a `*.brand` file whose base name matches the folder name. To find the active brand:
|
|
145
|
+
|
|
146
|
+
1. Search the project for brand folders, but **skip anything under `examples/`** — those are reference material, not the user's real brand.
|
|
147
|
+
2. **If exactly one non-example brand folder exists**, use it silently. That is the active brand.
|
|
148
|
+
3. **If multiple exist**, ask the user which brand they want to work on. Don't guess.
|
|
149
|
+
4. **If none exist but an example brand does** (e.g. `examples/krume/`), treat the example as the active brand — the user is in learning mode. Before doing work, say one line: *"Working with the Krume example brand — run `/brand-intent onboard` when you want to create your own."* Then proceed normally.
|
|
150
|
+
5. **If nothing exists at all**, suggest running `/brand-intent onboard` to create a brand, or `npx brand-intent init` to scaffold the Krume example.
|
|
151
|
+
|
|
152
|
+
Explicit references to an example brand (e.g. "show me the Krume example", "how does Krume handle headlines?") always reach into `examples/` directly, regardless of what the active brand is.
|
|
142
153
|
|
|
143
154
|
## Onboarding: Create a New Brand
|
|
144
155
|
|
|
@@ -200,13 +211,15 @@ Ask the user to confirm or adjust. Iterate until they're happy.
|
|
|
200
211
|
|
|
201
212
|
### File Generation
|
|
202
213
|
|
|
203
|
-
Once confirmed, generate these files
|
|
214
|
+
Once confirmed, create a folder named after the brand ID and generate these files inside it:
|
|
215
|
+
|
|
216
|
+
1. **`[brandid]/[brandid].identity`** — essence, promise, tagline, archetype, voice (register, persona, rhythm, always/never), pillars, audience blocks, anti-audience blocks. Follow the structure of the Krume reference.
|
|
204
217
|
|
|
205
|
-
|
|
218
|
+
2. **`[brandid]/[brandid].brand`** — id, name, language, locale, brand-colors, at least one theme (mapping semantic slots to color references), font blocks, typography blocks (headline, body, caption, label at minimum), spacing, dividers. Use `$name` references for theme colors.
|
|
206
219
|
|
|
207
|
-
|
|
220
|
+
3. **`[brandid]/purposes/starter.purpose`** — a starter purpose with slots that have on-brand sample content derived from the identity. Samples must be in the brand's language and respect voice rules.
|
|
208
221
|
|
|
209
|
-
|
|
222
|
+
The brand folder sits at the project root (or wherever the user invoked the skill). If a folder with that name already exists and contains brand files, stop and ask before overwriting.
|
|
210
223
|
|
|
211
224
|
### Rules
|
|
212
225
|
|