@heroiclands/package-build 22.21.0 → 22.22.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 (110) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/assets/styles/content-base.css +162 -0
  3. package/bin/content-commands.mjs +26 -3
  4. package/bin/package-build.mjs +15 -2
  5. package/ci/content-format-reference.mjs +68 -1
  6. package/config.mjs +26 -0
  7. package/content-config.mjs +73 -4
  8. package/docs/api.md +36 -18
  9. package/docs/authoring/dates-and-calendars.md +4 -2
  10. package/docs/authoring/frontmatter.md +2 -2
  11. package/docs/authoring/links-and-markup.md +237 -28
  12. package/docs/configuration.md +27 -1
  13. package/docs/content-format.md +3 -1
  14. package/docs/guides/reference-pages.md +15 -0
  15. package/docs/reference/format-details.md +224 -46
  16. package/docs/reference/note-types.md +21 -4
  17. package/engine/actor-compiler.mjs +23 -0
  18. package/engine/anchored-sections.mjs +35 -4
  19. package/engine/anchors.mjs +10 -20
  20. package/engine/base-compiler.mjs +86 -21
  21. package/engine/base-styles.mjs +149 -0
  22. package/engine/bundles.mjs +0 -1
  23. package/engine/compendiums.mjs +8 -1
  24. package/engine/content-blocks.mjs +410 -0
  25. package/engine/content-captions.mjs +57 -8
  26. package/engine/content-footnotes.mjs +189 -0
  27. package/engine/content-format-check.mjs +15 -12
  28. package/engine/content-index.mjs +2 -2
  29. package/engine/content-links.mjs +14 -6
  30. package/engine/content-tables.mjs +45 -4
  31. package/engine/content-tasklists.mjs +189 -0
  32. package/engine/docs-checks.mjs +4 -4
  33. package/engine/extension-attributes.mjs +48 -2
  34. package/engine/frontmatter-lint.mjs +47 -64
  35. package/engine/frontmatter.mjs +3 -3
  36. package/engine/heading-attributes.mjs +308 -0
  37. package/engine/helpers.mjs +14 -13
  38. package/engine/homepage.mjs +8 -8
  39. package/engine/index.mjs +3 -0
  40. package/engine/infobox-render.mjs +15 -3
  41. package/engine/infobox.mjs +83 -8
  42. package/engine/journals.mjs +36 -17
  43. package/engine/macros.mjs +1 -1
  44. package/engine/markdown-expressions.mjs +178 -53
  45. package/engine/note-dates.mjs +49 -0
  46. package/engine/note-frontmatter.mjs +7 -16
  47. package/engine/note-vocabulary.mjs +172 -37
  48. package/engine/page-lists.mjs +358 -0
  49. package/engine/pdf-build.mjs +76 -42
  50. package/engine/pdf-render.mjs +422 -49
  51. package/engine/rank-ladder.mjs +198 -0
  52. package/engine/reckoning-markers.mjs +1 -1
  53. package/engine/retired-fields.mjs +8 -13
  54. package/engine/scenes.mjs +10 -3
  55. package/engine/site-build.mjs +41 -12
  56. package/engine/site-config.mjs +10 -1
  57. package/engine/site-index.mjs +18 -2
  58. package/engine/site-preview.mjs +18 -7
  59. package/engine/social-ties.mjs +52 -14
  60. package/engine/sql-tables.mjs +66 -9
  61. package/engine/standings.mjs +89 -6
  62. package/engine/system-block.mjs +12 -14
  63. package/engine/web-wikilinks.mjs +48 -1
  64. package/hm3/actors.mjs +6 -3
  65. package/manifest.mjs +13 -0
  66. package/package.json +1 -1
  67. package/sohl/actors.mjs +22 -99
  68. package/sohl/item-fields.mjs +4 -5
  69. package/sohl/items.mjs +1 -1
  70. package/sohl/note-schemas.mjs +11 -21
  71. package/types/config.d.mts +9 -0
  72. package/types/engine/actor-compiler.d.mts +11 -0
  73. package/types/engine/anchored-sections.d.mts +24 -0
  74. package/types/engine/base-compiler.d.mts +19 -0
  75. package/types/engine/base-styles.d.mts +93 -0
  76. package/types/engine/content-blocks.d.mts +87 -0
  77. package/types/engine/content-captions.d.mts +14 -1
  78. package/types/engine/content-footnotes.d.mts +73 -0
  79. package/types/engine/content-format-check.d.mts +8 -6
  80. package/types/engine/content-tables.d.mts +12 -4
  81. package/types/engine/content-tasklists.d.mts +72 -0
  82. package/types/engine/extension-attributes.d.mts +22 -1
  83. package/types/engine/frontmatter.d.mts +1 -1
  84. package/types/engine/heading-attributes.d.mts +174 -0
  85. package/types/engine/helpers.d.mts +7 -4
  86. package/types/engine/homepage.d.mts +7 -7
  87. package/types/engine/index.d.mts +1 -0
  88. package/types/engine/infobox-render.d.mts +6 -1
  89. package/types/engine/journals.d.mts +14 -7
  90. package/types/engine/markdown-expressions.d.mts +23 -0
  91. package/types/engine/note-frontmatter.d.mts +1 -4
  92. package/types/engine/note-vocabulary.d.mts +82 -6
  93. package/types/engine/page-lists.d.mts +126 -0
  94. package/types/engine/pdf-render.d.mts +72 -2
  95. package/types/engine/rank-ladder.d.mts +32 -0
  96. package/types/engine/retired-fields.d.mts +4 -6
  97. package/types/engine/site-build.d.mts +13 -1
  98. package/types/engine/site-config.d.mts +9 -0
  99. package/types/engine/site-index.d.mts +13 -1
  100. package/types/engine/site-preview.d.mts +1 -1
  101. package/types/engine/sql-tables.d.mts +31 -7
  102. package/types/engine/standings.d.mts +21 -3
  103. package/types/engine/system-block.d.mts +11 -12
  104. package/types/engine/web-wikilinks.d.mts +3 -1
  105. package/types/hm3/actors.d.mts +3 -3
  106. package/types/sohl/actors.d.mts +6 -7
  107. package/engine/content-admonitions.mjs +0 -114
  108. package/engine/content-secrets.mjs +0 -112
  109. package/types/engine/content-admonitions.d.mts +0 -24
  110. package/types/engine/content-secrets.d.mts +0 -17
