@heroiclands/package-build 22.15.1 → 22.16.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 (221) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +303 -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 +294 -234
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +33 -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 +631 -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 +4420 -0
  36. package/docs/reference/note-types.md +531 -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-human.mjs +71 -0
  46. package/engine/calendar-notes.mjs +41 -3
  47. package/engine/calendars.mjs +8 -1
  48. package/engine/changelog-lint.mjs +2 -2
  49. package/engine/code-fences.mjs +23 -97
  50. package/engine/compile-corpus.mjs +6 -7
  51. package/engine/content-admonitions.mjs +114 -0
  52. package/engine/content-captions.mjs +181 -0
  53. package/engine/content-embeds.mjs +2 -2
  54. package/engine/content-footnotes.mjs +34 -0
  55. package/engine/content-format-check.mjs +2 -2
  56. package/engine/content-format.mjs +127 -73
  57. package/engine/content-format.yaml +745 -0
  58. package/engine/content-html.mjs +1 -1
  59. package/engine/content-icons.mjs +45 -92
  60. package/engine/content-images.mjs +22 -74
  61. package/engine/content-index.mjs +12 -3
  62. package/engine/content-links.mjs +21 -26
  63. package/engine/content-lint.mjs +1 -5
  64. package/engine/content-secrets.mjs +3 -1
  65. package/engine/content-tables.mjs +47 -1257
  66. package/engine/culture-choice.mjs +32 -0
  67. package/engine/date-conversion.mjs +47 -0
  68. package/engine/declaration-check.mjs +87 -0
  69. package/engine/docs-checks.mjs +140 -0
  70. package/engine/documentation-links.mjs +31 -0
  71. package/engine/exported-scene.mjs +1 -1
  72. package/engine/extension-attributes.mjs +74 -0
  73. package/engine/field-reference.mjs +1 -1
  74. package/engine/field-spec.mjs +4 -0
  75. package/engine/foreign-catalog.mjs +5 -5
  76. package/engine/format-generated.mjs +15 -0
  77. package/engine/foundry-entries.mjs +3 -1
  78. package/engine/frontmatter-lint.mjs +5 -1
  79. package/engine/frontmatter.mjs +10 -0
  80. package/engine/generate.mjs +2 -2
  81. package/engine/helpers.mjs +71 -117
  82. package/engine/homepage.mjs +2 -3
  83. package/engine/index-records.mjs +1 -0
  84. package/engine/index.mjs +18 -3
  85. package/engine/infobox-registry.mjs +3 -1
  86. package/engine/infobox.mjs +38 -20
  87. package/engine/item-compiler.mjs +10 -8
  88. package/engine/item-docs.mjs +7 -6
  89. package/engine/journals.mjs +51 -10
  90. package/engine/macros.mjs +1 -1
  91. package/engine/map-build.mjs +1 -1
  92. package/engine/map-dot.mjs +1 -1
  93. package/engine/markdown-expressions.mjs +117 -0
  94. package/engine/metadata-index.mjs +7 -3
  95. package/engine/note-addresses.mjs +1 -0
  96. package/engine/note-dates.mjs +283 -61
  97. package/engine/note-package.mjs +5 -25
  98. package/engine/note-state.mjs +6 -2
  99. package/engine/note-vocabulary.mjs +74 -19
  100. package/engine/number-words.mjs +75 -0
  101. package/engine/office-holders.mjs +188 -0
  102. package/engine/pack-config.mjs +2 -15
  103. package/engine/pack-router.mjs +24 -5
  104. package/engine/packages.mjs +3 -2
  105. package/engine/pathnames.mjs +21 -24
  106. package/engine/pdf-build.mjs +217 -55
  107. package/engine/pdf-fonts.mjs +2 -2
  108. package/engine/pdf-images.mjs +82 -0
  109. package/engine/pdf-render.mjs +174 -22
  110. package/engine/populations.mjs +3 -2
  111. package/engine/project-init.mjs +499 -0
  112. package/engine/prose-config.mjs +1 -1
  113. package/engine/prose-lint.mjs +7 -6
  114. package/engine/readability-lint.mjs +86 -0
  115. package/engine/reckoning-markers.mjs +205 -93
  116. package/engine/scenes.mjs +5 -2
  117. package/engine/schema-check.mjs +3 -3
  118. package/engine/site-build.mjs +87 -37
  119. package/engine/site-config.mjs +8 -8
  120. package/engine/site-index.mjs +4 -0
  121. package/engine/site-maps.mjs +1 -1
  122. package/engine/site-preview.mjs +19 -8
  123. package/engine/social-ties.mjs +5 -1
  124. package/engine/sql-tables.mjs +92 -22
  125. package/engine/web-wikilinks.mjs +15 -31
  126. package/engine/wikilink-syntax.mjs +4 -1
  127. package/engine/wikilinks.mjs +12 -14
  128. package/hm3/item-fields.mjs +1 -1
  129. package/hm3/items.mjs +12 -45
  130. package/index.mjs +2 -14
  131. package/manifest.mjs +31 -4
  132. package/markdownlint-config.mjs +1 -1
  133. package/package.json +32 -11
  134. package/prettier-config.mjs +1 -1
  135. package/sohl/being-info.mjs +3 -145
  136. package/sohl/index.mjs +1 -1
  137. package/sohl/infobox.mjs +2 -1
  138. package/sohl/item-builders.mjs +3 -27
  139. package/sohl/item-fields.mjs +61 -22
  140. package/sohl/kb-passes.mjs +2 -2
  141. package/sohl/note-schemas.mjs +0 -8
  142. package/stage.mjs +19 -0
  143. package/types/config.d.mts +12 -3
  144. package/types/content-config.d.mts +15 -81
  145. package/types/engine/address-render.d.mts +13 -0
  146. package/types/engine/address.d.mts +2 -11
  147. package/types/engine/anchors.d.mts +3 -4
  148. package/types/engine/asset-bindings.d.mts +9 -2
  149. package/types/engine/base-compiler.d.mts +1 -2
  150. package/types/engine/being-measurements.d.mts +16 -0
  151. package/types/engine/calendar-choice.d.mts +14 -0
  152. package/types/engine/calendar-human.d.mts +4 -0
  153. package/types/engine/calendar-notes.d.mts +2 -0
  154. package/types/engine/calendars.d.mts +5 -3
  155. package/types/engine/code-fences.d.mts +6 -72
  156. package/types/engine/compile-corpus.d.mts +1 -3
  157. package/types/engine/content-admonitions.d.mts +24 -0
  158. package/types/engine/content-captions.d.mts +45 -0
  159. package/types/engine/content-footnotes.d.mts +10 -0
  160. package/types/engine/content-format-check.d.mts +17 -4
  161. package/types/engine/content-format.d.mts +24 -11
  162. package/types/engine/content-icons.d.mts +14 -23
  163. package/types/engine/content-images.d.mts +2 -2
  164. package/types/engine/content-index.d.mts +9 -2
  165. package/types/engine/content-links.d.mts +3 -5
  166. package/types/engine/content-tables.d.mts +11 -140
  167. package/types/engine/culture-choice.d.mts +14 -0
  168. package/types/engine/date-conversion.d.mts +4 -0
  169. package/types/engine/declaration-check.d.mts +24 -0
  170. package/types/engine/docs-checks.d.mts +28 -0
  171. package/types/engine/documentation-links.d.mts +7 -0
  172. package/types/engine/exported-scene.d.mts +6 -2
  173. package/types/engine/extension-attributes.d.mts +14 -0
  174. package/types/engine/field-spec.d.mts +9 -0
  175. package/types/engine/format-generated.d.mts +7 -0
  176. package/types/engine/frontmatter.d.mts +14 -4
  177. package/types/engine/generate.d.mts +1 -1
  178. package/types/engine/helpers.d.mts +37 -92
  179. package/types/engine/homepage.d.mts +2 -3
  180. package/types/engine/index.d.mts +5 -0
  181. package/types/engine/infobox-registry.d.mts +3 -1
  182. package/types/engine/infobox.d.mts +17 -6
  183. package/types/engine/item-compiler.d.mts +4 -4
  184. package/types/engine/item-docs.d.mts +4 -3
  185. package/types/engine/journals.d.mts +10 -7
  186. package/types/engine/map-graphviz.d.mts +1 -1
  187. package/types/engine/markdown-expressions.d.mts +17 -0
  188. package/types/engine/note-dates.d.mts +47 -1
  189. package/types/engine/note-package.d.mts +3 -19
  190. package/types/engine/note-state.d.mts +2 -1
  191. package/types/engine/note-vocabulary.d.mts +9 -0
  192. package/types/engine/number-words.d.mts +4 -0
  193. package/types/engine/office-holders.d.mts +14 -0
  194. package/types/engine/pack-config.d.mts +1 -1
  195. package/types/engine/packages.d.mts +3 -2
  196. package/types/engine/pdf-images.d.mts +38 -0
  197. package/types/engine/populations.d.mts +2 -2
  198. package/types/engine/project-init.d.mts +17 -0
  199. package/types/engine/prose-config.d.mts +1 -1
  200. package/types/engine/prose-lint.d.mts +3 -2
  201. package/types/engine/readability-lint.d.mts +22 -0
  202. package/types/engine/reckoning-markers.d.mts +4 -2
  203. package/types/engine/schema-check.d.mts +3 -3
  204. package/types/engine/site-build.d.mts +38 -28
  205. package/types/engine/site-config.d.mts +2 -2
  206. package/types/engine/site-index.d.mts +4 -0
  207. package/types/engine/site-preview.d.mts +1 -1
  208. package/types/engine/social-ties.d.mts +8 -2
  209. package/types/engine/sql-tables.d.mts +13 -2
  210. package/types/engine/web-wikilinks.d.mts +2 -1
  211. package/types/engine/wikilinks.d.mts +7 -1
  212. package/types/hm3/items.d.mts +5 -6
  213. package/types/labels.d.mts +40 -0
  214. package/types/manifest.d.mts +3 -3
  215. package/types/sohl/actors.d.mts +2 -2
  216. package/types/sohl/being-info.d.mts +3 -43
  217. package/types/sohl/index.d.mts +1 -1
  218. package/types/sohl/infobox.d.mts +5 -2
  219. package/types/sohl/item-fields.d.mts +4 -13
  220. package/types/stage.d.mts +9 -0
  221. package/CONTENT.md +0 -3239
