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
package/README.md
CHANGED
|
@@ -43,7 +43,6 @@ npx brand-intent init
|
|
|
43
43
|
## Documentation
|
|
44
44
|
|
|
45
45
|
- [Specification](SPEC.md): formal grammar for all five file types
|
|
46
|
-
- [Design Decisions](DESIGN.md): reasoning behind the framework
|
|
47
46
|
- [intent.stoll.studio](https://intent.stoll.studio): project landing page
|
|
48
47
|
|
|
49
48
|
## License
|
package/cli/index.mjs
CHANGED
|
@@ -60,26 +60,9 @@ function copyDirRecursive(src, dest) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function scaffoldExample(targetDir) {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
copyDirRecursive(brandSrc, exampleDest);
|
|
67
|
-
|
|
68
|
-
const layers = [
|
|
69
|
-
{ layer: 'formats', file: 'instagram-4-5-feed-portrait.format' },
|
|
70
|
-
{ layer: 'purposes', file: 'daily-bread.purpose' },
|
|
71
|
-
{ layer: 'compositions', file: 'editorial.composition' },
|
|
72
|
-
];
|
|
73
|
-
|
|
74
|
-
for (const { layer, file } of layers) {
|
|
75
|
-
const src = join(PKG_ROOT, layer, file);
|
|
76
|
-
if (existsSync(src)) {
|
|
77
|
-
const dest = join(exampleDest, layer, file);
|
|
78
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
79
|
-
copyFileSync(src, dest);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
63
|
+
const src = join(PKG_ROOT, 'examples', 'krume');
|
|
64
|
+
const dest = join(targetDir, 'examples', 'krume');
|
|
65
|
+
copyDirRecursive(src, dest);
|
|
83
66
|
console.log(' \u2713 examples/krume/ \u2014 reference implementation (identity, brand, format, purpose, composition)');
|
|
84
67
|
}
|
|
85
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brand-intent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "An open file format specification for making brand intent machine-readable.",
|
|
5
5
|
"author": "Christophe Stoll <christophe@stoll.studio>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,18 @@
|
|
|
12
12
|
"bin": {
|
|
13
13
|
"brand-intent": "./cli/index.mjs"
|
|
14
14
|
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./parser/index.ts",
|
|
18
|
+
"default": "./parser/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"./parser": {
|
|
21
|
+
"types": "./parser/index.ts",
|
|
22
|
+
"default": "./parser/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"types": "./parser/index.ts",
|
|
15
27
|
"keywords": [
|
|
16
28
|
"brand",
|
|
17
29
|
"design-tokens",
|
|
@@ -25,10 +37,8 @@
|
|
|
25
37
|
},
|
|
26
38
|
"files": [
|
|
27
39
|
"cli/",
|
|
40
|
+
"parser/",
|
|
28
41
|
"skills/",
|
|
29
|
-
"
|
|
30
|
-
"formats/",
|
|
31
|
-
"purposes/",
|
|
32
|
-
"compositions/"
|
|
42
|
+
"examples/"
|
|
33
43
|
]
|
|
34
44
|
}
|
package/parser/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Brand Intent - Reference Parser
|
|
2
|
+
|
|
3
|
+
TypeScript parsers for all five Brand Intent file types.
|
|
4
|
+
|
|
5
|
+
## Parsers
|
|
6
|
+
|
|
7
|
+
| Parser | File Type | Function |
|
|
8
|
+
|--------|-----------|----------|
|
|
9
|
+
| `identityParser.ts` | `.identity` | `parseIdentityFile(content, fileName)` |
|
|
10
|
+
| `brandParser.ts` | `.brand` | `parseBrandFile(content, fileName)` |
|
|
11
|
+
| `formatParser.ts` | `.format` | `parseFormatFile(content, fileName)` |
|
|
12
|
+
| `purposeParser.ts` | `.purpose` | `parsePurposeFile(content, fileName)` |
|
|
13
|
+
| `compositionParser.ts` | `.composition` | `parseCompositionFile(content, fileName)` |
|
|
14
|
+
|
|
15
|
+
All parsers are **pure functions**: they take a string (file content) and a filename (for error messages), and return a parsed object. No I/O, no side effects, no dependencies beyond `types.ts`.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { readFileSync } from 'fs';
|
|
21
|
+
import { parseIdentityFile, parseBrandFile } from './parser';
|
|
22
|
+
|
|
23
|
+
const identity = parseIdentityFile(
|
|
24
|
+
readFileSync('examples/krume/krume.identity', 'utf-8'),
|
|
25
|
+
'krume.identity'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const brand = parseBrandFile(
|
|
29
|
+
readFileSync('examples/krume/krume.brand', 'utf-8'),
|
|
30
|
+
'krume.brand'
|
|
31
|
+
);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Notes
|
|
35
|
+
|
|
36
|
+
- These parsers throw on validation errors, with the filename and line number included in the error message.
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for .brand brand definition files.
|
|
3
|
+
*
|
|
4
|
+
* Line-based format, same conventions as .purpose / .composition / .format / .identity.
|
|
5
|
+
* Strategic/narrative content belongs in the sibling `.identity` file — this parser
|
|
6
|
+
* handles expression-layer primitives only (colors, typography, dividers, assets, badge styles).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ── Parsed type ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export interface ParsedBrand {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
language: string;
|
|
15
|
+
locale: string;
|
|
16
|
+
/** Canonical domain for this brand (used for OG meta, favicon paths, share links). */
|
|
17
|
+
domain?: string;
|
|
18
|
+
/** Named font primitives: name → font definition. First entry is the brand default. */
|
|
19
|
+
fonts?: Record<string, ParsedFontDef>;
|
|
20
|
+
/** Token source type: 'figma' | 'inline' */
|
|
21
|
+
tokensSource: 'figma' | 'inline';
|
|
22
|
+
/** Figma file key (only when tokensSource === 'figma') */
|
|
23
|
+
figmaFileKey?: string;
|
|
24
|
+
/** Figma collection name (only when tokensSource === 'figma') */
|
|
25
|
+
figmaCollection?: string;
|
|
26
|
+
/** Path to the runtime tokens file (relative to /src) */
|
|
27
|
+
tokensFile: string;
|
|
28
|
+
/** Path to app icon asset (relative to /public), used for PWA icon */
|
|
29
|
+
appIcon?: string;
|
|
30
|
+
/** Path to favicon asset (relative to /public), used for browser tab icon */
|
|
31
|
+
favicon?: string;
|
|
32
|
+
/** Path to OG image asset (relative to /public), used for social sharing previews */
|
|
33
|
+
ogImage?: string;
|
|
34
|
+
/** Core brand color primitives: name → hex (e.g. primary, secondary, accent1, accent2, white) */
|
|
35
|
+
brandColors?: Record<string, string>;
|
|
36
|
+
/** Structured print specs per brand color: name → {pantone?, hks?, cmyk?} */
|
|
37
|
+
brandColorPrint?: Record<string, BrandColorPrintSpec>;
|
|
38
|
+
/** Inline themes: theme name → slot name → resolved hex color (only when tokensSource === 'inline') */
|
|
39
|
+
themes?: Record<string, Record<string, string>>;
|
|
40
|
+
/** Print annotations from inline # comments: slot name → annotation text */
|
|
41
|
+
themeDescriptions?: Record<string, string>;
|
|
42
|
+
/** Spacing scale (only when tokensSource === 'inline') */
|
|
43
|
+
spacing?: { unit: string; xs: number; s: number; m: number; l: number; xl: number };
|
|
44
|
+
/** Divider style definitions: name → divider config */
|
|
45
|
+
dividers?: Record<string, ParsedDividerDef>;
|
|
46
|
+
/** Named typography styles: name → style definition */
|
|
47
|
+
typographies?: Record<string, ParsedTypographyDef>;
|
|
48
|
+
/** Brand-declared assets: curated logos, photos, videos with labels and metadata */
|
|
49
|
+
assets?: { logos?: ParsedAssetDef[]; photos?: ParsedAssetDef[]; videos?: ParsedAssetDef[] };
|
|
50
|
+
/** Label badge style — padding (top/right/bottom/left in cqh) and corner radius (cqh) */
|
|
51
|
+
label?: ParsedBadgeStyle;
|
|
52
|
+
/** CTA pill style — padding (top/right/bottom/left in cqh) and corner radius (cqh, or 999 for full pill) */
|
|
53
|
+
cta?: ParsedBadgeStyle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ParsedAssetDef {
|
|
57
|
+
id: string;
|
|
58
|
+
label: string;
|
|
59
|
+
src: string;
|
|
60
|
+
aspectRatio?: number;
|
|
61
|
+
scale?: number;
|
|
62
|
+
lottieSrc?: string;
|
|
63
|
+
animated?: boolean;
|
|
64
|
+
loop?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ParsedBadgeStyle {
|
|
68
|
+
/** Padding in cqh: [top, right, bottom, left] */
|
|
69
|
+
padding: [number, number, number, number];
|
|
70
|
+
/** Corner radius in cqh. Use 999 for a full pill. */
|
|
71
|
+
radius: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ParsedFontDef {
|
|
75
|
+
/** Display name, e.g. "Matter", "system-ui", "Inter" */
|
|
76
|
+
name: string;
|
|
77
|
+
/** CSS fallback stack, e.g. "sans-serif", "serif" */
|
|
78
|
+
fallback: string;
|
|
79
|
+
/** Font source: 'local' (bundled in public/fonts/), 'system' (no @font-face), 'google' (future) */
|
|
80
|
+
source: 'local' | 'system' | 'google';
|
|
81
|
+
/** Variable font filename, relative to the brand's fonts/ directory. Used by preview where weight interpolation matters. */
|
|
82
|
+
variable?: string;
|
|
83
|
+
/** Variable italic font filename, when the family ships separate Roman/Italic variable files. */
|
|
84
|
+
variableItalic?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Static font instances, keyed by `{weight}` or `{weight}i` (italic).
|
|
87
|
+
* E.g. `{ "400": "Pangea-Regular.ttf", "400i": "Pangea-Italic.ttf", "600": "Pangea-SemiBold.ttf" }`.
|
|
88
|
+
* Required for export pipelines (Chromium PDF, print) that cannot embed variable or WOFF2 fonts.
|
|
89
|
+
* Filenames are relative to the brand's `fonts/` directory and may contain subdirectories.
|
|
90
|
+
*/
|
|
91
|
+
static?: Record<string, string>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface BrandColorPrintSpec {
|
|
95
|
+
/** PANTONE color name, e.g. "286 C" */
|
|
96
|
+
pantone?: string;
|
|
97
|
+
/** HKS color name, e.g. "44 K" */
|
|
98
|
+
hks?: string;
|
|
99
|
+
/** CMYK values as "C/M/Y/K" string, e.g. "97/80/0/31" */
|
|
100
|
+
cmyk?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ParsedTypographyDef {
|
|
104
|
+
/** Font key referencing a named font block (e.g. "mono"). Defaults to the brand primary font. */
|
|
105
|
+
font?: string;
|
|
106
|
+
weight: number;
|
|
107
|
+
size: number;
|
|
108
|
+
opsz?: number;
|
|
109
|
+
lineHeight: number;
|
|
110
|
+
letterSpacing?: number;
|
|
111
|
+
italic?: boolean;
|
|
112
|
+
uppercase?: boolean;
|
|
113
|
+
/** Weight to use for **strong** inline markup. If absent, strong renders as normal. */
|
|
114
|
+
strong?: number;
|
|
115
|
+
/** Autofit minimum font size in cqh. If absent, autofit is disabled (fixed size). */
|
|
116
|
+
autofitMin?: number;
|
|
117
|
+
/** Autofit maximum font size in cqh. If absent, autofit is disabled (fixed size). */
|
|
118
|
+
autofitMax?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ParsedDividerDef {
|
|
122
|
+
/** Line thickness in cqmin */
|
|
123
|
+
thickness: number;
|
|
124
|
+
/** Line width — percentage string (e.g. '15%') or '100%' */
|
|
125
|
+
width: string;
|
|
126
|
+
/**
|
|
127
|
+
* Resolved color value. Supports:
|
|
128
|
+
* - Brand color names: 'accent1', 'accent2', 'primary', 'secondary', etc. (resolved to hex at parse time)
|
|
129
|
+
* - Raw hex: '#E32D39'
|
|
130
|
+
* - Theme slot names: 'logo' | 'cta' | 'sticker' | 'background' | 'text-primary' | … (resolved at render time via resolveDividerColor)
|
|
131
|
+
*/
|
|
132
|
+
color: string;
|
|
133
|
+
/** Spacing token name applied above and below the divider (xs | s | m | l | xl) */
|
|
134
|
+
spacing: string;
|
|
135
|
+
/** Horizontal alignment of a partial-width divider (default: 'left') */
|
|
136
|
+
align: 'left' | 'center' | 'right';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Parser ─────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
142
|
+
const lines = content.split('\n');
|
|
143
|
+
|
|
144
|
+
const result: Partial<ParsedBrand> = {
|
|
145
|
+
tokensFile: 'tokens.json',
|
|
146
|
+
tokensSource: 'figma',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
type ActiveBlock = 'none' | 'brand-colors' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'label' | 'cta';
|
|
150
|
+
let activeBlock: ActiveBlock = 'none';
|
|
151
|
+
let currentThemeName = '';
|
|
152
|
+
let currentDividerName = '';
|
|
153
|
+
let currentTypographyName = '';
|
|
154
|
+
let currentFontName = '';
|
|
155
|
+
const brandColors: Record<string, string> = {};
|
|
156
|
+
const brandColorPrint: Record<string, BrandColorPrintSpec> = {};
|
|
157
|
+
let currentBrandColorName = '';
|
|
158
|
+
const themes: Record<string, Record<string, string>> = {};
|
|
159
|
+
const themeDescriptions: Record<string, string> = {};
|
|
160
|
+
const spacingRaw: Record<string, string> = {};
|
|
161
|
+
const dividersRaw: Record<string, Record<string, string>> = {};
|
|
162
|
+
const typographiesRaw: Record<string, Record<string, string>> = {};
|
|
163
|
+
const fontsRaw: Array<{ name: string; props: Record<string, string> }> = [];
|
|
164
|
+
const logosRaw: Array<{ id: string; props: Record<string, string> }> = [];
|
|
165
|
+
const photosRaw: Array<{ id: string; props: Record<string, string> }> = [];
|
|
166
|
+
const videosRaw: Array<{ id: string; props: Record<string, string> }> = [];
|
|
167
|
+
let currentAssetId = '';
|
|
168
|
+
const labelRaw: Record<string, string> = {};
|
|
169
|
+
const ctaRaw: Record<string, string> = {};
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < lines.length; i++) {
|
|
172
|
+
const raw = lines[i];
|
|
173
|
+
const lineNum = i + 1;
|
|
174
|
+
const isIndented = raw.startsWith(' ') || raw.startsWith('\t');
|
|
175
|
+
|
|
176
|
+
// Comment — end any active block and skip
|
|
177
|
+
if (raw.startsWith('#')) {
|
|
178
|
+
activeBlock = 'none';
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Indented continuation line — route to the active block
|
|
183
|
+
if (isIndented) {
|
|
184
|
+
if (activeBlock === 'brand-colors') {
|
|
185
|
+
const trimmed = raw.trimStart();
|
|
186
|
+
const indentLen = raw.length - trimmed.length;
|
|
187
|
+
const colonIdx = trimmed.indexOf(':');
|
|
188
|
+
if (colonIdx !== -1) {
|
|
189
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
190
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
191
|
+
if (indentLen <= 2) {
|
|
192
|
+
// Top-level color line: " primary: #0324B1"
|
|
193
|
+
brandColors[key] = value;
|
|
194
|
+
currentBrandColorName = key;
|
|
195
|
+
} else if (currentBrandColorName) {
|
|
196
|
+
// Print sub-key: " pantone: 286 C" / " hks: 44 K" / " cmyk: 97/80/0/31"
|
|
197
|
+
if (!brandColorPrint[currentBrandColorName]) brandColorPrint[currentBrandColorName] = {};
|
|
198
|
+
const spec = brandColorPrint[currentBrandColorName];
|
|
199
|
+
if (key === 'pantone') spec.pantone = value;
|
|
200
|
+
else if (key === 'hks') spec.hks = value;
|
|
201
|
+
else if (key === 'cmyk') spec.cmyk = value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (activeBlock === 'theme' && currentThemeName) {
|
|
207
|
+
// slot: #HEX or $ref # optional comment
|
|
208
|
+
const colonIdx = raw.indexOf(':');
|
|
209
|
+
if (colonIdx !== -1) {
|
|
210
|
+
const slot = raw.slice(0, colonIdx).trim();
|
|
211
|
+
const rest = raw.slice(colonIdx + 1).trim();
|
|
212
|
+
// Split on first ' #' to separate value from inline comment
|
|
213
|
+
const commentIdx = rest.search(/\s+#/);
|
|
214
|
+
const value = commentIdx !== -1 ? rest.slice(0, commentIdx).trim() : rest.trim();
|
|
215
|
+
const comment = commentIdx !== -1 ? rest.slice(commentIdx).replace(/^\s+#\s*/, '') : '';
|
|
216
|
+
if (!themes[currentThemeName]) themes[currentThemeName] = {};
|
|
217
|
+
themes[currentThemeName][slot] = value; // may be $ref — resolved after parsing
|
|
218
|
+
if (comment && !themeDescriptions[slot]) {
|
|
219
|
+
themeDescriptions[slot] = comment;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (activeBlock === 'spacing') {
|
|
225
|
+
const colonIdx = raw.indexOf(':');
|
|
226
|
+
if (colonIdx !== -1) {
|
|
227
|
+
const key = raw.slice(0, colonIdx).trim();
|
|
228
|
+
const value = raw.slice(colonIdx + 1).trim();
|
|
229
|
+
spacingRaw[key] = value;
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (activeBlock === 'divider' && currentDividerName) {
|
|
234
|
+
const colonIdx = raw.indexOf(':');
|
|
235
|
+
if (colonIdx !== -1) {
|
|
236
|
+
const key = raw.slice(0, colonIdx).trim();
|
|
237
|
+
const value = raw.slice(colonIdx + 1).trim();
|
|
238
|
+
if (!dividersRaw[currentDividerName]) dividersRaw[currentDividerName] = {};
|
|
239
|
+
dividersRaw[currentDividerName][key] = value;
|
|
240
|
+
}
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (activeBlock === 'typography' && currentTypographyName) {
|
|
244
|
+
const trimmed = raw.trim();
|
|
245
|
+
if (!typographiesRaw[currentTypographyName]) typographiesRaw[currentTypographyName] = {};
|
|
246
|
+
const colonIdx = trimmed.indexOf(':');
|
|
247
|
+
if (colonIdx !== -1) {
|
|
248
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
249
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
250
|
+
typographiesRaw[currentTypographyName][key] = value;
|
|
251
|
+
} else if (trimmed === 'italic' || trimmed === 'uppercase') {
|
|
252
|
+
// Bare flag — presence means true
|
|
253
|
+
typographiesRaw[currentTypographyName][trimmed] = 'true';
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (activeBlock === 'font' && currentFontName) {
|
|
258
|
+
const colonIdx = raw.indexOf(':');
|
|
259
|
+
if (colonIdx !== -1) {
|
|
260
|
+
const key = raw.slice(0, colonIdx).trim();
|
|
261
|
+
const value = raw.slice(colonIdx + 1).trim();
|
|
262
|
+
const last = fontsRaw[fontsRaw.length - 1];
|
|
263
|
+
if (last && last.name === currentFontName) last.props[key] = value;
|
|
264
|
+
}
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if ((activeBlock === 'logo' || activeBlock === 'photo' || activeBlock === 'video') && currentAssetId) {
|
|
268
|
+
const colonIdx = raw.indexOf(':');
|
|
269
|
+
if (colonIdx !== -1) {
|
|
270
|
+
const key = raw.slice(0, colonIdx).trim();
|
|
271
|
+
const value = raw.slice(colonIdx + 1).trim();
|
|
272
|
+
const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : videosRaw;
|
|
273
|
+
const last = arr[arr.length - 1];
|
|
274
|
+
if (last && last.id === currentAssetId) last.props[key] = value;
|
|
275
|
+
} else {
|
|
276
|
+
const trimmed = raw.trim();
|
|
277
|
+
if (trimmed === 'animated' || trimmed === 'loop') {
|
|
278
|
+
const arr = activeBlock === 'logo' ? logosRaw : activeBlock === 'photo' ? photosRaw : videosRaw;
|
|
279
|
+
const last = arr[arr.length - 1];
|
|
280
|
+
if (last && last.id === currentAssetId) last.props[trimmed] = 'true';
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (activeBlock === 'label' || activeBlock === 'cta') {
|
|
286
|
+
const colonIdx = raw.indexOf(':');
|
|
287
|
+
if (colonIdx !== -1) {
|
|
288
|
+
const key = raw.slice(0, colonIdx).trim();
|
|
289
|
+
const value = raw.slice(colonIdx + 1).trim();
|
|
290
|
+
const target = activeBlock === 'label' ? labelRaw : ctaRaw;
|
|
291
|
+
target[key] = value;
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
// Indented line with no active block — ignore
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Non-indented line ends any active block
|
|
300
|
+
activeBlock = 'none';
|
|
301
|
+
|
|
302
|
+
if (raw.trim() === '') continue;
|
|
303
|
+
|
|
304
|
+
// brand-colors — starts the brand color primitives block
|
|
305
|
+
if (raw.trim() === 'brand-colors') {
|
|
306
|
+
activeBlock = 'brand-colors';
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// theme ThemeName — starts a theme block
|
|
311
|
+
const themeMatch = raw.match(/^theme\s+(\S+)/);
|
|
312
|
+
if (themeMatch) {
|
|
313
|
+
currentThemeName = themeMatch[1];
|
|
314
|
+
activeBlock = 'theme';
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// divider name — starts a divider block
|
|
319
|
+
const dividerMatch = raw.match(/^divider\s+(\S+)/);
|
|
320
|
+
if (dividerMatch) {
|
|
321
|
+
currentDividerName = dividerMatch[1];
|
|
322
|
+
activeBlock = 'divider';
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// typography name — starts a typography block
|
|
327
|
+
const typographyMatch = raw.match(/^typography\s+(\S+)/);
|
|
328
|
+
if (typographyMatch) {
|
|
329
|
+
currentTypographyName = typographyMatch[1];
|
|
330
|
+
activeBlock = 'typography';
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// font <name> or bare "font" — starts a font primitive block
|
|
335
|
+
// Bare "font" (no name) uses "_default" internally; first font is always the brand default.
|
|
336
|
+
const fontBlockMatch = raw.match(/^font(?:\s+(\S+))?$/);
|
|
337
|
+
if (fontBlockMatch) {
|
|
338
|
+
currentFontName = fontBlockMatch[1] ?? '_default';
|
|
339
|
+
fontsRaw.push({ name: currentFontName, props: {} });
|
|
340
|
+
activeBlock = 'font';
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// logo/photo/video <id> — starts an asset block
|
|
345
|
+
const assetMatch = raw.match(/^(logo|photo|video)\s+(\S+)/);
|
|
346
|
+
if (assetMatch) {
|
|
347
|
+
const [, type, id] = assetMatch;
|
|
348
|
+
currentAssetId = id;
|
|
349
|
+
activeBlock = type as 'logo' | 'photo' | 'video';
|
|
350
|
+
const arr = type === 'logo' ? logosRaw : type === 'photo' ? photosRaw : videosRaw;
|
|
351
|
+
arr.push({ id, props: {} });
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const colonIdx = raw.indexOf(':');
|
|
356
|
+
if (colonIdx === -1) continue;
|
|
357
|
+
|
|
358
|
+
const key = raw.slice(0, colonIdx).trim();
|
|
359
|
+
const value = raw.slice(colonIdx + 1).trim();
|
|
360
|
+
|
|
361
|
+
switch (key) {
|
|
362
|
+
case 'id': result.id = value; break;
|
|
363
|
+
case 'name': result.name = value; break;
|
|
364
|
+
case 'language': result.language = value; break;
|
|
365
|
+
case 'locale': result.locale = value; break;
|
|
366
|
+
case 'domain': result.domain = value; break;
|
|
367
|
+
case 'tokens':
|
|
368
|
+
if (value === 'figma' || value === 'inline') {
|
|
369
|
+
result.tokensSource = value;
|
|
370
|
+
} else {
|
|
371
|
+
console.warn(`${fileName}:${lineNum}: unknown tokens source "${value}", defaulting to "figma"`);
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
case 'figma-file-key': result.figmaFileKey = value; break;
|
|
375
|
+
case 'figma-collection': result.figmaCollection = value; break;
|
|
376
|
+
case 'tokens-file': result.tokensFile = value; break;
|
|
377
|
+
case 'app-icon': result.appIcon = value; break;
|
|
378
|
+
case 'favicon': result.favicon = value; break;
|
|
379
|
+
case 'og-image': result.ogImage = value; break;
|
|
380
|
+
case 'spacing':
|
|
381
|
+
activeBlock = 'spacing';
|
|
382
|
+
break;
|
|
383
|
+
case 'label':
|
|
384
|
+
activeBlock = 'label';
|
|
385
|
+
break;
|
|
386
|
+
case 'cta':
|
|
387
|
+
activeBlock = 'cta';
|
|
388
|
+
break;
|
|
389
|
+
default:
|
|
390
|
+
// Unknown keys silently ignored for forward compatibility
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Store brand colors
|
|
396
|
+
if (Object.keys(brandColors).length > 0) {
|
|
397
|
+
result.brandColors = brandColors;
|
|
398
|
+
if (Object.keys(brandColorPrint).length > 0) result.brandColorPrint = brandColorPrint;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Finalise inline tokens — resolve $ref values against brandColors
|
|
402
|
+
if (Object.keys(themes).length > 0) {
|
|
403
|
+
for (const theme of Object.values(themes)) {
|
|
404
|
+
for (const [slot, value] of Object.entries(theme)) {
|
|
405
|
+
if (value.startsWith('$')) {
|
|
406
|
+
const refName = value.slice(1);
|
|
407
|
+
const resolved = brandColors[refName];
|
|
408
|
+
if (resolved) {
|
|
409
|
+
theme[slot] = resolved;
|
|
410
|
+
} else {
|
|
411
|
+
console.warn(`${fileName}: unresolved brand color reference "$${refName}" in theme slot "${slot}"`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
result.themes = themes;
|
|
417
|
+
result.themeDescriptions = themeDescriptions;
|
|
418
|
+
}
|
|
419
|
+
if (Object.keys(spacingRaw).length > 0) {
|
|
420
|
+
result.spacing = {
|
|
421
|
+
unit: spacingRaw['unit'] ?? 'cqmin',
|
|
422
|
+
xs: parseFloat(spacingRaw['xs'] ?? '1.5'),
|
|
423
|
+
s: parseFloat(spacingRaw['s'] ?? '2'),
|
|
424
|
+
m: parseFloat(spacingRaw['m'] ?? '3'),
|
|
425
|
+
l: parseFloat(spacingRaw['l'] ?? '5'),
|
|
426
|
+
xl: parseFloat(spacingRaw['xl'] ?? '6.5'),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Finalise dividers
|
|
431
|
+
if (Object.keys(dividersRaw).length > 0) {
|
|
432
|
+
const dividers: Record<string, ParsedDividerDef> = {};
|
|
433
|
+
// Resolve a color value: brand color name (accent1, accent2, …) → hex,
|
|
434
|
+
// semantic slot names → kept as-is for resolveDividerColor(), raw hex → pass through.
|
|
435
|
+
const resolveColor = (c: string): string => brandColors[c] ?? c;
|
|
436
|
+
for (const [name, raw] of Object.entries(dividersRaw)) {
|
|
437
|
+
const alignRaw = raw['align'] ?? 'left';
|
|
438
|
+
const align: ParsedDividerDef['align'] =
|
|
439
|
+
alignRaw === 'center' ? 'center' : alignRaw === 'right' ? 'right' : 'left';
|
|
440
|
+
dividers[name] = {
|
|
441
|
+
thickness: parseFloat(raw['thickness'] ?? '0.2'),
|
|
442
|
+
width: raw['width'] ?? '100%',
|
|
443
|
+
color: resolveColor(raw['color'] ?? 'text-tertiary'),
|
|
444
|
+
spacing: raw['spacing'] ?? 'xs',
|
|
445
|
+
align,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
result.dividers = dividers;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Finalise font primitives
|
|
452
|
+
if (fontsRaw.length > 0) {
|
|
453
|
+
const fonts: Record<string, ParsedFontDef> = {};
|
|
454
|
+
for (const { name, props } of fontsRaw) {
|
|
455
|
+
const fontName = props['name'];
|
|
456
|
+
if (!fontName) {
|
|
457
|
+
console.warn(`${fileName}: font block "${name}" missing "name:" property — skipping`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const rawSource = props['source'];
|
|
461
|
+
const source: ParsedFontDef['source'] =
|
|
462
|
+
rawSource === 'system' ? 'system' : rawSource === 'google' ? 'google' : 'local';
|
|
463
|
+
const fallback = props['fallback'] ?? 'sans-serif';
|
|
464
|
+
const def: ParsedFontDef = { name: fontName, fallback, source };
|
|
465
|
+
if (props['variable']) def.variable = props['variable'];
|
|
466
|
+
if (props['variable-italic']) def.variableItalic = props['variable-italic'];
|
|
467
|
+
const staticInstances: Record<string, string> = {};
|
|
468
|
+
for (const [key, value] of Object.entries(props)) {
|
|
469
|
+
const match = key.match(/^static-(\d{3})(i)?$/);
|
|
470
|
+
if (match) staticInstances[match[1] + (match[2] ?? '')] = value;
|
|
471
|
+
}
|
|
472
|
+
if (Object.keys(staticInstances).length > 0) def.static = staticInstances;
|
|
473
|
+
fonts[name] = def;
|
|
474
|
+
}
|
|
475
|
+
if (Object.keys(fonts).length > 0) result.fonts = fonts;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Finalise typographies
|
|
479
|
+
if (Object.keys(typographiesRaw).length > 0) {
|
|
480
|
+
const typographies: Record<string, ParsedTypographyDef> = {};
|
|
481
|
+
for (const [name, raw] of Object.entries(typographiesRaw)) {
|
|
482
|
+
const weight = parseInt(raw['weight'] ?? '400', 10);
|
|
483
|
+
const size = parseFloat(raw['size'] ?? '3');
|
|
484
|
+
const lineHeight = parseFloat(raw['lineHeight'] ?? '1.4');
|
|
485
|
+
if (isNaN(weight) || isNaN(size) || isNaN(lineHeight)) {
|
|
486
|
+
console.warn(`${fileName}: typography "${name}" has invalid numeric values — skipping`);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const def: ParsedTypographyDef = { weight, size, lineHeight };
|
|
490
|
+
if (raw['font'] !== undefined) def.font = raw['font'].replace(/^\$/, '');
|
|
491
|
+
if (raw['opsz'] !== undefined) def.opsz = parseFloat(raw['opsz']);
|
|
492
|
+
if (raw['letterSpacing'] !== undefined) def.letterSpacing = parseFloat(raw['letterSpacing']);
|
|
493
|
+
if (raw['italic'] === 'true' || raw['italic'] === '') def.italic = true;
|
|
494
|
+
if (raw['uppercase'] === 'true' || raw['uppercase'] === '') def.uppercase = true;
|
|
495
|
+
if (raw['strong'] !== undefined) def.strong = parseInt(raw['strong'], 10);
|
|
496
|
+
if (raw['autofit-min'] !== undefined) def.autofitMin = parseFloat(raw['autofit-min']);
|
|
497
|
+
if (raw['autofit-max'] !== undefined) def.autofitMax = parseFloat(raw['autofit-max']);
|
|
498
|
+
typographies[name] = def;
|
|
499
|
+
}
|
|
500
|
+
result.typographies = typographies;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Finalise brand-declared assets
|
|
504
|
+
const toAssetDef = (raw: { id: string; props: Record<string, string> }): ParsedAssetDef => {
|
|
505
|
+
const def: ParsedAssetDef = { id: raw.id, label: raw.props['label'] ?? raw.id, src: raw.props['src'] ?? '' };
|
|
506
|
+
if (raw.props['aspect']) def.aspectRatio = parseFloat(raw.props['aspect']);
|
|
507
|
+
if (raw.props['scale']) def.scale = parseFloat(raw.props['scale']);
|
|
508
|
+
if (raw.props['lottie']) def.lottieSrc = raw.props['lottie'];
|
|
509
|
+
if (raw.props['animated'] === 'true') def.animated = true;
|
|
510
|
+
if (raw.props['loop'] === 'true') def.loop = true;
|
|
511
|
+
return def;
|
|
512
|
+
};
|
|
513
|
+
if (logosRaw.length > 0 || photosRaw.length > 0 || videosRaw.length > 0) {
|
|
514
|
+
result.assets = {};
|
|
515
|
+
if (logosRaw.length > 0) result.assets.logos = logosRaw.map(toAssetDef);
|
|
516
|
+
if (photosRaw.length > 0) result.assets.photos = photosRaw.map(toAssetDef);
|
|
517
|
+
if (videosRaw.length > 0) result.assets.videos = videosRaw.map(toAssetDef);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Finalise label / cta badge styles
|
|
521
|
+
const parseBadgeStyle = (raw: Record<string, string>, defaults: ParsedBadgeStyle): ParsedBadgeStyle => {
|
|
522
|
+
const padding = (() => {
|
|
523
|
+
const p = raw['padding'];
|
|
524
|
+
if (!p) return defaults.padding;
|
|
525
|
+
const nums = p.split(/\s+/).map(parseFloat).filter(n => !isNaN(n));
|
|
526
|
+
if (nums.length === 1) return [nums[0], nums[0], nums[0], nums[0]] as [number, number, number, number];
|
|
527
|
+
if (nums.length === 2) return [nums[0], nums[1], nums[0], nums[1]] as [number, number, number, number];
|
|
528
|
+
if (nums.length === 3) return [nums[0], nums[1], nums[2], nums[1]] as [number, number, number, number];
|
|
529
|
+
if (nums.length >= 4) return [nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number];
|
|
530
|
+
return defaults.padding;
|
|
531
|
+
})();
|
|
532
|
+
const radius = raw['radius'] !== undefined ? parseFloat(raw['radius']) : defaults.radius;
|
|
533
|
+
return { padding, radius: isNaN(radius) ? defaults.radius : radius };
|
|
534
|
+
};
|
|
535
|
+
if (Object.keys(labelRaw).length > 0) {
|
|
536
|
+
result.label = parseBadgeStyle(labelRaw, { padding: [0.5, 1, 0.7, 1], radius: 0 });
|
|
537
|
+
}
|
|
538
|
+
if (Object.keys(ctaRaw).length > 0) {
|
|
539
|
+
result.cta = parseBadgeStyle(ctaRaw, { padding: [0.8, 2.5, 0.8, 2.5], radius: 999 });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Validate required fields
|
|
543
|
+
const required: (keyof ParsedBrand)[] = ['id', 'name', 'language', 'locale'];
|
|
544
|
+
for (const field of required) {
|
|
545
|
+
if (!result[field]) {
|
|
546
|
+
throw new Error(`${fileName}: missing required field "${field}"`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return result as ParsedBrand;
|
|
551
|
+
}
|