@heroiclands/package-build 22.20.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 (118) hide show
  1. package/CHANGELOG-content-build.md +0 -8
  2. package/CHANGELOG.md +204 -0
  3. package/assets/styles/content-base.css +162 -0
  4. package/bin/content-commands.mjs +26 -3
  5. package/bin/package-build.mjs +15 -2
  6. package/ci/content-format-reference.mjs +68 -1
  7. package/config.mjs +26 -0
  8. package/content-config.mjs +73 -4
  9. package/docs/api.md +46 -28
  10. package/docs/authoring/dates-and-calendars.md +4 -2
  11. package/docs/authoring/frontmatter.md +2 -2
  12. package/docs/authoring/links-and-markup.md +237 -28
  13. package/docs/configuration.md +27 -1
  14. package/docs/content-format.md +3 -1
  15. package/docs/guides/reference-pages.md +15 -0
  16. package/docs/reference/format-details.md +265 -42
  17. package/docs/reference/note-types.md +45 -28
  18. package/engine/actor-compiler.mjs +23 -0
  19. package/engine/anchored-sections.mjs +35 -4
  20. package/engine/anchors.mjs +10 -20
  21. package/engine/base-compiler.mjs +86 -21
  22. package/engine/base-styles.mjs +149 -0
  23. package/engine/bundles.mjs +0 -1
  24. package/engine/compendiums.mjs +8 -1
  25. package/engine/content-blocks.mjs +410 -0
  26. package/engine/content-captions.mjs +57 -8
  27. package/engine/content-footnotes.mjs +189 -0
  28. package/engine/content-format-check.mjs +15 -12
  29. package/engine/content-index.mjs +2 -2
  30. package/engine/content-links.mjs +14 -6
  31. package/engine/content-tables.mjs +45 -4
  32. package/engine/content-tasklists.mjs +189 -0
  33. package/engine/docs-checks.mjs +4 -4
  34. package/engine/extension-attributes.mjs +48 -2
  35. package/engine/frontmatter-lint.mjs +62 -66
  36. package/engine/frontmatter.mjs +3 -3
  37. package/engine/heading-attributes.mjs +308 -0
  38. package/engine/helpers.mjs +19 -13
  39. package/engine/homepage.mjs +8 -8
  40. package/engine/index.mjs +3 -0
  41. package/engine/infobox-render.mjs +21 -4
  42. package/engine/infobox.mjs +167 -7
  43. package/engine/journals.mjs +36 -17
  44. package/engine/macros.mjs +1 -1
  45. package/engine/markdown-expressions.mjs +178 -53
  46. package/engine/note-addresses.mjs +17 -8
  47. package/engine/note-dates.mjs +49 -0
  48. package/engine/note-frontmatter.mjs +7 -16
  49. package/engine/note-vocabulary.mjs +216 -42
  50. package/engine/page-lists.mjs +358 -0
  51. package/engine/pdf-build.mjs +76 -42
  52. package/engine/pdf-render.mjs +422 -49
  53. package/engine/rank-ladder.mjs +198 -0
  54. package/engine/reckoning-markers.mjs +1 -1
  55. package/engine/reference-targets.mjs +13 -1
  56. package/engine/retired-fields.mjs +8 -13
  57. package/engine/scenes.mjs +10 -3
  58. package/engine/site-build.mjs +41 -12
  59. package/engine/site-config.mjs +10 -1
  60. package/engine/site-index.mjs +39 -4
  61. package/engine/site-preview.mjs +18 -7
  62. package/engine/social-ties.mjs +52 -14
  63. package/engine/sql-tables.mjs +66 -9
  64. package/engine/standing-terms.mjs +19 -0
  65. package/engine/standings.mjs +538 -0
  66. package/engine/svg-theme.mjs +15 -4
  67. package/engine/system-block.mjs +12 -14
  68. package/engine/web-wikilinks.mjs +48 -1
  69. package/engine/wikilinks.mjs +3 -1
  70. package/hm3/actors.mjs +6 -3
  71. package/manifest.mjs +13 -0
  72. package/package.json +1 -1
  73. package/sohl/actors.mjs +82 -103
  74. package/sohl/item-fields.mjs +4 -5
  75. package/sohl/items.mjs +1 -1
  76. package/sohl/note-schemas.mjs +11 -21
  77. package/types/config.d.mts +9 -0
  78. package/types/engine/actor-compiler.d.mts +11 -0
  79. package/types/engine/anchored-sections.d.mts +24 -0
  80. package/types/engine/base-compiler.d.mts +19 -0
  81. package/types/engine/base-styles.d.mts +93 -0
  82. package/types/engine/content-blocks.d.mts +87 -0
  83. package/types/engine/content-captions.d.mts +14 -1
  84. package/types/engine/content-footnotes.d.mts +73 -0
  85. package/types/engine/content-format-check.d.mts +8 -6
  86. package/types/engine/content-tables.d.mts +12 -4
  87. package/types/engine/content-tasklists.d.mts +72 -0
  88. package/types/engine/extension-attributes.d.mts +22 -1
  89. package/types/engine/frontmatter.d.mts +1 -1
  90. package/types/engine/heading-attributes.d.mts +174 -0
  91. package/types/engine/helpers.d.mts +7 -4
  92. package/types/engine/homepage.d.mts +7 -7
  93. package/types/engine/index.d.mts +1 -0
  94. package/types/engine/infobox-render.d.mts +6 -1
  95. package/types/engine/journals.d.mts +14 -7
  96. package/types/engine/markdown-expressions.d.mts +23 -0
  97. package/types/engine/note-frontmatter.d.mts +1 -4
  98. package/types/engine/note-vocabulary.d.mts +98 -8
  99. package/types/engine/page-lists.d.mts +126 -0
  100. package/types/engine/pdf-render.d.mts +72 -2
  101. package/types/engine/rank-ladder.d.mts +32 -0
  102. package/types/engine/retired-fields.d.mts +4 -6
  103. package/types/engine/site-build.d.mts +13 -1
  104. package/types/engine/site-config.d.mts +9 -0
  105. package/types/engine/site-index.d.mts +13 -1
  106. package/types/engine/site-preview.d.mts +1 -1
  107. package/types/engine/sql-tables.d.mts +31 -7
  108. package/types/engine/standing-terms.d.mts +16 -0
  109. package/types/engine/standings.d.mts +147 -0
  110. package/types/engine/system-block.d.mts +11 -12
  111. package/types/engine/web-wikilinks.d.mts +3 -1
  112. package/types/engine/wikilinks.d.mts +2 -1
  113. package/types/hm3/actors.d.mts +3 -3
  114. package/types/sohl/actors.d.mts +25 -7
  115. package/engine/content-admonitions.mjs +0 -114
  116. package/engine/content-secrets.mjs +0 -112
  117. package/types/engine/content-admonitions.d.mts +0 -24
  118. package/types/engine/content-secrets.d.mts +0 -17