@@ -15,21 +15,19 @@
15
15
  * Parse authored note dates into records used by content checks and renderers.
16
16
  *
17
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.
18
+ * the world's year. A named date is `datefrom <calendar> <date>`; its calendar
19
+ * and era resolve the value on the neutral timeline. Either form accepts a
20
+ * leading `~` for approximation. `unknown` is unordered.
23
21
  *
24
22
  * `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.
23
+ * neutral and resolved named dates. Resolved dates also carry `canonicalDay`,
24
+ * the ordinal day within the neutral year.
28
25
  *
29
26
  * @module
30
27
  */
31
28
 
32
29
  import { ADDRESS_SEGMENT_PATTERN } from "./address-charset.mjs";
30
+ import { addressedCalendarDate } from "./calendar-human.mjs";
33
31
  import {
34
32
  calendarStructure,
35
33
  canonicalDateFromOffset,
@@ -38,6 +36,8 @@ import {
38
36
  dateSortKey,
39
37
  dayOfYear,
40
38
  daysInMonth,
39
+ eraYear,
40
+ monthDayOfYear,
41
41
  parseCanonicalDate,
42
42
  } from "./calendars.mjs";
43
43
  import { positionOfFrontmatterPath } from "./diagnostics.mjs";
@@ -78,9 +78,12 @@ export const ERA_QUALIFIER_PATTERN = new RegExp(`^${ERA}$`);
78
78
  * @type {RegExp}
79
79
  */
80
80
  export const NOTE_DATE_PATTERN = new RegExp(
81
- `^(~)?\\s*(-?\\d+)(?:/(\\d+)(?:/(\\d+))?)?(?:\\s+(${ERA}))?$`,
81
+ `^(~)?\\s*(-?\\d+)(?:/(\\d+)(?:/(\\d+)(?::(\\d{6}))?)?)?(?:\\s+(${ERA}))?$`,
82
82
  );
83
83
 
84
+ /** A calendar conversion written as a frontmatter date value. */
85
+ export const DATEFROM_PATTERN = /^datefrom\s+(\S+)\s+(.+)$/;
86
+
84
87
  /** A day written with no month — ungrammatical, and worth its own sentence. */
85
88
  const DAY_WITHOUT_MONTH_PATTERN = /^(?:~)?\s*-?\d+\/\/\d+(?:\s+\S+)?$/;
86
89
 
@@ -129,8 +132,12 @@ function unknownRecord() {
129
132
  * the month it names. A calendar that declares no list bounds nothing.
130
133
  * @param {Map<string, object>} [options.markers] - Resolved era markers from
131
134
  * the corpus, each carrying its calendar and canonical start.
135
+ * @param {Map<string, object>} [options.eras] - Resolved calendar eras, keyed
136
+ * by their addressed qualifier.
137
+ * @param {boolean} [options.ignoreEraBounds=false] - Parse a calendar row's
138
+ * own boundary in a referenced era without applying that era's note span.
132
139
  * @param {number} [options.daysPerYear] - The world's year length, required for
133
- * canonical days and marked dates.
140
+ * canonical days and named dates.
134
141
  * @param {string} [options.field] - The key that carried it, named in every
135
142
  * message. Omitted, a message names the value alone.
136
143
  * @param {boolean} [options.allowUnknown=true] - Whether `"unknown"` is a
@@ -147,6 +154,8 @@ export function parseNoteDate(value, options) {
147
154
  const {
148
155
  calendar,
149
156
  markers,
157
+ eras,
158
+ ignoreEraBounds = false,
150
159
  daysPerYear,
151
160
  field,
152
161
  allowUnknown = true,
@@ -178,71 +187,109 @@ export function parseNoteDate(value, options) {
178
187
  return { date: null, findings };
179
188
  };
180
189
 
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}`);
190
+ const resolveEra = (parsed, reckoning, label) => {
191
+ if (!ignoreEraBounds && parsed.year < 0 && reckoning.firstEra === false)
192
+ return refuse(
193
+ `${subject} counts backward from ${label}, but only the first era of a calendar may do so`,
194
+ );
197
195
  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)
