brand-intent 0.30.0 → 0.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-intent",
3
- "version": "0.30.0",
3
+ "version": "0.32.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",
@@ -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/index.ts CHANGED
@@ -29,7 +29,7 @@ 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';
@@ -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 = '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"
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 {
@@ -40,8 +51,9 @@ export interface ParsedStoryPage {
40
51
  composition?: string;
41
52
  /** Illustration ids; implementations pick one per story date, the same one every time for that date. */
42
53
  illustrations?: string[];
43
- /** Photo id; overrides the purpose's `photo:` default for this page. */
44
- photo?: string;
54
+ /** Photo ids; override the purpose's `photo:` default for this page. Implementations pick one per
55
+ * story date, the same one every time for that date. */
56
+ photos?: string[];
45
57
  /** Page topic: what this page adds to the story. */
46
58
  topic?: string;
47
59
  /** Fixed slot texts, may contain `{variable}`s. A slot the page doesn't name is open. */
@@ -198,7 +210,7 @@ export function parseStoryFile(content: string, fileName: string): ParsedStory {
198
210
  if (read && 'multi' in read) { multiKey = read.multi; multiIndent = 4; }
199
211
  else if (read?.key === 'purpose') currentPage.purpose = read.value;
200
212
  else if (read?.key === 'illustration') currentPage.illustrations = read.value.split(',').map(id => id.trim()).filter(Boolean);
201
- else if (read?.key === 'photo') currentPage.photo = read.value;
213
+ else if (read?.key === 'photo') currentPage.photos = read.value.split(',').map(id => id.trim()).filter(Boolean);
202
214
  else if (read) currentPage.composition = read.value;
203
215
  continue;
204
216
  }
@@ -281,10 +293,11 @@ export function parseStoryFile(content: string, fileName: string): ParsedStory {
281
293
  // ── Cross-file checks ─────────────────────────────────────────────────────────
282
294
 
283
295
  /** Checks a story against the brand's other files. Returns problems as "file: message";
284
- * 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). */
285
298
  export function checkStory(
286
299
  story: ParsedStory,
287
- 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 },
288
301
  fileName: string,
289
302
  ): string[] {
290
303
  const problems: string[] = [];
@@ -297,6 +310,11 @@ export function checkStory(
297
310
  const slot = purpose.slots.find(s => s.id === slotId);
298
311
  if (!slot) { say(`${where}: slot "${slotId}" is not declared by purpose "${purpose.id}"`); continue; }
299
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
+ }
300
318
  const longest = text.replace(VARIABLE, (_, v: string) => 'x'.repeat(STORY_VARIABLES[v as StoryVariable]?.maxLength ?? 0));
301
319
  const parts = slot.slotType === 'list' ? longest.split('\n') : [longest];
302
320
  if (slot.slotType === 'list' && slot.maxItems && parts.length > slot.maxItems) say(`${where}: slot "${slotId}" has ${parts.length} items, maxItems is ${slot.maxItems}`);
@@ -317,7 +335,9 @@ export function checkStory(
317
335
  for (const id of page.illustrations ?? []) {
318
336
  if (brand.illustrations && !brand.illustrations.includes(id)) say(`${where}: illustration "${id}" does not exist`);
319
337
  }
320
- if (page.photo && brand.photos && !brand.photos.includes(page.photo)) say(`${where}: photo "${page.photo}" does not exist`);
338
+ for (const id of page.photos ?? []) {
339
+ if (brand.photos && !brand.photos.includes(id)) say(`${where}: photo "${id}" does not exist`);
340
+ }
321
341
  checkTexts(where, purpose, page.texts);
322
342
  });
323
343
  story.examples.forEach((ex, i) => {
@@ -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
  }
@@ -52,7 +52,7 @@
52
52
  }
53
53
  },
54
54
  "variable": {
55
- "comment": "{weekday}, {date} placeholders in page texts",
55
+ "comment": "{weekday}, {date}, {sunrise} … placeholders in page texts",
56
56
  "match": "\\{[a-z][a-z0-9-]*\\}",
57
57
  "name": "variable.other.brand-intent-story"
58
58
  },