@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
@@ -14,22 +14,27 @@
14
14
  /**
15
15
  * Parse authored note dates into records used by content checks and renderers.
16
16
  *
17
- * A neutral day is `<year>.<day>[:HHMMSS]`, where `day` is an ordinal within
18
- * the world's year. A marked date is `<marker>(<year>[/<month>[/<day>]])`.
19
- * The marker selects one era and its calendar's ordered month list; the era's
20
- * start places its year one on the neutral timeline. A bare year is year
21
- * precision, and `unknown` is unordered. The parser also accepts unmarked
22
- * slash dates and addressed era qualifiers.
17
+ * A neutral date is `<year>[.<day>[:HHMMSS]]`, where `day` is an ordinal
18
+ * within the world's year. A bare year spans that year; a day spans that day;
19
+ * a clock time identifies a second. A named date uses the `datefrom` form;
20
+ * its calendar and era resolve the value on the neutral timeline.
21
+ * Either form accepts `~` for uncertainty beyond its stated interval.
22
+ * `unknown` is unordered.
23
23
  *
24
24
  * `text` keeps the authored spelling. `canonicalYear` and `sort` are set for
25
- * neutral and resolved marked dates. A marked date also carries
26
- * `canonicalDay`, the ordinal day within its neutral year. An addressed era
27
- * qualifier carries no resolved year until a caller supplies its era facts.
25
+ * neutral and resolved named dates. A neutral year retains `precision: "year"`
26
+ * and has no `canonicalDay`; day one is used only to calculate its bounds.
28
27
  *
29
28
  * @module
30
29
  */
31
30
 
