brand-intent 0.23.0 → 0.25.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 CHANGED
@@ -22,11 +22,13 @@ Brand Intent is a set of five composable file types that encode brand knowledge
22
22
  | Purpose | `.purpose` | What is this content for? |
23
23
  | Composition | `.composition` | How are elements composed? |
24
24
 
25
+ Plus `.story`: ordered pages built from purposes and compositions, with fixed, variable and open texts.
26
+
25
27
  ## Get started
26
28
 
27
29
  ### 1. Install the VS Code extension
28
30
 
29
- Syntax highlighting, autocomplete, and validation for all five file types.
31
+ Syntax highlighting, autocomplete, and validation for all file types.
30
32
 
31
33
  ```
32
34
  ext install studiostoll.brand-intent-lang
@@ -51,7 +53,7 @@ Edit your Brand Intent files whenever strategy or expression evolves. Regenerate
51
53
 
52
54
  ## Documentation
53
55
 
54
- - [Specification](SPEC.md): formal grammar for all five file types
56
+ - [Specification](SPEC.md): formal grammar for all file types
55
57
  - [intent.stoll.studio](https://intent.stoll.studio): project landing page
56
58
 
57
59
  ## License
package/cli/index.mjs CHANGED
@@ -63,7 +63,7 @@ function scaffoldExample(targetDir) {
63
63
  const src = join(PKG_ROOT, 'examples', 'krume');
64
64
  const dest = join(targetDir, 'examples', 'krume');
65
65
  copyDirRecursive(src, dest);
66
- console.log(' \u2713 examples/krume/ \u2014 reference implementation (identity, brand, format, purpose, composition)');
66
+ console.log(' \u2713 examples/krume/ \u2014 reference implementation (identity, brand, format, purpose, composition, story)');
67
67
  }
68
68
 
69
69
  // ── Install skill ──
@@ -0,0 +1,42 @@
1
+ # breads-of-the-week.story
2
+ # Three breads of the week, one page each, posted as a carousel.
3
+ # The label on the first page is fixed and carries the date; everything else is open.
4
+
5
+ id: breads-of-the-week
6
+ name: Brote der Woche
7
+ format: 4:5
8
+ topic: The three breads baked every day this week
9
+
10
+ page
11
+ purpose: daily-bread
12
+ composition: editorial
13
+ label: Ab {date}
14
+
15
+ page
16
+ purpose: daily-bread
17
+
18
+ page
19
+ purpose: daily-bread
20
+
21
+ # ── Examples ──
22
+ # One whole story per example: the open slots of all pages, about one story topic.
23
+
24
+ example
25
+ topic: A rye week: three rye breads from mild to dark
26
+ page 1
27
+ topic: Light rye, the mild start
28
+ primary: Hellroggen
29
+ secondary: 750 g · 5,40 €
30
+ meta: Roggen 997 mit etwas Weizen, 14 Stunden Teigführung.
31
+ page 2
32
+ topic: The classic rye loaf
33
+ label: Der Klassiker
34
+ primary: Roggenlaib
35
+ secondary: 750 g · 5,80 €
36
+ meta: Roggen 1150, 18 Stunden Teigführung, Kümmel aus dem Wendland.
37
+ page 3
38
+ topic: Dark whole-grain rye, the strongest
39
+ label: Kräftig
40
+ primary: Vollkornroggen
41
+ secondary: 1 kg · 7,20 €
42
+ meta: Ganzes Korn, zwei Tage Sauerteig, hält eine Woche.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.23.0",
3
+ "version": "0.25.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",
@@ -36,7 +36,7 @@
36
36
  "scripts": {
37
37
  "release": "./scripts/release.sh",
38
38
  "typecheck": "tsc -p .",
39
- "test": "tsx tests/brandParser.test.ts && tsx tests/identityParser.test.ts && tsx tests/colorSystem.test.ts && tsx tests/icons.test.ts && tsx tests/purposeParser.test.ts && tsx tests/realBrands.test.ts"
39
+ "test": "tsx tests/brandParser.test.ts && tsx tests/identityParser.test.ts && tsx tests/colorSystem.test.ts && tsx tests/icons.test.ts && tsx tests/purposeParser.test.ts && tsx tests/storyParser.test.ts && tsx tests/realBrands.test.ts"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^26.6.5",
package/parser/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Brand Intent - Reference Parser
2
2
 
3
- TypeScript parsers for all five Brand Intent file types.
3
+ TypeScript parsers for all Brand Intent file types.
4
4
 
5
5
  ## Parsers
6
6
 
@@ -11,6 +11,7 @@ TypeScript parsers for all five Brand Intent file types.
11
11
  | `formatParser.ts` | `.format` | `parseFormatFile(content, fileName)` |
12
12
  | `purposeParser.ts` | `.purpose` | `parsePurposeFile(content, fileName)` |
13
13
  | `compositionParser.ts` | `.composition` | `parseCompositionFile(content, fileName)` |
14
+ | `storyParser.ts` | `.story` | `parseStoryFile(content, fileName)`; `checkStory(story, brand, fileName)` checks it against the brand's purposes, compositions and formats |
14
15
 
15
16
  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
 
@@ -55,8 +55,6 @@ export interface ParsedBrand {
55
55
  name: string;
56
56
  language: string;
57
57
  locale: string;
58
- /** Canonical domain for this brand (used for OG meta, favicon paths, share links). */
59
- domain?: string;
60
58
  /** Named font primitives: name → font definition. First entry is the brand default. */
61
59
  fonts?: Record<string, ParsedFontDef>;
62
60
  /** Token source type: 'figma' | 'inline' */
@@ -1166,7 +1164,6 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
1166
1164
  case 'name': result.name = value; break;
1167
1165
  case 'language': result.language = value; break;
1168
1166
  case 'locale': result.locale = value; break;
1169
- case 'domain': result.domain = value; break;
1170
1167
  case 'tokens':
1171
1168
  if (value === 'figma' || value === 'inline') {
1172
1169
  result.tokensSource = value;
@@ -30,5 +30,5 @@ export function isInDateWindow(ranges: DateWindowRange[], date: Date): boolean {
30
30
  return ranges.some(({ from, to }) => (from <= to ? key >= from && key <= to : key >= from || key <= to));
31
31
  }
32
32
 
33
- /** Trailing window marker on a sample line: `Raps blüht im Mai @rapsbluete`. */
33
+ /** Trailing window marker on a sample line: `Stollen ab Advent @christmas`. */
34
34
  export const SAMPLE_WINDOW_MARKER = /\s+@([a-z][a-z0-9-]*)\s*$/;
@@ -35,8 +35,6 @@ export interface ParsedFormat {
35
35
  gridCrop: ZoneInsets | null;
36
36
  comfort: number; // px (0 if comfortMm is used)
37
37
  comfortMm?: number; // mm (print/signage)
38
- /** Capability restrictions from the `no:` field (e.g. ['motion', 'video', 'photo']). */
39
- restrictions?: string[];
40
38
  /** Allowed purpose IDs from the `purposes:` field. Absent = all purposes allowed. */
41
39
  purposes?: string[];
42
40
  }
@@ -110,7 +108,6 @@ export function parseFormatFile(content: string, fileName: string): ParsedFormat
110
108
  let gridCrop: ZoneInsets | null = null;
111
109
  let comfortRaw: number | undefined; // px for screen, mm for print/signage
112
110
  let comfortIsMm = false;
113
- let restrictions: string[] | undefined;
114
111
  let purposes: string[] | undefined;
115
112
 
116
113
  for (let i = 0; i < lines.length; i++) {
@@ -217,11 +214,6 @@ export function parseFormatFile(content: string, fileName: string): ParsedFormat
217
214
  break;
218
215
  }
219
216
 
220
- case 'no': {
221
- restrictions = value.split(/\s+/).filter(Boolean);
222
- break;
223
- }
224
-
225
217
  case 'purposes': {
226
218
  purposes = value.split(/[\s,]+/).filter(Boolean);
227
219
  break;
@@ -277,7 +269,6 @@ export function parseFormatFile(content: string, fileName: string): ParsedFormat
277
269
  grid,
278
270
  danger, gridCrop,
279
271
  comfort, comfortMm,
280
- restrictions,
281
272
  purposes,
282
273
  };
283
274
  }
package/parser/icons.ts CHANGED
@@ -132,7 +132,7 @@ export function resolveIconRef(ref: IconRef, icons: ParsedIconDef[] | undefined)
132
132
  return resolved;
133
133
  }
134
134
 
135
- /** Split an optional `[id]` prefix off a list item: "[pin] Hafen Burgstaaken". */
135
+ /** Split an optional `[id]` prefix off a list item: "[shop] Laden am Markt". */
136
136
  export function parseListItem(item: string): { icon?: IconRef; text: string } {
137
137
  const m = item.match(/^\[([^\]]+)\]\s*(.*)$/s);
138
138
  if (!m) return { text: item };
package/parser/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Brand Intent — reference parser
2
- // All five parsers are pure functions:
2
+ // All six parsers are pure functions:
3
3
  // they take a string (file content) and filename, return a parsed object.
4
4
 
5
5
  export { parseIdentityFile } from './identityParser';
@@ -83,4 +83,7 @@ export type {
83
83
  GridYKeyword,
84
84
  } from './compositionParser';
85
85
 
86
+ export { parseStoryFile, checkStory, storyTextVariables, STORY_VARIABLES } from './storyParser';
87
+ export type { ParsedStory, ParsedStoryPage, ParsedStoryExample, ParsedStoryExamplePage, StoryVariable } from './storyParser';
88
+
86
89
  export type { SlotId, ZoneInsets, TextAlign } from './types';
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Parser for .story files: ordered pages, each a purpose + composition with
3
+ * fixed, variable or open slots, plus example fills for the open slots.
4
+ *
5
+ * Strict, line-based format. Every line must match a known pattern or be blank.
6
+ * Throws on parse errors with file name and line number. Cross-file checks
7
+ * (purposes, compositions, formats, maxLength) live in `checkStory`.
8
+ */
9
+
10
+ import type { SlotId } from './types';
11
+ import type { ParsedPurpose } from './purposeParser';
12
+
13
+ // ── Parsed types ──────────────────────────────────────────────────────────────
14
+
15
+ /** Placeholders a page text may contain. Implementations resolve them for the story's date. */
16
+ export type StoryVariable = 'weekday' | 'date';
17
+
18
+ /** Longest resolved form per variable (German and English), used for the `maxLength` check. */
19
+ export const STORY_VARIABLES: Record<StoryVariable, { maxLength: number }> = {
20
+ weekday: { maxLength: 10 }, // "Donnerstag"
21
+ date: { maxLength: 13 }, // "30. September"
22
+ };
23
+
24
+ export interface ParsedStory {
25
+ id: string;
26
+ name: string;
27
+ description: string;
28
+ /** Format id every page renders at. */
29
+ format: string;
30
+ /** Story topic: what the whole story is about, one sentence. */
31
+ topic?: string;
32
+ pages: ParsedStoryPage[];
33
+ /** Example fills for the open slots, one story topic each. */
34
+ examples: ParsedStoryExample[];
35
+ }
36
+
37
+ export interface ParsedStoryPage {
38
+ purpose: string;
39
+ /** Composition id; undefined = the implementation picks one of the purpose's compositions. */
40
+ composition?: string;
41
+ /** Page topic: what this page adds to the story. */
42
+ topic?: string;
43
+ /** Fixed slot texts, may contain `{variable}`s. A slot the page doesn't name is open. */
44
+ texts: Partial<Record<SlotId, string>>;
45
+ }
46
+
47
+ /** One `example` block: texts for the open slots of several pages, about one story topic. */
48
+ export interface ParsedStoryExample {
49
+ topic?: string;
50
+ pages: ParsedStoryExamplePage[];
51
+ }
52
+
53
+ export interface ParsedStoryExamplePage {
54
+ /** 1-based page number. */
55
+ page: number;
56
+ topic?: string;
57
+ texts: Partial<Record<SlotId, string>>;
58
+ }
59
+
60
+ // ── Constants ─────────────────────────────────────────────────────────────────
61
+
62
+ const VALID_SLOTS = new Set<string>(['primary', 'secondary', 'detail', 'meta', 'cta', 'label']);
63
+ const VARIABLE = /\{([a-z][a-z0-9-]*)\}/g;
64
+
65
+ /** Variable names in a text, in order of appearance. */
66
+ export function storyTextVariables(text: string): string[] {
67
+ return [...text.matchAll(VARIABLE)].map(m => m[1]);
68
+ }
69
+
70
+ // ── Main parser ───────────────────────────────────────────────────────────────
71
+
72
+ export function parseStoryFile(content: string, fileName: string): ParsedStory {
73
+ const lines = content.split('\n');
74
+
75
+ let id = '';
76
+ let name = '';
77
+ let description = '';
78
+ let format = '';
79
+ let topic: string | undefined;
80
+ const pages: (ParsedStoryPage & { line: number; keys: Set<string> })[] = [];
81
+ const examples: (ParsedStoryExample & { line: number })[] = [];
82
+
83
+ let currentPage: (typeof pages)[number] | null = null;
84
+ let currentExample: (typeof examples)[number] | null = null;
85
+ // Page inside the current example, with the line it starts on
86
+ let currentExamplePage: (ParsedStoryExamplePage & { line: number; keys: Set<string> }) | null = null;
87
+ // Slot whose multi-line value is being read, and the indent of its lines
88
+ let multiKey: SlotId | null = null;
89
+ let multiIndent = 0;
90
+ const examplePageLines = new Map<ParsedStoryExamplePage, number>();
91
+
92
+ /** `key: value` line of a page or example page: topic, one of `extra`, or a slot text.
93
+ * Returns the `extra` key and value, or the slot of a bare `slot:` line. */
94
+ function readKey(
95
+ line: string,
96
+ lineNum: number,
97
+ target: { topic?: string; texts: Partial<Record<SlotId, string>>; keys: Set<string> },
98
+ extra: string[],
99
+ allowVariables: boolean,
100
+ ): { key: string; value: string } | { multi: SlotId } | null {
101
+ const colonIdx = line.indexOf(':');
102
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: expected "key: value", got "${line}"`);
103
+ const key = line.slice(0, colonIdx).trim();
104
+ const value = line.slice(colonIdx + 1).trim();
105
+ if (target.keys.has(key)) throw new Error(`${fileName}:${lineNum}: duplicate "${key}"`);
106
+ target.keys.add(key);
107
+ if (key === 'topic') {
108
+ if (!value) throw new Error(`${fileName}:${lineNum}: empty topic`);
109
+ target.topic = value;
110
+ return null;
111
+ }
112
+ if (extra.includes(key)) {
113
+ if (!value) throw new Error(`${fileName}:${lineNum}: empty ${key}`);
114
+ return { key, value };
115
+ }
116
+ if (!VALID_SLOTS.has(key)) throw new Error(`${fileName}:${lineNum}: unknown key "${key}" (expected ${[...extra, 'topic'].join(', ')} or a slot id)`);
117
+ if (!value) return { multi: key as SlotId };
118
+ checkVariables(value, lineNum, allowVariables);
119
+ target.texts[key as SlotId] = value;
120
+ return null;
121
+ }
122
+
123
+ function checkVariables(text: string, lineNum: number, allowed: boolean) {
124
+ for (const v of storyTextVariables(text)) {
125
+ if (!allowed) throw new Error(`${fileName}:${lineNum}: variable "{${v}}" in an example — examples are sample texts, variables belong in page texts`);
126
+ if (!(v in STORY_VARIABLES)) throw new Error(`${fileName}:${lineNum}: unknown variable "{${v}}" (expected ${Object.keys(STORY_VARIABLES).map(k => `{${k}}`).join(', ')})`);
127
+ }
128
+ }
129
+
130
+ function closeBlocks() {
131
+ currentPage = null;
132
+ currentExample = null;
133
+ currentExamplePage = null;
134
+ multiKey = null;
135
+ }
136
+
137
+ for (let i = 0; i < lines.length; i++) {
138
+ const lineNum = i + 1;
139
+ const raw = lines[i];
140
+ const line = raw.trim();
141
+ const indent = raw.length - raw.trimStart().length;
142
+
143
+ if (line === '') continue;
144
+
145
+ if (line.startsWith('#')) {
146
+ if (indent === 0) description = description ? `${description} ${line.slice(1).trim()}` : line.slice(1).trim();
147
+ continue;
148
+ }
149
+
150
+ // Multi-line slot text: lines deeper than the bare `slot:` line
151
+ if (multiKey && indent >= multiIndent) {
152
+ const target = currentExamplePage ?? currentPage!;
153
+ checkVariables(line, lineNum, !currentExamplePage);
154
+ const prev = target.texts[multiKey];
155
+ target.texts[multiKey] = prev ? `${prev}\n${line}` : line;
156
+ continue;
157
+ }
158
+ if (multiKey) {
159
+ const target = currentExamplePage ?? currentPage!;
160
+ if (!target.texts[multiKey]) throw new Error(`${fileName}:${lineNum}: "${multiKey}:" has no text (write it after the colon or on the following, deeper lines)`);
161
+ multiKey = null;
162
+ }
163
+
164
+ if (indent === 0) {
165
+ closeBlocks();
166
+ if (line === 'page') {
167
+ currentPage = { purpose: '', texts: {}, line: lineNum, keys: new Set() };
168
+ pages.push(currentPage);
169
+ continue;
170
+ }
171
+ if (line === 'example') {
172
+ currentExample = { pages: [], line: lineNum };
173
+ examples.push(currentExample);
174
+ continue;
175
+ }
176
+ const colonIdx = line.indexOf(':');
177
+ const key = colonIdx === -1 ? line : line.slice(0, colonIdx).trim();
178
+ const value = colonIdx === -1 ? '' : line.slice(colonIdx + 1).trim();
179
+ if (key === 'id' || key === 'name' || key === 'format' || key === 'topic') {
180
+ if (!value) throw new Error(`${fileName}:${lineNum}: empty ${key}`);
181
+ if (key === 'id') id = value;
182
+ else if (key === 'name') name = value;
183
+ else if (key === 'format') format = value;
184
+ else topic = value;
185
+ continue;
186
+ }
187
+ throw new Error(`${fileName}:${lineNum}: unexpected "${line}"`);
188
+ }
189
+
190
+ // Page lines (2-space)
191
+ if (currentPage) {
192
+ if (indent !== 2) throw new Error(`${fileName}:${lineNum}: unexpected indent in page (page keys use 2 spaces)`);
193
+ const read = readKey(line, lineNum, currentPage, ['purpose', 'composition'], true);
194
+ if (read && 'multi' in read) { multiKey = read.multi; multiIndent = 4; }
195
+ else if (read?.key === 'purpose') currentPage.purpose = read.value;
196
+ else if (read) currentPage.composition = read.value;
197
+ continue;
198
+ }
199
+
200
+ // Example lines: `topic:` and `page N` (2-space), page keys (4-space)
201
+ if (currentExample) {
202
+ if (indent === 2) {
203
+ currentExamplePage = null;
204
+ const pageHead = /^page\s+(\d+)$/.exec(line);
205
+ if (pageHead) {
206
+ const n = parseInt(pageHead[1], 10);
207
+ if (currentExample.pages.some(p => p.page === n)) throw new Error(`${fileName}:${lineNum}: duplicate "page ${n}" in example`);
208
+ currentExamplePage = { page: n, texts: {}, line: lineNum, keys: new Set() };
209
+ currentExample.pages.push(currentExamplePage);
210
+ examplePageLines.set(currentExamplePage, lineNum);
211
+ continue;
212
+ }
213
+ if (line.startsWith('topic:')) {
214
+ const value = line.slice(6).trim();
215
+ if (!value) throw new Error(`${fileName}:${lineNum}: empty topic`);
216
+ if (currentExample.topic) throw new Error(`${fileName}:${lineNum}: duplicate "topic" in example`);
217
+ currentExample.topic = value;
218
+ continue;
219
+ }
220
+ throw new Error(`${fileName}:${lineNum}: expected "topic:" or "page N" in example, got "${line}"`);
221
+ }
222
+ if (indent === 4 && currentExamplePage) {
223
+ const read = readKey(line, lineNum, currentExamplePage, [], false);
224
+ if (read && 'multi' in read) { multiKey = read.multi; multiIndent = 6; }
225
+ continue;
226
+ }
227
+ throw new Error(`${fileName}:${lineNum}: unexpected indent in example (page N at 2 spaces, its keys at 4)`);
228
+ }
229
+
230
+ throw new Error(`${fileName}:${lineNum}: unexpected "${line}"`);
231
+ }
232
+ if (multiKey) {
233
+ const target = currentExamplePage ?? currentPage!;
234
+ if (!target.texts[multiKey]) throw new Error(`${fileName}:${lines.length}: "${multiKey}:" has no text`);
235
+ }
236
+
237
+ if (!id) throw new Error(`${fileName}: missing story id (add "id:" line)`);
238
+ if (!format) throw new Error(`${fileName}: missing "format:" line`);
239
+ if (pages.length === 0) throw new Error(`${fileName}: no pages defined`);
240
+ for (const p of pages) {
241
+ if (!p.purpose) throw new Error(`${fileName}:${p.line}: page without "purpose:"`);
242
+ }
243
+ for (const ex of examples) {
244
+ if (ex.pages.length === 0) throw new Error(`${fileName}:${ex.line}: example without pages`);
245
+ for (const ep of ex.pages) {
246
+ const at = `${fileName}:${examplePageLines.get(ep)}`;
247
+ const page = pages[ep.page - 1];
248
+ if (!page) throw new Error(`${at}: example fills page ${ep.page}, the story has ${pages.length}`);
249
+ if (Object.keys(ep.texts).length === 0 && !ep.topic) throw new Error(`${at}: example page ${ep.page} is empty`);
250
+ for (const slot of Object.keys(ep.texts)) {
251
+ if (page.texts[slot as SlotId] !== undefined) throw new Error(`${at}: slot "${slot}" is fixed on page ${ep.page} and takes no example text`);
252
+ }
253
+ }
254
+ }
255
+
256
+ if (!name) name = id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
257
+
258
+ return {
259
+ id,
260
+ name,
261
+ description,
262
+ format,
263
+ topic,
264
+ pages: pages.map(({ line: _line, keys: _keys, ...p }) => p),
265
+ examples: examples.map(({ line: _line, ...ex }) => ({
266
+ ...ex,
267
+ pages: ex.pages.map(p => {
268
+ const { line: _l, keys: _k, ...rest } = p as ParsedStoryExamplePage & { line: number; keys: Set<string> };
269
+ return rest;
270
+ }),
271
+ })),
272
+ };
273
+ }
274
+
275
+ // ── Cross-file checks ─────────────────────────────────────────────────────────
276
+
277
+ /** Checks a story against the brand's other files. Returns problems as "file: message";
278
+ * empty when the story fits. Variables count at their longest form for `maxLength`. */
279
+ export function checkStory(
280
+ story: ParsedStory,
281
+ brand: { purposes: ParsedPurpose[]; compositions: string[]; formats: string[] },
282
+ fileName: string,
283
+ ): string[] {
284
+ const problems: string[] = [];
285
+ const say = (msg: string) => problems.push(`${fileName}: ${msg}`);
286
+ if (!brand.formats.includes(story.format)) say(`format "${story.format}" does not exist`);
287
+
288
+ const purposes = new Map(brand.purposes.map(p => [p.id, p]));
289
+ const checkTexts = (where: string, purpose: ParsedPurpose, texts: Partial<Record<SlotId, string>>) => {
290
+ for (const [slotId, text] of Object.entries(texts) as [SlotId, string][]) {
291
+ const slot = purpose.slots.find(s => s.id === slotId);
292
+ if (!slot) { say(`${where}: slot "${slotId}" is not declared by purpose "${purpose.id}"`); continue; }
293
+ if (slot.value) { say(`${where}: slot "${slotId}" has a fixed value in purpose "${purpose.id}"`); continue; }
294
+ const longest = text.replace(VARIABLE, (_, v: string) => 'x'.repeat(STORY_VARIABLES[v as StoryVariable]?.maxLength ?? 0));
295
+ const parts = slot.slotType === 'list' ? longest.split('\n') : [longest];
296
+ if (slot.slotType === 'list' && slot.maxItems && parts.length > slot.maxItems) say(`${where}: slot "${slotId}" has ${parts.length} items, maxItems is ${slot.maxItems}`);
297
+ for (const part of parts) {
298
+ if (part.length > slot.maxLength) say(`${where}: slot "${slotId}" is ${part.length} characters long, maxLength is ${slot.maxLength}`);
299
+ }
300
+ }
301
+ };
302
+
303
+ story.pages.forEach((page, i) => {
304
+ const where = `page ${i + 1}`;
305
+ const purpose = purposes.get(page.purpose);
306
+ if (!purpose) { say(`${where}: purpose "${page.purpose}" does not exist`); return; }
307
+ if (page.composition) {
308
+ if (!brand.compositions.includes(page.composition)) say(`${where}: composition "${page.composition}" does not exist`);
309
+ else if (!purpose.preferredCompositions.includes(page.composition)) say(`${where}: composition "${page.composition}" is not one of purpose "${purpose.id}"'s compositions`);
310
+ }
311
+ checkTexts(where, purpose, page.texts);
312
+ });
313
+ story.examples.forEach((ex, i) => {
314
+ for (const ep of ex.pages) {
315
+ const purpose = purposes.get(story.pages[ep.page - 1].purpose);
316
+ if (purpose) checkTexts(`example ${i + 1}, page ${ep.page}`, purpose, ep.texts);
317
+ }
318
+ });
319
+ return problems;
320
+ }
@@ -535,7 +535,7 @@
535
535
  }