196
+ return refuse(`${subject} needs the world's year length to resolve ${label}`);
197
+ const { months } = calendarStructure(reckoning.calendar);
198
+ if (!months) return refuse(`${subject} names ${label}, whose calendar declares no months`);
199
+ if (parsed.month !== null && parsed.month > months.length)
212
200
  return refuse(
213
- `${subject} names two reckonings; the payload of ${marker} is a calendar date`,
201
+ `${subject} writes month ${parsed.month}, and ${label} keeps ${months.length} months`,
214
202
  );
215
- if (parsed.date.canonicalDay !== undefined)
203
+ const length = parsed.month === null ? null : daysInMonth(reckoning.calendar, parsed.month);
204
+ if (parsed.day !== null && length !== null && parsed.day > length)
216
205
  return refuse(
217
- `${subject} puts a canonical day inside ${marker}; write its calendar month and day`,
206
+ `${subject} writes day ${parsed.day}, and month ${parsed.month} is ${length} days long`,
218
207
  );
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);
208
+ const ordinal = dayOfYear(months, parsed.month ?? 1, parsed.day ?? 1);
222
209
  const start = canonicalDayOffset(reckoning.epochYear, reckoning.epochDay, 1, daysPerYear);
223
- const yearOffset = parsed.date.year > 0 ? parsed.date.year - 1 : parsed.date.year;
210
+ const yearOffset = parsed.year > 0 ? parsed.year - 1 : parsed.year;
224
211
  const offset = start + yearOffset * daysPerYear + ordinal - 1;
