brand-intent 0.2.0 → 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/package.json +14 -1
- 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
|
@@ -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';
|