@heroiclands/package-build 22.15.1 → 22.17.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.
Files changed (223) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +315 -42
  5. package/bin/report.mjs +1 -1
  6. package/ci/ci-docker.mjs +6 -3
  7. package/ci/ci-steps.mjs +6 -4
  8. package/ci/content-format-reference.mjs +106 -0
  9. package/config.mjs +22 -2
  10. package/content-config.mjs +74 -134
  11. package/docs/api.md +296 -236
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +85 -0
  14. package/docs/authoring/first-note.md +48 -0
  15. package/docs/authoring/frontmatter.md +40 -0
  16. package/docs/authoring/links-and-markup.md +137 -0
  17. package/docs/commands.md +633 -162
  18. package/docs/configuration.md +176 -140
  19. package/docs/content-format.md +50 -4229
  20. package/docs/design.md +32 -0
  21. package/docs/diagnostics.md +29 -22
  22. package/docs/getting-started.md +21 -877
  23. package/docs/guides/book.md +153 -0
  24. package/docs/guides/e2e.md +143 -0
  25. package/docs/guides/icons.md +126 -0
  26. package/docs/guides/localization.md +120 -0
  27. package/docs/guides/module-project.md +915 -0
  28. package/docs/guides/packs.md +167 -0
  29. package/docs/guides/reference-pages.md +148 -0
  30. package/docs/guides/release-and-deploy.md +114 -0
  31. package/docs/guides/site.md +152 -0
  32. package/docs/guides/system-project.md +45 -0
  33. package/docs/index.md +16 -0
  34. package/docs/project-setup.md +54 -35
  35. package/docs/reference/format-details.md +4362 -0
  36. package/docs/reference/note-types.md +532 -0
  37. package/engine/address-render.mjs +26 -0
  38. package/engine/address.mjs +3 -20
  39. package/engine/anchors.mjs +15 -6
  40. package/engine/asset-bindings.mjs +6 -2
  41. package/engine/base-compiler.mjs +47 -11
  42. package/engine/being-measurements.mjs +70 -0
  43. package/engine/calendar-artifacts.mjs +1 -1
  44. package/engine/calendar-choice.mjs +32 -0
  45. package/engine/calendar-format.mjs +441 -0
  46. package/engine/calendar-human.mjs +133 -0
  47. package/engine/calendar-notes.mjs +307 -88
  48. package/engine/calendars.mjs +8 -1
  49. package/engine/changelog-lint.mjs +2 -2
  50. package/engine/code-fences.mjs +23 -97
  51. package/engine/compile-corpus.mjs +6 -7
  52. package/engine/content-admonitions.mjs +114 -0
  53. package/engine/content-captions.mjs +181 -0
  54. package/engine/content-embeds.mjs +2 -2
  55. package/engine/content-footnotes.mjs +34 -0
  56. package/engine/content-format-check.mjs +2 -2
  57. package/engine/content-format.mjs +127 -73
  58. package/engine/content-format.yaml +746 -0
  59. package/engine/content-html.mjs +1 -1
  60. package/engine/content-icons.mjs +45 -92
  61. package/engine/content-images.mjs +22 -74
  62. package/engine/content-index.mjs +12 -3
  63. package/engine/content-links.mjs +21 -26
  64. package/engine/content-lint.mjs +1 -5
  65. package/engine/content-secrets.mjs +3 -1
  66. package/engine/content-tables.mjs +47 -1257
  67. package/engine/culture-choice.mjs +32 -0
  68. package/engine/date-conversion.mjs +47 -0
  69. package/engine/declaration-check.mjs +87 -0
  70. package/engine/docs-checks.mjs +140 -0
  71. package/engine/documentation-links.mjs +31 -0
  72. package/engine/exported-scene.mjs +1 -1
  73. package/engine/extension-attributes.mjs +74 -0
  74. package/engine/field-reference.mjs +1 -1
  75. package/engine/field-spec.mjs +4 -0
  76. package/engine/foreign-catalog.mjs +5 -5
  77. package/engine/format-generated.mjs +15 -0
  78. package/engine/foundry-entries.mjs +3 -1
  79. package/engine/frontmatter-lint.mjs +5 -1
  80. package/engine/frontmatter.mjs +10 -0
  81. package/engine/generate.mjs +2 -2
  82. package/engine/helpers.mjs +71 -117
  83. package/engine/homepage.mjs +2 -3
  84. package/engine/index-records.mjs +1 -0
  85. package/engine/index.mjs +18 -3
  86. package/engine/infobox-registry.mjs +3 -1
  87. package/engine/infobox.mjs +38 -20
  88. package/engine/item-compiler.mjs +10 -8
  89. package/engine/item-docs.mjs +7 -6
  90. package/engine/journals.mjs +51 -10
  91. package/engine/macros.mjs +1 -1
  92. package/engine/map-build.mjs +1 -1
  93. package/engine/map-dot.mjs +1 -1
  94. package/engine/markdown-expressions.mjs +118 -0
  95. package/engine/metadata-index.mjs +7 -3
  96. package/engine/note-addresses.mjs +1 -0
  97. package/engine/note-dates.mjs +314 -64
  98. package/engine/note-package.mjs +5 -25
  99. package/engine/note-state.mjs +6 -2
  100. package/engine/note-vocabulary.mjs +74 -19
  101. package/engine/number-words.mjs +75 -0
  102. package/engine/office-holders.mjs +188 -0
  103. package/engine/pack-config.mjs +2 -15
  104. package/engine/pack-router.mjs +24 -5
  105. package/engine/packages.mjs +3 -2
  106. package/engine/pathnames.mjs +21 -24
  107. package/engine/pdf-build.mjs +217 -55
  108. package/engine/pdf-fonts.mjs +2 -2
  109. package/engine/pdf-images.mjs +82 -0
  110. package/engine/pdf-render.mjs +174 -22
  111. package/engine/populations.mjs +3 -2
  112. package/engine/project-init.mjs +499 -0
  113. package/engine/prose-config.mjs +1 -1
  114. package/engine/prose-lint.mjs +7 -6
  115. package/engine/readability-lint.mjs +86 -0
  116. package/engine/reckoning-markers.mjs +176 -166
  117. package/engine/scenes.mjs +5 -2
  118. package/engine/schema-check.mjs +3 -3
  119. package/engine/site-build.mjs +87 -37
  120. package/engine/site-config.mjs +8 -8
  121. package/engine/site-index.mjs +4 -0
  122. package/engine/site-maps.mjs +1 -1
  123. package/engine/site-preview.mjs +19 -8
  124. package/engine/social-ties.mjs +5 -1
  125. package/engine/sql-tables.mjs +92 -22
  126. package/engine/web-wikilinks.mjs +15 -31
  127. package/engine/wikilink-syntax.mjs +4 -1
  128. package/engine/wikilinks.mjs +12 -14
  129. package/hm3/item-fields.mjs +1 -1
  130. package/hm3/items.mjs +12 -45
  131. package/index.mjs +2 -14
  132. package/manifest.mjs +31 -4
  133. package/markdownlint-config.mjs +1 -1
  134. package/package.json +32 -11
  135. package/prettier-config.mjs +1 -1
  136. package/sohl/being-info.mjs +3 -145
  137. package/sohl/index.mjs +1 -1
  138. package/sohl/infobox.mjs +2 -1
  139. package/sohl/item-builders.mjs +3 -27
  140. package/sohl/item-fields.mjs +61 -22
  141. package/sohl/kb-passes.mjs +2 -2
  142. package/sohl/note-schemas.mjs +0 -8
  143. package/stage.mjs +19 -0
  144. package/types/config.d.mts +12 -3
  145. package/types/content-config.d.mts +15 -81
  146. package/types/engine/address-render.d.mts +13 -0
  147. package/types/engine/address.d.mts +2 -11
  148. package/types/engine/anchors.d.mts +3 -4
  149. package/types/engine/asset-bindings.d.mts +9 -2
  150. package/types/engine/base-compiler.d.mts +1 -2
  151. package/types/engine/being-measurements.d.mts +16 -0
  152. package/types/engine/calendar-choice.d.mts +14 -0
  153. package/types/engine/calendar-format.d.mts +33 -0
  154. package/types/engine/calendar-human.d.mts +4 -0
  155. package/types/engine/calendar-notes.d.mts +5 -3
  156. package/types/engine/calendars.d.mts +5 -3
  157. package/types/engine/code-fences.d.mts +6 -72
  158. package/types/engine/compile-corpus.d.mts +1 -3
  159. package/types/engine/content-admonitions.d.mts +24 -0
  160. package/types/engine/content-captions.d.mts +45 -0
  161. package/types/engine/content-footnotes.d.mts +10 -0
  162. package/types/engine/content-format-check.d.mts +17 -4
  163. package/types/engine/content-format.d.mts +24 -11
  164. package/types/engine/content-icons.d.mts +14 -23
  165. package/types/engine/content-images.d.mts +2 -2
  166. package/types/engine/content-index.d.mts +9 -2
  167. package/types/engine/content-links.d.mts +3 -5
  168. package/types/engine/content-tables.d.mts +11 -140
  169. package/types/engine/culture-choice.d.mts +14 -0
  170. package/types/engine/date-conversion.d.mts +4 -0
  171. package/types/engine/declaration-check.d.mts +24 -0
  172. package/types/engine/docs-checks.d.mts +28 -0
  173. package/types/engine/documentation-links.d.mts +7 -0
  174. package/types/engine/exported-scene.d.mts +6 -2
  175. package/types/engine/extension-attributes.d.mts +14 -0
  176. package/types/engine/field-spec.d.mts +9 -0
  177. package/types/engine/format-generated.d.mts +7 -0
  178. package/types/engine/frontmatter.d.mts +14 -4
  179. package/types/engine/generate.d.mts +1 -1
  180. package/types/engine/helpers.d.mts +37 -92
  181. package/types/engine/homepage.d.mts +2 -3
  182. package/types/engine/index.d.mts +5 -0
  183. package/types/engine/infobox-registry.d.mts +3 -1
  184. package/types/engine/infobox.d.mts +17 -6
  185. package/types/engine/item-compiler.d.mts +4 -4
  186. package/types/engine/item-docs.d.mts +4 -3
  187. package/types/engine/journals.d.mts +10 -7
  188. package/types/engine/map-graphviz.d.mts +1 -1
  189. package/types/engine/markdown-expressions.d.mts +17 -0
  190. package/types/engine/note-dates.d.mts +47 -1
  191. package/types/engine/note-package.d.mts +3 -19
  192. package/types/engine/note-state.d.mts +2 -1
  193. package/types/engine/note-vocabulary.d.mts +9 -0
  194. package/types/engine/number-words.d.mts +4 -0
  195. package/types/engine/office-holders.d.mts +14 -0
  196. package/types/engine/pack-config.d.mts +1 -1
  197. package/types/engine/packages.d.mts +3 -2
  198. package/types/engine/pdf-images.d.mts +38 -0
  199. package/types/engine/populations.d.mts +2 -2
  200. package/types/engine/project-init.d.mts +17 -0
  201. package/types/engine/prose-config.d.mts +1 -1
  202. package/types/engine/prose-lint.d.mts +3 -2
  203. package/types/engine/readability-lint.d.mts +22 -0
  204. package/types/engine/reckoning-markers.d.mts +13 -26
  205. package/types/engine/schema-check.d.mts +3 -3
  206. package/types/engine/site-build.d.mts +38 -28
  207. package/types/engine/site-config.d.mts +2 -2
  208. package/types/engine/site-index.d.mts +4 -0
  209. package/types/engine/site-preview.d.mts +1 -1
  210. package/types/engine/social-ties.d.mts +8 -2
  211. package/types/engine/sql-tables.d.mts +13 -2
  212. package/types/engine/web-wikilinks.d.mts +2 -1
  213. package/types/engine/wikilinks.d.mts +7 -1
  214. package/types/hm3/items.d.mts +5 -6
  215. package/types/labels.d.mts +40 -0
  216. package/types/manifest.d.mts +3 -3
  217. package/types/sohl/actors.d.mts +2 -2
  218. package/types/sohl/being-info.d.mts +3 -43
  219. package/types/sohl/index.d.mts +1 -1
  220. package/types/sohl/infobox.d.mts +5 -2
  221. package/types/sohl/item-fields.d.mts +4 -13
  222. package/types/stage.d.mts +9 -0
  223. package/CONTENT.md +0 -3239