@@ -131,12 +131,12 @@ export function isHomepage(fm) {
131
131
  * `name` and `shortcode` are permitted: the shortcode is what a link is
132
132
  * written with, and `name` titles the page like every other note's.
133
133
  *
134
- * **A named class, not an allow-list, and that boundary is the decision.** A
135
- * homepage's frontmatter is *emitted into the published page*
136
- * ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
137
- * parameter this build has never heard of and has no standing to refuse.
138
- * Rejecting unknown keys would make every new theme parameter wait on a
139
- * package-build release.
134
+ * **A named class, not an allow-list, and that boundary is the decision.**
135
+ * What this class answers is a key the top-level vocabulary *accepts* and a
136
+ * homepage still cannot use, which is a different question from an unknown
137
+ * key: the frontmatter lint refuses those for every note alike, naming the
138
+ * closed vocabulary, so repeating that judgement per type would be a second
139
+ * answer to one question.
140
140
  *
141
141
  * `aliases` is deliberately not in the class: it is a **retired** field, refused
142
142
  * on every note whatever its type, so it is answered there rather than
@@ -340,7 +340,7 @@ function positionOfType(file) {
340
340
  * `packageBuild.manifest.title` — the name Foundry already shows for the
341
341
  * package — so a homepage that adds nothing to it need not restate it. An
342
342
  * authored `name.full` wins, because a front page may greet a reader
343
- * differently from a package browser. A top-level `title` is also readable.
343
+ * differently from a package browser.
344
344
  *
345
345
  * Falls back to `contentPackage` last, so a package that has no manifest of its
346
346
  * own still yields a titled page rather than a blank heading.
@@ -350,7 +350,7 @@ function positionOfType(file) {
350
350
  * @returns {string} The title.
351
351
  */
352
352
  export function homepageTitle(fm, config) {
353
- const authored = fm?.name?.full ?? fm?.title;
353
+ const authored = fm?.name?.full;
354
354
  if (typeof authored === "string" && authored.trim()) return authored;
355
355
  const manifest = /** @type {Record<string, unknown>|undefined} */ (
356
356
  config?.packageBuild?.manifest
package/engine/index.mjs CHANGED
@@ -220,6 +220,9 @@ export * as contentHtml from "./content-html.mjs";
220
220
  /** An image saying how wide it is and where it sits, in two closed vocabularies. */
221
221
  export * as contentImages from "./content-images.mjs";
222
222
 
223
+ /** A list item opened with `[ ]` or `[x]`, which every surface reads as text. */
224
+ export * as contentTaskLists from "./content-tasklists.mjs";
225
+
223
226
  /** `![[…]]` — the wikilink that renders a picture where it stands. */
224
227
  export * as contentEmbeds from "./content-embeds.mjs";
225
228
 
@@ -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.
@@ -139,7 +139,12 @@ function sectionToHtml(section, link) {
139
139
  if (section.layout === "rows") {
140
140
  out.push('<dl class="infobox-rows">');
141
141
  for (const row of section.rows ?? []) {
142
- out.push(`<dt>${escapeHtml(row.label)}</dt><dd>${valueToHtml(row, link)}</dd>`);
142
+ // A row that declares an anchor carries it on the `<dt>`, so the
143
+ // label is the destination a link to that row arrives at. A row
144
+ // that declares none emits none: an id nobody wrote would be
145
+ // invented once per renderer, and the two would not agree.
146
+ const id = row.id ? ` id="${escapeHtml(row.id)}"` : "";
147
+ out.push(`<dt${id}>${escapeHtml(row.label)}</dt><dd>${valueToHtml(row, link)}</dd>`);
143
148
  }
144
149
  out.push("</dl>");
145
150
  } else if (section.layout === "grid") {
@@ -221,16 +226,22 @@ export function sectionHasContent(section) {
221
226
  * reference, so the link works on paper as a cross-reference and in a PDF
222
227
  * viewer as a jump. Everything else is set as its own words.
223
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
+ *
224
233
  * @param {object} value - A `link` value.
225
234
  * @param {Map<string, string>} links - Address slug → the book's anchor.
226
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.
227
237
  * @returns {string} Typst markup.
228
238
  */
229
- export function linkToTypst(value, links, labelFor) {
239
+ export function linkToTypst(value, links, labelFor, site) {
230
240
  const text = escapeTypst(value?.text ?? "");
231
241
  const anchor = value?.address ? links?.get(encodeAddresses(value.address)) : undefined;
232
242
  if (anchor) return `#link(<${labelFor(anchor)}>)[${text}]`;
233
- if (value?.url) return `#link("${escapeTypstString(value.url)}")[${text}]`;
243
+ if (value?.url)
244
+ return `#link("${escapeTypstString(absolutePageUrl(value.url, site))}")[${text}]`;
234
245
  return text;
235
246
  }
236
247
 
@@ -370,7 +381,13 @@ export function infoboxTypstPreamble() {
370
381
  "fill: infobox-rule)[#upper(t)]; v(0.20em) }",
371
382
  '#let infobox-head(t) = { v(0.40em); text(size: 7.6pt, weight: "bold", ' +
372
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.
373
389
  "#let infobox-row(k, v) = grid(columns: (2.05cm, 1fr), column-gutter: 4pt, " +
390
+ "inset: (bottom: 2pt), align: (right + top, left + top), " +
374
391
  "text(size: 7pt, tracking: 0.5pt, fill: infobox-faint)[#upper(k)], text(size: 8pt)[#v])",
375
392
  "#let infobox-cell(lab, val) = align(center)[" +
376
393
  "#text(size: 7pt, tracking: 0.6pt, fill: infobox-faint)[#lab]#h(2.5pt)" +
@@ -121,6 +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 {
125
+ officeAnchor,
126
+ officeRoster,
127
+ rankAnchor,
128
+ readStandings,
129
+ standingPhrase,
130
+ } from "./standings.mjs";
124
131
 
125
132
  /**
126
133
  * How a section arranges what it holds.
@@ -326,7 +333,8 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
326
333
  }),
327
334
  "being.appearance.complexion": Object.freeze({
328
335
  group: "appearance",
329
- phrase: (v) => `${humanizeValue(v)} complexion`,
336
+ whole: true,
337
+ phrase: (v) => `${humanizedList(v)} complexion`,
330
338
  }),
331
339
  "being.appearance.extra_features": Object.freeze({ group: "appearance" }),
332
340
  });
@@ -468,6 +476,24 @@ export function humanizeValue(value) {
468
476
  .trim();
469
477
  }
470
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
+
471
497
  /**
472
498
  * A value as a row shows it.
473
499
  *
@@ -553,11 +579,143 @@ function rowValue(kind, raw, resolve, hint) {
553
579
  return presentValue(raw);
554
580
  }
555
581
 
582
+ /**
583
+ * A being's memberships, each with the standing it holds there.
584
+ *
585
+ * One row, one entry per body, and the standing reads inside the entry rather
586
+ * than beside it — "War Chief, of Vrystwald Tribes" rather than a rank link
587
+ * with nothing saying which body confers it.
588
+ *
589
+ * **The office carries the link where the body's page anchors it.** An office's
590
+ * description is already declared in the body's own `governance.offices`, and
591
+ * the row that prints it is anchored by the same derivation, so the string a
592
+ * being wrote reaches the description without anyone authoring a note about the
593
+ * post. In a medium with no page to reach — a compendium journal links by
594
+ * document UUID — the entry keeps the body's own document, so the reader still
595
+ * lands somewhere.
596
+ *
597
+ * @param {object} field - The declaration.
598
+ * @param {unknown} raw - The authored value, in either form.
599
+ * @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
600
+ * medium's resolver.
601
+ * @param {string} label - The row's label.
602
+ * @returns {object[]} The row, or none where nothing is held.
603
+ */
604
+ function standingRows(field, raw, resolve, label) {
605
+ const { entries } = readStandings(raw);
606
+ const value = [];
607
+ for (const { body, standing } of entries) {
608
+ if (!hasValue(body)) continue;
609
+ const link = linkValue(body, resolve, { type: field.ref });
610
+ const digest = resolve?.(body, { type: field.ref })?.standings;
611
+ const text = standingPhrase(standing, link.text, digest);
612
+ const anchor =
613
+ (
614
+ typeof standing?.office === "string" &&
615
+ digest?.offices?.[standing.office] !== undefined
616
+ ) ?
617
+ officeAnchor(standing.office)
618
+ : "";
619
+ value.push({
620
+ ...link,
621
+ text,
622
+ ...(anchor && link.url ? { url: `${link.url}#${anchor}` } : {}),
623
+ });
624
+ }
625
+ return value.length ? [{ label, kind: "links", value }] : [];
626
+ }
627
+
628
+ /**
629
+ * A map of named posts, one row each, anchored by its name.
630
+ *
631
+ * The **name is the label and its description is the value**, which is the way
632
+ * round a reader needs: a field name belongs in the label column, and a whole
633
+ * sentence there leaves the post itself standing where a value goes.
634
+ *
635
+ * Each row carries the anchor its name derives, so the description is a
636
+ * destination rather than merely text on a page — which is what lets a being's
637
+ * `office` string link to the post it names without anyone authoring a note
638
+ * about the post.
639
+ *
640
+ * @param {unknown} raw - The authored map.
641
+ * @returns {object[]|null} One row per post, in the order the body declared
642
+ * them, or `null` where the value is not a map of them — a note writing
643
+ * something else keeps its row and states what it wrote.
644
+ */
645
+ function rosterRows(raw) {
646
+ if (!isMapping(raw) || isAddressTuple(raw)) return null;
647
+ const rows = [];
648
+ for (const [post, description] of officeRoster(raw)) {
649
+ if (!hasValue(post)) continue;
650
+ const anchor = officeAnchor(post);
651
+ rows.push({
652
+ ...(anchor ? { id: anchor } : {}),
653
+ label: String(post),
654
+ kind: "text",
655
+ value: hasValue(description) ? String(description) : String(post),
656
+ });
657
+ }
658
+ return rows;
659
+ }
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
+
556
698
  /** Expand structured references using the existing linked-row shape. */
557
699
  function structuredRows(field, raw, resolve, label, fm) {
558
- if (Array.isArray(raw) && raw.some(isMapping)) {
559
- const links = raw
560
- .filter((entry) => isMapping(entry) && hasValue(entry.to))
700
+ if (field.standings) return standingRows(field, raw, resolve, label);
701
+ if (field.roster) {
702
+ const roster = rosterRows(raw);
703
+ if (roster) return roster;
704
+ }
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))
561
719
  .map((entry) => {
562
720
  const link = linkValue(entry.to, resolve, { type: field.ref ?? "place" });
563
721
  const details = Object.entries(entry)
@@ -581,7 +739,7 @@ function structuredRows(field, raw, resolve, label, fm) {
581
739
  const address = parseAddress(
582
740
  target,
583
741
  {
584
- package: fm.package ?? "local",
742
+ package: "local",
585
743
  system: "note",
586
744
  types: new Set(field.accepts),
587
745
  },
@@ -704,7 +862,7 @@ function noteBox(
704
862
  const rows = [];
705
863
  /** @type {Set<string>} */
706
864
  const shown = new Set();
707
- const name = fm?.name?.full ?? fm?.title;
865
+ const name = fm?.name?.full;
708
866
  if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
709
867
  if (Array.isArray(fm?.name?.aliases) && fm.name.aliases.length) {
710
868
  rows.push({ label: "Aliases", kind: "list", value: fm.name.aliases });
@@ -772,7 +930,9 @@ function noteBox(
772
930
  label: GROUP_LABELS[overlay.group] ?? humanizeFieldName(overlay.group),
773
931
  entries: [],
774
932
  };
775
- 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);
776
936
  for (const part of parts) {
777
937
  group.entries.push(overlay.phrase ? overlay.phrase(part) : humanizeValue(part));
778
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}`,