@@ -36,7 +36,7 @@
36
36
 
37
37
  import { encodeAddresses } from "./address-values.mjs";
38
38
  import { sectionHolds } from "./infobox.mjs";
39
- import { escapeTypst, escapeTypstString } from "./pdf-render.mjs";
39
+ import { absolutePageUrl, escapeTypst, escapeTypstString } from "./pdf-render.mjs";
40
40
 
41
41
  /**
42
42
  * How many cells an attribute grid packs into a row.
@@ -226,16 +226,22 @@ export function sectionHasContent(section) {
226
226
  * reference, so the link works on paper as a cross-reference and in a PDF
227
227
  * viewer as a jump. Everything else is set as its own words.
228
228
  *
229
+ * A panel's rows address pages the same way prose does, so a page the book does
230
+ * not print is set as the absolute URL a reader can follow — see
231
+ * {@link module:engine/pdf-render.absolutePageUrl}.
232
+ *
229
233
  * @param {object} value - A `link` value.
230
234
  * @param {Map<string, string>} links - Address slug → the book's anchor.
231
235
  * @param {(anchor: string) => string} labelFor - The anchor's Typst label.
236
+ * @param {string} [site] - The absolute address this book's pages are served at.
232
237
  * @returns {string} Typst markup.
233
238
  */
