@nowline/layout 0.0.0-dev.20261009042000.g52d5946 → 0.0.0-dev.20261012061520.ge3b2479

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.
@@ -4,12 +4,24 @@ import { type WorkingCalendar } from './working-calendar.js';
4
4
  export interface ResolvedCalendar {
5
5
  /** The preset or `calendar` block day counts. */
6
6
  config: CalendarConfig;
7
- /** The working calendar the preset implies. */
7
+ /**
8
+ * The working calendar of the preset and the file's declarations (its
9
+ * own and its merged includes'). A `non-working` declaration named
10
+ * `weekend` replaces the preset's rules. Its `rules` are in a canonical
11
+ * order, so nothing downstream depends on file or include order.
12
+ */
8
13
  working: WorkingCalendar;
9
14
  }
10
15
  /**
11
16
  * Resolve the calendar a roadmap declares (`calendar:` on the roadmap plus
12
- * the `config` block's `calendar` section); business when it declares none.
17
+ * the `config` block's `calendar` section, business when it declares none)
18
+ * and the non-working days its declarations add or take away.
19
+ *
20
+ * A malformed declaration is left out (the validator reports it in the root
21
+ * file; includes are not validated). Rules that leave an unbounded stretch
22
+ * with no working day (an include that closes the whole week; the root file
23
+ * would have had NL.E0906) fall back to the preset calendar rather than
24
+ * failing layout or export.
13
25
  */
14
26
  export declare function resolveWorkingCalendar(file: NowlineFile, resolved: ResolveResult): ResolvedCalendar;
15
27
  //# sourceMappingURL=calendar-resolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"calendar-resolver.d.ts","sourceRoot":"","sources":["../src/calendar-resolver.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAE,KAAK,cAAc,EAAmB,MAAM,eAAe,CAAC;AACrE,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAEjF,MAAM,WAAW,gBAAgB;IAC7B,iDAAiD;IACjD,MAAM,EAAE,cAAc,CAAC;IACvB,+CAA+C;IAC/C,OAAO,EAAE,eAAe,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAClC,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,aAAa,GACxB,gBAAgB,CAGlB"}