@@ -64,7 +64,20 @@
64
64
  * @module
65
65
  */
66
66
 
67
- import { dayOfYear, daysInYear, monthDayOfYear } from "./calendars.mjs";
67
+ import {
68
+ canonicalDayOffset,
69
+ canonicalYear,
70
+ dayOfYear,
71
+ daysInYear,
72
+ monthDayOfYear,
73
+ parseCanonicalDate,
74
+ } from "./calendars.mjs";
75
+ import {
76
+ CALENDAR_FORMAT_TOKENS,
77
+ calendarFormatSyntaxError,
78
+ readableCalendarFormatError,
79
+ selectCalendarFormat,
80
+ } from "./calendar-format.mjs";
68
81
  import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
69
82
  import { parseNoteDate } from "./note-dates.mjs";
70
83
  import { reckoningContext } from "./reckoning-markers.mjs";
@@ -89,6 +102,7 @@ export const CALENDAR_DATE_FORMAT_KEYS = Object.freeze([
89
102
  "long",
90
103
  "full",
91
104
  "time",
105
+ "time12",
92
106
  "weekHeader",
93
107
  "yearHeader",
94
108
  "yearLabel",
@@ -96,13 +110,7 @@ export const CALENDAR_DATE_FORMAT_KEYS = Object.freeze([
96
110
  ]);
97
111
 
98
112
  /** Bare date tokens Calendaria substitutes inside authored format strings. */
99
- export const CALENDAR_FORMAT_TOKENS = Object.freeze(
100
- new Set(
101
- "YYYY YY Y MMMM MMM MM Mo M EEEEE EEEE EEE EE E dddd ddd dd Do DDD DD D d e GGGG GGG GG G QQQQ QQQ QQ Q zzzz z ww w W HH H hh h mm m ss s A a".split(
102
- " ",
103
- ),
104
- ),
105
- );
113
+ export { CALENDAR_FORMAT_TOKENS };
106
114
 
107
115
  /**
108
116
  * The `data:` keys a calendar note may write — the closed list, in authored
@@ -117,9 +125,9 @@ export const CALENDAR_FORMAT_TOKENS = Object.freeze(
117
125
  export const CALENDAR_FIELDS = Object.freeze([
118
126
  {
119
127
  name: "epoch",
120
- shape: "a date",
128
+ shape: "a canonical `<year>.<day>`",
121
129
  kind: "string",
122
- describe: "Which in-world day the world's clock reads zero on — `720/1/1`.",
130
+ describe: "Canonical day when calendar year 1, day 1 begins — `1.1`.",
123
131
  },
124
132
  {
125
133
  name: "months",
@@ -139,22 +147,28 @@ export const CALENDAR_FIELDS = Object.freeze([
139
147
  },
140
148
  {
141
149
  name: "seasons",
142
- shape: "list of `{ name, abbreviation?, monthStart?, monthEnd?, dayStart?, dayEnd? }`",
150
+ shape: "list of `{ name, abbreviation?, start }`",
151
+ kind: "list",
152
+ describe: "The seasons this calendar marks, starting on numbered days of the year.",
153
+ },
154
+ {
155
+ name: "namedDays",
156
+ shape: "list of `{ name, abbreviation?, day }`",
143
157
  kind: "list",
144
- describe: "The seasons this calendar marks, bounded by month or by day of year.",
158
+ describe: "Names assigned to particular days of the year.",
145
159
  },
146
160
  {
147
161
  name: "eras",
148
- shape: "list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, end? }`",
162
+ shape: "list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }`",
149
163
  kind: "list",
150
164
  describe:
151
165
  "The year-counts kept in this calendar. A marker names one era and uses these months.",
152
166
  },
153
167
  {
154
- name: "dateFormats",
155
- shape: "map of short, long, full, time, weekHeader, yearHeader, yearLabel, crossCalendar strings",
168
+ name: "formats",
169
+ shape: "map of named Calendaria format strings",
156
170
  kind: "map",
157
- describe: "How this calendar writes dates and time in each display context.",
171
+ describe: "Named patterns for reading and writing this calendar's dates.",
158
172
  },
159
173
  ]);
160
174
 
@@ -248,7 +262,7 @@ export const INVARIANT_FIELDS = Object.freeze([
248
262
  },
249
263
  {
250
264
  name: "moon.newOn",
251
- shape: "a date",
265
+ shape: "a day-precision date",
252
266
  kind: "string",
253
267
  describe: "A day the body was new, written in the reference calendar.",
254
268
  },
@@ -363,46 +377,132 @@ function atData(note, keyPath, severity, message) {
363
377
  };
364
378
  }
365
379
 
366
- /** Check each authored format slot and every unescaped letter run in it. */
380
+ /** Check each named pattern and the standard pattern's readable fields. */
367
381
  export function checkCalendarDateFormats(note) {
368
- const formats = dataOf(note).dateFormats;
369
- if (formats === undefined || formats === null) return [];
370
- if (typeof formats !== "object" || Array.isArray(formats)) return [];
382
+ const formats = dataOf(note).formats;
383
+ if (formats === undefined) return [];
384
+ if (typeof formats !== "object" || Array.isArray(formats) || !Object.keys(formats).length)
385
+ return [
386
+ atData(note, ["formats"], "error", "data.formats needs at least one named pattern"),
387
+ ];
371
388
  const findings = [];
372
389
  for (const [slot, value] of Object.entries(formats)) {
373
- if (!CALENDAR_DATE_FORMAT_KEYS.includes(slot)) {
390
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(slot)) {
374
391
  findings.push(
375
392
  atData(
376
393
  note,
377
- ["dateFormats", slot],
394
+ ["formats", slot],
378
395
  "error",
379
- `data.dateFormats.${slot} is not a calendar display format; choose ${CALENDAR_DATE_FORMAT_KEYS.join(", ")}`,
396
+ `data.formats.${slot} needs a name made of letters and digits`,
380
397
  ),
381
398
  );
382
399
  continue;
383
400
  }
384
401
  if (typeof value !== "string") {
385
402
  findings.push(
386
- atData(
387
- note,
388
- ["dateFormats", slot],
389
- "error",
390
- `data.dateFormats.${slot} must be a string`,
391
- ),
403
+ atData(note, ["formats", slot], "error", `data.formats.${slot} must be a string`),
392
404
  );
393
405
  continue;
394
406
  }
395
- const bare = value.replace(/\[[^\]]*\]|\{[^}]*\}/g, " ");
396
- for (const run of bare.match(/\p{L}+/gu) ?? []) {
397
- if (CALENDAR_FORMAT_TOKENS.has(run)) continue;
398
- findings.push(
399
- atData(
400
- note,
401
- ["dateFormats", slot],
402
- "error",
403
- `data.dateFormats.${slot} has unescaped letter run ${JSON.stringify(run)}; write literal words in brackets, or use a Calendaria date token`,
404
- ),
405
- );
407
+ const syntaxError = calendarFormatSyntaxError(value);
408
+ if (syntaxError) findings.push(atData(note, ["formats", slot], "error", syntaxError));
409
+ }
410
+ const standard = Object.hasOwn(formats, "std") ? "std" : Object.keys(formats)[0];
411
+ if (typeof formats[standard] === "string") {
412
+ const error = readableCalendarFormatError(formats[standard]);
413
+ if (error) findings.push(atData(note, ["formats", standard], "error", error));
414
+ }
415
+ return findings;
416
+ }
417
+
418
+ /** Validate ordered, one-based season starts and named day positions. */
419
+ function checkCalendarDayNames(note, daysPerYear) {
420
+ const data = dataOf(note);
421
+ const findings = [];
422
+ const seasons = data.seasons;
423
+ if (seasons !== undefined && !Array.isArray(seasons))
424
+ findings.push(atData(note, ["seasons"], "error", "data.seasons must be a list"));
425
+ if (Array.isArray(seasons)) {
426
+ let prior = 0;
427
+ for (const [position, season] of seasons.entries()) {
428
+ const start = season?.start;
429
+ if (typeof season?.name !== "string" || !season.name.trim())
430
+ findings.push(
431
+ atData(note, ["seasons", position, "name"], "error", "a season needs a name"),
432
+ );
433
+ if (
434
+ !Number.isSafeInteger(start) ||
435
+ start <= prior ||
436
+ (Number.isSafeInteger(daysPerYear) && start > daysPerYear)
437
+ )
438
+ findings.push(
439
+ atData(
440
+ note,
441
+ ["seasons", position, "start"],
442
+ "error",
443
+ "season starts must increase within the year",
444
+ ),
445
+ );
446
+ if (
447
+ Object.keys(season ?? {}).some(
448
+ (key) => !["name", "abbreviation", "start"].includes(key),
449
+ )
450
+ )
451
+ findings.push(
452
+ atData(
453
+ note,
454
+ ["seasons", position],
455
+ "error",
456
+ "a season uses only name, abbreviation, and start",
457
+ ),
458
+ );
459
+ prior = start;
460
+ }
461
+ }
462
+ const namedDays = data.namedDays;
463
+ if (namedDays !== undefined && !Array.isArray(namedDays))
464
+ findings.push(atData(note, ["namedDays"], "error", "data.namedDays must be a list"));
465
+ if (Array.isArray(namedDays)) {
466
+ const seen = new Set();
467
+ for (const [position, namedDay] of namedDays.entries()) {
468
+ if (typeof namedDay?.name !== "string" || !namedDay.name.trim())
469
+ findings.push(
470
+ atData(
471
+ note,
472
+ ["namedDays", position, "name"],
473
+ "error",
474
+ "a named day needs a name",
475
+ ),
476
+ );
477
+ const day = namedDay?.day;
478
+ if (
479
+ !Number.isSafeInteger(day) ||
480
+ day < 1 ||
481
+ (Number.isSafeInteger(daysPerYear) && day > daysPerYear) ||
482
+ seen.has(day)
483
+ )
484
+ findings.push(
485
+ atData(
486
+ note,
487
+ ["namedDays", position, "day"],
488
+ "error",
489
+ "named days need distinct days within the year",
490
+ ),
491
+ );
492
+ if (
493
+ Object.keys(namedDay ?? {}).some(
494
+ (key) => !["name", "abbreviation", "day"].includes(key),
495
+ )
496
+ )
497
+ findings.push(
498
+ atData(
499
+ note,
500
+ ["namedDays", position],
501
+ "error",
502
+ "a named day uses only name, abbreviation, and day",
503
+ ),
504
+ );
505
+ seen.add(day);
406
506
  }
407
507
  }
408
508
  return findings;
@@ -466,16 +566,24 @@ export function checkWorldFacts(note, { index } = {}) {
466
566
  findings.push(...dated);
467
567
  }
468
568
  if (written.includes("moon") && dataOf(note).moon?.newOn !== undefined) {
469
- findings.push(
470
- ...parseNoteDate(dataOf(note).moon.newOn, {
471
- ...reckoningContext(index),
472
- field: "data.moon.newOn",
473
- allowUnknown: false,
474
- file: note.file,
475
- raw: note.raw,
476
- keyPath: ["data", "moon", "newOn"],
477
- }).findings,
478
- );
569
+ const parsed = parseNoteDate(dataOf(note).moon.newOn, {
570
+ ...reckoningContext(index),
571
+ field: "data.moon.newOn",
572
+ allowUnknown: false,
573
+ file: note.file,
574
+ raw: note.raw,
575
+ keyPath: ["data", "moon", "newOn"],
576
+ });
577
+ findings.push(...parsed.findings);
578
+ if (parsed.date && parsed.date.precision !== "day")
579
+ findings.push(
580
+ atData(
581
+ note,
582
+ ["moon", "newOn"],
583
+ "error",
584
+ "`data.moon.newOn` needs a specific day; write `<year>.<day>` or a day in a named calendar",
585
+ ),
586
+ );
479
587
  }
480
588
 
481
589
  // The corpus questions, which need every note and so are asked only where
@@ -559,7 +667,7 @@ export function checkCalendarNote(note, { index } = {}) {
559
667
  return findings;
560
668
  }
561
669
 
562
- for (const required of ["months", "epoch"]) {
670
+ for (const required of ["months", "epoch", "eras"]) {
563
671
  if (written.includes(required)) continue;
564
672
  findings.push({
565
673
  file: note.file,
@@ -572,20 +680,53 @@ export function checkCalendarNote(note, { index } = {}) {
572
680
  }
573
681
 
574
682
  if (written.includes("epoch")) {
575
- findings.push(
576
- ...parseNoteDate(dataOf(note).epoch, {
577
- ...reckoningContext(index),
578
- field: "data.epoch",
579
- allowUnknown: false,
580
- file: note.file,
581
- raw: note.raw,
582
- keyPath: ["data", "epoch"],
583
- }).findings,
584
- );
683
+ const epochText = String(dataOf(note).epoch);
684
+ if (!/^-?\d+\.\d+$/.test(epochText) || /^-?0+\./.test(epochText))
685
+ findings.push(
686
+ atData(
687
+ note,
688
+ ["epoch"],
689
+ "error",
690
+ "data.epoch is a canonical <year>.<day> for calendar year 1, day 1",
691
+ ),
692
+ );
693
+ const parsed = parseNoteDate(dataOf(note).epoch, {
694
+ ...reckoningContext(index),
695
+ field: "data.epoch",
696
+ allowUnknown: false,
697
+ file: note.file,
698
+ raw: note.raw,
699
+ keyPath: ["data", "epoch"],
700
+ });
701
+ findings.push(...parsed.findings);
702
+ if (parsed.date && parsed.date.precision !== "day")
703
+ findings.push(
704
+ atData(
705
+ note,
706
+ ["epoch"],
707
+ "error",
708
+ "`data.epoch` needs a specific day; write `<year>.<day>` or a day in a named calendar",
709
+ ),
710
+ );
585
711
  }
586
712
 
587
713
  findings.push(...checkMonthSum(note, index));
714
+ findings.push(...checkCalendarDayNames(note, worldInvariants(index).year?.days));
588
715
  findings.push(...checkEraShortcodes(note));
716
+ for (const [position, day] of (Array.isArray(dataOf(note).weekdays) ?
717
+ dataOf(note).weekdays
718
+ : []
719
+ ).entries()) {
720
+ if (day?.ordinal !== undefined)
721
+ findings.push(
722
+ atData(
723
+ note,
724
+ ["weekdays", position, "ordinal"],
725
+ "error",
726
+ "weekday order is its array position, starting at zero; omit ordinal",
727
+ ),
728
+ );
729
+ }
589
730
  findings.push(
590
731
  ...reckoningContext(index).findings.filter((finding) => finding.file === note.file),
591
732
  );
@@ -669,6 +810,35 @@ function checkEraShortcodes(note) {
669
810
  );
670
811
  }
671
812
  seen.add(shortcode);
813
+ if (era.label === undefined) return;
814
+ const label = era.label;
815
+ const forms = typeof label === "string" ? { after: label } : label;
816
+ if (!forms || typeof forms !== "object" || Array.isArray(forms)) {
817
+ findings.push(
818
+ atData(
819
+ note,
820
+ ["eras", position, "label"],
821
+ "error",
822
+ "an era label is a string or an {after, before} map",
823
+ ),
824
+ );
825
+ return;
826
+ }
827
+ for (const [key, value] of Object.entries(forms)) {
828
+ if (
829
+ !["after", "before"].includes(key) ||
830
+ typeof value !== "string" ||
831
+ value.split("{date}").length !== 2
832
+ )
833
+ findings.push(
834
+ atData(
835
+ note,
836
+ ["eras", position, "label", key],
837
+ "error",
838
+ "an era label uses after or before with exactly one {date} slot",
839
+ ),
840
+ );
841
+ }
672
842
  });
673
843
  return findings;
674
844
  }
@@ -720,7 +890,7 @@ function monthValues(months) {
720
890
  );
721
891
  }
722
892
 
723
- /** A weekday's name, abbreviation and position. An empty list stays empty. */
893
+ /** Calendaria stores a one-based ordinal; the authored order remains zero-based. */
724
894
  function weekdayValues(weekdays) {
725
895
  return (Array.isArray(weekdays) ? weekdays : []).map((day, position) =>
726
896
  written({
@@ -731,34 +901,47 @@ function weekdayValues(weekdays) {
731
901
  );
732
902
  }
733
903
 
734
- /** A season's name and whichever pair of bounds the note wrote. */
735
- function seasonValues(seasons) {
736
- return (Array.isArray(seasons) ? seasons : []).map((season) =>
904
+ /** A season's one-based start becomes an inclusive zero-based Calendaria range. */
905
+ function seasonValues(seasons, daysPerYear) {
906
+ return (Array.isArray(seasons) ? seasons : []).map((season, position, rows) =>
737
907
  written({
738
908
  name: String(season?.name ?? ""),
739
909
  abbreviation:
740
910
  season?.abbreviation === undefined ? undefined : String(season.abbreviation),
741
- monthStart: season?.monthStart,
742
- monthEnd: season?.monthEnd,
743
- dayStart: season?.dayStart,
744
- dayEnd: season?.dayEnd,
911
+ dayStart: season.start - 1,
912
+ dayEnd:
913
+ position + 1 < rows.length ? rows[position + 1].start - 2
914
+ : rows[0].start === 1 ? daysPerYear - 1
915
+ : rows[0].start - 2,
916
+ }),
917
+ );
918
+ }
919
+
920
+ /** A named day uses Calendaria's one-based year day number. */
921
+ function namedDayValues(namedDays) {
922
+ return (Array.isArray(namedDays) ? namedDays : []).map((day) =>
923
+ written({
924
+ name: day.name,
925
+ abbreviation: day.abbreviation,
926
+ dayNumber: day.day,
745
927
  }),
746
928
  );
747
929
  }
748
930
 
749
931
  /** The era rows, keyed by shortcode, with the addressing segments dropped. */
750
- function eraEntries(eras, dateContext = {}) {
932
+ function eraEntries(eras) {
751
933
  const entries = {};
752
- for (const era of Array.isArray(eras) ? eras : []) {
934
+ const dated = (Array.isArray(eras) ? eras : [])
935
+ .filter((era) => Number.isSafeInteger(era?.start) && era.start >= 1)
936
+ .sort((a, b) => a.start - b.start);
937
+ for (const [position, era] of dated.entries()) {
753
938
  const shortcode = String(era?.shortcode ?? "");
754
939
  if (!shortcode) continue;
755
- const start = parseNoteDate(era?.start, { ...dateContext, allowUnknown: false }).date;
756
- const end = parseNoteDate(era?.end, { ...dateContext, allowUnknown: false }).date;
757
940
  entries[shortcode] = written({
758
941
  name: String(era?.name ?? ""),
759
942
  abbreviation: era?.abbreviation === undefined ? undefined : String(era.abbreviation),
760
- start: era?.start === undefined ? undefined : String(start?.year ?? era.start),
761
- end: era?.end === undefined ? undefined : String(end?.year ?? era.end),
943
+ startYear: era.start,
944
+ endYear: dated[position + 1] ? dated[position + 1].start - 1 : null,
762
945
  });
763
946
  }
764
947
  return entries;
@@ -780,6 +963,8 @@ function eraEntries(eras, dateContext = {}) {
780
963
  function moonEntries(moon, body, name, months, dateContext = {}) {
781
964
  if (!moon || !name) return null;
782
965
  const date = parseNoteDate(moon.newOn, { ...dateContext, allowUnknown: false }).date;
966
+ if (date && date.precision !== "day")
967
+ throw new RangeError("data.moon.newOn needs a specific day");
783
968
  const calendarDay =
784
969
  date?.canonicalDay && months.length ? monthDayOfYear(months, date.canonicalDay) : null;
785
970
  return {
@@ -818,13 +1003,33 @@ function moonEntries(moon, body, name, months, dateContext = {}) {
818
1003
  * @param {string} [opts.contentPackage] - The package shipping the note, which
819
1004
  * is a fact about the tree rather than about the note: a note never declares
820
1005
  * its own package.
1006
+ * @param {object} [opts.dateContext] - Calendar conversion context.
821
1007
  * @returns {object} The definition.
822
1008
  */
823
1009
  export function compileCalendar({ note, invariants, contentPackage, dateContext = {} }) {
824
1010
  const data = dataOf(note);
825
1011
  const year = invariants?.year ?? {};
826
1012
  const months = Array.isArray(data.months) ? data.months : [];
827
- const epoch = parseNoteDate(data.epoch, { ...dateContext, allowUnknown: false }).date;
1013
+ const dayNameError = checkCalendarDayNames(note, year.days)[0];
1014
+ if (dayNameError) throw new RangeError(dayNameError.message);
1015
+ const starts = Array.isArray(data.eras) ? data.eras.map((era) => era?.start) : [];
1016
+ if (
1017
+ starts.filter((start) => start === null).length !== 1 ||
1018
+ starts.filter((start) => start === 1).length !== 1 ||
1019
+ starts.some((start) => start !== null && (!Number.isSafeInteger(start) || start < 1)) ||
1020
+ new Set(starts).size !== starts.length
1021
+ )
1022
+ throw new RangeError(
1023
+ "data.eras needs one null start, one year-one start, and distinct positive year starts",
1024
+ );
1025
+ const formatError = checkCalendarDateFormats(note).find(
1026
+ (finding) => finding.severity === "error",
1027
+ );
1028
+ if (formatError) throw new RangeError(formatError.message);
1029
+ const epoch = parseCanonicalDate(String(data.epoch ?? ""), year.days);
1030
+ if (!epoch || epoch.year === 0 || epoch.seconds !== undefined)
1031
+ throw new RangeError("data.epoch needs a canonical <year>.<day> specific day");
1032
+ const epochOffset = canonicalDayOffset(canonicalYear(epoch.year), epoch.day, 1, year.days);
828
1033
  const shortcode = String(note.fm?.shortcode ?? "");
829
1034
  const name = String(note.fm?.name?.full ?? "");
830
1035
  const description = String(note.fm?.description ?? "");
@@ -841,9 +1046,8 @@ export function compileCalendar({ note, invariants, contentPackage, dateContext
841
1046
  name,
842
1047
  description,
843
1048
  years: {
844
- // The year the epoch falls in is the year a reader is shown at the
845
- // world's own zero.
846
- yearZero: epoch?.year ?? 0,
1049
+ // Internal year zero displays as calendar year one at the epoch.
1050
+ yearZero: 1,
847
1051
  // Required, non-nullable and with no initial, so it must be
848
1052
  // written; with no weekdays it indexes nothing and 0 is the only
849
1053
  // defensible value.
@@ -855,19 +1059,34 @@ export function compileCalendar({ note, invariants, contentPackage, dateContext
855
1059
  months: { values: monthValues(months) },
856
1060
  days: written({
857
1061
  values: weekdayValues(data.weekdays),
1062
+ names: namedDayValues(data.namedDays),
858
1063
  daysPerYear: year.days,
859
1064
  hoursPerDay: year.hoursPerDay,
860
1065
  minutesPerHour: year.minutesPerHour,
861
1066
  secondsPerMinute: year.secondsPerMinute,
862
1067
  }),
863
- seasons: { values: seasonValues(data.seasons) },
864
- // How far into the year the world's zero falls. `1/1` is none.
865
- epochDayOffset:
866
- epoch && months.length ?
867
- (epoch.canonicalDay ?? dayOfYear(months, epoch.month ?? 1, epoch.day ?? 1)) - 1
868
- : 0,
869
- eras: eraEntries(data.eras, dateContext),
870
- dateFormats: data.dateFormats,
1068
+ seasons: { values: seasonValues(data.seasons, year.days) },
1069
+ // Shift world time so the authored epoch is calendar year one, day one.
1070
+ epochDayOffset: epochOffset === 0 ? 0 : -epochOffset,
1071
+ eras: eraEntries(data.eras),
1072
+ dateFormats:
1073
+ data.formats && Object.keys(data.formats).length ?
1074
+ written({
1075
+ short: selectCalendarFormat(data).pattern,
1076
+ long: data.formats.long ?? selectCalendarFormat(data).pattern,
1077
+ full:
1078
+ data.formats.full ??
1079
+ data.formats.long ??
1080
+ selectCalendarFormat(data).pattern,
1081
+ ...Object.fromEntries(
1082
+ CALENDAR_DATE_FORMAT_KEYS.filter(
1083
+ (key) => !["short", "long", "full"].includes(key),
1084
+ )
1085
+ .filter((key) => data.formats[key] !== undefined)
1086
+ .map((key) => [key, data.formats[key]]),
1087
+ ),
1088
+ })
1089
+ : undefined,
871
1090
  moons: moons ?? undefined,
872
1091
  metadata: written({
873
1092
  id: shortcode || undefined,
@@ -104,6 +104,13 @@ export function canonicalYear(year, epoch = CANONICAL_EPOCH) {
104
104
  return epoch + (year > 0 ? year - 1 : year);
105
105
  }
106
106
 
107
+ /** The signed year a reckoning counts at a canonical year, without year zero. */
108
+ export function eraYear(canonical, epoch = CANONICAL_EPOCH) {
109
+ if (!Number.isSafeInteger(canonical) || !Number.isSafeInteger(epoch))
110
+ throw new RangeError("era conversion needs whole canonical and epoch years");
111
+ return canonical >= epoch ? canonical - epoch + 1 : canonical - epoch;
112
+ }
113
+
107
114
  /**
108
115
  * The total-order key a date sorts on.
109
116
  *
@@ -267,7 +274,7 @@ export function calendarStructure(calendar) {
267
274
  /**
268
275
  * How long one month of a calendar is, or `null` where the calendar is silent.
269
276
  *
270
- * @param {{months?: readonly CalendarMonth[]|null}} [calendar] - The calendar.
277
+ * @param {{months?: readonly CalendarMonth[]|null}|undefined} calendar - The calendar.
271
278
  * @param {number} month - The month, numbered from 1.
272
279
  * @returns {number|null} Its length in days, or `null`.
273
280
  */
@@ -40,7 +40,7 @@
40
40
 
41
41
  import { codeRegions } from "./code-fences.mjs";
42
42
 
43
- /** The generated scaffold `content-build`/Changesets writes, never authored prose. */
43
+ /** The generated scaffold `package-build`/Changesets writes, never authored prose. */
44
44
  const RELEASE_HEADING_RE = /^## /;
45
45
  const CHANGES_HEADING_RE = /^### (?:Major|Minor|Patch) Changes\s*$/;
46
46
 
@@ -79,7 +79,7 @@ const SCOREBOARD_PATTERNS = [
79
79
 
80
80
  /** A token that reads as code but sits outside any code span — warning only. */
81
81
  const CODE_TOKEN_RE =
82
- /\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*(?:\(\))?\b|\b[\w-]+(?:\/[\w-]+)+\.[A-Za-z]{1,8}\b|\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g;
82
+ /(?<![\p{L}\p{N}\p{M}_])[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*(?:\(\))?(?![\p{L}\p{N}\p{M}_])|\b[\w-]+(?:\/[\w-]+)+\.[A-Za-z]{1,8}\b|\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/gu;
83
83
 
84
84
  /** Bullets over this many words read as a paragraph, not a release note. */
85
85
  const MAX_BULLET_WORDS = 40;