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,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for .composition 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
|
+
* Row names: row 1 = index 0, row 2 = index 1, ..., row 6 = index 5 (1-based in files, 0-based in output)
|
|
8
|
+
* Column names: 1=0, 2=1, 3=2, 4=3, 5=4, 6=5 (1-based in files, 0-based in output)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SlotId } from './types';
|
|
12
|
+
|
|
13
|
+
// ── Parsed types ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Grid Y keyword — resolved to actual % at layout time using buildGrid(). */
|
|
16
|
+
export type GridYKeyword = 'top' | 'center' | 'bottom';
|
|
17
|
+
|
|
18
|
+
export interface ParsedImageRegion {
|
|
19
|
+
yStart: GridYKeyword;
|
|
20
|
+
yEnd: GridYKeyword;
|
|
21
|
+
colStart?: number; // 0-based column (undefined = full width)
|
|
22
|
+
colEnd?: number; // 0-based column (undefined = full width)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ParsedComposition {
|
|
26
|
+
/** Programmatic ID (from `id:` line, or fallback from first `#` line). */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Human-readable display name (from `name:` line, or falls back to id). */
|
|
29
|
+
name: string;
|
|
30
|
+
description: string;
|
|
31
|
+
/** Explicit layout mode. 'flow' | 'grid' | null (inferred from structure). */
|
|
32
|
+
mode: 'flow' | 'grid' | null;
|
|
33
|
+
image: 'full' | 'none' | ParsedImageRegion;
|
|
34
|
+
/** Whether `image:` was explicitly written (vs. defaulted to 'full'). */
|
|
35
|
+
imageExplicit: boolean;
|
|
36
|
+
logo: { row: number | GridYKeyword; col: 'left' | 'center' | 'right'; size?: 's' | 'm' | 'l' } | null;
|
|
37
|
+
/** When true, the layout explicitly disallows logos (`logo: none` in .layout). */
|
|
38
|
+
logoNone?: boolean;
|
|
39
|
+
/** Icon slot placement (optional). */
|
|
40
|
+
icon?: { row: number | GridYKeyword; col: 'left' | 'center' | 'right'; size?: 's' | 'm' | 'l' };
|
|
41
|
+
/** Standalone slots (empty if composition uses flow:). */
|
|
42
|
+
slots: ParsedSlot[];
|
|
43
|
+
/** Sticky regions — composed repetition areas (logo, label). */
|
|
44
|
+
sticky: ParsedStickyRegion[];
|
|
45
|
+
/** Flow regions — content-driven flow areas. */
|
|
46
|
+
flow: ParsedFlowRegion | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A sticky (repetition) region — composed elements that don't participate in flow. */
|
|
50
|
+
export interface ParsedStickyRegion {
|
|
51
|
+
position: 'top' | 'bottom' | 'left' | 'right';
|
|
52
|
+
/** Size token — determines bar height and logo/label sizing. Default: 'm'. */
|
|
53
|
+
size: 's' | 'm' | 'l';
|
|
54
|
+
items: ParsedStickyItem[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ParsedStickyItem {
|
|
58
|
+
type: 'logo' | 'slot' | 'icon';
|
|
59
|
+
/** For logo: alignment. For slot: slot ID. */
|
|
60
|
+
id?: string;
|
|
61
|
+
align: 'left' | 'center' | 'right';
|
|
62
|
+
verticalAlign?: 'top' | 'center' | 'bottom';
|
|
63
|
+
size?: 's' | 'm' | 'l';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A flow region — where content flows. */
|
|
67
|
+
export interface ParsedFlowRegion {
|
|
68
|
+
/** Panel positioning (null = fill remaining space). */
|
|
69
|
+
panel?: {
|
|
70
|
+
position: string; // e.g. 'bottom-left', 'top-right'
|
|
71
|
+
widthFraction: number;
|
|
72
|
+
heightFraction: number;
|
|
73
|
+
};
|
|
74
|
+
/** Split: text occupies a fraction from one edge, image fills the rest. */
|
|
75
|
+
split?: {
|
|
76
|
+
edge: 'top' | 'bottom' | 'left' | 'right';
|
|
77
|
+
fraction: number; // e.g. 0.5 = half, 0.333 = one third
|
|
78
|
+
};
|
|
79
|
+
/** Flow direction (default: vertical). */
|
|
80
|
+
direction: 'vertical' | 'horizontal';
|
|
81
|
+
/** Number of text columns (default: 1). */
|
|
82
|
+
columns?: number;
|
|
83
|
+
/** Gap between columns — spacing token name (default: 'm'). */
|
|
84
|
+
columnGap?: string;
|
|
85
|
+
items: ParsedFlowItem[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ParsedSlot {
|
|
89
|
+
id: SlotId;
|
|
90
|
+
row: number;
|
|
91
|
+
colStart: number;
|
|
92
|
+
colEnd: number;
|
|
93
|
+
textAlign: 'left' | 'center' | 'right';
|
|
94
|
+
verticalAlign?: 'top' | 'center' | 'bottom';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ParsedFlowIcon {
|
|
98
|
+
type: 'icon';
|
|
99
|
+
align: 'left' | 'center' | 'right';
|
|
100
|
+
/** Size token (s/m/l). Default: 'm'. */
|
|
101
|
+
size: 's' | 'm' | 'l';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type ParsedFlowItem = ParsedFlowSlot | ParsedSpacer | ParsedDivider | ParsedFlowIcon;
|
|
105
|
+
|
|
106
|
+
export interface ParsedFlowSlot {
|
|
107
|
+
type: 'slot';
|
|
108
|
+
id: SlotId;
|
|
109
|
+
textAlign: 'left' | 'center' | 'right';
|
|
110
|
+
verticalAlign?: 'top' | 'center' | 'bottom';
|
|
111
|
+
/** Number of text columns for this slot (default: 1). */
|
|
112
|
+
columns?: number;
|
|
113
|
+
/** Gap between columns — spacing token name. */
|
|
114
|
+
columnGap?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ParsedSpacer {
|
|
118
|
+
type: 'spacer';
|
|
119
|
+
/** Token name from figma-tokens.json spacing (e.g. 's', 'm', 'l', 'xl') or 'auto'. */
|
|
120
|
+
size: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ParsedDivider {
|
|
124
|
+
type: 'divider';
|
|
125
|
+
/** 'ruler' (thick, accent color) or 'hairline' (thin, muted). */
|
|
126
|
+
variant: 'ruler' | 'hairline';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Backward-compat map: band letters A-F → 0-based row indices */
|
|
132
|
+
const LEGACY_BAND_MAP: Record<string, number> = { A: 0, B: 1, C: 2, D: 3, E: 4, F: 5 };
|
|
133
|
+
const GRID_Y_KEYWORDS = new Set<string>(['top', 'center', 'bottom']);
|
|
134
|
+
|
|
135
|
+
const VALID_SLOTS = new Set<string>(['primary', 'secondary', 'detail', 'meta', 'cta', 'label']);
|
|
136
|
+
// Spacer names: 'auto' + any key from figma-tokens.json spacing (s, m, l, xl, etc.)
|
|
137
|
+
// Parser accepts any alphanumeric name — validation against actual tokens happens at render time.
|
|
138
|
+
|
|
139
|
+
// ── Column parsing ────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Parse a column value: "1"--N (mapped to 0-based). */
|
|
142
|
+
function parseCol(value: string, fileName: string, lineNum: number, maxCol = 12): number {
|
|
143
|
+
const n = parseInt(value, 10);
|
|
144
|
+
if (isNaN(n) || n < 1 || n > maxCol) {
|
|
145
|
+
throw new Error(`${fileName}:${lineNum}: invalid column "${value}" (expected 1--${maxCol})`);
|
|
146
|
+
}
|
|
147
|
+
return n - 1; // 1-based → 0-based
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Parse a column range "A--B" where A and B are column values. */
|
|
151
|
+
function parseColRange(range: string, fileName: string, lineNum: number, maxCol = 12): { start: number; end: number } {
|
|
152
|
+
const parts = range.split('--');
|
|
153
|
+
if (parts.length !== 2) {
|
|
154
|
+
throw new Error(`${fileName}:${lineNum}: invalid column range "${range}" (expected "A--B")`);
|
|
155
|
+
}
|
|
156
|
+
const start = parseCol(parts[0].trim(), fileName, lineNum, maxCol);
|
|
157
|
+
const end = parseCol(parts[1].trim(), fileName, lineNum, maxCol);
|
|
158
|
+
return { start, end };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Validate a logo/icon col keyword (must be left, center, or right). */
|
|
162
|
+
function resolveColKeyword(raw: string, fileName: string, lineNum: number): 'left' | 'center' | 'right' {
|
|
163
|
+
if (raw === 'left' || raw === 'center' || raw === 'right') return raw;
|
|
164
|
+
throw new Error(`${fileName}:${lineNum}: invalid col keyword "${raw}" (expected left, center, or right)`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Main parser ───────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export function parseCompositionFile(content: string, fileName: string): ParsedComposition {
|
|
170
|
+
const lines = content.split('\n');
|
|
171
|
+
|
|
172
|
+
let id = '';
|
|
173
|
+
let name = '';
|
|
174
|
+
let description = '';
|
|
175
|
+
let mode: 'flow' | 'grid' | null = null;
|
|
176
|
+
let image: ParsedComposition['image'] | null = null;
|
|
177
|
+
let imageExplicit = false;
|
|
178
|
+
let logo: ParsedComposition['logo'] = null;
|
|
179
|
+
let logoNone = false;
|
|
180
|
+
let icon: ParsedComposition['icon'] | undefined = undefined;
|
|
181
|
+
const slots: ParsedSlot[] = [];
|
|
182
|
+
const sticky: ParsedStickyRegion[] = [];
|
|
183
|
+
let flow: ParsedFlowRegion | null = null;
|
|
184
|
+
|
|
185
|
+
/** Parse a fraction string like "2/3" → 0.667. */
|
|
186
|
+
function parseFraction(s: string, ctx: string): number {
|
|
187
|
+
const parts = s.split('/');
|
|
188
|
+
if (parts.length !== 2) throw new Error(`${ctx}: invalid fraction "${s}" (expected "N/M")`);
|
|
189
|
+
const num = parseInt(parts[0], 10);
|
|
190
|
+
const den = parseInt(parts[1], 10);
|
|
191
|
+
if (isNaN(num) || isNaN(den) || den === 0) throw new Error(`${ctx}: invalid fraction "${s}"`);
|
|
192
|
+
return num / den;
|
|
193
|
+
}
|
|
194
|
+
// For backward compat: track first # line text for fallback id
|
|
195
|
+
let firstHashText = '';
|
|
196
|
+
|
|
197
|
+
for (let i = 0; i < lines.length; i++) {
|
|
198
|
+
const lineNum = i + 1;
|
|
199
|
+
const raw = lines[i];
|
|
200
|
+
const line = raw.trim();
|
|
201
|
+
|
|
202
|
+
// Blank line
|
|
203
|
+
if (line === '') continue;
|
|
204
|
+
|
|
205
|
+
// Comment — ALL # lines go to description now
|
|
206
|
+
if (line.startsWith('#')) {
|
|
207
|
+
const text = line.slice(1).trim();
|
|
208
|
+
if (!firstHashText) firstHashText = text;
|
|
209
|
+
description = description ? `${description} ${text}` : text;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// id: field
|
|
214
|
+
if (line.startsWith('id:')) {
|
|
215
|
+
id = line.slice(3).trim();
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// name: field
|
|
220
|
+
if (line.startsWith('name:')) {
|
|
221
|
+
name = line.slice(5).trim();
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// mode: flow | grid (accepts legacy "compose" as alias for "grid")
|
|
226
|
+
if (line.startsWith('mode:')) {
|
|
227
|
+
const raw = line.slice(5).trim();
|
|
228
|
+
const value = raw === 'compose' ? 'grid' : raw;
|
|
229
|
+
if (value !== 'flow' && value !== 'grid') {
|
|
230
|
+
throw new Error(`${fileName}:${lineNum}: invalid mode "${value}" (expected "flow" or "grid")`);
|
|
231
|
+
}
|
|
232
|
+
mode = value;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Image region: "full", "none", "top--center", "top--center cols 1--2"
|
|
237
|
+
if (line.startsWith('image:')) {
|
|
238
|
+
const value = line.slice(6).trim();
|
|
239
|
+
imageExplicit = true;
|
|
240
|
+
if (value === 'none') {
|
|
241
|
+
image = 'none';
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (value === 'full') {
|
|
245
|
+
image = 'full';
|
|
246
|
+
} else {
|
|
247
|
+
// Parse "yStart--yEnd" with optional "cols N--M"
|
|
248
|
+
const match = value.match(/^(\w+)--(\w+)(?:\s+cols\s+(\S+))?$/);
|
|
249
|
+
if (!match) throw new Error(`${fileName}:${lineNum}: invalid image region "${value}" (expected "full" or "keyword--keyword [cols N--M]")`);
|
|
250
|
+
const [, yStartStr, yEndStr, colRangeStr] = match;
|
|
251
|
+
if (!GRID_Y_KEYWORDS.has(yStartStr)) throw new Error(`${fileName}:${lineNum}: unknown grid keyword "${yStartStr}" (expected top, center, bottom)`);
|
|
252
|
+
if (!GRID_Y_KEYWORDS.has(yEndStr)) throw new Error(`${fileName}:${lineNum}: unknown grid keyword "${yEndStr}" (expected top, center, bottom)`);
|
|
253
|
+
const parsed: ParsedImageRegion = { yStart: yStartStr as GridYKeyword, yEnd: yEndStr as GridYKeyword };
|
|
254
|
+
if (colRangeStr) {
|
|
255
|
+
const cols = parseColRange(colRangeStr, fileName, lineNum);
|
|
256
|
+
if (typeof cols.start !== 'number') throw new Error(`${fileName}:${lineNum}: image cols cannot use dynamic expressions`);
|
|
257
|
+
parsed.colStart = cols.start;
|
|
258
|
+
parsed.colEnd = cols.end;
|
|
259
|
+
}
|
|
260
|
+
image = parsed;
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// grid: is no longer used in layout files (format owns the grid). Silently skip.
|
|
266
|
+
if (line.startsWith('grid:')) {
|
|
267
|
+
console.warn(`${fileName}:${lineNum}: "grid:" in layout files is no longer needed — format defines the grid`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Logo: "logo: row N X [size]" or "logo: top/center/bottom X [size]"
|
|
272
|
+
// col: left | center | right
|
|
273
|
+
// size: s | m | l (optional, defaults to m)
|
|
274
|
+
if (line.startsWith('logo:')) {
|
|
275
|
+
const logoValue = line.slice(5).trim();
|
|
276
|
+
if (logoValue === 'none') {
|
|
277
|
+
logo = null;
|
|
278
|
+
logoNone = true;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// Grid keyword form: "logo: top/center/bottom [col] X [s|m|l]"
|
|
282
|
+
const kwMatch = line.match(/^logo:\s+(top|center|bottom)\s+(?:col\s+)?(left|center|right)(?:\s+(s|m|l))?\s*$/);
|
|
283
|
+
if (kwMatch) {
|
|
284
|
+
const col = resolveColKeyword(kwMatch[2], fileName, lineNum);
|
|
285
|
+
logo = { row: kwMatch[1] as GridYKeyword, col, size: (kwMatch[3] as 's' | 'm' | 'l') ?? undefined };
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
// Row number form: "logo: row N [col] X [s|m|l]"
|
|
289
|
+
const rowMatch = line.match(/^logo:\s+row\s+(\d+)\s+(?:col\s+)?(left|center|right)(?:\s+(s|m|l))?\s*$/);
|
|
290
|
+
if (rowMatch) {
|
|
291
|
+
const col = resolveColKeyword(rowMatch[2], fileName, lineNum);
|
|
292
|
+
logo = { row: parseInt(rowMatch[1], 10) - 1, col, size: (rowMatch[3] as 's' | 'm' | 'l') ?? undefined };
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// Backward compat: "logo: band A [col] X"
|
|
296
|
+
const bandMatch = line.match(/^logo:\s+band\s+([A-F])\s+(?:col\s+)?(left|center|right)(?:\s+(s|m|l))?\s*$/);
|
|
297
|
+
if (bandMatch) {
|
|
298
|
+
const row = LEGACY_BAND_MAP[bandMatch[1]];
|
|
299
|
+
if (row === undefined) throw new Error(`${fileName}:${lineNum}: unknown band "${bandMatch[1]}"`);
|
|
300
|
+
console.warn(`${fileName}:${lineNum}: deprecated "band ${bandMatch[1]}" syntax — use "row ${row + 1}" instead`);
|
|
301
|
+
const col = resolveColKeyword(bandMatch[2], fileName, lineNum);
|
|
302
|
+
logo = { row, col, size: (bandMatch[3] as 's' | 'm' | 'l') ?? undefined };
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
throw new Error(`${fileName}:${lineNum}: invalid logo line "${line}" (expected "logo: row N left|center|right" or "logo: top/center/bottom left|center|right")`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Icon: "icon: top X [s|m|l]" or "icon: row N X [s|m|l]"
|
|
309
|
+
if (line.startsWith('icon:')) {
|
|
310
|
+
const kwMatch = line.match(/^icon:\s+(top|center|bottom)\s+(?:col\s+)?(left|center|right)(?:\s+(s|m|l))?\s*$/);
|
|
311
|
+
if (kwMatch) {
|
|
312
|
+
const col = resolveColKeyword(kwMatch[2], fileName, lineNum);
|
|
313
|
+
icon = { row: kwMatch[1] as GridYKeyword, col, size: (kwMatch[3] as 's' | 'm' | 'l') ?? undefined };
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const rowMatch = line.match(/^icon:\s+row\s+(\d+)\s+(?:col\s+)?(left|center|right)(?:\s+(s|m|l))?\s*$/);
|
|
317
|
+
if (rowMatch) {
|
|
318
|
+
const col = resolveColKeyword(rowMatch[2], fileName, lineNum);
|
|
319
|
+
icon = { row: parseInt(rowMatch[1], 10) - 1, col, size: (rowMatch[3] as 's' | 'm' | 'l') ?? undefined };
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const bandMatch = line.match(/^icon:\s+band\s+([A-F])\s+(?:col\s+)?(left|center|right)(?:\s+(s|m|l))?\s*$/);
|
|
323
|
+
if (bandMatch) {
|
|
324
|
+
const row = LEGACY_BAND_MAP[bandMatch[1]];
|
|
325
|
+
if (row === undefined) throw new Error(`${fileName}:${lineNum}: unknown band "${bandMatch[1]}"`);
|
|
326
|
+
console.warn(`${fileName}:${lineNum}: deprecated "band ${bandMatch[1]}" syntax — use "row ${row + 1}" instead`);
|
|
327
|
+
const col = resolveColKeyword(bandMatch[2], fileName, lineNum);
|
|
328
|
+
icon = { row, col, size: (bandMatch[3] as 's' | 'm' | 'l') ?? undefined };
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`${fileName}:${lineNum}: invalid icon line "${line}"`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── New flow DSL: sticky: and flow: blocks ────────────────────────────────
|
|
335
|
+
|
|
336
|
+
// Sticky region: "sticky: top [s|m|l]" — size token optional, defaults to 'm'
|
|
337
|
+
if (line.startsWith('sticky:')) {
|
|
338
|
+
const stickyParts = line.slice(7).trim().split(/\s+/);
|
|
339
|
+
const position = stickyParts[0] as 'top' | 'bottom' | 'left' | 'right';
|
|
340
|
+
if (!['top', 'bottom', 'left', 'right'].includes(position)) {
|
|
341
|
+
throw new Error(`${fileName}:${lineNum}: invalid sticky position "${position}" (expected top, bottom, left, right)`);
|
|
342
|
+
}
|
|
343
|
+
const stickySize = (stickyParts[1] as 's' | 'm' | 'l') ?? 'm';
|
|
344
|
+
if (!['s', 'm', 'l'].includes(stickySize)) {
|
|
345
|
+
throw new Error(`${fileName}:${lineNum}: invalid sticky size "${stickyParts[1]}" (expected s, m, l)`);
|
|
346
|
+
}
|
|
347
|
+
const stickyItems: ParsedStickyItem[] = [];
|
|
348
|
+
// Read indented items (| prefix) — skip blank lines within the block
|
|
349
|
+
while (i + 1 < lines.length) {
|
|
350
|
+
const nextLine = lines[i + 1].trim();
|
|
351
|
+
if (nextLine === '') { i++; continue; }
|
|
352
|
+
if (!nextLine.startsWith('|')) break;
|
|
353
|
+
i++;
|
|
354
|
+
const itemStr = nextLine.slice(1).trim();
|
|
355
|
+
// Parse: "logo <align> [size]" or "<slotId> <align>"
|
|
356
|
+
const parts = itemStr.split(/\s+/);
|
|
357
|
+
// Parse optional vertical alignment: "meta left bottom" or "logo right" or "logo left s"
|
|
358
|
+
const VALIGNS = ['top', 'center', 'bottom'];
|
|
359
|
+
const parseVAlign = (p: string | undefined) => VALIGNS.includes(p ?? '') ? p as 'top' | 'center' | 'bottom' : undefined;
|
|
360
|
+
if (parts[0] === 'logo') {
|
|
361
|
+
// logo <align> [top|center|bottom] [s|m|l]
|
|
362
|
+
const vAlign = parseVAlign(parts[2]);
|
|
363
|
+
const sizeIdx = vAlign ? 3 : 2;
|
|
364
|
+
stickyItems.push({
|
|
365
|
+
type: 'logo',
|
|
366
|
+
align: (parts[1] as 'left' | 'center' | 'right') ?? 'left',
|
|
367
|
+
verticalAlign: vAlign,
|
|
368
|
+
size: parts[sizeIdx] as 's' | 'm' | 'l' | undefined,
|
|
369
|
+
});
|
|
370
|
+
} else if (parts[0] === 'icon') {
|
|
371
|
+
stickyItems.push({
|
|
372
|
+
type: 'icon',
|
|
373
|
+
align: (parts[1] as 'left' | 'center' | 'right') ?? 'left',
|
|
374
|
+
verticalAlign: parseVAlign(parts[2]),
|
|
375
|
+
});
|
|
376
|
+
} else if (VALID_SLOTS.has(parts[0])) {
|
|
377
|
+
stickyItems.push({
|
|
378
|
+
type: 'slot',
|
|
379
|
+
id: parts[0],
|
|
380
|
+
align: (parts[1] as 'left' | 'center' | 'right') ?? 'left',
|
|
381
|
+
verticalAlign: parseVAlign(parts[2]),
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
throw new Error(`${fileName}:${i + 1}: unknown sticky item "${parts[0]}"`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
sticky.push({ position, size: stickySize, items: stickyItems });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Flow region: "flow:" / "flow: panel bottom-left 2/3 1/1"
|
|
392
|
+
if (line.startsWith('flow:')) {
|
|
393
|
+
const value = line.slice(5).trim();
|
|
394
|
+
let panel: ParsedFlowRegion['panel'] = undefined;
|
|
395
|
+
let direction: 'vertical' | 'horizontal' = 'vertical';
|
|
396
|
+
let columns: number | undefined;
|
|
397
|
+
let columnGap: string | undefined;
|
|
398
|
+
|
|
399
|
+
let split: ParsedFlowRegion['split'] = undefined;
|
|
400
|
+
|
|
401
|
+
if (value.startsWith('panel')) {
|
|
402
|
+
// Parse: "panel <position> [<width>] [<height>]"
|
|
403
|
+
const parts = value.split(/\s+/);
|
|
404
|
+
const pos = parts[1] ?? 'bottom-left';
|
|
405
|
+
const wFrac = parts[2] ? parseFraction(parts[2], `${fileName}:${lineNum}`) : 2 / 3;
|
|
406
|
+
const hFrac = parts[3] ? parseFraction(parts[3], `${fileName}:${lineNum}`) : wFrac;
|
|
407
|
+
panel = { position: pos, widthFraction: wFrac, heightFraction: hFrac };
|
|
408
|
+
} else if (value.startsWith('split')) {
|
|
409
|
+
// Parse: "split <edge> [<fraction>]" — e.g. "split bottom 1/2"
|
|
410
|
+
const parts = value.split(/\s+/);
|
|
411
|
+
const edge = parts[1] as 'top' | 'bottom' | 'left' | 'right';
|
|
412
|
+
if (!['top', 'bottom', 'left', 'right'].includes(edge)) {
|
|
413
|
+
throw new Error(`${fileName}:${lineNum}: invalid split edge "${edge}" — expected top, bottom, left, right`);
|
|
414
|
+
}
|
|
415
|
+
const fraction = parts[2] ? parseFraction(parts[2], `${fileName}:${lineNum}`) : 0.5;
|
|
416
|
+
split = { edge, fraction };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const flowItems: ParsedFlowItem[] = [];
|
|
420
|
+
// Read indented items (| prefix or "direction:" property) — skip blank lines within the block
|
|
421
|
+
while (i + 1 < lines.length) {
|
|
422
|
+
const nextLine = lines[i + 1].trim();
|
|
423
|
+
if (nextLine === '') { i++; continue; }
|
|
424
|
+
if (nextLine.startsWith('direction:')) {
|
|
425
|
+
i++;
|
|
426
|
+
const dir = nextLine.slice(10).trim();
|
|
427
|
+
if (dir === 'horizontal') direction = 'horizontal';
|
|
428
|
+
else if (dir === 'vertical') direction = 'vertical';
|
|
429
|
+
else throw new Error(`${fileName}:${i + 1}: invalid direction "${dir}" (expected vertical, horizontal)`);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (nextLine.startsWith('columns:')) {
|
|
433
|
+
i++;
|
|
434
|
+
const n = parseInt(nextLine.slice(8).trim(), 10);
|
|
435
|
+
if (isNaN(n) || n < 1 || n > 6) throw new Error(`${fileName}:${i + 1}: invalid columns "${nextLine.slice(8).trim()}" (expected 1–6)`);
|
|
436
|
+
columns = n;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (nextLine.startsWith('column-gap:')) {
|
|
440
|
+
i++;
|
|
441
|
+
columnGap = nextLine.slice(11).trim();
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (!nextLine.startsWith('|')) break;
|
|
445
|
+
i++;
|
|
446
|
+
const itemStr = nextLine.slice(1).trim();
|
|
447
|
+
// Parse flow item syntax
|
|
448
|
+
if (itemStr.startsWith('icon')) {
|
|
449
|
+
const iconParts = itemStr.split(/\s+/);
|
|
450
|
+
const align = (iconParts[1] as 'left' | 'center' | 'right') ?? 'center';
|
|
451
|
+
const size = (iconParts[2] as 's' | 'm' | 'l') ?? 'm';
|
|
452
|
+
flowItems.push({ type: 'icon', align, size });
|
|
453
|
+
} else if (itemStr.startsWith('spacer-')) {
|
|
454
|
+
const size = itemStr.slice(7);
|
|
455
|
+
flowItems.push({ type: 'spacer', size });
|
|
456
|
+
} else if (itemStr.startsWith('divider-')) {
|
|
457
|
+
const variant = itemStr.slice(8) as 'ruler' | 'hairline';
|
|
458
|
+
flowItems.push({ type: 'divider', variant });
|
|
459
|
+
} else {
|
|
460
|
+
// Slot: "primary left [top|center|bottom] [columns:N] [gap:M]"
|
|
461
|
+
const parts = itemStr.split(/\s+/);
|
|
462
|
+
const slotId = parts[0];
|
|
463
|
+
if (!VALID_SLOTS.has(slotId)) throw new Error(`${fileName}:${i + 1}: unknown slot "${slotId}" in flow block`);
|
|
464
|
+
const align = (parts[1] as 'left' | 'center' | 'right') ?? 'left';
|
|
465
|
+
const VALIGNS = ['top', 'center', 'bottom'];
|
|
466
|
+
let vAlign: 'top' | 'center' | 'bottom' | undefined;
|
|
467
|
+
let slotCols: number | undefined;
|
|
468
|
+
let slotGap: string | undefined;
|
|
469
|
+
for (let p = 2; p < parts.length; p++) {
|
|
470
|
+
if (VALIGNS.includes(parts[p])) {
|
|
471
|
+
vAlign = parts[p] as 'top' | 'center' | 'bottom';
|
|
472
|
+
} else if (parts[p].startsWith('columns:')) {
|
|
473
|
+
slotCols = parseInt(parts[p].slice(8), 10);
|
|
474
|
+
if (isNaN(slotCols) || slotCols < 1 || slotCols > 6) throw new Error(`${fileName}:${i + 1}: invalid columns "${parts[p].slice(8)}"`);
|
|
475
|
+
} else if (parts[p].startsWith('gap:')) {
|
|
476
|
+
slotGap = parts[p].slice(4);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
flowItems.push({ type: 'slot', id: slotId as SlotId, textAlign: align, verticalAlign: vAlign, columns: slotCols, columnGap: slotGap });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
flow = { panel, split, direction, columns, columnGap, items: flowItems };
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Removed: group syntax (migrated to flow mode) ────────────────────────
|
|
488
|
+
|
|
489
|
+
if (line.startsWith('group:')) {
|
|
490
|
+
throw new Error(`${fileName}:${lineNum}: "group:" syntax is removed — use "flow:" with "split" instead`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Ungrouped slot: "primary row 3 cols 1--4 left" or "meta bottom cols 1--2 left bottom"
|
|
494
|
+
// Alignment: one keyword = horizontal only (vertical defaults to top)
|
|
495
|
+
// two keywords = horizontal + vertical (e.g. "left bottom", "center center")
|
|
496
|
+
const rowSlotMatch = line.match(/^(\w+)\s+(?:row\s+)?(\d+|top|center|bottom)\s+cols\s+(\S+)\s+(left|center|right)(?:\s+(top|center|bottom))?$/);
|
|
497
|
+
if (rowSlotMatch) {
|
|
498
|
+
const [, slotId, rowStr, colRange, hAlign, vAlign] = rowSlotMatch;
|
|
499
|
+
if (!VALID_SLOTS.has(slotId)) throw new Error(`${fileName}:${lineNum}: unknown slot "${slotId}"`);
|
|
500
|
+
// Keyword rows use negative sentinels (same convention as logo): top=-1, center=-2, bottom=-3
|
|
501
|
+
const rowNum = rowStr === 'top' ? -1 : rowStr === 'center' ? -2 : rowStr === 'bottom' ? -3 : parseInt(rowStr, 10) - 1;
|
|
502
|
+
const cols = parseColRange(colRange, fileName, lineNum, 12);
|
|
503
|
+
slots.push({
|
|
504
|
+
id: slotId as SlotId,
|
|
505
|
+
row: rowNum,
|
|
506
|
+
colStart: cols.start,
|
|
507
|
+
colEnd: cols.end,
|
|
508
|
+
textAlign: hAlign as 'left' | 'center' | 'right',
|
|
509
|
+
...(vAlign ? { verticalAlign: vAlign as 'top' | 'center' | 'bottom' } : {}),
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Backward compat: "primary band D cols 1--4 left"
|
|
515
|
+
const bandSlotMatch = line.match(/^(\w+)\s+band\s+([A-F])\s+cols\s+(\S+)\s+(left|center|right)$/);
|
|
516
|
+
if (bandSlotMatch) {
|
|
517
|
+
const [, slotId, bandLetter, colRange, align] = bandSlotMatch;
|
|
518
|
+
if (!VALID_SLOTS.has(slotId)) throw new Error(`${fileName}:${lineNum}: unknown slot "${slotId}"`);
|
|
519
|
+
const row = LEGACY_BAND_MAP[bandLetter];
|
|
520
|
+
if (row === undefined) throw new Error(`${fileName}:${lineNum}: unknown band "${bandLetter}"`);
|
|
521
|
+
console.warn(`${fileName}:${lineNum}: deprecated "band ${bandLetter}" syntax — use "row ${row + 1}" instead`);
|
|
522
|
+
const cols = parseColRange(colRange, fileName, lineNum, 12);
|
|
523
|
+
slots.push({
|
|
524
|
+
id: slotId as SlotId,
|
|
525
|
+
row,
|
|
526
|
+
colStart: cols.start,
|
|
527
|
+
colEnd: cols.end,
|
|
528
|
+
textAlign: align as 'left' | 'center' | 'right',
|
|
529
|
+
});
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Unknown line
|
|
534
|
+
throw new Error(`${fileName}:${lineNum}: unexpected "${line}"`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Backward compat: if no id: line but we have a first # line, use it as id
|
|
538
|
+
if (!id) {
|
|
539
|
+
if (firstHashText) {
|
|
540
|
+
console.warn(`${fileName}: deprecated — add "id: ${firstHashText}" line (first # line used as fallback id)`);
|
|
541
|
+
id = firstHashText;
|
|
542
|
+
} else {
|
|
543
|
+
throw new Error(`${fileName}: missing composition id (add "id:" line)`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Derive display name if not provided
|
|
548
|
+
if (!name) {
|
|
549
|
+
name = id;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Validate required fields
|
|
553
|
+
if (!image) image = 'full'; // default to full-bleed image
|
|
554
|
+
if (!logo && !logoNone && !sticky.length) throw new Error(`${fileName}: missing "logo:" line (or use sticky: block)`);
|
|
555
|
+
return { id, name, description, mode, image, imageExplicit, logo, logoNone: logoNone || undefined, icon, slots, sticky, flow };
|
|
556
|
+
}
|