536
536
  },
537
537
  "string-field": {
538
- "match": "^(language|locale|domain|app-icon|favicon|og-image|figma-file-key|figma-collection|tokens-file|spacing):?\\s*(.+)?",
538
+ "match": "^(language|locale|app-icon|favicon|og-image|figma-file-key|figma-collection|tokens-file|spacing):?\\s*(.+)?",
539
539
  "captures": {
540
540
  "1": { "name": "support.type.property-name.brand-intent-brand" },
541
541
  "2": { "name": "string.unquoted.brand-intent-brand" }
@@ -543,7 +543,7 @@
543
543
  },
544
544
  "keyword-partial": {
545
545
  "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
546
- "match": "^(id:|name:|language:|locale:|domain:|app-icon:|favicon:|og-image:|tokens:|figma-file-key:|figma-collection:|tokens-file:|spacing:|label:|cta:|color-mode:|color-strategy:|color-adherence:|brand-colors|state-colors|color-bounds|color-rules|color-families|theme|divider|font|typography|logo|photo|video|illustration|icons|counter|framing|windows)(?=\\s|$)",
546
+ "match": "^(id:|name:|language:|locale:|app-icon:|favicon:|og-image:|tokens:|figma-file-key:|figma-collection:|tokens-file:|spacing:|label:|cta:|color-mode:|color-strategy:|color-adherence:|brand-colors|state-colors|color-bounds|color-rules|color-families|theme|divider|font|typography|logo|photo|video|illustration|icons|counter|framing|windows)(?=\\s|$)",
547
547
  "captures": {
548
548
  "1": { "name": "keyword.control.brand-intent-brand" }
549
549
  }
@@ -6,7 +6,6 @@
6
6
  { "include": "#id-field" },
7
7
  { "include": "#name-field" },
8
8
  { "include": "#category" },
9
- { "include": "#no" },
10
9
  { "include": "#purposes" },
11
10
  { "include": "#size" },
12
11
  { "include": "#grid" },
@@ -41,13 +40,6 @@
41
40
  "2": { "name": "entity.name.tag.brand-intent-format" }
42
41
  }
43
42
  },