1
+ {"version":3,"file":"calendar-resolver.d.ts","sourceRoot":"","sources":["../src/calendar-resolver.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAGR,WAAW,EACX,aAAa,EAEhB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,KAAK,cAAc,EAAmB,MAAM,eAAe,CAAC;AAErE,OAAO,EAIH,KAAK,eAAe,EACvB,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,gBAAgB;IAC7B,iDAAiD;IACjD,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;OAKG;IACH,OAAO,EAAE,eAAe,CAAC;CAC5B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,sBAAsB,CAClC,IAAI,EAAE,WAAW,EACjB,QAAQ,EAAE,aAAa,GACxB,gBAAgB,CASlB"}
@@ -3,17 +3,171 @@
3
3
  // same `CalendarConfig` (duration arithmetic) and `WorkingCalendar`
4
4
  // (non-working days), so the chart and every export agree on what `1w` is.
5
5
  //
6
+ // It is also the one place the file's `non-working` and `working`
7
+ // declarations (its own and its merged includes', specs/working-calendar.md
8
+ // §4) become `CalendarRule`s, so every consumer picks them up unchanged.
9
+ //
6
10
  // It lives apart from `calendar.ts` because `working-calendar.ts` already
7
11
  // imports values from there; building a `WorkingCalendar` in `calendar.ts`
8
12
  // would close a value-import cycle.
9
13
  import { resolveCalendar } from './calendar.js';
10
- import { fromCalendarConfig } from './working-calendar.js';
14
+ import { formatIsoDate, parseDate, propValues } from './dsl-utils.js';
15
+ import { fromCalendarConfig, presetRules, } from './working-calendar.js';
11
16
  /**
12
17
  * Resolve the calendar a roadmap declares (`calendar:` on the roadmap plus
13
- * the `config` block's `calendar` section); business when it declares none.
18
+ * the `config` block's `calendar` section, business when it declares none)
19
+ * and the non-working days its declarations add or take away.
20
+ *
21
+ * A malformed declaration is left out (the validator reports it in the root
22
+ * file; includes are not validated). Rules that leave an unbounded stretch
23
+ * with no working day (an include that closes the whole week; the root file
24
+ * would have had NL.E0906) fall back to the preset calendar rather than
25
+ * failing layout or export.
14
26
  */
15
27
  export function resolveWorkingCalendar(file, resolved) {
16
28
  const config = resolveCalendar(file, resolved.config.calendar);
17
- return { config, working: fromCalendarConfig(config) };
29
+ const rules = calendarRules(config, resolved.config.calendarDeclarations);
30
+ try {
31
+ return { config, working: fromCalendarConfig(config, rules) };
32
+ }
33
+ catch (err) {
34
+ if (!(err instanceof RangeError))
35
+ throw err;
36
+ return { config, working: fromCalendarConfig(config) };
37
+ }
38
+ }
39
+ /** UTC weekday numbers (Sunday is 0) of the names `every:` takes. */
40
+ const WEEKDAY_INDEX = {
41
+ sun: 0,
42
+ mon: 1,
43
+ tue: 2,
44
+ wed: 3,
45
+ thu: 4,
46
+ fri: 5,
47
+ sat: 6,
48
+ };
49
+ /**
50
+ * The preset's rules, unless a `non-working` declaration is named `weekend`
51
+ * (decision 14), plus one rule per well-formed declaration, in canonical
52
+ * order.
53
+ */
54
+ function calendarRules(config, declarations) {
55
+ const replacesWeekend = declarations.some((decl) => decl.$type === 'NonWorkingDeclaration' && decl.name === 'weekend');
56
+ const rules = replacesWeekend ? [] : presetRules(config.mode);
57
+ for (const decl of declarations) {
58
+ const rule = declarationRule(decl);
59
+ if (rule)
60
+ rules.push(rule);
61
+ }
62
+ return rules.sort(compareRules);
63
+ }
64
+ /**
65
+ * The rule one declaration stands for, or undefined when its shape or a
66
+ * value is malformed. Only the first `date:`, `start:`, `end:` and `every:`
67
+ * count; any other key is the validator's NL.E0901 and changes nothing here.
68
+ */
69
+ function declarationRule(decl) {
70
+ const props = decl.properties;
71
+ const has = (key) => props.some((p) => stripColon(p.key) === key);
72
+ const rule = { working: decl.$type === 'WorkingDeclaration' };
73
+ if (decl.name)
74
+ rule.id = decl.name;
75
+ if (decl.title !== undefined)
76
+ rule.title = decl.title;
77
+ if (has('date')) {
78
+ if (has('every') || has('start') || has('end'))
79
+ return undefined;
80
+ const dates = propValues(props, 'date').map(realDate);
81
+ if (dates.length === 0 || dates.some((date) => date === undefined))
82
+ return undefined;
83
+ rule.dates = dates;
84
+ return rule;
85
+ }
86
+ if (!has('every') && !(has('start') && has('end')))
87
+ return undefined;
88
+ for (const key of ['start', 'end']) {
89
+ if (!has(key))
90
+ continue;
91
+ const date = singleDate(props, key);
92
+ if (!date)
93
+ return undefined;
94
+ rule[key] = date;
95
+ }
96
+ if (rule.start && rule.end && rule.end < rule.start)
97
+ return undefined;
98
+ if (has('every')) {
99
+ const weekdays = propValues(props, 'every').map((name) => WEEKDAY_INDEX[name]);
100
+ if (weekdays.length === 0 || weekdays.some((day) => day === undefined))
101
+ return undefined;
102
+ rule.every = new Set(weekdays);
103
+ }
104
+ return rule;
105
+ }
106
+ /** The one date a `start:` / `end:` names; a one-element bracket is that date. */
107
+ function singleDate(props, key) {
108
+ const values = propValues(props, key);
109
+ return values.length === 1 ? realDate(values[0]) : undefined;
110
+ }
111
+ /**
112
+ * A `YYYY-MM-DD` that names a real calendar day, at UTC midnight. `parseDate`
113
+ * rolls `2026-02-30` over to March 2; the validator rejects it (NL.E0405), so
114
+ * the resolver does too.
115
+ */
116
+ function realDate(raw) {
117
+ const date = parseDate(raw);
118
+ return date && formatIsoDate(date) === raw ? date : undefined;
119
+ }
120
+ function stripColon(key) {
121
+ return key.endsWith(':') ? key.slice(0, -1) : key;
122
+ }
123
+ /**
124
+ * Canonical rule order (decision 18): first date (none first), then title,
125
+ * id, `working`, the `every` mask, start and end, then the remaining dates.
126
+ * The engine breaks ties between titled runs by input order, so sorting
127
+ * here keeps `rules`, run titles and every downstream list independent of
128
+ * file and include order.
129
+ */
130
+ function compareRules(a, b) {
131
+ return (compareOptional(firstDay(a), firstDay(b)) ||
132
+ compareOptional(a.title, b.title) ||
133
+ compareOptional(a.id, b.id) ||
134
+ Number(a.working) - Number(b.working) ||
135
+ compareOptional(everyMask(a), everyMask(b)) ||
136
+ compareOptional(a.start?.getTime(), b.start?.getTime()) ||
137
+ compareOptional(a.end?.getTime(), b.end?.getTime()) ||
138
+ compareDates(a.dates ?? [], b.dates ?? []));
139
+ }
140
+ /** The earliest date a rule names (its first `date:` or its `start:`); undefined for none. */
141
+ function firstDay(rule) {
142
+ if (rule.dates)
143
+ return Math.min(...rule.dates.map((date) => date.getTime()));
144
+ return rule.start?.getTime();
145
+ }
146
+ function everyMask(rule) {
147
+ if (!rule.every)
148
+ return undefined;
149
+ let mask = 0;
150
+ for (const weekday of rule.every)
151
+ mask |= 1 << weekday;
152
+ return mask;
153
+ }
154
+ /** Undefined sorts first; strings compare by code unit, never by locale. */
155
+ function compareOptional(a, b) {
156
+ if (a === b)
157
+ return 0;
158
+ if (a === undefined)
159
+ return -1;
160
+ if (b === undefined)
161
+ return 1;
162
+ return a < b ? -1 : 1;
163
+ }
164
+ function compareDates(a, b) {
165
+ const left = a.map((date) => date.getTime()).sort((x, y) => x - y);
166
+ const right = b.map((date) => date.getTime()).sort((x, y) => x - y);
167
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
168
+ if (left[i] !== right[i])
169
+ return left[i] - right[i];
170
+ }
171
+ return left.length - right.length;
18
172
  }
