@jarenjs/mermaid 0.46.4 → 0.49.2

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,74 +4,416 @@
4
4
  "title": "Jaren Mermaid diagram document",
5
5
  "description": "The DiagramDocument envelope and per-type AST vocabulary of @jarenjs/mermaid (normative prose in docs/MERMAID-FORMAT.md). Draft-neutral: no $ref siblings, no unevaluated*, no $dynamic*. The AST is geometry-free — layout is a separate pass.",
6
6
  "type": "object",
7
- "required": ["$mermaid", "diagram", "config", "ast", "meta"],
7
+ "required": [
8
+ "$mermaid",
9
+ "diagram",
10
+ "config",
11
+ "ast",
12
+ "meta"
13
+ ],
8
14
  "properties": {
9
- "$mermaid": { "const": "0.1" },
10
- "diagram": { "type": "string" },
11
- "config": { "type": "object" },
12
- "ast": { "type": "object" },
15
+ "$mermaid": {
16
+ "const": "0.1"
17
+ },
18
+ "diagram": {
19
+ "type": "string"
20
+ },
21
+ "config": {
22
+ "type": "object"
23
+ },
24
+ "ast": {
25
+ "type": "object"
26
+ },
13
27
  "meta": {
14
28
  "type": "object",
15
- "required": ["hash", "direction", "title"],
29
+ "required": [
30
+ "hash",
31
+ "direction",
32
+ "title"
33
+ ],
16
34
  "properties": {
17
- "hash": { "type": "string" },
18
- "direction": { "type": ["string", "null"] },
19
- "title": { "type": ["string", "null"] }
35
+ "hash": {
36
+ "type": "string"
37
+ },
38
+ "direction": {
39
+ "type": [
40
+ "string",
41
+ "null"
42
+ ]
43
+ },
44
+ "title": {
45
+ "type": [
46
+ "string",
47
+ "null"
48
+ ]
49
+ }
20
50
  }
21
51
  }
22
52
  },
