brand-intent 0.21.0 → 0.23.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/examples/krume/krume.identity +70 -0
- package/examples/krume/purposes/daily-bread.purpose +24 -16
- package/package.json +1 -1
- package/parser/README.md +1 -0
- package/parser/brandParser.ts +27 -3
- package/parser/identityParser.ts +226 -21
- package/parser/index.ts +2 -2
- package/parser/purposeParser.ts +179 -16
- package/skills/brand-intent.md +20 -6
- package/skills/brief.md +31 -1
- package/vscode-extension/syntaxes/brand.tmLanguage.json +1 -1
- package/vscode-extension/syntaxes/identity.tmLanguage.json +4 -4
- package/vscode-extension/syntaxes/purpose.tmLanguage.json +13 -6
|
@@ -65,6 +65,14 @@ voice
|
|
|
65
65
|
- Superlative ohne Substanz (bestes Brot, einzigartiger Geschmack)
|
|
66
66
|
- Wellness- oder Gesundheitsversprechen (gut für dich, clean eating)
|
|
67
67
|
|
|
68
|
+
# instead: the phrase to avoid and what Krume writes instead.
|
|
69
|
+
# A pair teaches the rewrite, not just the ban.
|
|
70
|
+
|
|
71
|
+
instead:
|
|
72
|
+
- "beste Zutaten" -> Tipo 1 und Roggen 1150 aus der Mühle in Dachau
|
|
73
|
+
- "mit Liebe gebacken" -> achtzehn Stunden Teigruhe
|
|
74
|
+
- "frisch aus dem Ofen" -> seit 6 Uhr im Regal
|
|
75
|
+
|
|
68
76
|
# ── Values ──
|
|
69
77
|
# Not keywords - behavior statements. Each value describes
|
|
70
78
|
# how it manifests in what we say and do.
|
|
@@ -74,11 +82,15 @@ values
|
|
|
74
82
|
Wir benennen den Prozess, nicht das Ergebnis.
|
|
75
83
|
Backzeit in Stunden, Mehlsorte beim Namen.
|
|
76
84
|
Kein Brot ohne nachvollziehbaren Weg vom Korn zum Laib.
|
|
85
|
+
say: Der Roggen steht seit gestern Abend.
|
|
86
|
+
not: Mit Liebe handgemacht.
|
|
77
87
|
|
|
78
88
|
Ehrlichkeit
|
|
79
89
|
Kein Brot ohne Zutatenliste. Keine Versprechen
|
|
80
90
|
die der Teig nicht halten kann. Wenn etwas
|
|
81
91
|
nicht gelungen ist, sagen wir das.
|
|
92
|
+
say: Heute kein Dinkel. Der Teig wollte nicht.
|
|
93
|
+
not: Leider ausverkauft, schau morgen wieder vorbei!
|
|
82
94
|
|
|
83
95
|
Kiez
|
|
84
96
|
Der Laden gehört zur Weserstraße wie der Späti
|
|
@@ -125,3 +137,61 @@ anti-audience discount-hunters
|
|
|
125
137
|
Supermarkt-Toastbrot vergleicht, ist hier falsch.
|
|
126
138
|
Wir erklären gerne warum es kostet was es kostet -
|
|
127
139
|
aber wir rechtfertigen uns nicht.
|
|
140
|
+
|
|
141
|
+
# ── Imagery ──
|
|
142
|
+
# The image counterpart of voice: yes/no rules for photos and film.
|
|
143
|
+
# Framing and colour treatment live in krume.brand.
|
|
144
|
+
|
|
145
|
+
imagery
|
|
146
|
+
always:
|
|
147
|
+
- echte Arbeitsschritte in der Backstube, nichts nachgestellt
|
|
148
|
+
- Mehl, Teig und Werkzeug mit Gebrauchsspuren
|
|
149
|
+
- Tageslicht oder das Licht der Backstube
|
|
150
|
+
never:
|
|
151
|
+
- gestellte Lächeln in die Kamera
|
|
152
|
+
- Brot als Deko auf Leinen und Holzbrett
|
|
153
|
+
- KI-generierte oder KI-veränderte Bildinhalte
|
|
154
|
+
|
|
155
|
+
image-theme Vier Uhr morgens
|
|
156
|
+
Die Backstube vor Ladenöffnung: Hände im Teig,
|
|
157
|
+
Mehl auf der Arbeitsfläche, der Ofen offen.
|
|
158
|
+
|
|
159
|
+
image-theme Über den Tresen
|
|
160
|
+
Stammkunden und Laufkundschaft am Tresen,
|
|
161
|
+
Brot wechselt die Hand, die Weserstraße im Hintergrund.
|
|
162
|
+
|
|
163
|
+
# ── Goals ──
|
|
164
|
+
# What a post is for, independent of its format.
|
|
165
|
+
# Shares: monthly target for Instagram.
|
|
166
|
+
|
|
167
|
+
goal craft
|
|
168
|
+
label: Handwerk zeigen
|
|
169
|
+
share: 60%
|
|
170
|
+
question: Wie entsteht das Brot?
|
|
171
|
+
always:
|
|
172
|
+
- einen konkreten Arbeitsschritt, eine Mehlsorte oder eine Zeit nennen
|
|
173
|
+
|
|
174
|
+
goal kiez
|
|
175
|
+
label: Kiez
|
|
176
|
+
share: 30%
|
|
177
|
+
question: Wer kommt hierher und warum?
|
|
178
|
+
always:
|
|
179
|
+
- eine Person mit Vornamen und ihrem Grund zu kommen
|
|
180
|
+
|
|
181
|
+
goal service
|
|
182
|
+
label: Service
|
|
183
|
+
share: 10%
|
|
184
|
+
question: Was gibt es heute, wann ist offen?
|
|
185
|
+
always:
|
|
186
|
+
- Tag, Uhrzeit und Sorte konkret nennen
|
|
187
|
+
|
|
188
|
+
# ── Review ──
|
|
189
|
+
# Asked before anything goes out. Each one is a yes/no question.
|
|
190
|
+
|
|
191
|
+
review
|
|
192
|
+
copy:
|
|
193
|
+
- Nennt der Text Sorte, Mehl, Gewicht oder Backzeit?
|
|
194
|
+
- Klingt er wie die Bäckerin hinterm Tresen?
|
|
195
|
+
image:
|
|
196
|
+
- Zeigt das Bild einen echten Arbeitsschritt oder echte Kundschaft?
|
|
197
|
+
- Ist das Brot Lebensmittel, nicht Dekoration?
|
|
@@ -42,37 +42,45 @@ slot primary
|
|
|
42
42
|
typography: headline
|
|
43
43
|
color: text-primary
|
|
44
44
|
maxLength: 30
|
|
45
|
-
samples:
|
|
46
|
-
- Roggenlaib
|
|
47
|
-
- Weizenmisch
|
|
48
|
-
- Dinkelvollkorn
|
|
49
45
|
|
|
50
46
|
slot secondary
|
|
51
47
|
label: Gewicht & Preis
|
|
52
48
|
typography: kicker
|
|
53
49
|
color: text-secondary
|
|
54
50
|
maxLength: 25
|
|
55
|
-
samples:
|
|
56
|
-
- 750 g · 5,80 €
|
|
57
|
-
- 500 g · 4,20 €
|
|
58
|
-
- 1 kg · 7,50 €
|
|
59
51
|
|
|
60
52
|
slot meta
|
|
61
53
|
label: Beschreibung
|
|
62
54
|
typography: caption
|
|
63
55
|
color: text-secondary
|
|
64
56
|
maxLength: 100
|
|
65
|
-
samples:
|
|
66
|
-
- Roggen 1150, 18 Stunden Teigführung, Kümmel aus dem Wendland.
|
|
67
|
-
- Tipo 1 aus der Drax-Mühle, 72 % Hydration.
|
|
68
|
-
- Saisonale Walnüsse vom Wochenmarkt, Emmer-Vollkorn.
|
|
69
57
|
|
|
70
58
|
slot label
|
|
71
59
|
label: Badge
|
|
72
60
|
typography: label
|
|
73
61
|
color: label-fg
|
|
74
62
|
maxLength: 20
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
63
|
+
|
|
64
|
+
# ── Examples ──
|
|
65
|
+
# Whole pages: all slots written together about one bread.
|
|
66
|
+
|
|
67
|
+
example
|
|
68
|
+
topic: The classic rye loaf, today's bread
|
|
69
|
+
label: Heute frisch
|
|
70
|
+
primary: Roggenlaib
|
|
71
|
+
secondary: 750 g · 5,80 €
|
|
72
|
+
meta: Roggen 1150, 18 Stunden Teigführung, Kümmel aus dem Wendland.
|
|
73
|
+
|
|
74
|
+
example
|
|
75
|
+
topic: The wheat blend, only on Saturdays
|
|
76
|
+
label: Nur samstags
|
|
77
|
+
primary: Weizenmisch
|
|
78
|
+
secondary: 500 g · 4,20 €
|
|
79
|
+
meta: Tipo 1 aus der Drax-Mühle, 72 % Hydration.
|
|
80
|
+
|
|
81
|
+
example
|
|
82
|
+
topic: New in the shop: emmer bread with walnuts
|
|
83
|
+
label: Neu im Laden
|
|
84
|
+
primary: Emmer-Walnuss
|
|
85
|
+
secondary: 1 kg · 7,50 €
|
|
86
|
+
meta: Saisonale Walnüsse vom Wochenmarkt, Emmer-Vollkorn.
|
package/package.json
CHANGED
package/parser/README.md
CHANGED
|
@@ -34,3 +34,4 @@ const brand = parseBrandFile(
|
|
|
34
34
|
## Notes
|
|
35
35
|
|
|
36
36
|
- These parsers throw on validation errors, with the filename and line number included in the error message.
|
|
37
|
+
- Non-fatal findings (deprecated syntax) are returned in `warnings` (`parsePurposeFile`) as `file:line: message`, so implementations can show them in their UI.
|
package/parser/brandParser.ts
CHANGED
|
@@ -169,6 +169,10 @@ export interface ParsedFramingDef {
|
|
|
169
169
|
horizon: number | null;
|
|
170
170
|
/** The photographic task, one line. */
|
|
171
171
|
description?: string;
|
|
172
|
+
/** What a text adds to a photo in this framing, one line. */
|
|
173
|
+
caption?: string;
|
|
174
|
+
/** Identity goal ids this framing serves. */
|
|
175
|
+
goals?: string[];
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
export type CounterFormat = '1' | '01' | '1.' | '(1)' | 'i';
|
|
@@ -209,6 +213,14 @@ export interface ParsedAssetDef {
|
|
|
209
213
|
group?: string;
|
|
210
214
|
/** Photos only: name of the date window the photo fits (`when:`). Absent → all year. */
|
|
211
215
|
when?: string;
|
|
216
|
+
/** Photos only: the framing module the photo was taken in. */
|
|
217
|
+
framing?: string;
|
|
218
|
+
/** Photos only: where the photo was taken. */
|
|
219
|
+
place?: string;
|
|
220
|
+
/** Photos only: who is shown, with name and role. */
|
|
221
|
+
person?: string;
|
|
222
|
+
/** Photos only: date taken, YYYY-MM-DD. */
|
|
223
|
+
taken?: string;
|
|
212
224
|
}
|
|
213
225
|
|
|
214
226
|
export type LogoAlign = 'left' | 'center' | 'right';
|
|
@@ -1784,6 +1796,9 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1784
1796
|
if (raw.props['generator']) def.generator = raw.props['generator'];
|
|
1785
1797
|
if (raw.props['group']) def.group = raw.props['group'];
|
|
1786
1798
|
if (raw.props['when']) def.when = raw.props['when'];
|
|
1799
|
+
for (const key of ['framing', 'place', 'person', 'taken'] as const) {
|
|
1800
|
+
if (raw.props[key]) def[key] = raw.props[key];
|
|
1801
|
+
}
|
|
1787
1802
|
// generator-params are stored as "generator-params.KEY" by the sub-block parser.
|
|
1788
1803
|
const paramEntries: [string, number][] = [];
|
|
1789
1804
|
for (const [k, v] of Object.entries(raw.props)) {
|
|
@@ -1814,13 +1829,20 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1814
1829
|
}
|
|
1815
1830
|
result.windows = windows;
|
|
1816
1831
|
}
|
|
1832
|
+
const photoOnly = ['when', 'framing', 'place', 'person', 'taken'];
|
|
1817
1833
|
for (const [kind, arr] of [['logo', logosRaw], ['video', videosRaw], ['illustration', illustrationsRaw]] as const) {
|
|
1818
|
-
const
|
|
1819
|
-
|
|
1834
|
+
for (const key of photoOnly) {
|
|
1835
|
+
const a = arr.find(r => r.props[key]);
|
|
1836
|
+
if (a) throw new Error(`${fileName}: ${kind} "${a.id}": ${key}: is for photos only`);
|
|
1837
|
+
}
|
|
1820
1838
|
}
|
|
1821
1839
|
for (const p of photosRaw) {
|
|
1822
1840
|
const when = p.props['when'];
|
|
1823
1841
|
if (when && !result.windows?.[when]) throw new Error(`${fileName}: photo "${p.id}" references unknown window "${when}"`);
|
|
1842
|
+
const taken = p.props['taken'];
|
|
1843
|
+
if (taken && !/^\d{4}-\d{2}-\d{2}$/.test(taken)) throw new Error(`${fileName}: photo "${p.id}": taken: must be YYYY-MM-DD, got "${taken}"`);
|
|
1844
|
+
const framing = p.props['framing'];
|
|
1845
|
+
if (framing && !framingsRaw.some(f => f.id === framing)) throw new Error(`${fileName}: photo "${p.id}" references unknown framing "${framing}"`);
|
|
1824
1846
|
}
|
|
1825
1847
|
|
|
1826
1848
|
if (logosRaw.length > 0 || photosRaw.length > 0 || videosRaw.length > 0 || illustrationsRaw.length > 0) {
|
|
@@ -1987,7 +2009,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1987
2009
|
|
|
1988
2010
|
// Finalise framing modules
|
|
1989
2011
|
if (framingsRaw.length > 0) {
|
|
1990
|
-
const known = new Set(['label', 'horizon', 'description']);
|
|
2012
|
+
const known = new Set(['label', 'horizon', 'description', 'caption', 'goals']);
|
|
1991
2013
|
result.framings = framingsRaw.map(({ id, line, props }) => {
|
|
1992
2014
|
for (const [key, { line: l }] of Object.entries(props)) {
|
|
1993
2015
|
if (!known.has(key)) throw new Error(`${fileName}:${l}: unknown framing property "${key}"`);
|
|
@@ -2003,6 +2025,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
2003
2025
|
}
|
|
2004
2026
|
const def: ParsedFramingDef = { id, label: props['label']?.value || id, horizon };
|
|
2005
2027
|
if (props['description']?.value) def.description = props['description'].value;
|
|
2028
|
+
if (props['caption']?.value) def.caption = props['caption'].value;
|
|
2029
|
+
if (props['goals']?.value) def.goals = props['goals'].value.split(',').map(g => g.trim()).filter(Boolean);
|
|
2006
2030
|
return def;
|
|
2007
2031
|
});
|
|
2008
2032
|
}
|
package/parser/identityParser.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Parser for .identity brand identity files.
|
|
3
3
|
*
|
|
4
4
|
* Line-based format with indentation-based blocks.
|
|
5
|
-
* Contains brand strategy: essence, promise, voice, pillars, audiences, values
|
|
5
|
+
* Contains brand strategy: essence, promise, voice, pillars, audiences, values,
|
|
6
|
+
* imagery, goals and review checks.
|
|
6
7
|
* No visual properties of any kind.
|
|
7
8
|
*/
|
|
8
9
|
|
|
@@ -40,6 +41,14 @@ export interface ParsedIdentity {
|
|
|
40
41
|
values?: ParsedValue[];
|
|
41
42
|
/** Explicitly not-our-audience segments keyed by ID */
|
|
42
43
|
antiAudiences?: Record<string, ParsedAntiAudience>;
|
|
44
|
+
/** Rules for photos and film: what the imagery always shows and never shows */
|
|
45
|
+
imagery?: ParsedImagery;
|
|
46
|
+
/** Named image themes, in declaration order */
|
|
47
|
+
imageThemes?: ParsedImageTheme[];
|
|
48
|
+
/** Communication goals keyed by ID (e.g. the shares of a content mix), in declaration order */
|
|
49
|
+
goals?: Record<string, ParsedGoal>;
|
|
50
|
+
/** Questions to check before publishing */
|
|
51
|
+
review?: ParsedReview;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export interface ParsedVoice {
|
|
@@ -53,6 +62,15 @@ export interface ParsedVoice {
|
|
|
53
62
|
always: string[];
|
|
54
63
|
/** Concrete rules the brand never breaks */
|
|
55
64
|
never: string[];
|
|
65
|
+
/** Phrases to replace, each with the brand's own wording */
|
|
66
|
+
instead?: ParsedReplacement[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ParsedReplacement {
|
|
70
|
+
/** The phrase to avoid */
|
|
71
|
+
avoid: string;
|
|
72
|
+
/** What the brand writes instead */
|
|
73
|
+
use: string;
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
export interface ParsedPillars {
|
|
@@ -96,13 +114,56 @@ export interface ParsedValue {
|
|
|
96
114
|
name: string;
|
|
97
115
|
/** Behavior statement — what this value means in practice */
|
|
98
116
|
behavior: string;
|
|
117
|
+
/** Example lines that carry this value */
|
|
118
|
+
say?: string[];
|
|
119
|
+
/** Counter-examples: lines that miss it */
|
|
120
|
+
not?: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ParsedImagery {
|
|
124
|
+
/** What every photo and film shows */
|
|
125
|
+
always: string[];
|
|
126
|
+
/** What the imagery never shows */
|
|
127
|
+
never: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface ParsedImageTheme {
|
|
131
|
+
/** Theme title (free-form) */
|
|
132
|
+
name: string;
|
|
133
|
+
/** Name of the narrative this theme belongs to */
|
|
134
|
+
narrative?: string;
|
|
135
|
+
/** What the images show */
|
|
136
|
+
text: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ParsedGoal {
|
|
140
|
+
/** Human-readable goal name */
|
|
141
|
+
label: string;
|
|
142
|
+
/** Target share of the content mix, 0–100 */
|
|
143
|
+
share?: number;
|
|
144
|
+
/** The question content for this goal answers */
|
|
145
|
+
question?: string;
|
|
146
|
+
/** Narratives this goal draws on */
|
|
147
|
+
narratives?: string[];
|
|
148
|
+
/** Rules content for this goal follows */
|
|
149
|
+
always?: string[];
|
|
150
|
+
/** Rules content for this goal never breaks */
|
|
151
|
+
never?: string[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ParsedReview {
|
|
155
|
+
/** Questions for texts */
|
|
156
|
+
copy?: string[];
|
|
157
|
+
/** Questions for photos and film */
|
|
158
|
+
image?: string[];
|
|
99
159
|
}
|
|
100
160
|
|
|
101
161
|
// ── Parser ────────────────────────────────────────────────────────────────────
|
|
102
162
|
|
|
103
|
-
type Block = 'top' | 'voice' | 'pillars' | 'audience' | 'anti-audience' | 'values' | 'narrative';
|
|
163
|
+
type Block = 'top' | 'voice' | 'pillars' | 'audience' | 'anti-audience' | 'values' | 'narrative' | 'imagery' | 'image-theme' | 'goal' | 'review';
|
|
104
164
|
type NarrativeState = { name: string; primary: boolean; text: string };
|
|
105
|
-
type ListTarget = 'always' | 'never' | 'primary' | 'secondary' | 'avoid'
|
|
165
|
+
type ListTarget = 'always' | 'never' | 'instead' | 'primary' | 'secondary' | 'avoid'
|
|
166
|
+
| 'imagery-always' | 'imagery-never' | 'goal-always' | 'goal-never' | 'review-copy' | 'review-image';
|
|
106
167
|
|
|
107
168
|
export function parseIdentityFile(content: string, fileName: string): ParsedIdentity {
|
|
108
169
|
const lines = content.split('\n');
|
|
@@ -122,12 +183,22 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
122
183
|
const audiences: Record<string, Partial<ParsedAudience>> = {};
|
|
123
184
|
const antiAudiences: Record<string, Partial<ParsedAntiAudience>> = {};
|
|
124
185
|
const values: ParsedValue[] = [];
|
|
186
|
+
const imagery: ParsedImagery = { always: [], never: [] };
|
|
187
|
+
let imageryDeclared = false;
|
|
188
|
+
const imageThemes: ParsedImageTheme[] = [];
|
|
189
|
+
let currentImageTheme: ParsedImageTheme | null = null;
|
|
190
|
+
const goals: Record<string, Partial<ParsedGoal> & { line: number }> = {};
|
|
191
|
+
let currentGoal = '';
|
|
192
|
+
const review: ParsedReview = {};
|
|
193
|
+
let reviewDeclared = false;
|
|
125
194
|
|
|
126
195
|
let block: Block = 'top';
|
|
127
196
|
let currentAudience = '';
|
|
128
197
|
let currentAntiAudience = '';
|
|
129
198
|
let currentValueName = '';
|
|
130
199
|
let currentValueBehavior = '';
|
|
200
|
+
let currentValueSay: string[] = [];
|
|
201
|
+
let currentValueNot: string[] = [];
|
|
131
202
|
let listTarget: ListTarget | null = null;
|
|
132
203
|
let continuationKey: string | null = null;
|
|
133
204
|
|
|
@@ -144,10 +215,29 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
144
215
|
|
|
145
216
|
function flushValue() {
|
|
146
217
|
if (currentValueName && currentValueBehavior) {
|
|
147
|
-
|
|
218
|
+
const value: ParsedValue = { name: currentValueName, behavior: currentValueBehavior.trim() };
|
|
219
|
+
if (currentValueSay.length > 0) value.say = currentValueSay;
|
|
220
|
+
if (currentValueNot.length > 0) value.not = currentValueNot;
|
|
221
|
+
values.push(value);
|
|
148
222
|
}
|
|
149
223
|
currentValueName = '';
|
|
150
224
|
currentValueBehavior = '';
|
|
225
|
+
currentValueSay = [];
|
|
226
|
+
currentValueNot = [];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function flushImageTheme() {
|
|
230
|
+
if (currentImageTheme && currentImageTheme.text) {
|
|
231
|
+
imageThemes.push({ ...currentImageTheme, text: currentImageTheme.text.trim() });
|
|
232
|
+
}
|
|
233
|
+
currentImageTheme = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Close the free-text blocks before a new block or top-level key starts. */
|
|
237
|
+
function flushBlock() {
|
|
238
|
+
if (block === 'values') flushValue();
|
|
239
|
+
if (block === 'narrative') flushNarrative();
|
|
240
|
+
if (block === 'image-theme') flushImageTheme();
|
|
151
241
|
}
|
|
152
242
|
|
|
153
243
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -167,10 +257,13 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
167
257
|
if (block === 'values' && /^\s{2,}/.test(raw) && !raw.trim().startsWith('#')) {
|
|
168
258
|
const indent = raw.match(/^(\s*)/)?.[1].length ?? 0;
|
|
169
259
|
const text = raw.trim();
|
|
260
|
+
const example = text.match(/^(say|not):\s*(.+)$/);
|
|
170
261
|
if (indent <= 2 && !text.includes(':')) {
|
|
171
262
|
// New value name (2-space indent)
|
|
172
263
|
flushValue();
|
|
173
264
|
currentValueName = text;
|
|
265
|
+
} else if (currentValueName && example) {
|
|
266
|
+
(example[1] === 'say' ? currentValueSay : currentValueNot).push(example[2].trim());
|
|
174
267
|
} else if (currentValueName) {
|
|
175
268
|
// Behavior text (4+ space indent) or continuation
|
|
176
269
|
currentValueBehavior += (currentValueBehavior ? ' ' : '') + text;
|
|
@@ -189,6 +282,15 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
189
282
|
continue;
|
|
190
283
|
}
|
|
191
284
|
|
|
285
|
+
// ── Image theme block: optional narrative reference, then text lines ──
|
|
286
|
+
if (block === 'image-theme' && currentImageTheme && /^\s{2,}/.test(raw) && !raw.trim().startsWith('#')) {
|
|
287
|
+
const trimmed = raw.trim();
|
|
288
|
+
const ref = trimmed.match(/^narrative:\s*(.+)$/);
|
|
289
|
+
if (ref) currentImageTheme.narrative = ref[1].trim();
|
|
290
|
+
else currentImageTheme.text += (currentImageTheme.text ? '\n' : '') + trimmed;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
192
294
|
// ── Indented continuation lines ──
|
|
193
295
|
if (/^\s{2,}/.test(raw) && !raw.trim().startsWith('-') && !raw.trim().includes(':')) {
|
|
194
296
|
const continuation = raw.trim();
|
|
@@ -215,6 +317,19 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
215
317
|
case 'primary': pillars.primary!.push(value); break;
|
|
216
318
|
case 'secondary': (pillars.secondary ??= []).push(value); break;
|
|
217
319
|
case 'avoid': pillars.avoid!.push(value); break;
|
|
320
|
+
case 'instead': {
|
|
321
|
+
const arrow = value.indexOf('->');
|
|
322
|
+
if (arrow === -1) throw new Error(`${fileName}:${lineNum}: voice.instead item must be "PHRASE -> REPLACEMENT", got "${value}"`);
|
|
323
|
+
const unquote = (s: string) => s.trim().replace(/^["„“](.*)["“”]$/, '$1');
|
|
324
|
+
(voice.instead ??= []).push({ avoid: unquote(value.slice(0, arrow)), use: unquote(value.slice(arrow + 2)) });
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case 'imagery-always': imagery.always.push(value); break;
|
|
328
|
+
case 'imagery-never': imagery.never.push(value); break;
|
|
329
|
+
case 'goal-always': (goals[currentGoal].always ??= []).push(value); break;
|
|
330
|
+
case 'goal-never': (goals[currentGoal].never ??= []).push(value); break;
|
|
331
|
+
case 'review-copy': (review.copy ??= []).push(value); break;
|
|
332
|
+
case 'review-image': (review.image ??= []).push(value); break;
|
|
218
333
|
}
|
|
219
334
|
continuationKey = null;
|
|
220
335
|
continue;
|
|
@@ -235,6 +350,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
235
350
|
case 'rhythm': voice.rhythm = val; break;
|
|
236
351
|
case 'always': listTarget = 'always'; break;
|
|
237
352
|
case 'never': listTarget = 'never'; break;
|
|
353
|
+
case 'instead': listTarget = 'instead'; break;
|
|
238
354
|
default:
|
|
239
355
|
throw new Error(`${fileName}:${lineNum}: unknown voice key "${key}"`);
|
|
240
356
|
}
|
|
@@ -273,6 +389,38 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
273
389
|
default:
|
|
274
390
|
throw new Error(`${fileName}:${lineNum}: unknown anti-audience key "${key}"`);
|
|
275
391
|
}
|
|
392
|
+
} else if (block === 'imagery') {
|
|
393
|
+
switch (key) {
|
|
394
|
+
case 'always': listTarget = 'imagery-always'; break;
|
|
395
|
+
case 'never': listTarget = 'imagery-never'; break;
|
|
396
|
+
default:
|
|
397
|
+
throw new Error(`${fileName}:${lineNum}: unknown imagery key "${key}"`);
|
|
398
|
+
}
|
|
399
|
+
} else if (block === 'goal' && currentGoal) {
|
|
400
|
+
const goal = goals[currentGoal];
|
|
401
|
+
switch (key) {
|
|
402
|
+
case 'label': goal.label = val; break;
|
|
403
|
+
case 'question': goal.question = val; break;
|
|
404
|
+
case 'share': {
|
|
405
|
+
const m = val.match(/^(\d+(?:\.\d+)?)%$/);
|
|
406
|
+
const n = m ? parseFloat(m[1]) : NaN;
|
|
407
|
+
if (!(n >= 0 && n <= 100)) throw new Error(`${fileName}:${lineNum}: goal share must be a percentage between 0% and 100%, got "${val}"`);
|
|
408
|
+
goal.share = n;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'narratives': goal.narratives = val.split(',').map(s => s.trim()).filter(Boolean); break;
|
|
412
|
+
case 'always': listTarget = 'goal-always'; break;
|
|
413
|
+
case 'never': listTarget = 'goal-never'; break;
|
|
414
|
+
default:
|
|
415
|
+
throw new Error(`${fileName}:${lineNum}: unknown goal key "${key}"`);
|
|
416
|
+
}
|
|
417
|
+
} else if (block === 'review') {
|
|
418
|
+
switch (key) {
|
|
419
|
+
case 'copy': listTarget = 'review-copy'; break;
|
|
420
|
+
case 'image': listTarget = 'review-image'; break;
|
|
421
|
+
default:
|
|
422
|
+
throw new Error(`${fileName}:${lineNum}: unknown review key "${key}"`);
|
|
423
|
+
}
|
|
276
424
|
}
|
|
277
425
|
continue;
|
|
278
426
|
}
|
|
@@ -282,8 +430,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
282
430
|
if (topMatch) {
|
|
283
431
|
const [, key, value] = topMatch;
|
|
284
432
|
const val = value.trim();
|
|
285
|
-
|
|
286
|
-
if (block === 'narrative') flushNarrative();
|
|
433
|
+
flushBlock();
|
|
287
434
|
block = 'top';
|
|
288
435
|
listTarget = null;
|
|
289
436
|
continuationKey = null;
|
|
@@ -327,8 +474,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
327
474
|
|
|
328
475
|
// ── Block headers ──
|
|
329
476
|
if (raw.match(/^voice\s*$/)) {
|
|
330
|
-
|
|
331
|
-
if (block === 'narrative') flushNarrative();
|
|
477
|
+
flushBlock();
|
|
332
478
|
block = 'voice';
|
|
333
479
|
listTarget = null;
|
|
334
480
|
continuationKey = null;
|
|
@@ -336,8 +482,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
336
482
|
}
|
|
337
483
|
|
|
338
484
|
if (raw.match(/^pillars\s*$/)) {
|
|
339
|
-
|
|
340
|
-
if (block === 'narrative') flushNarrative();
|
|
485
|
+
flushBlock();
|
|
341
486
|
block = 'pillars';
|
|
342
487
|
listTarget = null;
|
|
343
488
|
continuationKey = null;
|
|
@@ -345,8 +490,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
345
490
|
}
|
|
346
491
|
|
|
347
492
|
if (raw.match(/^values\s*$/)) {
|
|
348
|
-
|
|
349
|
-
if (block === 'narrative') flushNarrative();
|
|
493
|
+
flushBlock();
|
|
350
494
|
block = 'values';
|
|
351
495
|
listTarget = null;
|
|
352
496
|
continuationKey = null;
|
|
@@ -355,8 +499,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
355
499
|
|
|
356
500
|
const narrativeMatch = raw.match(/^narrative\s+(.+?)\s*$/);
|
|
357
501
|
if (narrativeMatch) {
|
|
358
|
-
|
|
359
|
-
if (block === 'narrative') flushNarrative();
|
|
502
|
+
flushBlock();
|
|
360
503
|
block = 'narrative';
|
|
361
504
|
currentNarrative = { name: narrativeMatch[1].trim(), primary: false, text: '' };
|
|
362
505
|
listTarget = null;
|
|
@@ -364,10 +507,49 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
364
507
|
continue;
|
|
365
508
|
}
|
|
366
509
|
|
|
510
|
+
if (raw.match(/^imagery\s*$/)) {
|
|
511
|
+
flushBlock();
|
|
512
|
+
block = 'imagery';
|
|
513
|
+
imageryDeclared = true;
|
|
514
|
+
listTarget = null;
|
|
515
|
+
continuationKey = null;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (raw.match(/^review\s*$/)) {
|
|
520
|
+
flushBlock();
|
|
521
|
+
block = 'review';
|
|
522
|
+
reviewDeclared = true;
|
|
523
|
+
listTarget = null;
|
|
524
|
+
continuationKey = null;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const imageThemeMatch = raw.match(/^image-theme\s+(.+?)\s*$/);
|
|
529
|
+
if (imageThemeMatch) {
|
|
530
|
+
flushBlock();
|
|
531
|
+
block = 'image-theme';
|
|
532
|
+
currentImageTheme = { name: imageThemeMatch[1].trim(), text: '' };
|
|
533
|
+
listTarget = null;
|
|
534
|
+
continuationKey = null;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const goalMatch = raw.match(/^goal\s+(\S+)\s*$/);
|
|
539
|
+
if (goalMatch) {
|
|
540
|
+
flushBlock();
|
|
541
|
+
block = 'goal';
|
|
542
|
+
currentGoal = goalMatch[1];
|
|
543
|
+
if (goals[currentGoal]) throw new Error(`${fileName}:${lineNum}: duplicate goal "${currentGoal}"`);
|
|
544
|
+
goals[currentGoal] = { line: lineNum };
|
|
545
|
+
listTarget = null;
|
|
546
|
+
continuationKey = null;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
367
550
|
const audienceMatch = raw.match(/^audience\s+(\S+)\s*$/);
|
|
368
551
|
if (audienceMatch) {
|
|
369
|
-
|
|
370
|
-
if (block === 'narrative') flushNarrative();
|
|
552
|
+
flushBlock();
|
|
371
553
|
block = 'audience';
|
|
372
554
|
currentAudience = audienceMatch[1];
|
|
373
555
|
audiences[currentAudience] ??= {};
|
|
@@ -378,8 +560,7 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
378
560
|
|
|
379
561
|
const antiAudienceMatch = raw.match(/^anti-audience\s+(\S+)\s*$/);
|
|
380
562
|
if (antiAudienceMatch) {
|
|
381
|
-
|
|
382
|
-
if (block === 'narrative') flushNarrative();
|
|
563
|
+
flushBlock();
|
|
383
564
|
block = 'anti-audience';
|
|
384
565
|
currentAntiAudience = antiAudienceMatch[1];
|
|
385
566
|
antiAudiences[currentAntiAudience] ??= {};
|
|
@@ -389,9 +570,8 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
389
570
|
}
|
|
390
571
|
}
|
|
391
572
|
|
|
392
|
-
// Flush last
|
|
393
|
-
|
|
394
|
-
if (block === 'narrative') flushNarrative();
|
|
573
|
+
// Flush the last free-text block
|
|
574
|
+
flushBlock();
|
|
395
575
|
|
|
396
576
|
// ── Validation ──────────────────────────────────────────────────────────────
|
|
397
577
|
|
|
@@ -416,6 +596,23 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
416
596
|
if (!aa.description) throw new Error(`${fileName}: anti-audience "${id}" missing required field "description:"`);
|
|
417
597
|
}
|
|
418
598
|
|
|
599
|
+
const narrativeNames = new Set(narratives.map(n => n.name));
|
|
600
|
+
for (const theme of imageThemes) {
|
|
601
|
+
if (theme.narrative && !narrativeNames.has(theme.narrative)) {
|
|
602
|
+
throw new Error(`${fileName}: image-theme "${theme.name}" references unknown narrative "${theme.narrative}"`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
let shareTotal = 0;
|
|
607
|
+
for (const [id, goal] of Object.entries(goals)) {
|
|
608
|
+
if (!goal.label) throw new Error(`${fileName}:${goal.line}: goal "${id}" missing required field "label:"`);
|
|
609
|
+
for (const name of goal.narratives ?? []) {
|
|
610
|
+
if (!narrativeNames.has(name)) throw new Error(`${fileName}:${goal.line}: goal "${id}" references unknown narrative "${name}"`);
|
|
611
|
+
}
|
|
612
|
+
shareTotal += goal.share ?? 0;
|
|
613
|
+
}
|
|
614
|
+
if (shareTotal > 100) throw new Error(`${fileName}: goal shares add up to ${shareTotal}%, more than 100%`);
|
|
615
|
+
|
|
419
616
|
// ── Hex value exclusion rule ──
|
|
420
617
|
const hexPattern = /#[0-9A-Fa-f]{3,8}\b/;
|
|
421
618
|
if (hexPattern.test(content.replace(/#.*$/gm, ''))) {
|
|
@@ -442,6 +639,14 @@ export function parseIdentityFile(content: string, fileName: string): ParsedIden
|
|
|
442
639
|
if (Object.keys(antiAudiences).length > 0) {
|
|
443
640
|
result.antiAudiences = antiAudiences as Record<string, ParsedAntiAudience>;
|
|
444
641
|
}
|
|
642
|
+
if (imageryDeclared) result.imagery = imagery;
|
|
643
|
+
if (imageThemes.length > 0) result.imageThemes = imageThemes;
|
|
644
|
+
if (Object.keys(goals).length > 0) {
|
|
645
|
+
result.goals = Object.fromEntries(
|
|
646
|
+
Object.entries(goals).map(([id, { line: _line, ...goal }]) => [id, goal as ParsedGoal]),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
if (reviewDeclared) result.review = review;
|
|
445
650
|
|
|
446
651
|
return result;
|
|
447
652
|
}
|
package/parser/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// they take a string (file content) and filename, return a parsed object.
|
|
4
4
|
|
|
5
5
|
export { parseIdentityFile } from './identityParser';
|
|
6
|
-
export type { ParsedIdentity, ParsedVoice, ParsedPillars, ParsedAudience, ParsedAntiAudience, ParsedValue } from './identityParser';
|
|
6
|
+
export type { ParsedIdentity, ParsedVoice, ParsedReplacement, ParsedPillars, ParsedAudience, ParsedAntiAudience, ParsedValue, ParsedImagery, ParsedImageTheme, ParsedGoal, ParsedReview } from './identityParser';
|
|
7
7
|
|
|
8
8
|
export { parseBrandFile } from './brandParser';
|
|
9
9
|
export {
|
|
@@ -69,7 +69,7 @@ export { parseFormatFile } from './formatParser';
|
|
|
69
69
|
export type { ParsedFormat } from './formatParser';
|
|
70
70
|
|
|
71
71
|
export { parsePurposeFile } from './purposeParser';
|
|
72
|
-
export type { ParsedPurpose, ParsedPurposeSlot, Density, ListMarker } from './purposeParser';
|
|
72
|
+
export type { ParsedPurpose, ParsedPurposeSlot, ParsedExample, CountRange, Density, ListMarker } from './purposeParser';
|
|
73
73
|
|
|
74
74
|
export { parseCompositionFile } from './compositionParser';
|
|
75
75
|
export type {
|
package/parser/purposeParser.ts
CHANGED
|
@@ -43,25 +43,50 @@ export interface ParsedPurpose {
|
|
|
43
43
|
cameraFacing?: 'front' | 'rear';
|
|
44
44
|
/** `framing: <id>` — the brand framing module photos for this purpose are shot in. */
|
|
45
45
|
framing?: string;
|
|
46
|
+
/** `goal: <id>` — the identity goal this purpose serves by default. */
|
|
47
|
+
goal?: string;
|
|
46
48
|
/** When false, photo/video analysis skips this purpose (no AI text generation). Default: true. */
|
|
47
49
|
analyze?: boolean;
|
|
48
50
|
/** Default icon for the icon slot. Resolves in the brand set first, then Phosphor (see resolveIconRef). */
|
|
49
51
|
defaultIcon?: IconRef;
|
|
50
52
|
/** Optional prompt text shown in the empty background placeholder. Default: "Select a background". */
|
|
51
53
|
bgPrompt?: string;
|
|
54
|
+
/** Example pages: all texts of one page written together, one topic each. Built from deprecated
|
|
55
|
+
* slot `samples:` (paired by position, `legacy: true`) when the file has no `example` blocks. */
|
|
56
|
+
examples: ParsedExample[];
|
|
57
|
+
/** Deprecations and other non-fatal findings, as "file:line: message". Implementations show them. */
|
|
58
|
+
warnings: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One `example` block: a whole page about one topic. */
|
|
62
|
+
export interface ParsedExample {
|
|
63
|
+
/** What the page is about, one sentence. Becomes the page topic. */
|
|
64
|
+
topic?: string;
|
|
65
|
+
/** Identity goal id; overrides the purpose `goal:` for this page. */
|
|
66
|
+
goal?: string;
|
|
67
|
+
/** Date window name from the `.brand` `windows` block; undefined = all year. */
|
|
68
|
+
when?: string;
|
|
69
|
+
/** Text per slot. List items are newline-separated. Slots with `value:` never appear. */
|
|
70
|
+
texts: Partial<Record<SlotId, string>>;
|
|
71
|
+
/** True when built from deprecated slot `samples:`. */
|
|
72
|
+
legacy?: boolean;
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
export interface ParsedPurposeSlot {
|
|
55
76
|
id: SlotId;
|
|
56
77
|
label: string;
|
|
57
|
-
/**
|
|
78
|
+
/** @deprecated Slot `samples:` from older files; content lives in `ParsedPurpose.examples`.
|
|
79
|
+
* Empty for files with `example` blocks; `[value]` for a fixed-value slot. */
|
|
58
80
|
samples: string[];
|
|
59
|
-
/** Date window per sample (parallel to `samples`), from a trailing `@name` marker
|
|
60
|
-
* Absent when no sample in the slot has a marker. Names resolve against the `.brand` `windows` block. */
|
|
81
|
+
/** @deprecated Date window per legacy sample (parallel to `samples`), from a trailing `@name` marker. */
|
|
61
82
|
sampleWindows?: (string | undefined)[];
|
|
62
|
-
/** Fixed value — when set, this slot is never AI-generated or shuffled.
|
|
83
|
+
/** Fixed value — when set, this slot is never AI-generated or shuffled. Never part of an example. */
|
|
63
84
|
value?: string;
|
|
64
85
|
maxLength: number;
|
|
86
|
+
/** Editorial word range (`words: 3..8`). Guidance on top of the hard `maxLength`. */
|
|
87
|
+
words?: CountRange;
|
|
88
|
+
/** Editorial sentence range (`sentences: 1..2`). Guidance on top of the hard `maxLength`. */
|
|
89
|
+
sentences?: CountRange;
|
|
65
90
|
/** 'text' (default) or 'list'. */
|
|
66
91
|
slotType?: 'text' | 'list';
|
|
67
92
|
/** Max number of items (list slots only). */
|
|
@@ -109,6 +134,12 @@ export interface ParsedPurposeSlot {
|
|
|
109
134
|
}>;
|
|
110
135
|
}
|
|
111
136
|
|
|
137
|
+
/** Inclusive count range; `words: 3` is { min: 3, max: 3 }. */
|
|
138
|
+
export interface CountRange {
|
|
139
|
+
min: number;
|
|
140
|
+
max: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
112
143
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
113
144
|
|
|
114
145
|
const VALID_SLOTS = new Set<string>(['primary', 'secondary', 'detail', 'meta', 'cta', 'label']);
|
|
@@ -142,10 +173,18 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
142
173
|
let camera: boolean | undefined;
|
|
143
174
|
let cameraFacing: 'front' | 'rear' | undefined;
|
|
144
175
|
let framing: string | undefined;
|
|
176
|
+
let goal: string | undefined;
|
|
145
177
|
let analyze: boolean | undefined;
|
|
146
178
|
let defaultIcon: IconRef | undefined;
|
|
147
179
|
let bgPrompt: string | undefined;
|
|
148
180
|
const slots: ParsedPurposeSlot[] = [];
|
|
181
|
+
const warnings: string[] = [];
|
|
182
|
+
const examples: (ParsedExample & { line: number; keyLines: Partial<Record<string, number>> })[] = [];
|
|
183
|
+
// Example block being built, and the slot key whose multi-line value is being read (4-space lines)
|
|
184
|
+
let currentExample: (typeof examples)[number] | null = null;
|
|
185
|
+
let exampleMultiKey: SlotId | null = null;
|
|
186
|
+
// First line of a deprecated slot `samples:` (0 = none)
|
|
187
|
+
let legacySamplesLine = 0;
|
|
149
188
|
// For backward compat: track first # line text for fallback id
|
|
150
189
|
let firstHashText = '';
|
|
151
190
|
|
|
@@ -163,7 +202,6 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
163
202
|
const s = currentSlot;
|
|
164
203
|
if (!s.id) throw new Error(`${fileName}:${lineNum}: slot missing id`);
|
|
165
204
|
if (!s.label) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing label`);
|
|
166
|
-
if ((!s.samples || s.samples.length === 0) && !s.iconOnly && !s.value) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing samples or value`);
|
|
167
205
|
if (!s.maxLength) throw new Error(`${fileName}:${lineNum}: slot "${s.id}" missing maxLength`);
|
|
168
206
|
if (s.iconOnly) {
|
|
169
207
|
if (s.marker && s.marker !== 'icon') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": iconOnly needs "marker: icon", got "${s.marker}"`);
|
|
@@ -204,6 +242,17 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
204
242
|
currentSlot = null;
|
|
205
243
|
}
|
|
206
244
|
|
|
245
|
+
function finalizeExample() {
|
|
246
|
+
currentExample = null;
|
|
247
|
+
exampleMultiKey = null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A top-level line closes the open slot or example block. */
|
|
251
|
+
function closeBlocks(lineNum: number) {
|
|
252
|
+
finalizeSlot(lineNum);
|
|
253
|
+
finalizeExample();
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
for (let i = 0; i < lines.length; i++) {
|
|
208
257
|
const lineNum = i + 1;
|
|
209
258
|
const raw = lines[i];
|
|
@@ -234,7 +283,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
234
283
|
|
|
235
284
|
// scope block header
|
|
236
285
|
if (line === 'scope' && !raw.startsWith(' ')) {
|
|
237
|
-
|
|
286
|
+
closeBlocks(lineNum);
|
|
238
287
|
inScopeBlock = true;
|
|
239
288
|
inContextBlock = false;
|
|
240
289
|
inVoiceBlock = false;
|
|
@@ -244,7 +293,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
244
293
|
|
|
245
294
|
// context block header
|
|
246
295
|
if (line === 'context' && !raw.startsWith(' ')) {
|
|
247
|
-
|
|
296
|
+
closeBlocks(lineNum);
|
|
248
297
|
inContextBlock = true;
|
|
249
298
|
inScopeBlock = false;
|
|
250
299
|
inVoiceBlock = false;
|
|
@@ -253,7 +302,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
253
302
|
|
|
254
303
|
// voice block header
|
|
255
304
|
if (line === 'voice' && !raw.startsWith(' ')) {
|
|
256
|
-
|
|
305
|
+
closeBlocks(lineNum);
|
|
257
306
|
inVoiceBlock = true;
|
|
258
307
|
inScopeBlock = false;
|
|
259
308
|
inContextBlock = false;
|
|
@@ -261,6 +310,44 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
261
310
|
continue;
|
|
262
311
|
}
|
|
263
312
|
|
|
313
|
+
// example block header: one whole page
|
|
314
|
+
if (line === 'example' && !raw.startsWith(' ')) {
|
|
315
|
+
closeBlocks(lineNum);
|
|
316
|
+
inScopeBlock = inContextBlock = inVoiceBlock = false;
|
|
317
|
+
currentExample = { texts: {}, line: lineNum, keyLines: {} };
|
|
318
|
+
examples.push(currentExample);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// example block lines: `key: value`, or bare `key:` followed by 4-space lines (list items, line breaks)
|
|
323
|
+
if (currentExample && raw.startsWith(' ')) {
|
|
324
|
+
if (raw.startsWith(' ')) {
|
|
325
|
+
if (!exampleMultiKey) throw new Error(`${fileName}:${lineNum}: unexpected "${line}" in example (multi-line text follows a bare "slot:" line)`);
|
|
326
|
+
const prev = currentExample.texts[exampleMultiKey];
|
|
327
|
+
currentExample.texts[exampleMultiKey] = prev ? `${prev}\n${line}` : line;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
exampleMultiKey = null;
|
|
331
|
+
const colonIdx = line.indexOf(':');
|
|
332
|
+
if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: expected "key: value" in example, got "${line}"`);
|
|
333
|
+
const key = line.slice(0, colonIdx).trim();
|
|
334
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
335
|
+
if (key in currentExample.keyLines) throw new Error(`${fileName}:${lineNum}: duplicate "${key}" in example`);
|
|
336
|
+
currentExample.keyLines[key] = lineNum;
|
|
337
|
+
if (key === 'topic' || key === 'goal' || key === 'when') {
|
|
338
|
+
if (!value) throw new Error(`${fileName}:${lineNum}: empty ${key}`);
|
|
339
|
+
if (key === 'when' && !/^[a-z][a-z0-9-]*$/.test(value)) throw new Error(`${fileName}:${lineNum}: invalid window name "${value}" (lowercase kebab-case)`);
|
|
340
|
+
currentExample[key] = value;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (!VALID_SLOTS.has(key)) throw new Error(`${fileName}:${lineNum}: unknown example key "${key}" (expected topic, goal, when or a slot id)`);
|
|
344
|
+
if (value) currentExample.texts[key as SlotId] = value;
|
|
345
|
+
else exampleMultiKey = key as SlotId;
|
|
346
|
+
continue;
|
|
347
|
+
} else if (currentExample && !raw.startsWith(' ')) {
|
|
348
|
+
finalizeExample();
|
|
349
|
+
}
|
|
350
|
+
|
|
264
351
|
// scope block properties (2-space indent)
|
|
265
352
|
if (inScopeBlock && raw.startsWith(' ') && !raw.startsWith(' ')) {
|
|
266
353
|
if (line.startsWith('audience:')) {
|
|
@@ -349,6 +436,12 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
349
436
|
continue;
|
|
350
437
|
}
|
|
351
438
|
|
|
439
|
+
// Goal: one of the identity's `goal NAME` blocks
|
|
440
|
+
if (line.startsWith('goal:') && !raw.startsWith(' ')) {
|
|
441
|
+
goal = line.slice(5).trim() || undefined;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
352
445
|
// Photo/video analysis control
|
|
353
446
|
if (line.startsWith('analyze:')) {
|
|
354
447
|
analyze = line.slice(8).trim() !== 'false';
|
|
@@ -369,14 +462,14 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
369
462
|
|
|
370
463
|
// Backward compat: "icon: warning-circle" (deprecated — use defaultIcon:)
|
|
371
464
|
if (line.startsWith('icon:') && !raw.startsWith(' ')) {
|
|
372
|
-
|
|
465
|
+
warnings.push(`${fileName}:${lineNum}: deprecated "icon:" — use "defaultIcon:" instead`);
|
|
373
466
|
defaultIcon = parseIconRef(line.slice(5), `${fileName}:${lineNum}`);
|
|
374
467
|
continue;
|
|
375
468
|
}
|
|
376
469
|
|
|
377
470
|
// Slot start
|
|
378
471
|
if (line.startsWith('slot ')) {
|
|
379
|
-
|
|
472
|
+
closeBlocks(lineNum);
|
|
380
473
|
const slotId = line.slice(5).trim();
|
|
381
474
|
if (!VALID_SLOTS.has(slotId)) throw new Error(`${fileName}:${lineNum}: unknown slot "${slotId}"`);
|
|
382
475
|
currentSlot = { id: slotId as SlotId, samples: [], typography: {} as ParsedPurposeSlot['typography'] };
|
|
@@ -492,6 +585,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
492
585
|
continue;
|
|
493
586
|
}
|
|
494
587
|
if (prop.startsWith('samples:')) {
|
|
588
|
+
legacySamplesLine ||= lineNum;
|
|
495
589
|
const raw = prop.slice(8).trim();
|
|
496
590
|
if (!raw) {
|
|
497
591
|
// Bare `samples:` — enter multi-line mode
|
|
@@ -513,12 +607,12 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
513
607
|
continue;
|
|
514
608
|
}
|
|
515
609
|
if (prop === 'icons: true' || prop === 'icons:true') {
|
|
516
|
-
|
|
610
|
+
warnings.push(`${fileName}:${lineNum}: deprecated "icons: true" — use "marker: counter" or "marker: icon"`);
|
|
517
611
|
currentSlot.marker = currentSlot.marker ?? 'counter';
|
|
518
612
|
continue;
|
|
519
613
|
}
|
|
520
614
|
if (prop === 'icons: false' || prop === 'icons:false') {
|
|
521
|
-
|
|
615
|
+
warnings.push(`${fileName}:${lineNum}: deprecated "icons: false" — use "marker: none"`);
|
|
522
616
|
currentSlot.marker = currentSlot.marker ?? 'none';
|
|
523
617
|
continue;
|
|
524
618
|
}
|
|
@@ -581,6 +675,7 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
581
675
|
continue;
|
|
582
676
|
}
|
|
583
677
|
if (prop.startsWith('samples:')) {
|
|
678
|
+
legacySamplesLine ||= lineNum;
|
|
584
679
|
const raw = prop.slice(8).trim();
|
|
585
680
|
if (!raw) {
|
|
586
681
|
// Bare `samples:` — enter multi-line mode
|
|
@@ -597,6 +692,15 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
597
692
|
currentSlot.maxLength = n;
|
|
598
693
|
continue;
|
|
599
694
|
}
|
|
695
|
+
if (prop.startsWith('words:') || prop.startsWith('sentences:')) {
|
|
696
|
+
const key = prop.startsWith('words:') ? 'words' : 'sentences';
|
|
697
|
+
const v = prop.slice(key.length + 1).trim();
|
|
698
|
+
const m = v.match(/^(\d+)(?:\.\.(\d+))?$/);
|
|
699
|
+
const range = m ? { min: parseInt(m[1], 10), max: parseInt(m[2] ?? m[1], 10) } : null;
|
|
700
|
+
if (!range || range.min <= 0 || range.max < range.min) throw new Error(`${fileName}:${lineNum}: invalid ${key} "${v}" — expected N or MIN..MAX`);
|
|
701
|
+
currentSlot[key] = range;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
600
704
|
if (prop.startsWith('typography:')) {
|
|
601
705
|
currentSlot.typographyRef = prop.slice(11).trim();
|
|
602
706
|
continue;
|
|
@@ -655,13 +759,13 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
655
759
|
throw new Error(`${fileName}:${lineNum}: unexpected "${line}"`);
|
|
656
760
|
}
|
|
657
761
|
|
|
658
|
-
// Finalize last
|
|
659
|
-
|
|
762
|
+
// Finalize last block
|
|
763
|
+
closeBlocks(lines.length);
|
|
660
764
|
|
|
661
765
|
// Backward compat: if no id: line but we have a first # line, use it as id
|
|
662
766
|
if (!id) {
|
|
663
767
|
if (firstHashText) {
|
|
664
|
-
|
|
768
|
+
warnings.push(`${fileName}: deprecated — add "id: ${firstHashText}" line (first # line used as fallback id)`);
|
|
665
769
|
id = firstHashText;
|
|
666
770
|
} else {
|
|
667
771
|
throw new Error(`${fileName}: missing purpose id (add "id:" line)`);
|
|
@@ -682,5 +786,64 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
682
786
|
context = contextLines.join('\n').trim();
|
|
683
787
|
}
|
|
684
788
|
|
|
685
|
-
|
|
789
|
+
const parsedExamples = resolveExamples(examples, slots, legacySamplesLine, fileName, warnings);
|
|
790
|
+
|
|
791
|
+
return { id, name, description, preferredCompositions: compositions, slots, density, palette, scope, context, voice, defaultLogo, defaultPhoto, defaultVideo, camera, cameraFacing, framing, goal, analyze, defaultIcon, bgPrompt, examples: parsedExamples, warnings };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Validate `example` blocks against the declared slots, or build examples from deprecated slot
|
|
795
|
+
* `samples:` (paired by position) when the file has none. Mixing both is an error. */
|
|
796
|
+
function resolveExamples(
|
|
797
|
+
examples: (ParsedExample & { line: number; keyLines: Partial<Record<string, number>> })[],
|
|
798
|
+
slots: ParsedPurposeSlot[],
|
|
799
|
+
legacySamplesLine: number,
|
|
800
|
+
fileName: string,
|
|
801
|
+
warnings: string[],
|
|
802
|
+
): ParsedExample[] {
|
|
803
|
+
const byId = new Map(slots.map(s => [s.id, s]));
|
|
804
|
+
const contentSlots = slots.filter(s => !s.value && !s.iconOnly);
|
|
805
|
+
|
|
806
|
+
if (examples.length > 0) {
|
|
807
|
+
if (legacySamplesLine) throw new Error(`${fileName}:${legacySamplesLine}: slot "samples:" next to "example" blocks — move the texts into the examples`);
|
|
808
|
+
for (const ex of examples) {
|
|
809
|
+
const at = (key: string) => `${fileName}:${ex.keyLines[key] ?? ex.line}`;
|
|
810
|
+
const filled = Object.keys(ex.texts) as SlotId[];
|
|
811
|
+
if (filled.length === 0) throw new Error(`${fileName}:${ex.line}: example without slot texts`);
|
|
812
|
+
for (const slotId of filled) {
|
|
813
|
+
const slot = byId.get(slotId);
|
|
814
|
+
if (!slot) throw new Error(`${at(slotId)}: example fills slot "${slotId}", which the purpose does not declare`);
|
|
815
|
+
if (slot.value) throw new Error(`${at(slotId)}: slot "${slotId}" has a fixed value and takes no example text`);
|
|
816
|
+
const text = ex.texts[slotId]!;
|
|
817
|
+
if (text.split('\n').some(l => SAMPLE_WINDOW_MARKER.test(l))) throw new Error(`${at(slotId)}: window marker in example text — use "when:" on the example`);
|
|
818
|
+
if (slot.slotType === 'list') {
|
|
819
|
+
for (const item of text.split('\n')) {
|
|
820
|
+
const { icon } = parseListItem(item);
|
|
821
|
+
if (icon && slot.marker !== 'icon') throw new Error(`${at(slotId)}: slot "${slotId}": item "${item}" names an icon, but the list has no "marker: icon"`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
for (const slot of contentSlots) {
|
|
827
|
+
if (!examples.some(ex => ex.texts[slot.id])) throw new Error(`${fileName}: slot "${slot.id}" is filled by no example`);
|
|
828
|
+
}
|
|
829
|
+
return examples.map(({ line: _line, keyLines: _keyLines, ...ex }) => ex);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
for (const slot of contentSlots) {
|
|
833
|
+
if (slot.samples.length === 0) throw new Error(`${fileName}: slot "${slot.id}" has no text — add "example" blocks or a "value:"`);
|
|
834
|
+
}
|
|
835
|
+
if (!legacySamplesLine) return [];
|
|
836
|
+
warnings.push(`${fileName}:${legacySamplesLine}: deprecated slot "samples:" — write whole pages as "example" blocks`);
|
|
837
|
+
const sampled = slots.filter(s => !s.value && s.samples.length > 0);
|
|
838
|
+
const count = Math.max(...sampled.map(s => s.samples.length));
|
|
839
|
+
return Array.from({ length: count }, (_, i) => {
|
|
840
|
+
const texts: Partial<Record<SlotId, string>> = {};
|
|
841
|
+
let when: string | undefined;
|
|
842
|
+
for (const slot of sampled) {
|
|
843
|
+
const idx = i % slot.samples.length;
|
|
844
|
+
texts[slot.id] = slot.samples[idx];
|
|
845
|
+
when ??= slot.sampleWindows?.[idx];
|
|
846
|
+
}
|
|
847
|
+
return { texts, ...(when ? { when } : {}), legacy: true };
|
|
848
|
+
});
|
|
686
849
|
}
|
package/skills/brand-intent.md
CHANGED
|
@@ -38,11 +38,19 @@ Identity → Brand → Format → Purpose → Composition
|
|
|
38
38
|
|
|
39
39
|
**Required:** essence, promise, voice (register, persona, rhythm, always/never), pillars, audience segments.
|
|
40
40
|
|
|
41
|
-
**Optional:** tagline, positioning, mission, vision, archetype, narrative, values (with behavior statements), anti-audience segments.
|
|
41
|
+
**Optional:** tagline, positioning, mission, vision, archetype, narrative, values (with behavior statements and `say:`/`not:` examples), anti-audience segments, imagery, image-theme, goal, review.
|
|
42
42
|
|
|
43
43
|
**The `always`/`never` construct is your primary guardrail.** These are concrete, testable rules. When generating or reviewing content, check every item in `never:` and confirm every item in `always:`. They are not suggestions; they are constraints.
|
|
44
44
|
|
|
45
|
-
**`
|
|
45
|
+
**`voice.instead`** pairs a phrase to avoid with the brand's own wording. When a draft contains the phrase, use the replacement as the model for the rewrite.
|
|
46
|
+
|
|
47
|
+
**`values`** are not just keywords; each has a behavior statement that describes how it manifests. Use these to inform decisions when voice rules don't cover a situation. `say:` lines are examples to match in tone, `not:` lines are the tone to avoid.
|
|
48
|
+
|
|
49
|
+
**`imagery`** is the image counterpart of `voice`: `always`/`never` rules for photos and film. Apply them when reviewing a photo or writing an image brief or prompt. **`image-theme`** blocks name subjects for photos and film, some tied to a narrative.
|
|
50
|
+
|
|
51
|
+
**`goal`** says what a piece of content is for, independent of its format. A purpose names a default goal with `goal:`. Follow the goal's `always`/`never` rules and answer its `question`. `share:` is a planning target, not a rule for a single piece.
|
|
52
|
+
|
|
53
|
+
**`review`** lists yes/no questions to check before publishing: `copy` for texts, `image` for photos and film.
|
|
46
54
|
|
|
47
55
|
**`anti-audience`** segments define who the brand is explicitly *not* for. Never generate content that sounds like it's addressing an anti-audience. This is as important as `voice.never`.
|
|
48
56
|
|
|
@@ -92,11 +100,11 @@ When generating content for a specific purpose, read all layers silently (do not
|
|
|
92
100
|
|
|
93
101
|
### Reading layers (internal, not shown to user):
|
|
94
102
|
|
|
95
|
-
1. **Read `.identity`** — voice.always/never (hard constraints), persona, register, pillars.avoid, anti-audience, values, positioning
|
|
103
|
+
1. **Read `.identity`** — voice.always/never/instead (hard constraints), persona, register, pillars.avoid, anti-audience, values and their examples, positioning; the goal named by the purpose
|
|
96
104
|
2. **Read `.brand`** — voice-constraints: sentence-max, headline-pattern, number-format, register
|
|
97
105
|
3. **Apply `scope` from `.purpose`** — audience and pillar filters narrow the upstream context
|
|
98
106
|
4. **Apply `context` from `.purpose`** — slot-specific instructions, follow literally
|
|
99
|
-
5. **Generate per-slot content** — respect maxLength, use
|
|
107
|
+
5. **Generate per-slot content** — respect maxLength and any `words:`/`sentences:` range, use the purpose's `example` pages as structural reference (match pattern, not words; all slots of one output share one topic), follow context instructions
|
|
100
108
|
|
|
101
109
|
### Output format:
|
|
102
110
|
|
|
@@ -117,7 +125,13 @@ After generating or reviewing content, validate against these rules in order:
|
|
|
117
125
|
6. **`sentence-max`:** Count words in every sentence. Does any exceed the limit? If yes, split.
|
|
118
126
|
7. **`headline-pattern`:** Does the headline follow the specified pattern? If not, restructure.
|
|
119
127
|
8. **`maxLength`:** Does each slot's content fit within its character limit? If not, shorten.
|
|
120
|
-
9. **`
|
|
128
|
+
9. **`words:` / `sentences:`:** Is each slot within its editorial range, where one is set? If not, adjust.
|
|
129
|
+
10. **`register`:** Does the formality level match? (e.g., if `informal, Du`, no formal address)
|
|
130
|
+
11. **`voice.instead`:** Does the text contain a phrase from the instead list? If yes, rewrite along the replacement.
|
|
131
|
+
12. **`goal`:** Where a goal applies, does the text follow its rules and answer its question?
|
|
132
|
+
13. **`review.copy`:** Answer each question with yes or no. Any no is a failure.
|
|
133
|
+
|
|
134
|
+
When reviewing a photo, check `imagery.always`/`never`, the brand's framing (horizon position) and each `review.image` question.
|
|
121
135
|
|
|
122
136
|
## What NOT to Do
|
|
123
137
|
|
|
@@ -238,7 +252,7 @@ Read the files and walk the user through them:
|
|
|
238
252
|
- **`krume.identity`** — the strategic layer: essence, voice, values, audiences. Start here.
|
|
239
253
|
- **`krume.brand`** — derived expression: colors, themes, typography, spacing. Every decision traces back to identity.
|
|
240
254
|
- **`formats/`** — canvas dimensions, safe zones, grid. Where content appears.
|
|
241
|
-
- **`purposes/`** — content type with semantic slots,
|
|
255
|
+
- **`purposes/`** — content type with semantic slots, constraints, and example pages. What content does.
|
|
242
256
|
- **`compositions/`** — spatial arrangement of slots. How elements are placed.
|
|
243
257
|
|
|
244
258
|
If `examples/krume/` doesn't exist, tell the user to run `npx brand-intent init` first.
|
package/skills/brief.md
CHANGED
|
@@ -39,7 +39,9 @@ The brief is a snapshot. Regenerate it after any change to `.identity`, `.brand`
|
|
|
39
39
|
6. **Auto-derive the Guardrails section** from `.identity`:
|
|
40
40
|
- every item in `voice.never`
|
|
41
41
|
- every item in `voice.always`
|
|
42
|
+
- every `voice.instead` pair
|
|
42
43
|
- `pillars.avoid` list
|
|
44
|
+
- every item in `imagery.never` and `imagery.always`
|
|
43
45
|
- every `anti-audience` block (label + description)
|
|
44
46
|
|
|
45
47
|
The Guardrails *section in the brief* is derived. Never add a `guardrails:` block to the DSL — guardrails live inside `.identity` already.
|
|
@@ -93,6 +95,26 @@ _Role: how this brand sounds._
|
|
|
93
95
|
_Role: behavior statements, not keywords._
|
|
94
96
|
|
|
95
97
|
- **{name}** — {behavior}
|
|
98
|
+
- Say: {each say: line}
|
|
99
|
+
- Not: {each not: line}
|
|
100
|
+
|
|
101
|
+
## Imagery (optional)
|
|
102
|
+
_Role: what photos and film show. Only emitted if `imagery` or `image-theme` is present._
|
|
103
|
+
|
|
104
|
+
- **Image themes:** for each — **{name}** ({narrative, if set}) — {text}
|
|
105
|
+
|
|
106
|
+
## Goals (optional)
|
|
107
|
+
_Role: what a piece of content is for, independent of its format. Only emitted if `goal` blocks are present._
|
|
108
|
+
|
|
109
|
+
- **{label}** ({share, if set}) — {question}
|
|
110
|
+
- Narratives: {narratives}
|
|
111
|
+
- Always: {each always item} · Never: {each never item}
|
|
112
|
+
|
|
113
|
+
## Review (optional)
|
|
114
|
+
_Role: yes/no questions before publishing. Only emitted if `review` is present._
|
|
115
|
+
|
|
116
|
+
- **Copy:** {each review.copy question}
|
|
117
|
+
- **Image:** {each review.image question}
|
|
96
118
|
|
|
97
119
|
## Content Pillars
|
|
98
120
|
_Role: what the brand talks about, and what it avoids._
|
|
@@ -132,6 +154,12 @@ _Role: hard rules any generator, agent, or reviewer must enforce._
|
|
|
132
154
|
**Always:**
|
|
133
155
|
- {each voice.always item}
|
|
134
156
|
|
|
157
|
+
**Instead:**
|
|
158
|
+
- "{avoid}" → {use}
|
|
159
|
+
|
|
160
|
+
**Imagery never / always:**
|
|
161
|
+
- {each imagery.never item} / {each imagery.always item}
|
|
162
|
+
|
|
135
163
|
**Avoid topics:**
|
|
136
164
|
- {each pillars.avoid item}
|
|
137
165
|
|
|
@@ -151,7 +179,9 @@ For each purpose, emit **glossed** tags — not verbatim DSL values:
|
|
|
151
179
|
- **Logo:** gloss — `text` = "wordmark (text logo)"; `mark` = "graphic mark"; other values pass through verbatim.
|
|
152
180
|
- Scope: {scope audience + pillars}
|
|
153
181
|
- Context: {context prose per slot}
|
|
154
|
-
-
|
|
182
|
+
- Goal: {goal label}, if set
|
|
183
|
+
- Examples: each `example` block as one page — topic (if set), then slot texts. For files with deprecated slot `samples:`, list them per slot and note that they are not paired.
|
|
184
|
+
- Slots: for each slot — id, typography, maxLength, words/sentences range if set. Omit `color:` references (theme-internal detail). Override hints (`hyphenate`, `textAlign`) pass through as-is.
|
|
155
185
|
|
|
156
186
|
## Formats (optional)
|
|
157
187
|
_Role: where content appears. Only emitted if any `.format` files are present._
|
|
@@ -451,7 +451,7 @@
|
|
|
451
451
|
}
|
|
452
452
|
},
|
|
453
453
|
"asset-value": {
|
|
454
|
-
"match": "^(\\s{2,})(label|src|lottie|gltf|generator|group)(:\\s*)(.*)|^(\\s{2,})(aspect|scale)(:\\s*)(\\S+)|^(\\s{2,})(animated|generator-params|align-left|align-center|align-right)\\s*$|^(\\s{2,})(loop)(?:(:\\s*)(true|false))?\\s*$",
|
|
454
|
+
"match": "^(\\s{2,})(label|src|lottie|gltf|generator|group|when|framing|place|person|taken|description|caption|goals)(:\\s*)(.*)|^(\\s{2,})(aspect|scale)(:\\s*)(\\S+)|^(\\s{2,})(animated|generator-params|align-left|align-center|align-right)\\s*$|^(\\s{2,})(loop)(?:(:\\s*)(true|false))?\\s*$",
|
|
455
455
|
"captures": {
|
|
456
456
|
"2": { "name": "support.type.property-name.brand-intent-brand" },
|
|
457
457
|
"3": { "name": "punctuation.separator.key-value.brand-intent-brand" },
|
|
@@ -25,16 +25,16 @@
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
"named-block": {
|
|
28
|
-
"comment": "Block headers: voice, pillars, values, narrative NAME, audience NAME, anti-audience NAME",
|
|
28
|
+
"comment": "Block headers: voice, pillars, values, imagery, review, narrative NAME, audience NAME, anti-audience NAME, image-theme NAME, goal NAME",
|
|
29
29
|
"patterns": [
|
|
30
30
|
{
|
|
31
|
-
"match": "^(voice|pillars|values)\\s*$",
|
|
31
|
+
"match": "^(voice|pillars|values|imagery|review)\\s*$",
|
|
32
32
|
"captures": {
|
|
33
33
|
"1": { "name": "keyword.control.brand-intent-identity" }
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
|
-
"match": "^(narrative|audience|anti-audience)\\s+(\\S.*)",
|
|
37
|
+
"match": "^(narrative|audience|anti-audience|image-theme|goal)\\s+(\\S.*)",
|
|
38
38
|
"captures": {
|
|
39
39
|
"1": { "name": "keyword.control.brand-intent-identity" },
|
|
40
40
|
"2": { "name": "entity.name.type.brand-intent-identity" }
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
45
|
"indented-property": {
|
|
46
|
-
"match": "^(\\s{2,})(register|persona|rhythm|always|never|primary|secondary|avoid|label|profile|motivation|language|description)(:)\\s+(.*)",
|
|
46
|
+
"match": "^(\\s{2,})(register|persona|rhythm|always|never|instead|primary|secondary|avoid|label|profile|motivation|language|description|say|not|narrative|share|question|narratives|copy|image)(:)\\s+(.*)",
|
|
47
47
|
"captures": {
|
|
48
48
|
"2": { "name": "support.type.property-name.brand-intent-identity" },
|
|
49
49
|
"3": { "name": "punctuation.separator.key-value.brand-intent-identity" },
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
{ "include": "#bg-prompt-field" },
|
|
20
20
|
{ "include": "#context-header" },
|
|
21
21
|
{ "include": "#slot-header" },
|
|
22
|
+
{ "include": "#example-header" },
|
|
22
23
|
{ "include": "#override-header" },
|
|
23
24
|
{ "include": "#override-value" },
|
|
24
25
|
{ "include": "#list-property" },
|
|
@@ -123,7 +124,7 @@
|
|
|
123
124
|
}
|
|
124
125
|
},
|
|
125
126
|
"framing-field": {
|
|
126
|
-
"match": "^(framing:)\\s+([\\w-]+)",
|
|
127
|
+
"match": "^(framing:|goal:)\\s+([\\w-]+)",
|
|
127
128
|
"captures": {
|
|
128
129
|
"1": { "name": "keyword.control.brand-intent-purpose" },
|
|
129
130
|
"2": { "name": "entity.name.type.brand-intent-purpose" }
|
|
@@ -156,6 +157,12 @@
|
|
|
156
157
|
"2": { "name": "entity.name.tag.brand-intent-purpose" }
|
|
157
158
|
}
|
|
158
159
|
},
|
|
160
|
+
"example-header": {
|
|
161
|
+
"match": "^(example)\\s*$",
|
|
162
|
+
"captures": {
|
|
163
|
+
"1": { "name": "keyword.control.brand-intent-purpose" }
|
|
164
|
+
}
|
|
165
|
+
},
|
|
159
166
|
"override-header": {
|
|
160
167
|
"match": "^\\s+(override:)\\s*$",
|
|
161
168
|
"captures": {
|
|
@@ -216,7 +223,7 @@
|
|
|
216
223
|
]
|
|
217
224
|
},
|
|
218
225
|
"property": {
|
|
219
|
-
"match": "^\\s+(label|samples|value|maxLength|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|editable)(:)\\s+(.*)",
|
|
226
|
+
"match": "^\\s+(label|samples|value|maxLength|words|sentences|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|editable|topic|goal|when|primary|secondary|detail|meta|cta)(:)\\s+(.*)",
|
|
220
227
|
"captures": {
|
|
221
228
|
"1": { "name": "support.type.property-name.brand-intent-purpose" },
|
|
222
229
|
"2": { "name": "punctuation.separator.brand-intent-purpose" },
|
|
@@ -241,8 +248,8 @@
|
|
|
241
248
|
}
|
|
242
249
|
},
|
|
243
250
|
"sample-icon-continuation": {
|
|
244
|
-
"comment": "[ID] icon prefix on list
|
|
245
|
-
"match": "^(\\s{
|
|
251
|
+
"comment": "[ID] icon prefix on list items (example blocks, legacy sample continuation lines)",
|
|
252
|
+
"match": "^(\\s{4,})(\\[[^\\]]+\\])",
|
|
246
253
|
"captures": {
|
|
247
254
|
"2": { "name": "entity.name.function.brand-intent-purpose" }
|
|
248
255
|
}
|
|
@@ -258,13 +265,13 @@
|
|
|
258
265
|
"comment": "Fallback: color the keyword token on incomplete lines (while typing)",
|
|
259
266
|
"patterns": [
|
|
260
267
|
{
|
|
261
|
-
"match": "^(id:|name:|compositions:|density:|defaultIcon:|scope|voice|palette:|logo:|photo:|video:|camera:|framing:|analyze:|bg-prompt:|context|slot)(?=\\s|$)",
|
|
268
|
+
"match": "^(id:|name:|compositions:|density:|defaultIcon:|scope|voice|palette:|logo:|photo:|video:|camera:|framing:|goal:|analyze:|bg-prompt:|context|slot|example)(?=\\s|$)",
|
|
262
269
|
"captures": {
|
|
263
270
|
"1": { "name": "keyword.control.brand-intent-purpose" }
|
|
264
271
|
}
|
|
265
272
|
},
|
|
266
273
|
{
|
|
267
|
-
"match": "^\\s+(label|samples|value|maxLength|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|override|audience|pillars|editable)(?=:|\\s|$)",
|
|
274
|
+
"match": "^\\s+(label|samples|value|maxLength|words|sentences|font|size|opsz|lineHeight|letterSpacing|align|typography|color|type|override|audience|pillars|editable|topic|goal|when|primary|secondary|detail|meta|cta)(?=:|\\s|$)",
|
|
268
275
|
"captures": {
|
|
269
276
|
"1": { "name": "support.type.property-name.brand-intent-purpose" }
|
|
270
277
|
}
|