19
173
  //# sourceMappingURL=calendar-resolver.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"calendar-resolver.js","sourceRoot":"","sources":["../src/calendar-resolver.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,yEAAyE;AACzE,oEAAoE;AACpE,2EAA2E;AAC3E,EAAE;AACF,0EAA0E;AAC1E,2EAA2E;AAC3E,oCAAoC;AAGpC,OAAO,EAAuB,eAAe,EAAE,MAAM,eAAe,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAwB,MAAM,uBAAuB,CAAC;AASjF;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CAClC,IAAiB,EACjB,QAAuB;IAEvB,MAAM,MAAM,GAAG,eAAe,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC/D,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC;AAC3D,CAAC"}
1
+ {"version":3,"file":"calendar-resolver.js","sourceRoot":"","sources":["../src/calendar-resolver.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowline/layout",
3
- "version": "0.0.0-dev.20261009042000.g52d5946",
3
+ "version": "0.0.0-dev.20261012061520.ge3b2479",
4
4
  "description": "Nowline layout engine — works out where everything goes on the page before it's drawn.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -22,7 +22,7 @@
22
22
  "src/"
23
23
  ],
24
24
  "dependencies": {
25
- "@nowline/core": "0.0.0-dev.20261009042000.g52d5946",
25
+ "@nowline/core": "0.0.0-dev.20261012061520.ge3b2479",
26
26
  "d3-scale": "^4.0.2",
27
27
  "d3-time": "^3.1.0"
28
28
  },
@@ -3,29 +3,203 @@
3
3
  // same `CalendarConfig` (duration arithmetic) and `WorkingCalendar`
4
4
  // (non-working days), so the chart and every export agree on what `1w` is.
5
5
  //
6
+ // It is also the one place the file's `non-working` and `working`
7
+ // declarations (its own and its merged includes', specs/working-calendar.md
8
+ // §4) become `CalendarRule`s, so every consumer picks them up unchanged.
9
+ //
6
10
  // It lives apart from `calendar.ts` because `working-calendar.ts` already
7
11
  // imports values from there; building a `WorkingCalendar` in `calendar.ts`
8
12
  // would close a value-import cycle.
9
13
 
10
- import type { NowlineFile, ResolveResult } from '@nowline/core';
14
+ import type {
15
+ EntityProperty,
16
+ NonWorkingDeclaration,
17
+ NowlineFile,
18
+ ResolveResult,
19
+ WorkingDeclaration,
20
+ } from '@nowline/core';
11
21
  import { type CalendarConfig, resolveCalendar } from './calendar.js';
12
- import { fromCalendarConfig, type WorkingCalendar } from './working-calendar.js';
22
+ import { formatIsoDate, parseDate, propValues } from './dsl-utils.js';
23
+ import {
24
+ type CalendarRule,
25
+ fromCalendarConfig,
26
+ presetRules,
27
+ type WorkingCalendar,
28
+ } from './working-calendar.js';
13
29
 
14
30
  export interface ResolvedCalendar {
15
31
  /** The preset or `calendar` block day counts. */
16
32
  config: CalendarConfig;
17
- /** The working calendar the preset implies. */
33
+ /**
34
+ * The working calendar of the preset and the file's declarations (its
35
+ * own and its merged includes'). A `non-working` declaration named
36
+ * `weekend` replaces the preset's rules. Its `rules` are in a canonical
37
+ * order, so nothing downstream depends on file or include order.
38
+ */
18
39
  working: WorkingCalendar;
19
40
  }
20
41
 
21
42
  /**
22
43
  * Resolve the calendar a roadmap declares (`calendar:` on the roadmap plus
23
- * the `config` block's `calendar` section); business when it declares none.
44
+ * the `config` block's `calendar` section, business when it declares none)
45
+ * and the non-working days its declarations add or take away.
46
+ *
47
+ * A malformed declaration is left out (the validator reports it in the root
48
+ * file; includes are not validated). Rules that leave an unbounded stretch
49
+ * with no working day (an include that closes the whole week; the root file
50
+ * would have had NL.E0906) fall back to the preset calendar rather than
51
+ * failing layout or export.
24
52
  */
25
53
  export function resolveWorkingCalendar(
26
54
  file: NowlineFile,
27
55
  resolved: ResolveResult,
28
56
  ): ResolvedCalendar {
29
57
  const config = resolveCalendar(file, resolved.config.calendar);
30
- return { config, working: fromCalendarConfig(config) };
58
+ const rules = calendarRules(config, resolved.config.calendarDeclarations);
59
+ try {
60
+ return { config, working: fromCalendarConfig(config, rules) };
61
+ } catch (err) {
62
+ if (!(err instanceof RangeError)) throw err;
63
+ return { config, working: fromCalendarConfig(config) };
64
+ }
65
+ }
66
+
67
+ type CalendarDeclaration = NonWorkingDeclaration | WorkingDeclaration;
68
+
69
+ /** UTC weekday numbers (Sunday is 0) of the names `every:` takes. */
70
+ const WEEKDAY_INDEX: Readonly<Record<string, number>> = {
71
+ sun: 0,
72
+ mon: 1,
73
+ tue: 2,
74
+ wed: 3,
75
+ thu: 4,
76
+ fri: 5,
77
+ sat: 6,
78
+ };
79
+
80
+ /**
81
+ * The preset's rules, unless a `non-working` declaration is named `weekend`
82
+ * (decision 14), plus one rule per well-formed declaration, in canonical
83
+ * order.
84
+ */
85
+ function calendarRules(
86
+ config: CalendarConfig,
87
+ declarations: ReadonlyArray<CalendarDeclaration>,
88
+ ): CalendarRule[] {
89
+ const replacesWeekend = declarations.some(
90
+ (decl) => decl.$type === 'NonWorkingDeclaration' && decl.name === 'weekend',
91
+ );
92
+ const rules = replacesWeekend ? [] : presetRules(config.mode);
93
+ for (const decl of declarations) {
94
+ const rule = declarationRule(decl);
95
+ if (rule) rules.push(rule);
96
+ }
97
+ return rules.sort(compareRules);
98
+ }
99
+
100
+ /**
101
+ * The rule one declaration stands for, or undefined when its shape or a
102
+ * value is malformed. Only the first `date:`, `start:`, `end:` and `every:`
103
+ * count; any other key is the validator's NL.E0901 and changes nothing here.
104
+ */
105
+ function declarationRule(decl: CalendarDeclaration): CalendarRule | undefined {
106
+ const props = decl.properties;
107
+ const has = (key: string) => props.some((p) => stripColon(p.key) === key);
108
+ const rule: CalendarRule = { working: decl.$type === 'WorkingDeclaration' };
109
+ if (decl.name) rule.id = decl.name;
110
+ if (decl.title !== undefined) rule.title = decl.title;
111
+
112
+ if (has('date')) {
113
+ if (has('every') || has('start') || has('end')) return undefined;
114
+ const dates = propValues(props, 'date').map(realDate);
115
+ if (dates.length === 0 || dates.some((date) => date === undefined)) return undefined;
116
+ rule.dates = dates as Date[];
117
+ return rule;
118
+ }
119
+ if (!has('every') && !(has('start') && has('end'))) return undefined;
120
+
121
+ for (const key of ['start', 'end'] as const) {
122
+ if (!has(key)) continue;
123
+ const date = singleDate(props, key);
124
+ if (!date) return undefined;
125
+ rule[key] = date;
126
+ }
127
+ if (rule.start && rule.end && rule.end < rule.start) return undefined;
128
+
129
+ if (has('every')) {
130
+ const weekdays = propValues(props, 'every').map((name) => WEEKDAY_INDEX[name]);
131
+ if (weekdays.length === 0 || weekdays.some((day) => day === undefined)) return undefined;
132
+ rule.every = new Set(weekdays);
133
+ }
134
+ return rule;
135
+ }
136
+
137
+ /** The one date a `start:` / `end:` names; a one-element bracket is that date. */
138
+ function singleDate(props: EntityProperty[], key: 'start' | 'end'): Date | undefined {
139
+ const values = propValues(props, key);
140
+ return values.length === 1 ? realDate(values[0]) : undefined;
141
+ }
142
+
143
+ /**
144
+ * A `YYYY-MM-DD` that names a real calendar day, at UTC midnight. `parseDate`
145
+ * rolls `2026-02-30` over to March 2; the validator rejects it (NL.E0405), so
146
+ * the resolver does too.
147
+ */
148
+ function realDate(raw: string): Date | undefined {
149
+ const date = parseDate(raw);
150
+ return date && formatIsoDate(date) === raw ? date : undefined;
151
+ }
152
+
153
+ function stripColon(key: string): string {
154
+ return key.endsWith(':') ? key.slice(0, -1) : key;
155
+ }
156
+
157
+ /**
158
+ * Canonical rule order (decision 18): first date (none first), then title,
159
+ * id, `working`, the `every` mask, start and end, then the remaining dates.
160
+ * The engine breaks ties between titled runs by input order, so sorting
161
+ * here keeps `rules`, run titles and every downstream list independent of
162
+ * file and include order.
163
+ */
164
+ function compareRules(a: CalendarRule, b: CalendarRule): number {
165
+ return (
166
+ compareOptional(firstDay(a), firstDay(b)) ||
167
+ compareOptional(a.title, b.title) ||
168
+ compareOptional(a.id, b.id) ||
169
+ Number(a.working) - Number(b.working) ||
170
+ compareOptional(everyMask(a), everyMask(b)) ||
171
+ compareOptional(a.start?.getTime(), b.start?.getTime()) ||
172
+ compareOptional(a.end?.getTime(), b.end?.getTime()) ||
173
+ compareDates(a.dates ?? [], b.dates ?? [])
174
+ );
175
+ }
176
+
177
+ /** The earliest date a rule names (its first `date:` or its `start:`); undefined for none. */
178
+ function firstDay(rule: CalendarRule): number | undefined {
179
+ if (rule.dates) return Math.min(...rule.dates.map((date) => date.getTime()));
180
+ return rule.start?.getTime();
181
+ }
182
+
183
+ function everyMask(rule: CalendarRule): number | undefined {
184
+ if (!rule.every) return undefined;
185
+ let mask = 0;
186
+ for (const weekday of rule.every) mask |= 1 << weekday;
187
+ return mask;
188
+ }
189
+
190
+ /** Undefined sorts first; strings compare by code unit, never by locale. */
191
+ function compareOptional<T extends number | string>(a: T | undefined, b: T | undefined): number {
192
+ if (a === b) return 0;
193
+ if (a === undefined) return -1;
194
+ if (b === undefined) return 1;
195
+ return a < b ? -1 : 1;
196
+ }
197
+
198
+ function compareDates(a: ReadonlyArray<Date>, b: ReadonlyArray<Date>): number {
199
+ const left = a.map((date) => date.getTime()).sort((x, y) => x - y);
200
+ const right = b.map((date) => date.getTime()).sort((x, y) => x - y);
201
+ for (let i = 0; i < Math.min(left.length, right.length); i++) {
202
+ if (left[i] !== right[i]) return left[i] - right[i];
203
+ }
204
+ return left.length - right.length;
31
205
  }