225
212
  if (!Number.isSafeInteger(offset))
226
213
  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}`);
214
+ const spanDays =
215
+ parsed.precision === "year" ? daysPerYear
216
+ : parsed.precision === "month" ? length
217
+ : 1;
218
+ if (
219
+ !ignoreEraBounds &&
220
+ reckoning.endOffset !== undefined &&
221
+ offset + spanDays - 1 > reckoning.endOffset
222
+ )
223
+ return refuse(`${subject} lies after the end of ${label}`);
229
224
  const canonical = canonicalDateFromOffset(offset, 1, daysPerYear);
230
225
  return {
231
226
  date: {
232
- ...parsed.date,
227
+ ...parsed,
233
228
  text,
234
229
  era: reckoning.era,
235
- marker,
230
+ qualifier: reckoning.qualifier,
236
231
  canonicalYear: canonical.year,
237
232
  canonicalDay: canonical.day,
238
- sort: canonical.year + (canonical.day - 1) / daysPerYear,
233
+ spanDays,
234
+ sort:
235
+ canonical.year +
236
+ (canonical.day - 1 + (parsed.seconds ?? 0) / 86400) / daysPerYear,
239
237
  },
240
- findings: parsed.findings,
238
+ findings,
241
239
  };
240
+ };
241
+
242
+ if (text.trim() === UNKNOWN_DATE) {
243
+ if (allowUnknown) return { date: unknownRecord(), findings };
244
+ return refuse(
245
+ `${subject} — \`${UNKNOWN_DATE}\` records that something happened and ` +
246
+ `the date is not known, which only a field whose absence means it ` +
247
+ `never happened has any use for. \`${field ?? "this field"}\` is not ` +
248
+ `one, so leave it out instead`,
249
+ );
242
250
  }