32
31
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
32
+ import { addressedCalendarDate } from "./calendar-human.mjs";
33
+ import {
34
+ calendarFormatHasClock,
35
+ formatCalendarPattern,
36
+ selectCalendarFormat,
37
+ } from "./calendar-format.mjs";
33
38
  import {
34
39
  calendarStructure,
35
40
  canonicalDateFromOffset,
@@ -38,6 +43,8 @@ import {
38
43
  dateSortKey,
39
44
  dayOfYear,
40
45
  daysInMonth,
46
+ eraYear,
47
+ monthDayOfYear,
41
48
  parseCanonicalDate,
42
49
  } from "./calendars.mjs";
43
50
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
@@ -78,9 +85,12 @@ export const ERA_QUALIFIER_PATTERN = new RegExp(`^${ERA}$`);
78
85
  * @type {RegExp}
79
86
  */
80
87
  export const NOTE_DATE_PATTERN = new RegExp(
81
- `^(~)?\\s*(-?\\d+)(?:/(\\d+)(?:/(\\d+))?)?(?:\\s+(${ERA}))?$`,
88
+ `^(~)?\\s*(-?\\d+)(?:/(\\d+)(?:/(\\d+)(?::(\\d{6}))?)?)?(?:\\s+(${ERA}))?$`,
82
89
  );
83
90
 
91
+ /** A calendar conversion written as a frontmatter date value. */
92
+ export const DATEFROM_PATTERN = /^datefrom\s+(\S+)\s+(.+)$/;
93
+
84
94
  /** A day written with no month — ungrammatical, and worth its own sentence. */
85
95
  const DAY_WITHOUT_MONTH_PATTERN = /^(?:~)?\s*-?\d+\/\/\d+(?:\s+\S+)?$/;
86
96
 
@@ -129,8 +139,12 @@ function unknownRecord() {
129
139
  * the month it names. A calendar that declares no list bounds nothing.
130
140
  * @param {Map<string, object>} [options.markers] - Resolved era markers from
131
141
  * the corpus, each carrying its calendar and canonical start.
142
+ * @param {Map<string, object>} [options.eras] - Resolved calendar eras, keyed
143
+ * by their addressed qualifier.
144
+ * @param {boolean} [options.ignoreEraBounds=false] - Parse a calendar row's
145
+ * own boundary in a referenced era without applying that era's note span.
132
146
  * @param {number} [options.daysPerYear] - The world's year length, required for
133
- * canonical days and marked dates.
147
+ * canonical days and named dates.
134
148
  * @param {string} [options.field] - The key that carried it, named in every
135
149
  * message. Omitted, a message names the value alone.
136
150
  * @param {boolean} [options.allowUnknown=true] - Whether `"unknown"` is a
@@ -147,6 +161,8 @@ export function parseNoteDate(value, options) {
147
161
  const {
148
162
  calendar,
149
163
  markers,
164
+ eras,
165
+ ignoreEraBounds = false,
150
166
  daysPerYear,
151
167
  field,
152
168
  allowUnknown = true,
@@ -178,71 +194,106 @@ export function parseNoteDate(value, options) {
178
194
  return { date: null, findings };
179
195
  };
180
196
 
181
- if (text.trim() === UNKNOWN_DATE) {
182
- if (allowUnknown) return { date: unknownRecord(), findings };
183
- return refuse(
184
- `${subject} — \`${UNKNOWN_DATE}\` records that something happened and ` +
185
- `the date is not known, which only a field whose absence means it ` +
186
- `never happened has any use for. \`${field ?? "this field"}\` is not ` +
187
- `one, so leave it out instead`,
188
- );
189
- }
190
-
191
- const trimmed = text.trim();
192
- const marked = /^([A-Z][A-Z0-9]*)\((.*)\)$/.exec(trimmed);
193
- if (marked) {
194
- const [, marker, payload] = marked;
195
- const reckoning = markers?.get(marker);
196
- if (!reckoning) return refuse(`${subject} names unknown reckoning marker ${marker}`);
197
+ const resolveEra = (parsed, reckoning, label) => {
198
+ if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
197
199
  if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
198
- return refuse(`${subject} needs the world's year length to resolve ${marker}`);
199
- const parsed = parseNoteDate(payload, {
200
- calendar: reckoning.calendar,
201
- field,
202
- allowUnknown: false,
203
- file,
204
- raw,
205
- keyPath,
206
- });
207
- if (parsed.findings.some((finding) => finding.severity === "error") || !parsed.date)
208
- return { date: null, findings: parsed.findings };
209
- if (!parsed.date.known)
210
- return refuse(`${subject} wraps an unknown date; write \`unknown\` by itself`);
211
- if (parsed.date.era !== null)
200
+ return refuse(`${subject} needs the world's year length to resolve ${label}`);
201
+ const { months } = calendarStructure(reckoning.calendar);
202
+ if (!months) return refuse(`${subject} names ${label}, whose calendar declares no months`);
203
+ if (parsed.month !== null && parsed.month > months.length)
212
204
  return refuse(
213
- `${subject} names two reckonings; the payload of ${marker} is a calendar date`,
205
+ `${subject} writes month ${parsed.month}, and ${label} keeps ${months.length} months`,
214
206
  );
215
- if (parsed.date.canonicalDay !== undefined)
207
+ const length = parsed.month === null ? null : daysInMonth(reckoning.calendar, parsed.month);
208
+ if (parsed.day !== null && length !== null && parsed.day > length)
216
209
  return refuse(
217
- `${subject} puts a canonical day inside ${marker}; write its calendar month and day`,
210
+ `${subject} writes day ${parsed.day}, and month ${parsed.month} is ${length} days long`,
218
211
  );
219
- const { months } = calendarStructure(reckoning.calendar);
220
- if (!months) return refuse(`${subject} names ${marker}, whose calendar declares no months`);
221
- const ordinal = dayOfYear(months, parsed.date.month ?? 1, parsed.date.day ?? 1);
212
+ const ordinal = dayOfYear(months, parsed.month ?? 1, parsed.day ?? 1);
222
213
  const start = canonicalDayOffset(reckoning.epochYear, reckoning.epochDay, 1, daysPerYear);
223
- const yearOffset = parsed.date.year > 0 ? parsed.date.year - 1 : parsed.date.year;
214
+ const yearOffset = reckoning.beforeEra ? -parsed.year : parsed.year - 1;
224
215
  const offset = start + yearOffset * daysPerYear + ordinal - 1;
225
216
  if (!Number.isSafeInteger(offset))
226
217
  return refuse(`${subject} lies outside the supported canonical day range`);
227
- if (reckoning.endOffset !== undefined && offset > reckoning.endOffset)
228
- return refuse(`${subject} lies after the end of reckoning marker ${marker}`);
218
+ const spanDays =
219
+ parsed.precision === "year" ? daysPerYear
220
+ : parsed.precision === "month" ? length
221
+ : 1;
222
+ if (
223
+ !ignoreEraBounds &&
224
+ reckoning.endOffset !== undefined &&
225
+ offset + spanDays - 1 > reckoning.endOffset
226
+ )
227
+ return refuse(`${subject} lies after the end of ${label}`);
229
228
  const canonical = canonicalDateFromOffset(offset, 1, daysPerYear);
230
229
  return {
231
230
  date: {
232
- ...parsed.date,
231
+ ...parsed,
233
232
  text,
234
233
  era: reckoning.era,
235
- marker,
234
+ qualifier: reckoning.qualifier,
236
235
  canonicalYear: canonical.year,
237
236
  canonicalDay: canonical.day,
238
- sort: canonical.year + (canonical.day - 1) / daysPerYear,
237
+ spanDays,
238
+ sort:
239
+ canonical.year +
240
+ (canonical.day - 1 + (parsed.seconds ?? 0) / 86400) / daysPerYear,
239
241
  },
240
- findings: parsed.findings,
242
+ findings,
241
243
  };
244
+ };
245
+
246
+ if (text.trim() === UNKNOWN_DATE) {
247
+ if (allowUnknown) return { date: unknownRecord(), findings };
248
+ return refuse(
249
+ `${subject} — \`${UNKNOWN_DATE}\` records that something happened and ` +
250
+ `the date is not known, which only a field whose absence means it ` +
251
+ `never happened has any use for. \`${field ?? "this field"}\` is not ` +
252
+ `one, so leave it out instead`,
253
+ );
242
254
  }
243
255
 
244
- if (trimmed.includes(".")) {
245
- const canonical = parseCanonicalDate(trimmed, daysPerYear);
256
+ const trimmed = text.trim();
257
+ const approximateInput = trimmed.startsWith("~");
258
+ const unmarked = approximateInput ? trimmed.slice(1).trim() : trimmed;
259
+ const conversion = DATEFROM_PATTERN.exec(unmarked);
260
+ if (conversion) {
261
+ try {
262
+ const [, reference, human] = conversion;
263
+ const addressed = addressedCalendarDate(
264
+ human.trim(),
265
+ calendarEras(reference, { eras }),
266
+ );
267
+ if (!addressed) return refuse(`${subject} needs a named calendar date`);
268
+ const parsed = parseNoteDate(`${approximateInput ? "~" : ""}${addressed}`, {
269
+ markers,
270
+ eras,
271
+ daysPerYear,
272
+ ignoreEraBounds,
273
+ allowUnknown: false,
274
+ file,
275
+ raw,
276
+ keyPath,
277
+ });
278
+ if (!parsed.date || parsed.findings.some((item) => item.severity === "error"))
279
+ return parsed;
280
+ return { date: { ...parsed.date, text }, findings: parsed.findings };
281
+ } catch (err) {
282
+ return refuse(`${subject} cannot resolve its calendar date: ${err.message}`);
283
+ }
284
+ }
285
+ if (/^-?0+\.\d+(?::\d{6})?$/.test(unmarked))
286
+ return refuse(
287
+ `${subject} writes year 0, and no reckoning has one — the years either ` +
288
+ `side of an epoch are -1 and 1`,
289
+ );
290
+ if (field && !/^-?\d+(?:\.\d+(?::\d{6})?)?$/.test(unmarked))
291
+ return refuse(
292
+ `${subject} is not a frontmatter date — write ` +
293
+ "`<year>[.<day>[:HHMMSS]]` or `datefrom <calendar> <date>`",
294
+ );
295
+ if (unmarked.includes(".")) {
296
+ const canonical = parseCanonicalDate(unmarked, daysPerYear ?? Number.MAX_SAFE_INTEGER);
246
297
  if (canonical) {
247
298
  return {
248
299
  date: {
@@ -252,14 +303,17 @@ export function parseNoteDate(value, options) {
252
303
  year: canonical.year,
253
304
  month: null,
254
305
  day: canonical.day,
255
- approximate: false,
306
+ approximate: approximateInput,
256
307
  precision: "day",
257
- canonicalYear: canonical.year,
308
+ canonicalYear: canonicalYear(canonical.year),
258
309
  canonicalDay: canonical.day,
310
+ spanDays: 1,
259
311
  ...(canonical.seconds === undefined ? {} : { seconds: canonical.seconds }),
260
312
  sort:
261
- canonical.year +
262
- (canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear,
313
+ Number.isSafeInteger(daysPerYear) ?
314
+ canonicalYear(canonical.year) +
315
+ (canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear
316
+ : null,
263
317
  },
264
318
  findings,
265
319
  };
@@ -298,16 +352,22 @@ export function parseNoteDate(value, options) {
298
352
  }
299
353
  return refuse(
300
354
  `${subject} is not a date — write ` +
301
- `\`<year>.<day>[:HHMMSS]\` or ` +
302
- `\`<marker>(<year>[/<month>[/<day>]])\``,
355
+ `\`<year>[.<day>[:HHMMSS]]\` or ` +
356
+ `\`datefrom <calendar> <date>\``,
303
357
  );
304
358
  }
305
359
 
306
- const [, tilde, yearText, monthText, dayText, era] = match;
360
+ const [, tilde, yearText, monthText, dayText, timeText, era] = match;
307
361
  const approximate = tilde === "~";
308
362
  const year = Number(yearText);
309
363
  const month = monthText === undefined ? null : Number(monthText);
310
364
  const day = dayText === undefined ? null : Number(dayText);
365
+ const seconds =
366
+ timeText === undefined ? undefined : (
367
+ Number(timeText.slice(0, 2)) * 3600 +
368
+ Number(timeText.slice(2, 4)) * 60 +
369
+ Number(timeText.slice(4, 6))
370
+ );
311
371
  if (
312
372
  !Number.isSafeInteger(year) ||
313
373
  (month !== null && !Number.isSafeInteger(month)) ||
@@ -325,6 +385,13 @@ export function parseNoteDate(value, options) {
325
385
  `side of an epoch are -1 and 1`,
326
386
  );
327
387
  }
388
+ if (
389
+ timeText !== undefined &&
390
+ (Number(timeText.slice(0, 2)) > 23 ||
391
+ Number(timeText.slice(2, 4)) > 59 ||
392
+ Number(timeText.slice(4, 6)) > 59)
393
+ )
394
+ return refuse(`${subject} needs a 24-hour time in HHMMSS form`);
328
395
 
329
396
  const { months } = calendarStructure(calendar);
330
397
  if (month !== null && month < 1) {
@@ -376,15 +443,198 @@ export function parseNoteDate(value, options) {
376
443
  year,
377
444
  month,
378
445
  day,
446
+ ...(seconds === undefined ? {} : { seconds }),
379
447
  approximate,
380
448
  precision,
381
449
  };
382
450
  // A named era's epoch is a fact the corpus states, so the numbers derived
383
451
  // from it are written by the pass that resolves it and are absent until
384
452
  // then. A bare value is already on the axis.
453
+ if (era !== undefined && eras) {
454
+ const reckoning = eras.get(era);
455
+ if (reckoning?.ambiguous)
456
+ return refuse(
457
+ `${subject} names ambiguous calendar era ${era}; qualify its package and system`,
458
+ );
459
+ if (!reckoning) return refuse(`${subject} names unknown calendar era ${era}`);
460
+ return resolveEra(date, reckoning, `calendar era ${era}`);
461
+ }
385
462
  if (era === undefined) {
386
463
  date.canonicalYear = canonicalYear(year);
387
- date.sort = dateSortKey(date.canonicalYear, month, day);
464
+ if (precision === "year" && Number.isSafeInteger(daysPerYear)) date.spanDays = daysPerYear;
465
+ date.sort =
466
+ dateSortKey(date.canonicalYear, month, day) +
467
+ (seconds ?? 0) / 86400 / (daysPerYear ?? 1);
388
468
  }
389
469
  return { date, findings };
390
470
  }
471
+
472
+ /** Select the single era covering the full precision of a resolved date. */
473
+ export function eraCovering(date, eraRows, daysPerYear) {
474
+ if (
475
+ !date?.known ||
476
+ !Number.isSafeInteger(date.canonicalYear) ||
477
+ !Number.isSafeInteger(daysPerYear) ||
478
+ !Number.isSafeInteger(date.spanDays)
479
+ )
480
+ return null;
481
+ const start = canonicalDayOffset(date.canonicalYear, date.canonicalDay ?? 1, 1, daysPerYear);
482
+ const end = start + date.spanDays - 1;
483
+ const matches = [...new Set(eraRows ?? [])].filter(
484
+ (era) =>
485
+ Number.isSafeInteger(era?.startOffset) &&
486
+ (era.firstEra === true || era.startOffset <= start) &&
487
+ (era.endOffset === undefined || end <= era.endOffset),
488
+ );
489
+ if (matches.length > 1)
490
+ throw new RangeError(
491
+ `date is covered by overlapping eras ${matches.map((era) => era.era).join(", ")}`,
492
+ );
493
+ return matches[0] ?? null;
494
+ }
495
+
496
+ /** Resolve a calendar or one of its eras from an Address or shortcode. */
497
+ export function calendarEras(reference, context) {
498
+ const name = String(reference ?? "");
499
+ const rows = [...new Set(context?.eras?.values() ?? [])].filter(
500
+ (era) => era && !era.ambiguous && era.era,
501
+ );
502
+ const explicit = context?.eras?.get(name);
503
+ if (explicit?.ambiguous) throw new RangeError(`calendar era ${name} is ambiguous`);
504
+ if (explicit?.era) return [explicit];
505
+ const calendars = new Map();
506
+ for (const era of rows) {
507
+ const full = era.qualifier.slice(0, -(era.era.split(".").at(-1).length + 1));
508
+ const short = era.calendarShortcode;
509
+ if ([full, short, `lore-${short}`, `note-lore-${short}`].includes(name)) {
510
+ const list = calendars.get(full) ?? [];
511
+ list.push(era);
512
+ calendars.set(full, list);
513
+ }
514
+ }
515
+ if (calendars.size === 0) throw new RangeError(`calendar ${name} does not resolve`);
516
+ if (calendars.size > 1)
517
+ throw new RangeError(`calendar ${name} is ambiguous; use its full Address`);
518
+ return [...calendars.values()][0];
519
+ }
520
+
521
+ /** Print a resolved date using the era active in one addressed calendar. */
522
+ export function formatDateInCalendar(date, reference, context, formatName) {
523
+ const eras = calendarEras(reference, context);
524
+ const chosen = eraCovering(date, eras, context?.daysPerYear);
525
+ const name = String(reference ?? "");
526
+ if (context?.eras?.get(name)?.era && chosen === null)
527
+ throw new RangeError(`date falls outside calendar era ${name}`);
528
+ return formatNoteDate(date, chosen, context?.daysPerYear, formatName);
529
+ }
530
+
531
+ /** Print a resolved date in one era, retaining its authored precision. */
532
+ export function formatNoteDate(date, era, daysPerYear, formatName) {
533
+ if (!date?.known || !Number.isSafeInteger(date.canonicalYear)) return null;
534
+ if (!era) return { era: null, year: eraYear(date.canonicalYear), text: date.text, prose: null };
535
+ if (!Number.isSafeInteger(daysPerYear) || !Number.isSafeInteger(date.spanDays)) return null;
536
+ const target = era;
537
+ const start = canonicalDayOffset(date.canonicalYear, date.canonicalDay ?? 1, 1, daysPerYear);
538
+ if (start < target.startOffset && target.firstEra !== true) return null;
539
+ if (target.endOffset !== undefined && start + date.spanDays - 1 > target.endOffset) return null;
540
+ const epoch =
541
+ target.startOffset ??
542
+ canonicalDayOffset(target.epochYear, target.epochDay ?? 1, 1, daysPerYear);
543
+ const delta = start - epoch;
544
+ const cycle = Math.floor(delta / daysPerYear);
545
+ const year = target.beforeEra ? -cycle : cycle + 1;
546
+ if (year < 1) return null;
547
+ if (Math.floor((delta + date.spanDays - 1) / daysPerYear) !== cycle) return null;
548
+
549
+ let month = null;
550
+ let day = null;
551
+ if (date.precision !== "year") {
552
+ const months = calendarStructure(target.calendar).months;
553
+ if (!months) return null;
554
+ const ordinal = (((delta % daysPerYear) + daysPerYear) % daysPerYear) + 1;
555
+ const named = monthDayOfYear(months, ordinal);
556
+ if (!named) return null;
557
+ month = named.month;
558
+ if (date.precision === "month") {
559
+ if (named.day !== 1 || date.spanDays !== daysInMonth(target.calendar, month))
560
+ return null;
561
+ } else day = named.day;
562
+ }
563
+ const clock =
564
+ date.seconds === undefined ?
565
+ ""
566
+ : ` ${String(Math.floor(date.seconds / 3600)).padStart(2, "0")}:` +
567
+ `${String(Math.floor(date.seconds / 60) % 60).padStart(2, "0")}:` +
568
+ `${String(date.seconds % 60).padStart(2, "0")}`;
569
+ const monthName =
570
+ month === null ? null : calendarStructure(target.calendar).months?.[month - 1]?.name;
571
+ const eraName =
572
+ target.abbreviation || target.marker || target.name || target.era.split(".").at(-1);
573
+ let text = `${date.approximate ? "~" : ""}${day === null ? "" : `${day} `}${monthName ? `${monthName} ` : ""}${year} ${eraName}${clock}`;
574
+ if (target.calendar?.formats) {
575
+ const { pattern } = selectCalendarFormat(target.calendar, formatName);
576
+ const hasClock = calendarFormatHasClock(pattern);
577
+ const rendered = formatCalendarPattern(
578
+ pattern,
579
+ {
580
+ calendar: target.calendar,
581
+ era: target,
582
+ eraYear: year,
583
+ calendarYear: target.beforeEra ? -year : target.startYear + year - 1,
584
+ month,
585
+ day,
586
+ dayOfYear: (((delta % daysPerYear) + daysPerYear) % daysPerYear) + 1,
587
+ seconds: date.seconds,
588
+ canonicalOffset: start,
589
+ calendarEpochOffset: target.calendarEpochOffset,
590
+ },
591
+ date.precision,
592
+ date.seconds !== undefined,
593
+ );
594
+ text = `${date.approximate ? "~" : ""}${rendered}${date.seconds !== undefined && !hasClock ? clock : ""}`;
595
+ }
596
+ const digits = `${Math.abs(year)}${month === null ? "" : `/${month}`}${day === null ? "" : `/${day}${clock.replaceAll(":", "").replace(/^ /, ":")}`}`;
597
+ const label =
598
+ typeof target.label === "string" ?
599
+ target.label
600
+ : target.label?.[year < 0 ? "before" : "after"];
601
+ const prose =
602
+ target.calendar?.formats ? null
603
+ : typeof label === "string" && label.includes("{date}") ?
604
+ label.replace("{date}", `${date.approximate ? "~" : ""}${digits}`)
605
+ : null;
606
+ return { era, year, month, day, text, prose };
607
+ }
608
+
609
+ /** Normalize a note's declared dates for indexes and generated pages. */
610
+ export function resolvedDateFields(fm, context) {
611
+ const fields =
612
+ fm?.type === "being" ?
613
+ [
614
+ ["born", fm.data?.born],
615
+ ["died", fm.data?.died],
616
+ ]
617
+ : fm?.type === "lore" ?
618
+ [
619
+ ["when", fm.data?.event?.when],
620
+ ["until", fm.data?.event?.until],
621
+ ]
622
+ : [];
623
+ const out = {};
624
+ for (const [key, value] of fields) {
625
+ if (value === undefined || value === null) continue;
626
+ const parsed = parseNoteDate(value, {
627
+ ...context,
628
+ allowUnknown: key === "born" || key === "died",
629
+ });
630
+ if (parsed.date && !parsed.findings.some((finding) => finding.severity === "error")) {
631
+ const era =
632
+ parsed.date.marker ? context?.markers?.get(parsed.date.marker)
633
+ : parsed.date.qualifier ? context?.eras?.get(parsed.date.qualifier)
634
+ : null;
635
+ const printable = formatNoteDate(parsed.date, era, context?.daysPerYear);
636
+ out[key] = { ...parsed.date, prose: printable?.prose ?? null };
637
+ }
638
+ }
639
+ return out;
640
+ }
@@ -43,12 +43,8 @@
43
43
  * carries. There is no value that makes writing it correct, so the diagnostic
44
44
  * says what to write instead rather than which value to change.
45
45
  *
46
- * **One `package` survives, and it is synthesised.** A `dataview` table scopes
47
- * itself with `WHERE … and package = "<pkg>"`, and that clause resolves against
48
- * frontmatter like any other field. {@link searchableFrontmatter} supplies the
49
- * derived value to the search so the 45 authored clauses across `sohl` and
50
- * `thalorna` keep matching. It is a *search* value, never an authored one, and
51
- * it is never written back to a note.
46
+ * SQL queries can refer to the configured package. Search readers receive that
47
+ * derived value without adding a field to authored frontmatter.
52
48
  *
53
49
  * @module
54
50
  */
@@ -57,26 +53,10 @@ import { contentPackage } from "./content-package.mjs";
57
53
  import { locateFrontmatterKey } from "./retired-fields.mjs";
58
54
 
59
55
  /**
60
- * A note's frontmatter as a generated table searches it — its package present,
61
- * though no note declares one.
62
- *
63
- * A `dataview` query resolves `package` out of frontmatter like any other
64
- * field, so a collection note that scopes itself with `WHERE … and package =
65
- * "sohl"` would match nothing now that the field is gone, and would render an
66
- * **empty table** in silence. Supplying the derived value here is what kept the
67
- * sweep mechanical rather than a trap — and a query that never mentions
68
- * `package` is unaffected either way.
69
- *
70
- * The frontmatter is copied rather than written into: it is the note's own
71
- * parsed object, shared with every other reader, and the derived package is a
72
- * property of *this search*, not of the note.
73
- *
56
+ * A copy of frontmatter carrying the configured package for query evaluation.
74
57
  * @param {object|null|undefined} fm - Parsed frontmatter.
75
- * @param {string} [configured] - The package this build compiles. Defaults to
76
- * the configured `contentPackage`; passed explicitly by callers that already
77
- * carry it in a context object, so a caller's configuration drives every read.
78
- * @returns {object|null|undefined} A shallow copy carrying the derived package,
79
- * or whatever was passed when it is not frontmatter at all.
58
+ * @param {string} [configured] - The package this build compiles.
59
+ * @returns {object|null|undefined} Frontmatter with a derived package value.
80
60
  */
81
61
  export function searchableFrontmatter(fm, configured) {
82
62
  if (!fm || typeof fm !== "object") return fm;
@@ -225,7 +225,8 @@ export function placeholderBody(body) {
225
225
  *
226
226
  * Fence blocks, headings, thematic breaks and HTML comments are dropped, and a
227
227
  * wikilink counts as the one word it renders — `[[affiliation-meivor|Mëivōr]]`
228
- * is a word, not three. What is left is split on whitespace.
228
+ * is a word, not three. A dash, slash, or ellipsis between words separates
229
+ * them even without surrounding spaces.
229
230
  *
230
231
  * @param {string|null|undefined} body - The note body.
231
232
  * @returns {number} The word count.
@@ -238,5 +239,8 @@ export function bodyWordCount(body) {
238
239
  .split("\n")
239
240
  .filter((line) => !STRUCTURE_LINE.test(line.trim()))
240
241
  .join(" ");
241
- return text.split(/\s+/).filter((word) => /[\p{L}\p{N}]/u.test(word)).length;
242
+ return text
243
+ .replace(/(?<=[\p{L}\p{N}])(?:[—–/…]|\.\.\.)(?=[\p{L}\p{N}])/gu, " ")
244
+ .split(/\s+/)
245
+ .filter((word) => /[\p{L}\p{N}]/u.test(word)).length;
242
246
  }