234
- export function linkToTypst(value, links, labelFor) {
239
+ export function linkToTypst(value, links, labelFor, site) {
235
240
  const text = escapeTypst(value?.text ?? "");
236
241
  const anchor = value?.address ? links?.get(encodeAddresses(value.address)) : undefined;
237
242
  if (anchor) return `#link(<${labelFor(anchor)}>)[${text}]`;
238
- if (value?.url) return `#link("${escapeTypstString(value.url)}")[${text}]`;
243
+ if (value?.url)
244
+ return `#link("${escapeTypstString(absolutePageUrl(value.url, site))}")[${text}]`;
239
245
  return text;
240
246
  }
241
247
 
@@ -375,7 +381,13 @@ export function infoboxTypstPreamble() {
375
381
  "fill: infobox-rule)[#upper(t)]; v(0.20em) }",
376
382
  '#let infobox-head(t) = { v(0.40em); text(size: 7.6pt, weight: "bold", ' +
377
383
  "tracking: 1.3pt, fill: infobox-rule)[#upper(t)]; v(0.16em) }",
384
+ // One definition-list style across the three surfaces: the term right
385
+ // against a fixed column so every term in the panel shares one edge,
386
+ // and space below the row rather than rows run together. A page has one
387
+ // width, so the stacked state the screen surfaces fall back to has
388
+ // nothing to answer here and is not drawn.
378
389
  "#let infobox-row(k, v) = grid(columns: (2.05cm, 1fr), column-gutter: 4pt, " +
390
+ "inset: (bottom: 2pt), align: (right + top, left + top), " +
379
391
  "text(size: 7pt, tracking: 0.5pt, fill: infobox-faint)[#upper(k)], text(size: 8pt)[#v])",
380
392
  "#let infobox-cell(lab, val) = align(center)[" +
381
393
  "#text(size: 7pt, tracking: 0.6pt, fill: infobox-faint)[#lab]#h(2.5pt)" +
@@ -121,7 +121,13 @@ import { subtypeRow } from "./document-subtypes.mjs";
121
121
  import { authoredKey } from "./system-block.mjs";
122
122
  import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
123
123
  import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
124
- import { officeAnchor, officeRoster, readStandings, standingPhrase } from "./standings.mjs";
124
+ import {
125
+ officeAnchor,
126
+ officeRoster,
127
+ rankAnchor,
128
+ readStandings,
129
+ standingPhrase,
130
+ } from "./standings.mjs";
125
131
 
126
132
  /**
127
133
  * How a section arranges what it holds.
@@ -327,7 +333,8 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
327
333
  }),
328
334
  "being.appearance.complexion": Object.freeze({
329
335
  group: "appearance",
330
- phrase: (v) => `${humanizeValue(v)} complexion`,
336
+ whole: true,
337
+ phrase: (v) => `${humanizedList(v)} complexion`,
331
338
  }),
332
339
  "being.appearance.extra_features": Object.freeze({ group: "appearance" }),
333
340
  });
@@ -469,6 +476,24 @@ export function humanizeValue(value) {
469
476
  .trim();
470
477
  }
471
478
 
479
+ /**
480
+ * Several enumerated values as one clause — `weathered, ruddy and scarred`.
481
+ *
482
+ * A field that admits one value or a list of them reads as a single phrase, so
483
+ * the noun it qualifies is said once rather than once per value. Internal: the
484
+ * phrase that wants it is declared in this module.
485
+ *
486
+ * @param {unknown} value - One value, or a list of them.
487
+ * @returns {string} The text.
488
+ */
489
+ function humanizedList(value) {
490
+ const parts = (Array.isArray(value) ? value : [value])
491
+ .filter((entry) => entry !== undefined && entry !== null && entry !== "")
492
+ .map((entry) => humanizeValue(entry));
493
+ if (parts.length < 2) return parts[0] ?? "";
494
+ return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
495
+ }
496
+
472
497
  /**
473
498
  * A value as a row shows it.
474
499
  *
@@ -633,6 +658,43 @@ function rosterRows(raw) {
633
658
  return rows;
634
659
  }
635
660
 
661
+ /**
662
+ * A ladder of named ranks, one row each, ordered by the level a body confers.
663
+ *
664
+ * The **title is the label and its description is the value**, the same
665
+ * convention {@link rosterRows} draws a body's offices with, so a reader
666
+ * meets the two lists the same way. A rung's optional `lore` — already
667
+ * resolved to an Address by the time this runs — reads alongside the
668
+ * description rather than replacing it, since the description is what the
669
+ * rung itself states and the lore is a pointer to more.
670
+ *
671
+ * @param {unknown} raw - The authored `governance.ranks` list.
672
+ * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
673
+ * medium's resolver.
674
+ * @returns {object[]|null} One row per rung, in ascending level, or `null`
675
+ * where the value is not a list of them.
676
+ */
677
+ function rankRows(raw, resolve) {
678
+ if (!Array.isArray(raw)) return null;
679
+ const rungs = raw
680
+ .filter((rung) => isMapping(rung) && hasValue(rung.title))
681
+ .toSorted((a, b) => Number(a.level ?? 0) - Number(b.level ?? 0));
682
+ const rows = [];
683
+ for (const rung of rungs) {
684
+ const anchor = rankAnchor(String(rung.title));
685
+ const description =
686
+ hasValue(rung.description) ? String(rung.description) : String(rung.title);
687
+ const lore = hasValue(rung.lore) ? linkValue(rung.lore, resolve, { type: "lore" }) : null;
688
+ rows.push({
689
+ ...(anchor ? { id: anchor } : {}),
690
+ label: String(rung.title),
691
+ kind: "text",
692
+ value: lore?.text ? `${description} (Lore: ${lore.text})` : description,
693
+ });
694
+ }
695
+ return rows;
696
+ }
697
+
636
698
  /** Expand structured references using the existing linked-row shape. */
637
699
  function structuredRows(field, raw, resolve, label, fm) {
638
700
  if (field.standings) return standingRows(field, raw, resolve, label);
@@ -640,9 +702,20 @@ function structuredRows(field, raw, resolve, label, fm) {
640
702
  const roster = rosterRows(raw);
641
703
  if (roster) return roster;
642
704
  }
643
- if (Array.isArray(raw) && raw.some(isMapping)) {
644
- const links = raw
645
- .filter((entry) => isMapping(entry) && hasValue(entry.to))
705
+ if (field.ranks) {
706
+ const ranks = rankRows(raw, resolve);
707
+ if (ranks) return ranks;
708
+ }
709
+ // A relationship entry — `{to, ...}` — is a mapping that is not itself an
710
+ // Address: an authored plain list of addresses normalises to a list of
711
+ // Address tuples, which `isMapping` also reports true for, and treating
712
+ // one as a relationship keyed off a `to` it never carries is what emptied
713
+ // `lore`, `parents`, `domains` and `economy` silently.
714
+ const relations =
715
+ Array.isArray(raw) ? raw.filter((entry) => isMapping(entry) && !isAddressTuple(entry)) : [];
716
+ if (relations.length) {
717
+ const links = relations
718
+ .filter((entry) => hasValue(entry.to))
646
719
  .map((entry) => {
647
720
  const link = linkValue(entry.to, resolve, { type: field.ref ?? "place" });
648
721
  const details = Object.entries(entry)
@@ -666,7 +739,7 @@ function structuredRows(field, raw, resolve, label, fm) {
666
739
  const address = parseAddress(
667
740
  target,
668
741
  {
669
- package: fm.package ?? "local",
742
+ package: "local",
670
743
  system: "note",
671
744
  types: new Set(field.accepts),
672
745
  },
@@ -789,7 +862,7 @@ function noteBox(
789
862
  const rows = [];
790
863
  /** @type {Set<string>} */
791
864
  const shown = new Set();
792
- const name = fm?.name?.full ?? fm?.title;
865
+ const name = fm?.name?.full;
793
866
  if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
794
867
  if (Array.isArray(fm?.name?.aliases) && fm.name.aliases.length) {
795
868
  rows.push({ label: "Aliases", kind: "list", value: fm.name.aliases });
@@ -857,7 +930,9 @@ function noteBox(
857
930
  label: GROUP_LABELS[overlay.group] ?? humanizeFieldName(overlay.group),
858
931
  entries: [],
859
932
  };
860
- const parts = Array.isArray(raw) ? raw.filter(hasValue) : [raw];
933
+ // A `whole` phrase reads the value entire, so several values compose
934
+ // into one clause rather than repeating the noun once each.
935
+ const parts = overlay.whole || !Array.isArray(raw) ? [raw] : raw.filter(hasValue);
861
936
  for (const part of parts) {
862
937
  group.entries.push(overlay.phrase ? overlay.phrase(part) : humanizeValue(part));
863
938
  }
@@ -71,6 +71,14 @@ import { journalHasContent } from "./note-state.mjs";
71
71
  import { draftNoticeFor } from "./draft-notice.mjs";
72
72
  import { scanCaptions } from "./content-captions.mjs";
73
73
  import { separateFootnotes } from "./content-footnotes.mjs";
74
+ import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
75
+
76
+ /**
77
+ * `CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE` — the default ownership a withheld
78
+ * page states. A GM reads every document whatever it says; everyone else reads
79
+ * this one not at all.
80
+ */
81
+ const WITHHELD_OWNERSHIP = 0;
74
82
 
75
83
  /**
76
84
  * Splits a markdown body into pages by top-level H1 headings. Fenced
@@ -78,7 +86,8 @@ import { separateFootnotes } from "./content-footnotes.mjs";
78
86
  * a split. Content before the first H1 (if non-empty) becomes a leading
79
87
  * page. Each H1 yields a page whose name is the heading
80
88
  * text (with any `{#anchor-id}` suffix stripped out and surfaced as
81
- * `anchorSlug`).
89
+ * `anchorSlug`). The classes the heading declares come along as `classes`, so
90
+ * a surface that honours one has it without reading the suffix again.
82
91
  *
83
92
  * `leadName` names that leading page. A journal note's is "Introduction",
84
93
  * because it introduces the pages that follow. An item doc's is the item — a
@@ -89,7 +98,7 @@ import { separateFootnotes } from "./content-footnotes.mjs";
89
98
  * @param {string} body - Markdown body to split.
90
99
  * @param {string} [leadName] - Name of the page before the first heading.
91
100
  * @returns {Array<{name: string, anchorSlug: string|null, level: number,
92
- * markdown: string}>} Pages in document order.
101
+ * classes: string[], markdown: string}>} Pages in document order.
93
102
  */
94
103
  export function splitPages(body, leadName = "Introduction") {
95
104
  const { markdown, definitions } = separateFootnotes(body);
@@ -101,6 +110,7 @@ export function splitPages(body, leadName = "Introduction") {
101
110
  .filter((caption) => /^\s*#{1,6}\s/.test(lines[caption.blockStart] ?? ""))
102
111
  .map((caption) => caption.blockStart),
103
112
  );
113
+ const openings = pageOpenings(markdown);
104
114
  const pages = [];
105
115
  const beforeFirstH1 = [];
106
116
  let current = null;
@@ -113,6 +123,7 @@ export function splitPages(body, leadName = "Introduction") {
113
123
  name: current.name,
114
124
  anchorSlug: current.anchorSlug,
115
125
  level: current.level,
126
+ classes: current.classes,
116
127
  markdown: current.lines.join("\n").trim(),
117
128
  });
118
129
  current = null;
@@ -128,11 +139,13 @@ export function splitPages(body, leadName = "Introduction") {
128
139
  // An H1 starts a page, as does any heading carrying an `{#slug}`
129
140
  // anchor: a Foundry UUID can only address a page, so a linkable
130
141
  // section has to be one.
131
- const headingMatch =
132
- !inCodeBlock && !inSecret ? line.match(/^\s*(#{1,6})\s+(.+?)\s*#*\s*$/) : null;
133
- const rawHeading = headingMatch?.[2]?.trim();
134
- const anchorMatch = rawHeading?.match(/^(.*?)\s*\{#([^}]+)\}\s*$/);
135
- const startsPage = headingMatch && (headingMatch[1].length === 1 || anchorMatch);
142
+ // {@link module:engine/heading-attributes.parseHeadingLine} is the one
143
+ // reading of that rule — `scanBlocks` and `scanCaptions` refuse such a
144
+ // heading written where it cannot become a page, from the same
145
+ // function, so none of them can disagree about what starts one. The
146
+ // lines it claims here are collected by `pageOpenings`, which also
147
+ // skips a fence and a named block.
148
+ const opening = openings.get(lineIndex);
136
149
  const caption = !inCodeBlock && !inSecret ? captionStarts.get(lineIndex) : null;
137
150
  if (caption) {
138
151
  closeCurrent();
@@ -140,16 +153,18 @@ export function splitPages(body, leadName = "Introduction") {
140
153
  name: caption.label,
141
154
  anchorSlug: caption.id,
142
155
  level: 1,
156
+ classes: [],
143
157
  lines: [line],
144
158
  };
145
159
  continue;
146
160
  }
147
- if (startsPage && !captionedHeadings.has(lineIndex)) {
161
+ if (opening && !captionedHeadings.has(lineIndex)) {
148
162
  closeCurrent();
149
163
  current = {
150
- name: (anchorMatch ? anchorMatch[1] : rawHeading).trim(),
151
- anchorSlug: anchorMatch?.[2]?.trim() || null,
152
- level: headingMatch[1].length,
164
+ name: opening.text,
165
+ anchorSlug: opening.id || null,
166
+ level: opening.level,
167
+ classes: opening.classes,
153
168
  lines: [],
154
169
  };
155
170
  continue;
@@ -169,6 +184,7 @@ export function splitPages(body, leadName = "Introduction") {
169
184
  name: leadName,
170
185
  anchorSlug: null,
171
186
  level: 1,
187
+ classes: [],
172
188
  markdown: intro,
173
189
  });
174
190
  }
@@ -273,10 +289,14 @@ export function journalPageId(entryId, page) {
273
289
  * @param {Array<object>} rawPages - From {@link splitPages}.
274
290
  * @param {string} entryId - The owning JournalEntry's `_id`.
275
291
  * @param {string} noteName - The note, for error messages.
292
+ * A page whose heading carries `.secret` is given to the GM alone, by the one
293
+ * ownership value Foundry reads as "nobody but a GM". Every other page states
294
+ * no ownership and inherits the journal's.
295
+ *
276
296
  * @returns {Array<{_id: string, name: string, type: string,
277
297
  * title: {show: boolean, level: number},
278
- * text: {format: number, content: string}, _key: string}>} The page
279
- * documents, in order.
298
+ * text: {format: number, content: string}, _key: string,
299
+ * ownership?: {default: number}}>} The page documents, in order.
280
300
  * @throws {Error} When the note has no content at all, or repeats an anchor.
281
301
  */
282
302
  export function buildPages(rawPages, entryId, noteName, captions) {
@@ -296,6 +316,7 @@ export function buildPages(rawPages, entryId, noteName, captions) {
296
316
  }
297
317
  return rawPages.map((page) => {
298
318
  const pageId = journalPageId(entryId, page);
319
+ const withheld = (page.classes ?? []).includes(WITHHELD_CLASS);
299
320
  return {
300
321
  _id: pageId,
301
322
  name: page.name,
@@ -308,6 +329,7 @@ export function buildPages(rawPages, entryId, noteName, captions) {
308
329
  renderFoundryMarkdown(page.markdown, captions, footnoteNumbers, pageId)
309
330
  : "",
310
331
  },
332
+ ...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
311
333
  _key: `!journal.pages!${entryId}.${pageId}`,
312
334
  };
313
335
  });
@@ -329,7 +351,6 @@ export function buildPages(rawPages, entryId, noteName, captions) {
329
351
  * @param {string} [params.leadName] - Name for the page before the first
330
352
  * heading; see {@link splitPages}.
331
353
  * @param {string|null} [params.folder] - The folder id, or `null`.
332
- * @param {object} [params.flags] - Document flags.
333
354
  * @param {object} [params.stats] - The `_stats` block to stamp. Passed by the
334
355
  * caller because it is a property of the *pack* being written, not of the
335
356
  * entry: a module may ship the same content for two systems, and each pack's
@@ -352,7 +373,6 @@ export function buildJournalEntry({
352
373
  markdown,
353
374
  leadName,
354
375
  folder = null,
355
- flags,
356
376
  stats = defaultStats(),
357
377
  infoboxes = [],
358
378
  notice = "",
@@ -379,7 +399,7 @@ export function buildJournalEntry({
379
399
  folder,
380
400
  sort: 0,
381
401
  ownership: { default: 0 },
382
- flags: flags || {},
402
+ flags: {},
383
403
  _id: id,
384
404
  _stats: stats,
385
405
  _key: `!journal!${id}`,
@@ -546,7 +566,6 @@ export class Journals extends BasePackCompiler {
546
566
  // "Introduction" — see {@link splitPages}.
547
567
  leadName: ownsDoc ? name : undefined,
548
568
  folder,
549
- flags: fm.flags,
550
569
  stats: this.stats,
551
570
  });
552
571
  }
package/engine/macros.mjs CHANGED
@@ -272,7 +272,7 @@ export function buildMacroEntry(fm, { command, folder = null, stats = defaultSta
272
272
  folder,
273
273
  sort: 0,
274
274
  ownership: { default: 0 },
275
- flags: fm.flags || {},
275
+ flags: {},
276
276
  _id: id,
277
277
  _stats: stats,
278
278
  _key: `!macros!${id}`,
@@ -7,8 +7,182 @@ import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
7
7
  import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
8
8
  import { numberWords, numberDigits } from "./number-words.mjs";
9
9
 
10
- // Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
11
- const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
10
+ /**
11
+ * An inline expression, as an author writes one.
12
+ *
13
+ * Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
14
+ *
15
+ * Exported because a renderer that meets one has met a body this pass never ran
16
+ * over, and has to be able to say so.
17
+ *
18
+ * @type {RegExp}
19
+ */
20
+ export const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
21
+
22
+ /**
23
+ * Every helper an expression may call, with the parameters it takes.
24
+ *
25
+ * **This is the one list.** The engine registers from it and the authoring
26
+ * document is checked against it, so a helper added here is a helper the
27
+ * document must describe, and a helper the document describes but the engine
28
+ * does not register fails the same test.
29
+ *
30
+ * `make` receives the render's context — the prepared SQL results and the date
31
+ * rules — and returns the function Handlebars calls. A helper that needs
32
+ * neither ignores it.
33
+ */
34
+ const HELPERS = Object.freeze([
35
+ Object.freeze({
36
+ name: "eq",
37
+ params: "a b",
38
+ summary: 'True when both are the same value. Compares exactly, so 1 and "1" differ.',
39
+ make: () => (left, right) => left === right,
40
+ }),
41
+ Object.freeze({
42
+ name: "gt",
43
+ params: "a b",
44
+ summary: "True when a is greater than b. Both must be finite numbers.",
45
+ make: () => numericCompare("gt", (a, b) => a > b),
46
+ }),
47
+ Object.freeze({
48
+ name: "gte",
49
+ params: "a b",
50
+ summary: "True when a is greater than or equal to b. Both must be finite numbers.",
51
+ make: () => numericCompare("gte", (a, b) => a >= b),
52
+ }),
53
+ Object.freeze({
54
+ name: "lt",
55
+ params: "a b",
56
+ summary: "True when a is less than b. Both must be finite numbers.",
57
+ make: () => numericCompare("lt", (a, b) => a < b),
58
+ }),
59
+ Object.freeze({
60
+ name: "lte",
61
+ params: "a b",
62
+ summary: "True when a is less than or equal to b. Both must be finite numbers.",
63
+ make: () => numericCompare("lte", (a, b) => a <= b),
64
+ }),
65
+ Object.freeze({
66
+ name: "not",
67
+ params: "value",
68
+ summary: "True when the value is false, zero, empty or absent.",
69
+ make: () => (value) => !value,
70
+ }),
71
+ Object.freeze({
72
+ name: "and",
73
+ params: "value …",
74
+ summary: "True when every value given is true. Takes two or more.",
75
+ make:
76
+ () =>
77
+ (...args) =>
78
+ args.slice(0, -1).every(Boolean),
79
+ }),
80
+ Object.freeze({
81
+ name: "or",
82
+ params: "value …",
83
+ summary: "True when any value given is true. Takes two or more.",
84
+ make:
85
+ () =>
86
+ (...args) =>
87
+ args.slice(0, -1).some(Boolean),
88
+ }),
89
+ Object.freeze({
90
+ name: "words",
91
+ params: "number",
92
+ summary:
93
+ "The number spelled out for running prose — `1200` becomes one thousand two hundred. " +
94
+ "Whole numbers only; a decimal is a finding, and `digits` takes one.",
95
+ make: () => (value) => numberWords(value),
96
+ }),
97
+ Object.freeze({
98
+ name: "digits",
99
+ params: "number",
100
+ summary:
101
+ "The number as a grouped numeral — `1200` becomes 1,200, and a fractional part is kept.",
102
+ make: () => (value) => numberDigits(value),
103
+ }),
104
+ Object.freeze({
105
+ name: "sql",
106
+ params: '"query"',
107
+ summary:
108
+ "The single value a query returns, for use inside a sentence. The query is a quoted " +
109
+ "string, the same SQL a table fence takes.",
110
+ make:
111
+ ({ sqlResults }) =>
112
+ (query) => {
113
+ if (typeof query !== "string" || !query.trim())
114
+ throw new TypeError("sql needs a nonempty query string");
115
+ const result = sqlResults?.get(query);
116
+ if (!result)
117
+ throw new RangeError(`SQL expression has no prepared result: ${query}`);
118
+ if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
119
+ return result.value;
120
+ },
121
+ }),
122
+ Object.freeze({
123
+ name: "dateformat",
124
+ params: 'calendar date ["format"]',
125
+ summary:
126
+ "A date written in a calendar. The calendar is an Address or its shortcode, the date " +
127
+ "is the value a note carries, and the optional third argument names an output pattern.",
128
+ make:
129
+ ({ dates }) =>
130
+ (calendar, authored, formatOrOptions) => {
131
+ if (
132
+ !(typeof calendar === "string" || isAddressTuple(calendar)) ||
133
+ !["string", "number"].includes(typeof authored)
134
+ )
135
+ throw new TypeError("dateformat needs a calendar Address and a date");
136
+ const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
137
+ const error = parsed.findings.find((item) => item.severity === "error");
138
+ if (error || !parsed.date)
139
+ throw new RangeError(error?.message ?? "date does not resolve");
140
+ const printable = formatDateInCalendar(
141
+ parsed.date,
142
+ isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
143
+ dates,
144
+ typeof formatOrOptions === "string" ? formatOrOptions : undefined,
145
+ );
146
+ if (!printable)
147
+ throw new RangeError(`date cannot be printed in calendar ${calendar}`);
148
+ return printable.prose ?? printable.text;
149
+ },
150
+ }),
151
+ ]);
152
+
153
+ /**
154
+ * A comparison of two numbers, which refuses anything else.
155
+ *
156
+ * Four helpers differ only in the comparison, and a value that is not a finite
157
+ * number is a finding rather than a silent `false`.
158
+ *
159
+ * @param {string} name - The helper's name, for the message.
160
+ * @param {(a: number, b: number) => boolean} compare - The comparison.
161
+ * @returns {(left: unknown, right: unknown) => boolean} The helper.
162
+ */
163
+ function numericCompare(name, compare) {
164
+ return (left, right) => {
165
+ const finite = (value) =>
166
+ typeof value === "bigint" || (typeof value === "number" && Number.isFinite(value));
167
+ if (!finite(left) || !finite(right))
168
+ throw new TypeError(`${name} needs two finite numbers`);
169
+ return compare(left, right);
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Each helper's parameters and what it does, keyed by name.
175
+ *
176
+ * The authoring document describes this surface, and a test holds the two
177
+ * together. A consumer reads it to list what an expression may call.
178
+ *
179
+ * @type {Readonly<Record<string, Readonly<{params: string, summary: string}>>>}
180
+ */
181
+ export const EXPRESSION_HELPERS = Object.freeze(
182
+ Object.fromEntries(
183
+ HELPERS.map(({ name, params, summary }) => [name, Object.freeze({ params, summary })]),
184
+ ),
185
+ );
12
186
 
13
187
  /** Find SQL helper calls, including calls nested inside Boolean expressions. */
14
188
  export function sqlQueriesInMarkdown(body, fm = {}) {
@@ -47,57 +221,8 @@ export function renderMarkdownExpressions(
47
221
  ) {
48
222
  const findings = [];
49
223
  const engine = Handlebars.create();
50
- const numeric = (name, left, right) => {
51
- if (
52
- !["number", "bigint"].includes(typeof left) ||
53
- !["number", "bigint"].includes(typeof right) ||
54
- (typeof left === "number" && !Number.isFinite(left)) ||
55
- (typeof right === "number" && !Number.isFinite(right))
56
- )
57
- throw new TypeError(`${name} needs two finite numbers`);
58
- };
59
- for (const [name, compare] of Object.entries({
60
- gt: (a, b) => a > b,
61
- gte: (a, b) => a >= b,
62
- lt: (a, b) => a < b,
63
- lte: (a, b) => a <= b,
64
- }))
65
- engine.registerHelper(name, (left, right) => {
66
- numeric(name, left, right);
67
- return compare(left, right);
68
- });
69
- engine.registerHelper("eq", (left, right) => left === right);
70
- engine.registerHelper("not", (value) => !value);
71
- engine.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
72
- engine.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
73
- engine.registerHelper("words", (value) => numberWords(value));
74
- engine.registerHelper("digits", (value) => numberDigits(value));
75
- engine.registerHelper("sql", (query) => {
76
- if (typeof query !== "string" || !query.trim())
77
- throw new TypeError("sql needs a nonempty query string");
78
- const result = sqlResults?.get(query);
79
- if (!result) throw new RangeError(`SQL expression has no prepared result: ${query}`);
80
- if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
81
- return result.value;
82
- });
83
- engine.registerHelper("dateformat", (calendar, authored, formatOrOptions) => {
84
- if (
85
- !(typeof calendar === "string" || isAddressTuple(calendar)) ||
86
- !["string", "number"].includes(typeof authored)
87
- )
88
- throw new TypeError("dateformat needs a calendar Address and a date");
89
- const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
90
- const error = parsed.findings.find((item) => item.severity === "error");
91
- if (error || !parsed.date) throw new RangeError(error?.message ?? "date does not resolve");
92
- const printable = formatDateInCalendar(
93
- parsed.date,
94
- isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
95
- dates,
96
- typeof formatOrOptions === "string" ? formatOrOptions : undefined,
97
- );
98
- if (!printable) throw new RangeError(`date cannot be printed in calendar ${calendar}`);
99
- return printable.prose ?? printable.text;
100
- });
224
+ for (const helper of HELPERS)
225
+ engine.registerHelper(helper.name, helper.make({ sqlResults, dates }));
101
226
  const markdown = replaceOutsideCode(body, EXPRESSION, (source, _inside, offset) => {
102
227
  try {
103
228
  return engine.compile(source, { strict: true })(fm ?? {});
@@ -117,6 +117,39 @@ function unknownRecord() {
117
117
  };
118
118
  }
119
119
 
120
+ /**
121
+ * Whether a number-valued date may have lost a trailing zero off its day.
122
+ *
123
+ * YAML reads an unquoted `<year>.<day>` as a float, and a float carries no
124
+ * trailing zero: `born: 667.130` and `born: 667.13` both arrive as `667.13`.
125
+ * The authored digits are unrecoverable by then, so the question a reader needs
126
+ * answered is whether a longer day was *possible* — and it was exactly when the
127
+ * value with one more zero on the end still names a day the world's year holds.
128
+ *
129
+ * One zero settles it. A second only makes the day ten times longer again, so a
130
+ * value whose single-zero expansion already falls outside the year has no
131
+ * reading but the one it was handed.
132
+ *
133
+ * **The world's year length is what makes the question answerable**, so a tree
134
+ * whose notes declare none gets no claim either way — the same rule the upper
135
+ * bound on a day follows. Refusing every float there would red a whole corpus
136
+ * over a test nothing could evaluate.
137
+ *
138
+ * @param {number} value - The authored value, as YAML parsed it.
139
+ * @param {number|undefined} daysPerYear - The world's year length.
140
+ * @returns {boolean} True when the authored day is ambiguous.
141
+ */
142
+ function dayCouldHaveLostAZero(value, daysPerYear) {
143
+ if (Number.isInteger(value)) return false;
144
+ const fraction = String(Math.abs(value)).split(".")[1] ?? "";
145
+ // An exponent-form string carries no day at all.
146
+ if (!/^\d+$/.test(fraction)) return false;
147
+ const tenfold = Number(`${fraction}0`);
148
+ if (!Number.isSafeInteger(tenfold)) return false;
149
+ if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1) return false;
150
+ return tenfold <= daysPerYear;
151
+ }
152
+
120
153
  /**
121
154
  * Parse one authored date.
122
155
  *
@@ -194,6 +227,22 @@ export function parseNoteDate(value, options) {
194
227
  return { date: null, findings };
195
228
  };
196
229
 
230
+ // An unquoted `<year>.<day>` reaches here as a float, and YAML has already
231
+ // dropped a trailing zero off the day: `667.13` and `667.130` arrive as one
232
+ // value, so nothing downstream can tell day 13 from day 130. Refused where
233
+ // the lost zero would still name a day inside the world's year, and read as
234
+ // written where no such day exists — `675.281` cannot have been `675.2810`.
235
+ if (typeof value === "number" && dayCouldHaveLostAZero(value, daysPerYear)) {
236
+ const grown = `${text}0`;
237
+ return refuse(
238
+ `${subject} is a number, and a day's trailing zero is lost before a date ` +
239
+ `is read — \`${text}\` and \`${grown}\` reach this check as one value. ` +
240
+ `Quote a canonical date that states a day, \`"${text}"\` or ` +
241
+ `\`"${grown}"\`, so the digits the note states are the digits the ` +
242
+ `build reads`,
243
+ );
244
+ }
245
+
197
246
  const resolveEra = (parsed, reckoning, label) => {
198
247
  if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
199
248
  if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)