243
251
 
244
- if (trimmed.includes(".")) {
245
- const canonical = parseCanonicalDate(trimmed, daysPerYear);
252
+ const trimmed = text.trim();
253
+ const approximateInput = trimmed.startsWith("~");
254
+ const unmarked = approximateInput ? trimmed.slice(1).trim() : trimmed;
255
+ const conversion = DATEFROM_PATTERN.exec(unmarked);
256
+ if (conversion) {
257
+ try {
258
+ const [, reference, human] = conversion;
259
+ const addressed = addressedCalendarDate(
260
+ human.trim(),
261
+ calendarEras(reference, { eras }),
262
+ );
263
+ if (!addressed) return refuse(`${subject} needs a named calendar date`);
264
+ const parsed = parseNoteDate(`${approximateInput ? "~" : ""}${addressed}`, {
265
+ markers,
266
+ eras,
267
+ daysPerYear,
268
+ ignoreEraBounds,
269
+ allowUnknown: false,
270
+ file,
271
+ raw,
272
+ keyPath,
273
+ });
274
+ if (!parsed.date || parsed.findings.some((item) => item.severity === "error"))
275
+ return parsed;
276
+ return { date: { ...parsed.date, text }, findings: parsed.findings };
277
+ } catch (err) {
278
+ return refuse(`${subject} cannot resolve its calendar date: ${err.message}`);
279
+ }
280
+ }
281
+ if (/^-?0+\.\d+(?::\d{6})?$/.test(unmarked))
282
+ return refuse(
283
+ `${subject} writes year 0, and no reckoning has one — the years either ` +
284
+ `side of an epoch are -1 and 1`,
285
+ );
286
+ if (field && !/^-?\d+\.\d+(?::\d{6})?$/.test(unmarked))
287
+ return refuse(
288
+ `${subject} is not a frontmatter date — write ` +
289
+ "`<year>.<day>[:HHMMSS]` or `datefrom <calendar> <date>`",
290
+ );
291
+ if (unmarked.includes(".")) {
292
+ const canonical = parseCanonicalDate(unmarked, daysPerYear ?? Number.MAX_SAFE_INTEGER);
246
293
  if (canonical) {
247
294
  return {
248
295
  date: {
@@ -252,14 +299,17 @@ export function parseNoteDate(value, options) {
252
299
  year: canonical.year,
253
300
  month: null,
254
301
  day: canonical.day,
255
- approximate: false,
302
+ approximate: approximateInput,
256
303
  precision: "day",
257
304
  canonicalYear: canonical.year,
258
305
  canonicalDay: canonical.day,
306
+ spanDays: 1,
259
307
  ...(canonical.seconds === undefined ? {} : { seconds: canonical.seconds }),
260
308
  sort:
261
- canonical.year +
262
- (canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear,
309
+ Number.isSafeInteger(daysPerYear) ?
310
+ canonical.year +
311
+ (canonical.day - 1 + (canonical.seconds ?? 0) / 86400) / daysPerYear
312
+ : null,
263
313
  },
264
314
  findings,
265
315
  };
@@ -299,15 +349,21 @@ export function parseNoteDate(value, options) {
299
349
  return refuse(
300
350
  `${subject} is not a date — write ` +
301
351
  `\`<year>.<day>[:HHMMSS]\` or ` +
302
- `\`<marker>(<year>[/<month>[/<day>]])\``,
352
+ `\`datefrom <calendar> <date>\``,
303
353
  );
304
354
  }
305
355
 
306
- const [, tilde, yearText, monthText, dayText, era] = match;
356
+ const [, tilde, yearText, monthText, dayText, timeText, era] = match;
307
357
  const approximate = tilde === "~";
308
358
  const year = Number(yearText);
309
359
  const month = monthText === undefined ? null : Number(monthText);
310
360
  const day = dayText === undefined ? null : Number(dayText);
361
+ const seconds =
362
+ timeText === undefined ? undefined : (
363
+ Number(timeText.slice(0, 2)) * 3600 +
364
+ Number(timeText.slice(2, 4)) * 60 +
365
+ Number(timeText.slice(4, 6))
366
+ );
311
367
  if (
312
368
  !Number.isSafeInteger(year) ||
313
369
  (month !== null && !Number.isSafeInteger(month)) ||
@@ -325,6 +381,13 @@ export function parseNoteDate(value, options) {
325
381
  `side of an epoch are -1 and 1`,
326
382
  );
327
383
  }
384
+ if (
385
+ timeText !== undefined &&
386
+ (Number(timeText.slice(0, 2)) > 23 ||
387
+ Number(timeText.slice(2, 4)) > 59 ||
388
+ Number(timeText.slice(4, 6)) > 59)
389
+ )
390
+ return refuse(`${subject} needs a 24-hour time in HHMMSS form`);
328
391
 
329
392
  const { months } = calendarStructure(calendar);
330
393
  if (month !== null && month < 1) {
@@ -376,15 +439,174 @@ export function parseNoteDate(value, options) {
376
439
  year,
377
440
  month,
378
441
  day,
442
+ ...(seconds === undefined ? {} : { seconds }),
379
443
  approximate,
380
444
  precision,
381
445
  };
382
446
  // A named era's epoch is a fact the corpus states, so the numbers derived
383
447
  // from it are written by the pass that resolves it and are absent until
384
448
  // then. A bare value is already on the axis.
449
+ if (era !== undefined && eras) {
450
+ const reckoning = eras.get(era);
451
+ if (reckoning?.ambiguous)
452
+ return refuse(
453
+ `${subject} names ambiguous calendar era ${era}; qualify its package and system`,
454
+ );
455
+ if (!reckoning) return refuse(`${subject} names unknown calendar era ${era}`);
456
+ return resolveEra(date, reckoning, `calendar era ${era}`);
457
+ }
385
458
  if (era === undefined) {
386
459
  date.canonicalYear = canonicalYear(year);
387
- date.sort = dateSortKey(date.canonicalYear, month, day);
460
+ if (precision === "year" && Number.isSafeInteger(daysPerYear)) date.spanDays = daysPerYear;
461
+ date.sort =
462
+ dateSortKey(date.canonicalYear, month, day) +
463
+ (seconds ?? 0) / 86400 / (daysPerYear ?? 1);
388
464
  }
389
465
  return { date, findings };
390
466
  }
467
+
468
+ /** Select the single era covering the full precision of a resolved date. */
469
+ export function eraCovering(date, eraRows, daysPerYear) {
470
+ if (
471
+ !date?.known ||
472
+ !Number.isSafeInteger(date.canonicalYear) ||
473
+ !Number.isSafeInteger(daysPerYear) ||
474
+ !Number.isSafeInteger(date.spanDays)
475
+ )
476
+ return null;
477
+ const start = canonicalDayOffset(date.canonicalYear, date.canonicalDay ?? 1, 1, daysPerYear);
478
+ const end = start + date.spanDays - 1;
479
+ const matches = [...new Set(eraRows ?? [])].filter(
480
+ (era) =>
481
+ Number.isSafeInteger(era?.startOffset) &&
482
+ (era.firstEra === true || era.startOffset <= start) &&
483
+ (era.endOffset === undefined || end <= era.endOffset),
484
+ );
485
+ if (matches.length > 1)
486
+ throw new RangeError(
487
+ `date is covered by overlapping eras ${matches.map((era) => era.era).join(", ")}`,
488
+ );
489
+ return matches[0] ?? null;
490
+ }
491
+
492
+ /** Resolve a calendar or one of its eras from an Address or shortcode. */
493
+ export function calendarEras(reference, context) {
494
+ const name = String(reference ?? "");
495
+ const rows = [...new Set(context?.eras?.values() ?? [])].filter(
496
+ (era) => era && !era.ambiguous && era.era,
497
+ );
498
+ const explicit = context?.eras?.get(name);
499
+ if (explicit?.ambiguous) throw new RangeError(`calendar era ${name} is ambiguous`);
500
+ if (explicit?.era) return [explicit];
501
+ const calendars = new Map();
502
+ for (const era of rows) {
503
+ const full = era.qualifier.slice(0, -(era.era.split(".").at(-1).length + 1));
504
+ const short = era.calendarShortcode;
505
+ if ([full, short, `lore-${short}`, `note-lore-${short}`].includes(name)) {
506
+ const list = calendars.get(full) ?? [];
507
+ list.push(era);
508
+ calendars.set(full, list);
509
+ }
510
+ }
511
+ if (calendars.size === 0) throw new RangeError(`calendar ${name} does not resolve`);
512
+ if (calendars.size > 1)
513
+ throw new RangeError(`calendar ${name} is ambiguous; use its full Address`);
514
+ return [...calendars.values()][0];
515
+ }
516
+
517
+ /** Print a resolved date using the era active in one addressed calendar. */
518
+ export function formatDateInCalendar(date, reference, context) {
519
+ const eras = calendarEras(reference, context);
520
+ const chosen = eraCovering(date, eras, context?.daysPerYear);
521
+ const name = String(reference ?? "");
522
+ if (context?.eras?.get(name)?.era && chosen === null)
523
+ throw new RangeError(`date falls outside calendar era ${name}`);
524
+ return formatNoteDate(date, chosen, context?.daysPerYear);
525
+ }
526
+
527
+ /** Print a resolved date in one era, retaining its authored precision. */
528
+ export function formatNoteDate(date, era, daysPerYear) {
529
+ if (!date?.known || !Number.isSafeInteger(date.canonicalYear)) return null;
530
+ if (!era) return { era: null, year: eraYear(date.canonicalYear), text: date.text, prose: null };
531
+ if (!Number.isSafeInteger(daysPerYear) || !Number.isSafeInteger(date.spanDays)) return null;
532
+ const target = era;
533
+ const start = canonicalDayOffset(date.canonicalYear, date.canonicalDay ?? 1, 1, daysPerYear);
534
+ if (start < target.startOffset && target.firstEra !== true) return null;
535
+ if (target.endOffset !== undefined && start + date.spanDays - 1 > target.endOffset) return null;
536
+ const epoch =
537
+ target.startOffset ??
538
+ canonicalDayOffset(target.epochYear, target.epochDay ?? 1, 1, daysPerYear);
539
+ const delta = start - epoch;
540
+ const cycle = Math.floor(delta / daysPerYear);
541
+ const year = cycle >= 0 ? cycle + 1 : cycle;
542
+ if (Math.floor((delta + date.spanDays - 1) / daysPerYear) !== cycle) return null;
543
+
544
+ let month = null;
545
+ let day = null;
546
+ if (date.precision !== "year") {
547
+ const months = calendarStructure(target.calendar).months;
548
+ if (!months) return null;
549
+ const ordinal = (((delta % daysPerYear) + daysPerYear) % daysPerYear) + 1;
550
+ const named = monthDayOfYear(months, ordinal);
551
+ if (!named) return null;
552
+ month = named.month;
553
+ if (date.precision === "month") {
554
+ if (named.day !== 1 || date.spanDays !== daysInMonth(target.calendar, month))
555
+ return null;
556
+ } else day = named.day;
557
+ }
558
+ const clock =
559
+ date.seconds === undefined ?
560
+ ""
561
+ : ` ${String(Math.floor(date.seconds / 3600)).padStart(2, "0")}:` +
562
+ `${String(Math.floor(date.seconds / 60) % 60).padStart(2, "0")}:` +
563
+ `${String(date.seconds % 60).padStart(2, "0")}`;
564
+ const monthName =
565
+ month === null ? null : calendarStructure(target.calendar).months?.[month - 1]?.name;
566
+ const eraName =
567
+ target.abbreviation || target.marker || target.name || target.era.split(".").at(-1);
568
+ const text = `${date.approximate ? "~" : ""}${day === null ? "" : `${day} `}${monthName ? `${monthName} ` : ""}${year} ${eraName}${clock}`;
569
+ const digits = `${Math.abs(year)}${month === null ? "" : `/${month}`}${day === null ? "" : `/${day}${clock.replaceAll(":", "").replace(/^ /, ":")}`}`;
570
+ const label =
571
+ typeof target.label === "string" ?
572
+ target.label
573
+ : target.label?.[year < 0 ? "before" : "after"];
574
+ const prose =
575
+ typeof label === "string" && label.includes("{date}") ?
576
+ label.replace("{date}", `${date.approximate ? "~" : ""}${digits}`)
577
+ : null;
578
+ return { era, year, month, day, text, prose };
579
+ }
580
+
581
+ /** Normalize a note's declared dates for indexes and generated pages. */
582
+ export function resolvedDateFields(fm, context) {
583
+ const fields =
584
+ fm?.type === "being" ?
585
+ [
586
+ ["born", fm.data?.born],
587
+ ["died", fm.data?.died],
588
+ ]
589
+ : fm?.type === "lore" ?
590
+ [
591
+ ["when", fm.data?.event?.when],
592
+ ["until", fm.data?.event?.until],
593
+ ]
594
+ : [];
595
+ const out = {};
596
+ for (const [key, value] of fields) {
597
+ if (value === undefined || value === null) continue;
598
+ const parsed = parseNoteDate(value, {
599
+ ...context,
600
+ allowUnknown: key === "born" || key === "died",
601
+ });
602
+ if (parsed.date && !parsed.findings.some((finding) => finding.severity === "error")) {
603
+ const era =
604
+ parsed.date.marker ? context?.markers?.get(parsed.date.marker)
605
+ : parsed.date.qualifier ? context?.eras?.get(parsed.date.qualifier)
606
+ : null;
607
+ const printable = formatNoteDate(parsed.date, era, context?.daysPerYear);
608
+ out[key] = { ...parsed.date, prose: printable?.prose ?? null };
609
+ }
610
+ }
611
+ return out;
612
+ }
@@ -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
  }