23
53
  "$defs": {
24
54
  "flowNode": {
25
55
  "type": "object",
26
- "required": ["id", "label", "shape"],
56
+ "required": [
57
+ "id",
58
+ "label",
59
+ "shape"
60
+ ],
27
61
  "properties": {
28
- "id": { "type": "string" },
29
- "label": { "type": "string" },
30
- "shape": { "type": "string" }
62
+ "id": {
63
+ "type": "string"
64
+ },
65
+ "label": {
66
+ "type": "string"
67
+ },
68
+ "shape": {
69
+ "type": "string"
70
+ }
31
71
  }
32
72
  },
33
73
  "flowEdge": {
34
74
  "type": "object",
35
- "required": ["from", "to", "stroke", "head", "tail", "length", "label"],
75
+ "required": [
76
+ "from",
77
+ "to",
78
+ "stroke",
79
+ "head",
80
+ "tail",
81
+ "length",
82
+ "label"
83
+ ],
36
84
  "properties": {
37
- "from": { "type": "string" },
38
- "to": { "type": "string" },
39
- "stroke": { "enum": ["solid", "thick", "dotted"] },
40
- "head": { "enum": ["none", "arrow", "circle", "cross"] },
41
- "tail": { "enum": ["none", "arrow", "circle", "cross"] },
42
- "length": { "type": "integer" },
43
- "label": { "type": ["string", "null"] }
85
+ "from": {
86
+ "type": "string"
87
+ },
88
+ "to": {
89
+ "type": "string"
90
+ },
91
+ "stroke": {
92
+ "enum": [
93
+ "solid",
94
+ "thick",
95
+ "dotted"
96
+ ]
97
+ },
98
+ "head": {
99
+ "enum": [
100
+ "none",
101
+ "arrow",
102
+ "circle",
103
+ "cross"
104
+ ]
105
+ },
106
+ "tail": {
107
+ "enum": [
108
+ "none",
109
+ "arrow",
110
+ "circle",
111
+ "cross"
112
+ ]
113
+ },
114
+ "length": {
115
+ "type": "integer"
116
+ },
117
+ "label": {
118
+ "type": [
119
+ "string",
120
+ "null"
121
+ ]
122
+ }
44
123
  }
45
124
  },
46
125
  "flowchartAst": {
47
126
  "type": "object",
48
- "required": ["direction", "nodes", "edges", "subgraphs", "classDefs", "classes", "styles"],
127
+ "required": [
128
+ "direction",
129
+ "nodes",
130
+ "edges",
131
+ "subgraphs",
132
+ "classDefs",
133
+ "classes",
134
+ "styles"
135
+ ],
49
136
  "properties": {
50
- "direction": { "type": "string" },
51
- "nodes": { "type": "array", "items": { "$ref": "#/$defs/flowNode" } },
52
- "edges": { "type": "array", "items": { "$ref": "#/$defs/flowEdge" } },
53
- "subgraphs": { "type": "array" },
54
- "classDefs": { "type": "array" },
55
- "classes": { "type": "array" },
56
- "styles": { "type": "array" }
137
+ "direction": {
138
+ "type": "string"
139
+ },
140
+ "nodes": {
141
+ "type": "array",
142
+ "items": {
143
+ "$ref": "#/$defs/flowNode"
144
+ }
145
+ },
146
+ "edges": {
147
+ "type": "array",
148
+ "items": {
149
+ "$ref": "#/$defs/flowEdge"
150
+ }
151
+ },
152
+ "subgraphs": {
153
+ "type": "array"
154
+ },
155
+ "classDefs": {
156
+ "type": "array"
157
+ },
158
+ "classes": {
159
+ "type": "array"
160
+ },
161
+ "styles": {
162
+ "type": "array"
163
+ }
57
164
  }
58
165
  },
59
166
  "seqParticipant": {
60
167
  "type": "object",
61
- "required": ["id", "label", "kind"],
168
+ "required": [
169
+ "id",
170
+ "label",
171
+ "kind"
172
+ ],
62
173
  "properties": {
63
- "id": { "type": "string" },
64
- "label": { "type": "string" },
65
- "kind": { "enum": ["participant", "actor"] }
174
+ "id": {
175
+ "type": "string"
176
+ },
177
+ "label": {
178
+ "type": "string"
179
+ },
180
+ "kind": {
181
+ "enum": [
182
+ "participant",
183
+ "actor"
184
+ ]
185
+ }
66
186
  }
67
187
  },
68
188
  "sequenceAst": {
69
189
  "type": "object",
70
- "required": ["participants", "statements", "autonumber"],
190
+ "required": [
191
+ "participants",
192
+ "statements",
193
+ "autonumber"
194
+ ],
195
+ "properties": {
196
+ "participants": {
197
+ "type": "array",
198
+ "items": {
199
+ "$ref": "#/$defs/seqParticipant"
200
+ }
201
+ },
202
+ "statements": {
203
+ "type": "array"
204
+ },
205
+ "autonumber": {
206
+ "type": "boolean"
207
+ }
208
+ }
209
+ },
210
+ "ganttTask": {
211
+ "description": "A resolved task row. `info` is the raw metadata string the printer round-trips; the rest is the schedule the layout draws. `start`/`end` are epoch milliseconds and the interval is half-open, so `end` is the first instant the task no longer occupies — except for a milestone, which is the instant `start === end`.",
212
+ "type": "object",
213
+ "required": [
214
+ "name",
215
+ "info",
216
+ "id",
217
+ "flags",
218
+ "start",
219
+ "end",
220
+ "duration",
221
+ "after",
222
+ "line"
223
+ ],
224
+ "properties": {
225
+ "name": {
226
+ "type": "string"
227
+ },
228
+ "info": {
229
+ "type": "string"
230
+ },
231
+ "id": {
232
+ "type": "string",
233
+ "minLength": 1
234
+ },
235
+ "flags": {
236
+ "type": "array",
237
+ "items": {
238
+ "enum": [
239
+ "done",
240
+ "active",
241
+ "crit",
242
+ "milestone"
243
+ ]
244
+ }
245
+ },
246
+ "start": {
247
+ "type": "number"
248
+ },
249
+ "end": {
250
+ "type": "number"
251
+ },
252
+ "duration": {
253
+ "type": "number",
254
+ "minimum": 0
255
+ },
256
+ "after": {
257
+ "type": "array",
258
+ "items": {
259
+ "type": "string"
260
+ }
261
+ },
262
+ "line": {
263
+ "type": "integer",
264
+ "minimum": 0
265
+ }
266
+ }
267
+ },
268
+ "ganttRules": {
269
+ "description": "The header directives, interpreted. `dateFormat` and `axisFormat` keep their Mermaid spelling (dayjs and strftime respectively, not LDML); `tick` is the parsed `tickInterval`; `weekStart` and `weekendStart` are ISO weekdays, 1 is Monday.",
270
+ "type": "object",
271
+ "required": [
272
+ "dateFormat",
273
+ "axisFormat",
274
+ "tick",
275
+ "weekStart",
276
+ "weekendStart",
277
+ "excludes",
278
+ "todayMarker"
279
+ ],
280
+ "properties": {
281
+ "dateFormat": {
282
+ "type": "string"
283
+ },
284
+ "axisFormat": {
285
+ "type": "string"
286
+ },
287
+ "tick": {
288
+ "type": [
289
+ "object",
290
+ "null"
291
+ ],
292
+ "required": [
293
+ "amount",
294
+ "unit"
295
+ ],
296
+ "properties": {
297
+ "amount": {
298
+ "type": "integer",
299
+ "minimum": 1
300
+ },
301
+ "unit": {
302
+ "enum": [
303
+ "millisecond",
304
+ "second",
305
+ "minute",
306
+ "hour",
307
+ "day",
308
+ "week",
309
+ "month"
310
+ ]
311
+ }
312
+ }
313
+ },
314
+ "weekStart": {
315
+ "type": "integer",
316
+ "minimum": 1,
317
+ "maximum": 7
318
+ },
319
+ "weekendStart": {
320
+ "type": "integer",
321
+ "minimum": 1,
322
+ "maximum": 7
323
+ },
324
+ "excludes": {
325
+ "type": "object",
326
+ "required": [
327
+ "weekends",
328
+ "weekdays",
329
+ "days"
330
+ ],
331
+ "properties": {
332
+ "weekends": {
333
+ "type": "boolean"
334
+ },
335
+ "weekdays": {
336
+ "type": "array",
337
+ "items": {
338
+ "type": "integer",
339
+ "minimum": 1,
340
+ "maximum": 7
341
+ }
342
+ },
343
+ "days": {
344
+ "type": "array",
345
+ "items": {
346
+ "type": "integer"
347
+ }
348
+ }
349
+ }
350
+ },
351
+ "todayMarker": {
352
+ "type": [
353
+ "string",
354
+ "null"
355
+ ]
356
+ }
357
+ }
358
+ },
359
+ "ganttAst": {
360
+ "description": "gantt — a resolved schedule. `meta` holds the header lines verbatim so the printer stays a fixed point; `rules` holds the same directives interpreted, and `domain` is the half-open span every task falls inside.",
361
+ "type": "object",
362
+ "required": [
363
+ "meta",
364
+ "rules",
365
+ "sections",
366
+ "domain"
367
+ ],
71
368
  "properties": {
72
- "participants": { "type": "array", "items": { "$ref": "#/$defs/seqParticipant" } },
73
- "statements": { "type": "array" },
74
- "autonumber": { "type": "boolean" }
369
+ "meta": {
370
+ "type": "object",
371
+ "additionalProperties": {
372
+ "type": "string"
373
+ }
374
+ },
375
+ "rules": {
376
+ "$ref": "#/$defs/ganttRules"
377
+ },
378
+ "sections": {
379
+ "type": "array",
380
+ "items": {
381
+ "type": "object",
382
+ "required": [
383
+ "name",
384
+ "tasks"
385
+ ],
386
+ "properties": {
387
+ "name": {
388
+ "type": [
389
+ "string",
390
+ "null"
391
+ ]
392
+ },
393
+ "tasks": {
394
+ "type": "array",
395
+ "items": {
396
+ "$ref": "#/$defs/ganttTask"
397
+ }
398
+ }
399
+ }
400
+ }
401
+ },
402
+ "domain": {
403
+ "type": "object",
404
+ "required": [
405
+ "start",
406
+ "end"
407
+ ],
408
+ "properties": {
409
+ "start": {
410
+ "type": "number"
411
+ },
412
+ "end": {
413
+ "type": "number"
414
+ }
415
+ }
416
+ }
75
417
  }
76
418
  }
77
419
  }
