brand-intent 0.24.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 +4 -2
- package/cli/index.mjs +1 -1
- package/examples/krume/stories/breads-of-the-week.story +42 -0
- package/package.json +2 -2
- package/parser/README.md +2 -1
- package/parser/index.ts +4 -1
- package/parser/storyParser.ts +320 -0
- package/vscode-extension/syntaxes/story.tmLanguage.json +77 -0
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
package/parser/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Brand Intent — reference parser
|
|
2
|
-
// All
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|