44
- "no": {
45
- "match": "^(no:)\\s+(.*)",
46
- "captures": {
47
- "1": { "name": "keyword.control.brand-intent-format" },
48
- "2": { "name": "string.unquoted.brand-intent-format" }
49
- }
50
- },
51
43
  "purposes": {
52
44
  "match": "^(purposes:)\\s+(.*)",
53
45
  "captures": {
@@ -94,7 +86,7 @@
94
86
  },
95
87
  "keyword-partial": {
96
88
  "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
97
- "match": "^(id:|name:|category:|no:|purposes:|size:|grid:|dpi:|bleed:|comfort:|danger:|crop:|label:|sublabel:)(?=\\s|$)",
89
+ "match": "^(id:|name:|category:|purposes:|size:|grid:|dpi:|bleed:|comfort:|danger:|crop:|label:|sublabel:)(?=\\s|$)",
98
90
  "captures": {
99
91
  "1": { "name": "keyword.control.brand-intent-format" }
100
92
  }
@@ -0,0 +1,77 @@
1
+ {
2
+ "scopeName": "source.brand-intent-story",
3
+ "name": "Brand Intent Story",
4
+ "patterns": [
5
+ { "include": "#comment" },
6
+ { "include": "#top-field" },
7
+ { "include": "#block-header" },
8
+ { "include": "#example-page" },
9
+ { "include": "#reference" },
10
+ { "include": "#property" },
11
+ { "include": "#variable" },
12
+ { "include": "#keyword-partial" }
13
+ ],
14
+ "repository": {
15
+ "comment": {
16
+ "match": "^\\s*#.*$",
17
+ "name": "comment.line.number-sign.brand-intent-story"
18
+ },
19
+ "top-field": {
20
+ "match": "^(id:|format:)\\s+(.*)",
21
+ "captures": {
22
+ "1": { "name": "keyword.control.brand-intent-story" },
23
+ "2": { "name": "entity.name.type.brand-intent-story" }
24
+ }
25
+ },
26
+ "block-header": {
27
+ "match": "^(page|example)\\s*$",
28
+ "captures": {
29
+ "1": { "name": "keyword.control.brand-intent-story" }
30
+ }
31
+ },
32
+ "example-page": {
33
+ "match": "^\\s{2}(page)\\s+(\\d+)\\s*$",
34
+ "captures": {
35
+ "1": { "name": "keyword.control.brand-intent-story" },
36
+ "2": { "name": "constant.numeric.brand-intent-story" }
37
+ }
38
+ },
39
+ "reference": {
40
+ "match": "^\\s+(purpose|composition)(:)\\s+([\\w:-]+)",
41
+ "captures": {
42
+ "1": { "name": "support.type.property-name.brand-intent-story" },
43
+ "2": { "name": "punctuation.separator.brand-intent-story" },
44
+ "3": { "name": "entity.name.type.brand-intent-story" }
45
+ }
46
+ },
47
+ "property": {
48
+ "match": "^\\s*(name|topic|primary|secondary|detail|meta|cta|label)(:)",
49
+ "captures": {
50
+ "1": { "name": "support.type.property-name.brand-intent-story" },
51
+ "2": { "name": "punctuation.separator.brand-intent-story" }
52
+ }
53
+ },
54
+ "variable": {
55
+ "comment": "{weekday}, {date} placeholders in page texts",
56
+ "match": "\\{[a-z][a-z0-9-]*\\}",
57
+ "name": "variable.other.brand-intent-story"
58
+ },
59
+ "keyword-partial": {
60
+ "comment": "Fallback: color the keyword token on incomplete lines (while typing)",
61
+ "patterns": [
62
+ {
63
+ "match": "^(id:|name:|format:|topic:|page|example)(?=\\s|$)",
64
+ "captures": {
65
+ "1": { "name": "keyword.control.brand-intent-story" }
66
+ }
67
+ },
68
+ {
69
+ "match": "^\\s+(purpose|composition|topic|primary|secondary|detail|meta|cta|label)(?=:|\\s|$)",
70
+ "captures": {
71
+ "1": { "name": "support.type.property-name.brand-intent-story" }
72
+ }
73
+ }
74
+ ]
75
+ }
76
+ }
77
+ }