@@ -0,0 +1,152 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The working calendar behind `excludes`: which whole days a
4
+ * schedule skips, and what a duration therefore means.
5
+ *
6
+ * Two implementations of one answer live here on purpose. The exported
7
+ * one expresses an excluded stretch as half-open day intervals and
8
+ * measures it with `@jarenjs/core/series` (`mergeIntervals`,
9
+ * `coverageOf`) — the suite's one interval algebra, so a weekend here
10
+ * and an unavailability there are the same kind of thing. The other,
11
+ * {@link pushEndDayByDay}, is the naive day-stepping loop the shipped
12
+ * Mermaid renderer runs; it exists so the fast answer has an
13
+ * independent oracle to be differential-tested against, and it is the
14
+ * definition when the two disagree.
15
+ *
16
+ * The rule is Mermaid's, not an invention: an excluded day pushes a
17
+ * task's END out by a whole day, the task's own START is left where the
18
+ * document put it, and an end that was written as an explicit DATE is
19
+ * never pushed at all — only one derived from a duration is. Days are
20
+ * UTC days, because this engine has no local zone and no clock.
21
+ *
22
+ * This is not a holiday service. A schedule skips exactly the weekends,
23
+ * weekday names and dates its own `excludes` line lists.
24
+ */
25
+
26
+ import { mergeIntervals, coverageOf } from '@jarenjs/core/series';
27
+ import { isoWeekdayFromDays } from '@jarenjs/core/dates';
28
+
29
+ /** Milliseconds in a UTC day. */
30
+ export const DAY_MS = 86400000;
31
+
32
+ /**
33
+ * Mermaid's own guard: a schedule whose exclusions swallow every day
34
+ * would push an end forever, so the walk stops and the caller reports a
35
+ * `JM` error instead of hanging.
36
+ */
37
+ export const MAX_PUSH_DAYS = 10000;
38
+
39
+ /**
40
+ * The index of the UTC day holding `ms`, counting from 1970-01-01.
41
+ * @param {number} ms
42
+ * @returns {number}
43
+ */
44
+ export function dayIndexOf(ms) {
45
+ return Math.floor(ms / DAY_MS);
46
+ }
47
+
48
+ /**
49
+ * @typedef {object} Excluder
50
+ * @property {boolean} any whether the document excludes anything at all
51
+ * @property {(dayIndex: number) => boolean} isExcludedDay
52
+ * @property {(fromDay: number, toDay: number) => { start: number, end: number }[]}
53
+ * intervalsIn merged half-open day spans over `[fromDay, toDay)`
54
+ */
55
+
56
+ /**
57
+ * Build the working calendar a document's `excludes`, `weekend` and
58
+ * `dateFormat` describe.
59
+ *
60
+ * @param {{ weekends: boolean, weekdays: number[], days: number[] }} rules -
61
+ * `weekdays` are ISO weekday numbers, `days` are day indexes
62
+ * @param {number} weekendStart - ISO weekday the weekend starts on
63
+ * @returns {Excluder}
64
+ */
65
+ export function createExcluder(rules, weekendStart) {
66
+ const weekdays = new Set(rules.weekdays);
67
+ if (rules.weekends) {
68
+ weekdays.add(weekendStart);
69
+ // the weekend is two days: friday+saturday, or saturday+sunday
70
+ weekdays.add(weekendStart === 7 ? 1 : weekendStart + 1);
71
+ }
72
+ const days = new Set(rules.days);
73
+ const any = weekdays.size > 0 || days.size > 0;
74
+
75
+ /** @param {number} dayIndex @returns {boolean} */
76
+ const isExcludedDay = (dayIndex) => days.has(dayIndex)
77
+ || weekdays.has(isoWeekdayFromDays(dayIndex));
78
+
79
+ /** @param {number} fromDay @param {number} toDay */
80
+ const intervalsIn = (fromDay, toDay) => {
81
+ if (!any || toDay <= fromDay) return [];
82
+ const spans = [];
83
+ for (let d = fromDay; d < toDay; d++) {
84
+ if (isExcludedDay(d))
85
+ spans.push({ start: d * DAY_MS, end: (d + 1) * DAY_MS });
86
+ }
87
+ // touching days merge by default (core/series D4), so a weekend is
88
+ // one two-day span rather than two abutting ones
89
+ return spans.length === 0 ? [] : mergeIntervals(spans);
90
+ };
91
+
92
+ return { any, isExcludedDay, intervalsIn };
93
+ }
94
+
95
+ /**
96
+ * Push a task's end past the excluded days its span crosses.
97
+ *
98
+ * The probes are the instants `start + k days` for `k` from 1, which is
99
+ * what makes this a *day* rule rather than a millisecond one: a task
100
+ * that starts at noon on Friday and lasts a day ends at noon on Monday
101
+ * when the weekend is excluded, not at midnight.
102
+ *
103
+ * @param {number} start - epoch milliseconds
104
+ * @param {number} end - epoch milliseconds, at or after `start`
105
+ * @param {Excluder} excluder
106
+ * @returns {number} the pushed end, or `NaN` when the exclusions never
107
+ * let the task finish
108
+ */
109
+ export function pushEndPastExclusions(start, end, excluder) {
110
+ if (!excluder.any || end <= start) return end;
111
+ const firstDay = dayIndexOf(start) + 1;
112
+ let out = end;
113
+ // the iteration is monotone (each round can only find more excluded
114
+ // days), so it reaches the least fixed point from below
115
+ for (let round = 0; round < MAX_PUSH_DAYS; round++) {
116
+ const spanDays = Math.floor((out - start) / DAY_MS);
117
+ if (spanDays < 1) return out;
118
+ if (spanDays > MAX_PUSH_DAYS) return NaN;
119
+ const window = { start: firstDay * DAY_MS, end: (firstDay + spanDays) * DAY_MS };
120
+ const excluded = coverageOf(excluder.intervalsIn(firstDay, firstDay + spanDays), window);
121
+ const next = end + (excluded / DAY_MS) * DAY_MS;
122
+ if (next === out) return out;
123
+ out = next;
124
+ }
125
+ return NaN;
126
+ }
127
+
128
+ /**
129
+ * The oracle: Mermaid's own day-stepping loop, written plainly. Kept as
130
+ * a test double rather than as the implementation because it walks
131
+ * every day of a span one at a time, but it is the definition of the
132
+ * answer {@link pushEndPastExclusions} computes.
133
+ *
134
+ * @param {number} start - epoch milliseconds
135
+ * @param {number} end - epoch milliseconds
136
+ * @param {Excluder} excluder
137
+ * @returns {number} the pushed end, or `NaN` when it never finishes
138
+ */
139
+ export function pushEndDayByDay(start, end, excluder) {
140
+ if (!excluder.any || end <= start) return end;
141
+ let out = end;
142
+ let probe = start + DAY_MS;
143
+ let steps = 0;
144
+ while (probe <= out) {
145
+ if (excluder.isExcludedDay(dayIndexOf(probe))) {
146
+ out += DAY_MS;
147
+ if (++steps > MAX_PUSH_DAYS) return NaN;
148
+ }
149
+ probe += DAY_MS;
150
+ }
151
+ return out;
152
+ }
package/src/index.js CHANGED
@@ -39,7 +39,11 @@ export {
39
39
  /**
40
40
  * Convenience: parse → layout → render, error-safe, in one call.
41
41
  * @param {string} source
42
- * @param {{ theme?: any }} [options]
42
+ * @param {{ theme?: any, dateNames?: import('@jarenjs/core/dates').DateNames,
43
+ * parseFrontmatter?: (text: string) => any }} [options] - `dateNames`
44
+ * is the locale-name record a Gantt's `dateFormat`/`axisFormat` needs
45
+ * for a month or weekday token (`compileDateLocale(pack).names`);
46
+ * this engine ships none of its own
43
47
  * @returns {any} an SVG vnode
44
48
  */
45
49
  export function renderMermaid(source, options = {}) {