brand-intent 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/parser/brandParser.ts +51 -1
- package/parser/icons.ts +7 -0
- package/parser/index.ts +2 -2
- package/parser/purposeParser.ts +37 -1
- package/parser/storyParser.ts +25 -8
- package/vscode-extension/syntaxes/brand.tmLanguage.json +3 -3
- package/vscode-extension/syntaxes/purpose.tmLanguage.json +1 -1
- package/vscode-extension/syntaxes/story.tmLanguage.json +1 -1
package/package.json
CHANGED
package/parser/brandParser.ts
CHANGED
|
@@ -117,6 +117,8 @@ export interface ParsedBrand {
|
|
|
117
117
|
photoLook?: ParsedPhotoLookDef;
|
|
118
118
|
/** Named yearly date windows (`windows` block): name → ranges. Referenced by sample `@name` markers and photo `when:`. */
|
|
119
119
|
windows?: Record<string, DateWindowRange[]>;
|
|
120
|
+
/** Where the brand is (`location` block). Sky and weather story variables need it. */
|
|
121
|
+
location?: ParsedLocationDef;
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
export type PhotoLookKey = 'saturation' | 'contrast' | 'warmth' | 'fade' | 'mono' | 'duotone';
|
|
@@ -145,6 +147,15 @@ export interface ParsedCameraDef {
|
|
|
145
147
|
grid?: { rows: number; cols: number };
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
export interface ParsedLocationDef {
|
|
151
|
+
/** Place name, as people say it. */
|
|
152
|
+
name: string;
|
|
153
|
+
/** Decimal degrees, north positive. */
|
|
154
|
+
latitude: number;
|
|
155
|
+
/** Decimal degrees, east positive. */
|
|
156
|
+
longitude: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
148
159
|
export interface ParsedFramingDef {
|
|
149
160
|
id: string;
|
|
150
161
|
/** Display name. Defaults to the id. */
|
|
@@ -438,7 +449,7 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
438
449
|
|
|
439
450
|
const result: Partial<ParsedBrand> = {};
|
|
440
451
|
|
|
441
|
-
type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'color-rules' | 'tones' | 'levels' | 'color-families' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta' | 'icons' | 'counter' | 'framing' | 'camera' | 'photo-look' | 'windows';
|
|
452
|
+
type ActiveBlock = 'none' | 'brand-colors' | 'state-colors' | 'color-bounds' | 'color-adherence' | 'color-rules' | 'tones' | 'levels' | 'color-families' | 'theme' | 'spacing' | 'divider' | 'typography' | 'font' | 'logo' | 'photo' | 'video' | 'illustration' | 'label' | 'cta' | 'icons' | 'counter' | 'framing' | 'camera' | 'photo-look' | 'windows' | 'location';
|
|
442
453
|
let activeBlock: ActiveBlock = 'none';
|
|
443
454
|
let currentThemeName = '';
|
|
444
455
|
let currentDividerName = '';
|
|
@@ -498,6 +509,8 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
498
509
|
let counterDeclared = false;
|
|
499
510
|
const cameraRaw: Record<string, { value: string; line: number }> = {};
|
|
500
511
|
let cameraDeclared = false;
|
|
512
|
+
const locationRaw: Record<string, { value: string; line: number }> = {};
|
|
513
|
+
let locationDeclared = false;
|
|
501
514
|
const photoLookRaw: Record<string, { value: string; line: number }> = {};
|
|
502
515
|
let photoLookDeclared = false;
|
|
503
516
|
const windowsRaw: Array<{ name: string; value: string; line: number }> = [];
|
|
@@ -947,6 +960,15 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
947
960
|
cameraRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
|
|
948
961
|
continue;
|
|
949
962
|
}
|
|
963
|
+
if (activeBlock === 'location') {
|
|
964
|
+
const trimmed = raw.trim();
|
|
965
|
+
if (trimmed.startsWith('#')) continue;
|
|
966
|
+
const entry = trimmed.replace(/\s+#.*$/, '');
|
|
967
|
+
const colonIdx = entry.indexOf(':');
|
|
968
|
+
if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: location property must be "KEY: VALUE", got "${trimmed}"`);
|
|
969
|
+
locationRaw[entry.slice(0, colonIdx).trim()] = { value: entry.slice(colonIdx + 1).trim(), line: lineNum };
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
950
972
|
if (activeBlock === 'photo-look') {
|
|
951
973
|
const trimmed = raw.trim();
|
|
952
974
|
if (trimmed.startsWith('#')) continue;
|
|
@@ -1065,6 +1087,14 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1065
1087
|
continue;
|
|
1066
1088
|
}
|
|
1067
1089
|
|
|
1090
|
+
// location — where the brand is (sky and weather variables)
|
|
1091
|
+
if (raw.trim() === 'location') {
|
|
1092
|
+
if (locationDeclared) throw new Error(`${fileName}:${lineNum}: duplicate location block`);
|
|
1093
|
+
locationDeclared = true;
|
|
1094
|
+
activeBlock = 'location';
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1068
1098
|
// photo-look — photo treatment (saturation)
|
|
1069
1099
|
if (raw.trim() === 'photo-look') {
|
|
1070
1100
|
if (photoLookDeclared) throw new Error(`${fileName}:${lineNum}: duplicate photo-look block`);
|
|
@@ -1919,6 +1949,26 @@ export function parseBrandFile(content: string, fileName: string): ParsedBrand {
|
|
|
1919
1949
|
result.camera = camera;
|
|
1920
1950
|
}
|
|
1921
1951
|
|
|
1952
|
+
// Finalise location
|
|
1953
|
+
if (locationDeclared) {
|
|
1954
|
+
const known = new Set(['name', 'latitude', 'longitude']);
|
|
1955
|
+
for (const [key, { line }] of Object.entries(locationRaw)) {
|
|
1956
|
+
if (!known.has(key)) throw new Error(`${fileName}:${line}: unknown location property "${key}"`);
|
|
1957
|
+
}
|
|
1958
|
+
for (const key of known) {
|
|
1959
|
+
if (!locationRaw[key]?.value) throw new Error(`${fileName}: location needs ${key}:`);
|
|
1960
|
+
}
|
|
1961
|
+
const degrees = (key: 'latitude' | 'longitude', limit: number) => {
|
|
1962
|
+
const { value, line } = locationRaw[key];
|
|
1963
|
+
const n = Number(value);
|
|
1964
|
+
if (!/^-?\d+(\.\d+)?$/.test(value) || Math.abs(n) > limit) {
|
|
1965
|
+
throw new Error(`${fileName}:${line}: location ${key} must be decimal degrees (−${limit} to ${limit}), got "${value}"`);
|
|
1966
|
+
}
|
|
1967
|
+
return n;
|
|
1968
|
+
};
|
|
1969
|
+
result.location = { name: locationRaw['name'].value, latitude: degrees('latitude', 90), longitude: degrees('longitude', 180) };
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1922
1972
|
// Finalise photo treatment
|
|
1923
1973
|
if (photoLookDeclared) {
|
|
1924
1974
|
const keys: PhotoLookKey[] = ['saturation', 'contrast', 'warmth', 'fade', 'mono', 'duotone'];
|
package/parser/icons.ts
CHANGED
|
@@ -138,3 +138,10 @@ export function parseListItem(item: string): { icon?: IconRef; text: string } {
|
|
|
138
138
|
if (!m) return { text: item };
|
|
139
139
|
return { icon: parseIconRef(m[1], `list item "${item}"`), text: m[2] };
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
/** A `labels: above` list item: `Label: value`, split at the first colon followed by a space. */
|
|
143
|
+
export function splitLabelledItem(text: string): { label?: string; value: string } {
|
|
144
|
+
const at = text.indexOf(': ');
|
|
145
|
+
if (at <= 0) return { value: text };
|
|
146
|
+
return { label: text.slice(0, at).trim(), value: text.slice(at + 2).trim() };
|
|
147
|
+
}
|
package/parser/index.ts
CHANGED
|
@@ -29,12 +29,12 @@ export {
|
|
|
29
29
|
DEFAULT_COLOR_RULES,
|
|
30
30
|
} from './colorSystem';
|
|
31
31
|
export type { ReachConfig, ToneAnchors } from './colorSystem';
|
|
32
|
-
export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape, ParsedFramingDef, ParsedCameraDef, ParsedPhotoLookDef, PhotoLookKey } from './brandParser';
|
|
32
|
+
export type { ParsedBrand, ParsedFontDef, ParsedTypographyDef, ParsedDividerDef, BrandColorPrintSpec, ParsedAssetDef, ParsedBadgeStyle, LogoAlign, ParsedCounterDef, CounterFormat, CounterShape, ParsedFramingDef, ParsedCameraDef, ParsedLocationDef, ParsedPhotoLookDef, PhotoLookKey } from './brandParser';
|
|
33
33
|
|
|
34
34
|
export { parseDateWindowRanges, isInDateWindow, SAMPLE_WINDOW_MARKER } from './dateWindows';
|
|
35
35
|
export type { DateWindowRange } from './dateWindows';
|
|
36
36
|
|
|
37
|
-
export { parseIconRef, parseIconSource, resolveIconRef, parseListItem, phosphorName, PHOSPHOR_WEIGHTS } from './icons';
|
|
37
|
+
export { parseIconRef, parseIconSource, resolveIconRef, parseListItem, splitLabelledItem, phosphorName, PHOSPHOR_WEIGHTS } from './icons';
|
|
38
38
|
export type { IconRef, IconSource, ParsedIconDef, ResolvedIcon, PhosphorWeight } from './icons';
|
|
39
39
|
export type {
|
|
40
40
|
ColorMode,
|
package/parser/purposeParser.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { SlotId } from './types';
|
|
9
|
-
import { parseIconRef, parseListItem } from './icons';
|
|
9
|
+
import { parseIconRef, parseListItem, splitLabelledItem } from './icons';
|
|
10
10
|
import type { IconRef } from './icons';
|
|
11
11
|
import { SAMPLE_WINDOW_MARKER } from './dateWindows';
|
|
12
12
|
|
|
@@ -105,6 +105,14 @@ export interface ParsedPurposeSlot {
|
|
|
105
105
|
itemGap?: number;
|
|
106
106
|
/** List layout direction: 'vertical' (default) or 'horizontal'. */
|
|
107
107
|
listDirection?: 'vertical' | 'horizontal';
|
|
108
|
+
/** Grid columns for a vertical list, filled row by row (list slots only). Default: 1 */
|
|
109
|
+
listColumns?: number;
|
|
110
|
+
/** 'above': each item is `Label: value`, the label set small above the value (list slots only). */
|
|
111
|
+
listLabels?: 'none' | 'above';
|
|
112
|
+
/** Label size relative to the item text, with `labels: above`. Default: 0.7 */
|
|
113
|
+
labelSize?: number;
|
|
114
|
+
/** Line between grid columns (list slots with `columns:` 2 or more). */
|
|
115
|
+
listDivider?: 'none' | 'hairline';
|
|
108
116
|
/** When false, this slot is locked — users cannot edit it in any mode. Default: true (editable). */
|
|
109
117
|
editable?: boolean;
|
|
110
118
|
typography: {
|
|
@@ -210,6 +218,9 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
210
218
|
if (s.marker && s.marker !== 'icon') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": iconOnly needs "marker: icon", got "${s.marker}"`);
|
|
211
219
|
s.marker = 'icon';
|
|
212
220
|
}
|
|
221
|
+
if (s.listColumns && s.listColumns > 1 && s.listDirection === 'horizontal') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": columns needs a vertical list`);
|
|
222
|
+
if (s.labelSize !== undefined && s.listLabels !== 'above') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": labelSize needs "labels: above"`);
|
|
223
|
+
if (s.listDivider === 'hairline' && !(s.listColumns && s.listColumns > 1)) throw new Error(`${fileName}:${lineNum}: slot "${s.id}": divider needs "columns:" 2 or more`);
|
|
213
224
|
if (s.defaultIcon && s.marker !== 'icon') throw new Error(`${fileName}:${lineNum}: slot "${s.id}": defaultIcon needs "marker: icon"`);
|
|
214
225
|
if (!s.value && s.samples) {
|
|
215
226
|
// Trailing `@name` window marker, on the sample's first line only
|
|
@@ -644,6 +655,30 @@ export function parsePurposeFile(content: string, fileName: string): ParsedPurpo
|
|
|
644
655
|
currentSlot.listDirection = d;
|
|
645
656
|
continue;
|
|
646
657
|
}
|
|
658
|
+
if (prop.startsWith('columns:')) {
|
|
659
|
+
const n = Number(prop.slice(8).trim());
|
|
660
|
+
if (!Number.isInteger(n) || n < 1 || n > 6) throw new Error(`${fileName}:${lineNum}: invalid columns "${prop.slice(8).trim()}" — must be 1 to 6`);
|
|
661
|
+
currentSlot.listColumns = n;
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
if (prop.startsWith('labels:')) {
|
|
665
|
+
const l = prop.slice(7).trim();
|
|
666
|
+
if (l !== 'none' && l !== 'above') throw new Error(`${fileName}:${lineNum}: invalid labels "${l}" — must be "none" or "above"`);
|
|
667
|
+
currentSlot.listLabels = l;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (prop.startsWith('labelSize:')) {
|
|
671
|
+
const n = parseFloat(prop.slice(10).trim());
|
|
672
|
+
if (isNaN(n) || n <= 0 || n > 1) throw new Error(`${fileName}:${lineNum}: invalid labelSize "${prop.slice(10).trim()}" — must be above 0 and at most 1`);
|
|
673
|
+
currentSlot.labelSize = n;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (prop.startsWith('divider:')) {
|
|
677
|
+
const d = prop.slice(8).trim();
|
|
678
|
+
if (d !== 'none' && d !== 'hairline') throw new Error(`${fileName}:${lineNum}: invalid list divider "${d}" — must be "none" or "hairline"`);
|
|
679
|
+
currentSlot.listDivider = d;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
647
682
|
throw new Error(`${fileName}:${lineNum}: unknown list property "${prop}"`);
|
|
648
683
|
}
|
|
649
684
|
}
|
|
@@ -828,6 +863,7 @@ function resolveExamples(
|
|
|
828
863
|
for (const item of text.split('\n')) {
|
|
829
864
|
const { icon } = parseListItem(item);
|
|
830
865
|
if (icon && slot.marker !== 'icon') throw new Error(`${at(slotId)}: slot "${slotId}": item "${item}" names an icon, but the list has no "marker: icon"`);
|
|
866
|
+
if (slot.listLabels === 'above' && !splitLabelledItem(item).label) throw new Error(`${at(slotId)}: slot "${slotId}": item "${item}" needs a label ("Label: value") for "labels: above"`);
|
|
831
867
|
}
|
|
832
868
|
}
|
|
833
869
|
}
|
package/parser/storyParser.ts
CHANGED
|
@@ -13,12 +13,23 @@ import type { ParsedPurpose } from './purposeParser';
|
|
|
13
13
|
// ── Parsed types ──────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
15
15
|
/** Placeholders a page text may contain. Implementations resolve them for the story's date. */
|
|
16
|
-
export type StoryVariable =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
export type StoryVariable =
|
|
17
|
+
| 'weekday' | 'date'
|
|
18
|
+
| 'sunrise' | 'sunset' | 'day-length' | 'moon'
|
|
19
|
+
| 'temperature' | 'wind' | 'weather';
|
|
20
|
+
|
|
21
|
+
/** Longest resolved form per variable (German and English), used for the `maxLength` check.
|
|
22
|
+
* `location`: the variable needs the brand's `location` block. */
|
|
23
|
+
export const STORY_VARIABLES: Record<StoryVariable, { maxLength: number; location?: true }> = {
|
|
24
|
+
weekday: { maxLength: 10 }, // "Donnerstag"
|
|
25
|
+
date: { maxLength: 13 }, // "30. September"
|
|
26
|
+
sunrise: { maxLength: 8, location: true }, // "12:59 PM"
|
|
27
|
+
sunset: { maxLength: 8, location: true },
|
|
28
|
+
'day-length': { maxLength: 15, location: true }, // "16 Std. 59 Min."
|
|
29
|
+
moon: { maxLength: 20, location: true }, // "abnehmender Halbmond"
|
|
30
|
+
temperature: { maxLength: 6, location: true }, // "-12 °C"
|
|
31
|
+
wind: { maxLength: 6, location: true }, // "12 Bft"
|
|
32
|
+
weather: { maxLength: 13, location: true }, // "teils bewölkt"
|
|
22
33
|
};
|
|
23
34
|
|
|
24
35
|
export interface ParsedStory {
|
|
@@ -282,10 +293,11 @@ export function parseStoryFile(content: string, fileName: string): ParsedStory {
|
|
|
282
293
|
// ── Cross-file checks ─────────────────────────────────────────────────────────
|
|
283
294
|
|
|
284
295
|
/** Checks a story against the brand's other files. Returns problems as "file: message";
|
|
285
|
-
* empty when the story fits. Variables count at their longest form for `maxLength`.
|
|
296
|
+
* empty when the story fits. Variables count at their longest form for `maxLength`.
|
|
297
|
+
* `location: false` reports sky and weather variables (the brand has no `location` block). */
|
|
286
298
|
export function checkStory(
|
|
287
299
|
story: ParsedStory,
|
|
288
|
-
brand: { purposes: ParsedPurpose[]; compositions: string[]; formats: string[]; illustrations?: string[]; photos?: string[] },
|
|
300
|
+
brand: { purposes: ParsedPurpose[]; compositions: string[]; formats: string[]; illustrations?: string[]; photos?: string[]; location?: boolean },
|
|
289
301
|
fileName: string,
|
|
290
302
|
): string[] {
|
|
291
303
|
const problems: string[] = [];
|
|
@@ -298,6 +310,11 @@ export function checkStory(
|
|
|
298
310
|
const slot = purpose.slots.find(s => s.id === slotId);
|
|
299
311
|
if (!slot) { say(`${where}: slot "${slotId}" is not declared by purpose "${purpose.id}"`); continue; }
|
|
300
312
|
if (slot.value) { say(`${where}: slot "${slotId}" has a fixed value in purpose "${purpose.id}"`); continue; }
|
|
313
|
+
if (brand.location === false) {
|
|
314
|
+
for (const v of new Set(storyTextVariables(text))) {
|
|
315
|
+
if (STORY_VARIABLES[v as StoryVariable]?.location) say(`${where}: variable "{${v}}" needs a location block in the .brand`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
301
318
|
const longest = text.replace(VARIABLE, (_, v: string) => 'x'.repeat(STORY_VARIABLES[v as StoryVariable]?.maxLength ?? 0));
|
|
302
319
|
const parts = slot.slotType === 'list' ? longest.split('\n') : [longest];
|
|
303
320
|
if (slot.slotType === 'list' && slot.maxItems && parts.length > slot.maxItems) say(`${where}: slot "${slotId}" has ${parts.length} items, maxItems is ${slot.maxItems}`);
|
|
@@ -458,8 +458,8 @@
|
|
|
458
458
|
}
|
|
459
459
|
},
|
|
460
460
|
"icons-counter-header": {
|
|
461
|
-
"comment": "Bare `icons` / `counter` / `camera` / `photo-look` / `windows` block headers.",
|
|
462
|
-
"match": "^(icons|counter|camera|photo-look|windows)\\s*$",
|
|
461
|
+
"comment": "Bare `icons` / `counter` / `camera` / `photo-look` / `windows` / `location` block headers.",
|
|
462
|
+
"match": "^(icons|counter|camera|photo-look|windows|location)\\s*$",
|
|
463
463
|
"captures": {
|
|
464
464
|
"1": { "name": "keyword.control.brand-intent-brand" }
|
|
465
465
|
}
|
|
@@ -535,7 +535,7 @@
|
|
|
535
535
|
},
|
|
536
536
|
"keyword-partial": {
|
|
537
537
|
"comment": "Fallback: color the keyword token on incomplete lines (while typing)",
|
|
538
|
-
"match": "^(id:|name:|language:|locale:|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|$)",
|
|
538
|
+
"match": "^(id:|name:|language:|locale:|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|location)(?=\\s|$)",
|
|
539
539
|
"captures": {
|
|
540
540
|
"1": { "name": "keyword.control.brand-intent-brand" }
|
|
541
541
|
}
|
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
},
|
|
233
233
|
"list-property": {
|
|
234
234
|
"comment": "4-space indented properties under type: list blocks",
|
|
235
|
-
"match": "^\\s{4,}(marker|icons|iconOnly|iconGap|itemGap|maxItems|direction)(:)\\s+(.*)",
|
|
235
|
+
"match": "^\\s{4,}(marker|icons|iconOnly|iconGap|itemGap|maxItems|direction|columns|labels|labelSize|divider)(:)\\s+(.*)",
|
|
236
236
|
"captures": {
|
|
237
237
|
"1": { "name": "support.type.property-name.brand-intent-purpose" },
|
|
238
238
|
"2": { "name": "punctuation.separator.brand-intent-purpose" },
|