@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
@@ -60,6 +60,7 @@ import {
60
60
  CHARACTER_NAME_KEYS,
61
61
  NOTE_NAME_KEYS,
62
62
  NOTE_TOP_LEVEL_KEY_SET,
63
+ NOTE_TOP_LEVEL_KEYS,
63
64
  } from "./note-frontmatter.mjs";
64
65
  import { AddressEntries } from "./address-values.mjs";
65
66
  import { addressPositions } from "./note-addresses.mjs";
@@ -291,6 +292,21 @@ function distance(a, b) {
291
292
  return prev[cols - 1];
292
293
  }
293
294
 
295
+ /**
296
+ * A closed vocabulary as an English list, for the message that names it.
297
+ *
298
+ * Written from the declaration rather than spelled into the message, so a key
299
+ * the vocabulary gains cannot go unmentioned by the finding that refuses its
300
+ * neighbours.
301
+ *
302
+ * @param {readonly string[]} keys - The declared keys, in declared order.
303
+ * @returns {string} `"a, b, or c"`.
304
+ */
305
+ function listed(keys) {
306
+ if (keys.length < 2) return keys.join("");
307
+ return `${keys.slice(0, -1).join(", ")}, or ${keys[keys.length - 1]}`;
308
+ }
309
+
294
310
  /**
295
311
  * The declared key an unknown one was most likely meant to be.
296
312
  *
@@ -357,6 +373,18 @@ export function matchesKind(value, kind, context = {}) {
357
373
  // this answers is whether the value has one of the two shapes the
358
374
  // field admits. A list has neither.
359
375
  return matchesKind(value, "string") || matchesKind(value, "map");
376
+ case "string-or-list":
377
+ // One value or several of the same kind. Unlike `list-or-map` the
378
+ // two halves say the same thing at different lengths, so either is
379
+ // accepted here and the entries are read by the field's own check.
380
+ return matchesKind(value, "string") || Array.isArray(value);
381
+ case "list-or-map":
382
+ // A list, or a map. The two carry different facts rather than
383
+ // being two spellings of one — a map keyed by Address says what is
384
+ // true *of* each key, which a list has nowhere to put — so the
385
+ // field's own check is what reads the entries, and all this
386
+ // answers is whether the value has one of the two shapes.
387
+ return Array.isArray(value) || matchesKind(value, "map");
360
388
  default:
361
389
  return true;
362
390
  }
@@ -408,9 +436,8 @@ function dataBlock(fm) {
408
436
  * Check a note's `data:` container against the closed vocabulary its type
409
437
  * declares.
410
438
  *
411
- * Unlike the top level, which is passed through to the published page and so
412
- * cannot be refused, `data:` holds the type-specific facts about the subject
413
- * and every key of it is declared. An unrecognised key is therefore a finding
439
+ * `data:` holds the type-specific facts about the subject, and every key of it
440
+ * is declared by the note's type. An unrecognised key is therefore a finding
414
441
  * naming the note, with the key it was most likely meant to be — the same
415
442
  * capped edit distance {@link nearest} applies to a `sohl:` key, drawn from
416
443
  * this type's own vocabulary rather than from every type's.
@@ -485,8 +512,8 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
485
512
  type === "affiliation" && key === "commonSkills" ?
486
513
  "`data.commonSkills` is not an affiliation field; write skill Addresses at `sohl.system.commonSkills`"
487
514
  : `"${key}" is not a \`data:\` property declared by ${type}; ` +
488
- `the container is closed, so unlike a top-level key it is ` +
489
- `not passed through to the page` +
515
+ `the container is closed, so the key reaches no document ` +
516
+ `and no page` +
490
517
  (guess ? `. Did you mean "${guess}"?` : ""),
491
518
  });
492
519
  }
@@ -539,7 +566,13 @@ function checkDataContainer(note, { type, fields, packs, addressContext, index }
539
566
  function checkDataReferences(note, field, value, segments, context, index) {
540
567
  const checks = [];
541
568
  if (field.kind === "address") checks.push({ value, path: [], kind: "address" });
542
- if (field.entryKind) {
569
+ // A `list-or-map` field declares both halves, and each describes one shape:
570
+ // `entryKind` the list's entries, `keyKind` the map's keys. Reading both of
571
+ // one value would check a map's standings as if they were Addresses and a
572
+ // list's indices as if they were its keys.
573
+ const dual = field.kind === "list-or-map";
574
+ const written = Array.isArray(value) ? "list" : "map";
575
+ if (field.entryKind && !(dual && written === "map")) {
543
576
  if (Array.isArray(value)) {
544
577
  value.forEach((entry, i) =>
545
578
  checks.push({ value: entry, path: [i], kind: field.entryKind }),
@@ -551,7 +584,7 @@ function checkDataReferences(note, field, value, segments, context, index) {
551
584
  });
552
585
  } else checks.push({ value, path: [], kind: field.entryKind });
553
586
  }
554
- if (field.keyKind) {
587
+ if (field.keyKind && !(dual && written === "list")) {
555
588
  if (value instanceof AddressEntries) {
556
589
  for (const entry of value.entries)
557
590
  checks.push({
@@ -874,9 +907,9 @@ function checkSubType(note, { type, entry }) {
874
907
  /**
875
908
  * Check a note's `tags` for near misses against the tags that classify.
876
909
  *
877
- * `tags:` is top-level and the top level is open, so an unrecognised tag is
878
- * **not** a finding: a theme, a region or a working state is the author's own
879
- * vocabulary and this build has no standing to refuse it.
910
+ * `tags:` is a closed key holding an open vocabulary of values, so an
911
+ * unrecognised tag is **not** a finding: a theme, a region or a working state
912
+ * is the author's own vocabulary and this build has no standing to refuse it.
880
913
  *
881
914
  * **Distance alone is not enough either**, which the corpus settles rather than
882
915
  * argues: `azravan` on a faith, `barter` on an economy note and `secret` on
@@ -976,13 +1009,10 @@ function inBlockKeys(schema) {
976
1009
  * note-level field either, and a check about the note-level field must not read
977
1010
  * it.
978
1011
  *
979
- * `affiliation`'s `title` is the case that named this. A note's top-level
980
- * `title` is its page heading, which the site emitter publishes as
981
- * `fm.title ?? name`; `sohl.title` is the style of address an office carries —
982
- * "Ajaw", "Warden". Twenty-eight `sohl-kethira-basic` affiliations author
983
- * `sohl.title: ""` — an office with no style of address, which is ordinary —
984
- * and every one of them was reported as publishing a page with no heading. None
985
- * of them does; their pages take `name.full` exactly as intended.
1012
+ * `affiliation`'s `commonSkills` is the case to read it against: the skills
1013
+ * common among a society's members belong to the SoHL affiliation item, so
1014
+ * nothing outside that item supplies them and a note-level check must not go
1015
+ * looking in the block for one.
986
1016
  *
987
1017
  * Keyed on the **in-block** key — `legacyKey` where a field declares one, and
988
1018
  * its first segment where that is dotted — because that is the position a note
@@ -1349,26 +1379,16 @@ export function lintNote(
1349
1379
  // it. It is transitional in the same sense — `""` is a legal thing to mean,
1350
1380
  // and the message says so, but nothing in any tree means it yet.
1351
1381
  //
1352
- // **These two only, never `title`.** The rule reads as a general one about
1353
- // optional strings, and it is not — it belongs to `resolveImg`, and `title`
1354
- // never goes through it.
1355
- //
1356
- // It once had a sharper reason: a note's top-level `title` was
1357
- // simultaneously the shared source for an `affiliation` item's
1358
- // `system.title`, so asking an author for `title: null` would have compiled
1359
- // the literal string `"null"` into the document. The field declares
1360
- // `topLevelMeans` now, so the top-level key is no longer a source for it and
1361
- // `title: null` is harmless. `title: ""` is warned about on its own account
1362
- // below, as the *page's* heading rather than as an art path.
1382
+ // **The art slots only.** The rule reads as a general one about optional
1383
+ // strings, and it is not — it belongs to `resolveImg`, and nothing else
1384
+ // goes through it.
1363
1385
  //
1364
- // **The collision itself did not go away, and this was where that was
1365
- // misread.** `topLevelMeans` settles which position the *emitted field*
1366
- // reads; it says nothing about which position a *check* reads, and
1367
- // `authoredValue` went on resolving through the block regardless — so an
1368
- // office with no style of address answered for its note's heading, in
1369
- // twenty-eight `sohl-kethira-basic` affiliations. Hence
1370
- // `blockCollisions`: a note-level check reads past a block key its type
1371
- // claims for something else.
1386
+ // **A block key a type claims for something else is read past.**
1387
+ // `topLevelMeans` settles which position the *emitted field* reads; it says
1388
+ // nothing about which position a *check* reads, and `authoredValue` would
1389
+ // go on resolving through the block regardless — so an office with no style
1390
+ // of address would answer for a note's art, in twenty-eight
1391
+ // `sohl-kethira-basic` affiliations. Hence `blockCollisions`.
1372
1392
 
1373
1393
  // The template priority is a *shared source* — the specification states it
1374
1394
  // once for every type, as it does `pack` — so its retirement is reported
@@ -1495,35 +1515,6 @@ export function lintNote(
1495
1515
  "meant to have no image",
1496
1516
  });
1497
1517
  }
1498
- // `title: ""` publishes a blank heading. The rule the art fields
1499
- // follow — `null` falls back, `""` is blank on purpose — reads the same way
1500
- // here, and for a *page heading* the deliberate blank is almost never what
1501
- // anyone wants: the emitter is `fm.title ?? name`, so `""` survives, the
1502
- // page publishes with no name, and it sorts to the front of its section
1503
- // landing ahead of every named page. Fifteen notes in `sohl-thalorna` are
1504
- // in exactly that state.
1505
- //
1506
- // A warning rather than an error: the value is legal under the rule, and a
1507
- // note that genuinely wants no heading may keep it — it just has to mean it.
1508
- //
1509
- // **The emitter reads `fm.title`, so this reads the note level.** On an
1510
- // `affiliation` `sohl.title` is the office's style of address, which the
1511
- // heading has nothing to do with — and `blockCollisions` is what keeps the
1512
- // two apart. On every other type nothing claims the block key, so the
1513
- // resolution is the unchanged one.
1514
- if (authoredValue(fm, "title", { blockCollides: blockCollisions.has("title") }) === "") {
1515
- findings.push({
1516
- file: note.file,
1517
- ...at("title"),
1518
- severity: "warning",
1519
- message:
1520
- '`title: ""` publishes a page with no heading, which sorts to ' +
1521
- "the front of its section ahead of every named page. Write " +
1522
- "`title: null` to fall back to `name.full`, or give the page a " +
1523
- 'heading; keep `""` only where the blank is meant',
1524
- });
1525
- }
1526
-
1527
1518
  if (Object.hasOwn(fm, "draft")) {
1528
1519
  findings.push({
1529
1520
  file: note.file,
@@ -1606,11 +1597,16 @@ export function lintNote(
1606
1597
  ) {
1607
1598
  continue;
1608
1599
  }
1600
+ const guess = nearest(key, NOTE_TOP_LEVEL_KEYS);
1609
1601
  findings.push({
1610
1602
  file: note.file,
1611
1603
  ...position,
1612
1604
  severity: "error",
1613
- message: `unknown top-level frontmatter key ${JSON.stringify(key)}; use shortcode, name, type, subType, description, tags, data, hm3, or sohl`,
1605
+ message:
1606
+ `unknown top-level frontmatter key ${JSON.stringify(key)}; ` +
1607
+ `the region is closed, so the key reaches no document and no ` +
1608
+ `page. Use ${listed(NOTE_TOP_LEVEL_KEYS)}` +
1609
+ (guess ? `. Did you mean "${guess}"?` : ""),
1614
1610
  });
1615
1611
  }
1616
1612
 
@@ -309,14 +309,14 @@ export function resolveRelation(fm, ctx = "item") {
309
309
  *
310
310
  * @param {object} fm - The item frontmatter.
311
311
  * @param {string} [ctx] - Optional label for the error (defaults to the item's
312
- * title/name, else "item").
312
+ * display name, else "item").
313
313
  * @returns {string} The declared subType.
314
314
  * @throws {Error} When `subType` is missing or blank.
315
315
  */
316
316
  export function requireSubType(fm, ctx) {
317
317
  const subType = sohlSystemField(fm, "subType", undefined);
318
318
  if (subType == null || subType === "") {
319
- const label = ctx || fm?.title || fm?.name || "item";
319
+ const label = ctx || fm?.name?.full || "item";
320
320
  throw new Error(
321
321
  `${label}: missing required 'subType' — every subType-bearing item must declare its kind (the builder substitutes no default).`,
322
322
  );
@@ -379,7 +379,7 @@ export function parseValueDesc(raw) {
379
379
  * two spellings, and there is only one left to be.
380
380
  */
381
381
  export function folderField(fm) {
382
- const asAddress = fm?.sohl?.packFolder ?? fm?.data?.packFolder ?? fm?.packFolder ?? null;
382
+ const asAddress = fm?.sohl?.packFolder ?? fm?.data?.packFolder ?? null;
383
383
  if (asAddress != null && asAddress !== "") return { value: asAddress, isAddress: true };
384
384
  return { value: null, isAddress: true };
385
385
  }
@@ -0,0 +1,308 @@
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
2
+
3
+ /**
4
+ * The braced attribute block a heading may end with, read once.
5
+ *
6
+ * ```
7
+ * # The Harbor {#harbor .wide}
8
+ * ```
9
+ *
10
+ * The index, the link checker, the journal compiler, the caption pass and the
11
+ * book all ask what a heading declares, and they have to give the same answer:
12
+ * an id a surface publishes as an anchor, classes a surface may honour, and
13
+ * attributes an HTML surface writes onto the element. This is where that
14
+ * reading lives, so there is one grammar — the same one a `:::` block and a
15
+ * `:::caption` are written in.
16
+ *
17
+ * **A leaf, deliberately.** `anchors.mjs` is shared by the compilers, the index
18
+ * and the link checker and cannot import any of them; the caption pass is
19
+ * imported by `anchors.mjs` and so cannot import it back. A module that imports
20
+ * only the attribute grammar can be asked by all of them.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { parseExtensionAttributes, refusedAttributes } from "./extension-attributes.mjs";
26
+
27
+ /**
28
+ * A heading line: its hashes and the text after them.
29
+ *
30
+ * Shared with every pass that has to find a heading before reading what it
31
+ * declares, so the passes cannot disagree about which lines are headings.
32
+ */
33
+ export const HEADING_LINE = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/;
34
+
35
+ /** The braces a heading's text may end with, and what is inside them. */
36
+ const SUFFIX = /^(.*?)\s*\{([^}\n]*)\}\s*$/;
37
+
38
+ /**
39
+ * A heading's id.
40
+ *
41
+ * Looser than a caption's or a block's, which must begin with a letter: a
42
+ * heading's id is published as an HTML `id` and as a Foundry page anchor, both
43
+ * of which admit a digit first, and the corpus carries ids that are Foundry
44
+ * document ids and ids derived from a numbered heading.
45
+ */
46
+ const HEADING_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
47
+
48
+ /**
49
+ * Read a heading's text and the attribute block it may end with.
50
+ *
51
+ * A suffix that is not an attribute block — a set written in braces, a die
52
+ * expression — is left in the text and reported, so no surface silently prints
53
+ * a heading with words missing and no author is told to fix nothing.
54
+ *
55
+ * **One mistake draws one finding.** Braces holding nothing the grammar
56
+ * recognises are reported once, naming the braces: an author who wrote
57
+ * `{a, b}` meant no attribute block at all, and a complaint per fragment inside
58
+ * it says the same thing twice in words that name neither the heading nor the
59
+ * fix. Braces that *are* an attribute block, carrying one bad attribute beside
60
+ * good ones, relay the grammar's own complaint about that attribute.
61
+ *
62
+ * @param {string} heading - A heading's text, hashes already removed.
63
+ * @returns {{text: string, id: string, classes: string[],
64
+ * values: Record<string, string>, problems: string[], braces: boolean}}
65
+ * `text` is what a surface sets, `braces` whether a suffix was there at all.
66
+ */
67
+ export function splitHeadingAttributes(heading) {
68
+ const raw = String(heading ?? "").trim();
69
+ const blank = { text: raw, id: "", classes: [], values: {}, problems: [], braces: false };
70
+ const match = SUFFIX.exec(raw);
71
+ if (!match) return blank;
72
+ const parsed = parseExtensionAttributes(match[2], { idPattern: HEADING_ID });
73
+ const recognised =
74
+ Boolean(parsed.id) || parsed.classes.length > 0 || Object.keys(parsed.values).length > 0;
75
+ let problems;
76
+ if (recognised) {
77
+ // An id, a class or an attribute the markup owns: refused here as it is
78
+ // on a named block, because a heading writes its attributes onto the
79
+ // element on both HTML surfaces.
80
+ problems = [...parsed.problems, ...refusedAttributes(parsed.values)];
81
+ } else if (match[2].trim()) {
82
+ problems = [`{${match[2]}} is not an attribute block`];
83
+ } else {
84
+ problems = ["an attribute block states nothing"];
85
+ }
86
+ if (problems.length) return { ...blank, braces: true, problems };
87
+ return {
88
+ text: match[1].trim(),
89
+ id: parsed.id,
90
+ classes: parsed.classes,
91
+ values: parsed.values,
92
+ problems: [],
93
+ braces: true,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * The class that withholds a section from players.
99
+ *
100
+ * Written on the heading that opens the section, so the section stays linkable:
101
+ * secrecy is a property of the section, not of the name a link resolves to.
102
+ */
103
+ export const WITHHELD_CLASS = "secret";
104
+
105
+ /**
106
+ * Whether a heading opens a journal page.
107
+ *
108
+ * A Foundry UUID addresses a page and nothing smaller, so a section a link can
109
+ * reach has to be one: a top-level heading starts a page, and so does any
110
+ * heading that names an anchor. {@link module:engine/journals.splitPages}
111
+ * decides *where* a page begins — a captioned heading and a heading inside a
112
+ * fence or a named block are its business — and asks this which headings are
113
+ * eligible, so no caller re-decides it.
114
+ *
115
+ * @param {{level: number, id: string}} heading - From
116
+ * {@link scanHeadingAttributes}, or any `{level, id}` pair.
117
+ * @returns {boolean} Whether the heading opens a page.
118
+ */
119
+ export function opensPage({ level, id }) {
120
+ return level === 1 || Boolean(id);
121
+ }
122
+
123
+ /**
124
+ * Every heading in a body, with what its attribute block declares.
125
+ *
126
+ * Fenced code is skipped: `#` opens a comment in most of what gets fenced, and
127
+ * an example heading in a document is not a heading of it.
128
+ *
129
+ * @param {string} source - A note's markdown body.
130
+ * @param {number} [bodyLine=1] - The 1-based file line the body starts on, so a
131
+ * finding lands where an editor can open it.
132
+ * @returns {{headings: Array<{level: number, line: number, text: string,
133
+ * id: string, classes: string[], values: Record<string, string>}>,
134
+ * errors: Array<{line: number, column: number, message: string}>}}
135
+ */
136
+ export function scanHeadingAttributes(source, bodyLine = 1) {
137
+ const lines = String(source ?? "").split("\n");
138
+ const headings = [];
139
+ const errors = [];
140
+ let codeFence = null;
141
+ for (let i = 0; i < lines.length; i++) {
142
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
143
+ if (codeFence) {
144
+ if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length)
145
+ codeFence = null;
146
+ continue;
147
+ }
148
+ if (fence) {
149
+ codeFence = fence[1];
150
+ continue;
151
+ }
152
+ const heading = HEADING_LINE.exec(lines[i]);
153
+ if (!heading) continue;
154
+ const parsed = splitHeadingAttributes(heading[2]);
155
+ const line = bodyLine + i;
156
+ if (parsed.problems.length) {
157
+ const column = lines[i].indexOf("{") + 1;
158
+ for (const message of parsed.problems) errors.push({ line, column, message });
159
+ continue;
160
+ }
161
+ headings.push({
162
+ level: heading[1].length,
163
+ line,
164
+ text: parsed.text,
165
+ id: parsed.id,
166
+ classes: parsed.classes,
167
+ values: parsed.values,
168
+ });
169
+ }
170
+ return { headings, errors };
171
+ }
172
+
173
+ /**
174
+ * A markdown-it plugin writing a heading's attribute block onto its element.
175
+ *
176
+ * The website's renderer reads the block itself, so the surfaces that use
177
+ * markdown-it read it here and publish the same `id`, the same classes and the
178
+ * same attributes — rather than typesetting the braces an author wrote.
179
+ *
180
+ * @param {object} md - A markdown-it instance.
181
+ * @returns {void}
182
+ */
183
+ export function headingAttributesPlugin(md) {
184
+ md.core.ruler.push("heading_attributes", (state) => {
185
+ const tokens = state.tokens;
186
+ for (let i = 0; i < tokens.length; i++) {
187
+ if (tokens[i].type !== "heading_open") continue;
188
+ const inline = tokens[i + 1];
189
+ const children = inline?.children ?? [];
190
+ const last = children[children.length - 1];
191
+ if (last?.type !== "text") continue;
192
+ const parsed = splitHeadingAttributes(String(last.content ?? ""));
193
+ if (!parsed.braces || parsed.problems.length) continue;
194
+ last.content = parsed.text;
195
+ inline.content = splitHeadingAttributes(String(inline.content ?? "")).text;
196
+ if (parsed.id) tokens[i].attrSet("id", parsed.id);
197
+ if (parsed.classes.length) tokens[i].attrJoin("class", parsed.classes.join(" "));
198
+ for (const [key, value] of Object.entries(parsed.values)) tokens[i].attrSet(key, value);
199
+ }
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Parse one line as a heading, and say whether it starts a journal page.
205
+ *
206
+ * The single reading of that rule. {@link module:engine/journals.splitPages}
207
+ * builds its pages from it; `scanBlocks` and `scanCaptions` refuse a heading
208
+ * that starts a page written where it cannot become one. All three ask this, so
209
+ * none of them can disagree about what a heading line means or about what starts
210
+ * a page — and the attribute block comes off here, so a class beside an anchor
211
+ * is not read as part of the anchor by any of them.
212
+ *
213
+ * @param {string} line - One line of a note's body.
214
+ * @returns {{level: number, text: string, id: string, classes: string[],
215
+ * values: Record<string, string>, problems: string[], startsPage: boolean}|null}
216
+ * `null` when the line is not a heading.
217
+ */
218
+ export function parseHeadingLine(line) {
219
+ const heading = HEADING_LINE.exec(line);
220
+ if (!heading) return null;
221
+ const level = heading[1].length;
222
+ const parsed = splitHeadingAttributes(heading[2]);
223
+ return {
224
+ level,
225
+ text: parsed.text,
226
+ id: parsed.id,
227
+ classes: parsed.classes,
228
+ values: parsed.values,
229
+ problems: parsed.problems,
230
+ startsPage: opensPage({ level, id: parsed.id }),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Every heading that opens a journal page, by the line it sits on.
236
+ *
237
+ * The walk that decides it: fenced code holds no headings, a named block holds
238
+ * no page — the block is one passage of the page it is written in — and what is
239
+ * left is read by {@link opensPage}. {@link module:engine/journals.splitPages}
240
+ * builds the pages from this, and the passes that honour a class on a
241
+ * page-opening heading ask the same question of the same answer.
242
+ *
243
+ * A caption also opens a page, which is `splitPages`' business: a caption is
244
+ * found by the caption pass, and a heading it captions is listed here and
245
+ * dropped there.
246
+ *
247
+ * @param {string} source - A note's markdown body.
248
+ * @returns {Map<number, {level: number, line: number, text: string, id: string,
249
+ * classes: string[], values: Record<string, string>}>} Keyed by 0-based line.
250
+ */
251
+ export function pageOpenings(source) {
252
+ const lines = String(source ?? "").split("\n");
253
+ const openings = new Map();
254
+ let inCodeBlock = false;
255
+ let inBlock = false;
256
+ for (const [index, line] of lines.entries()) {
257
+ if (line.trim().startsWith("```")) inCodeBlock = !inCodeBlock;
258
+ if (!inCodeBlock && /^:::secret[ \t]*$/.test(line)) inBlock = true;
259
+ else if (!inCodeBlock && /^:::[ \t]*$/.test(line)) inBlock = false;
260
+ if (inCodeBlock || inBlock) continue;
261
+ const heading = parseHeadingLine(line);
262
+ if (!heading?.startsPage) continue;
263
+ openings.set(index, { ...heading, line: index + 1 });
264
+ }
265
+ return openings;
266
+ }
267
+
268
+ /**
269
+ * The spans a `.secret` heading withholds, and the classes written where no
270
+ * surface can honour them.
271
+ *
272
+ * A span runs from its heading to the line before the next heading that opens a
273
+ * page, which is the stretch Foundry makes one page of. A `.secret` on a heading
274
+ * that opens no page covers no page, so no surface can withhold it: that is a
275
+ * finding at the heading's own line rather than a class quietly ignored.
276
+ *
277
+ * @param {string} source - A note's markdown body.
278
+ * @param {number} [bodyLine=1] - The 1-based file line the body starts on.
279
+ * @returns {{sections: Array<{start: number, end: number, heading: object}>,
280
+ * errors: Array<{line: number, column: number, message: string}>}} `start` and
281
+ * `end` are 0-based line indexes, `end` exclusive.
282
+ */
283
+ export function withheldSections(source, bodyLine = 1) {
284
+ const lines = String(source ?? "").split("\n");
285
+ const openings = pageOpenings(source);
286
+ const starts = [...openings.keys()];
287
+ const sections = [];
288
+ const errors = [];
289
+ for (const [at, index] of starts.entries()) {
290
+ const heading = openings.get(index);
291
+ if (!heading.classes.includes(WITHHELD_CLASS)) continue;
292
+ sections.push({ start: index, end: starts[at + 1] ?? lines.length, heading });
293
+ }
294
+ // A heading that opens no page is not among the openings at all, so the
295
+ // class is looked for again across every heading the body states.
296
+ for (const heading of scanHeadingAttributes(source, bodyLine).headings) {
297
+ if (openings.has(heading.line - 1)) continue;
298
+ if (!heading.classes.includes(WITHHELD_CLASS)) continue;
299
+ errors.push({
300
+ line: heading.line,
301
+ column: (lines[heading.line - bodyLine]?.indexOf("{") ?? 0) + 1,
302
+ message:
303
+ `.${WITHHELD_CLASS} withholds the page a heading opens, and this heading ` +
304
+ "opens none — give it an anchor, or raise it to the top level",
305
+ });
306
+ }
307
+ return { sections, errors };
308
+ }
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { buildReferenceTargets } from "./reference-targets.mjs";
29
+ import { standingsDigest } from "./standings.mjs";
29
30
 
30
31
  import { decodeNoteAddresses } from "./note-addresses.mjs";
31
32
  import { positionOfYamlPath } from "./diagnostics.mjs";
@@ -64,8 +65,8 @@ import { linkFindingMessage } from "./wikilink-syntax.mjs";
64
65
  import { isDraftNote } from "./note-vocabulary.mjs";
65
66
  import { DERIVED_PACKED_TYPES, NEVER_PACKED_TYPES } from "./note-claims.mjs";
66
67
  import { expandContentTables } from "./content-tables.mjs";
67
- import { renderSecretBlocks } from "./content-secrets.mjs";
68
- import { renderAdmonitions } from "./content-admonitions.mjs";
68
+ import { renderBlocks } from "./content-blocks.mjs";
69
+ import { headingAttributesPlugin } from "./heading-attributes.mjs";
69
70
  import { renderCaptionBlocks, scanCaptions } from "./content-captions.mjs";
70
71
  import { positionInBody } from "./diagnostics.mjs";
71
72
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
@@ -94,6 +95,9 @@ export {
94
95
  export const md = markdownit({ html: true })
95
96
  .use(footnotePlugin)
96
97
  .use(deflistPlugin)
98
+ // A heading's attribute block, written onto the heading element rather than
99
+ // typeset into it — the reading the website's renderer does for itself.
100
+ .use(headingAttributesPlugin)
97
101
  .use(
98
102
  // Resolved per render, not at import: this constant is built before any
99
103
  // configuration is read, and a package's own icons live in the
@@ -151,15 +155,10 @@ md.renderer.rules.footnote_open = (tokens, idx, options, env, renderer) => {
151
155
  return `<li id="fn${id}" class="footnote-item" value="${number}">`;
152
156
  };
153
157
 
154
- /** Render a note body with Foundry's secret section markup. */
158
+ /** Render a note body with Foundry's named-block markup. */
155
159
  export function renderFoundryMarkdown(body, captions, footnoteNumbers, docId) {
156
- const { markdown } = renderSecretBlocks(body, "foundry", (inner) => md.render(inner));
157
- const admonitions = renderAdmonitions(markdown, (inner) => md.render(inner));
158
- const captioned = renderCaptionBlocks(
159
- admonitions.markdown,
160
- (block) => md.render(block),
161
- captions,
162
- );
160
+ const blocks = renderBlocks(body, "foundry");
161
+ const captioned = renderCaptionBlocks(blocks.markdown, (block) => md.render(block), captions);
163
162
  return md.render(captioned.markdown, { footnoteNumbers, docId });
164
163
  }
165
164
 
@@ -834,6 +833,10 @@ export function buildContentLinkIndex(
834
833
  // can group by the family its target declares rather than only by
835
834
  // where the target lives.
836
835
  subType: fm.subType ?? null,
836
+ // The rungs and posts this body confers, carried for the same
837
+ // reason: a being holds a rank as a number, and the body is where
838
+ // what that number is called is declared.
839
+ standings: standingsDigest(fm) ?? null,
837
840
  name: fm.name?.full ?? base,
838
841
  // Whether the note is tagged `draft`. Read from the tag
839
842
  // vocabulary that declares it, and used for one thing: a link
@@ -1030,18 +1033,21 @@ export function convertNoteWikilinks(
1030
1033
  /* ------------------------------------------------------------------------ */
1031
1034
 
1032
1035
  /**
1033
- * Expand prepared SQL tables before resolving wikilinks in generated cells.
1036
+ * Expand prepared SQL tables and page lists before resolving wikilinks in the
1037
+ * links they generate.
1034
1038
  * @param {string} body - The note body.
1035
- * @param {object} ctx - Source and prepared query results.
1039
+ * @param {object} ctx - Source and prepared results.
1036
1040
  * @param {string} ctx.name - Name used in diagnostics.
1037
1041
  * @param {number} [ctx.bodyLine] - The first body line in the source file.
1038
1042
  * @param {object[]} [ctx.sqlTables] - Prepared SQL results in document order.
1043
+ * @param {object[]} [ctx.pageLists] - Prepared page lists in document order.
1039
1044
  * @returns {{markdown: string, lineMap: Array<{line: number, generated: boolean}>}}
1040
1045
  */
1041
- export function expandNoteTables(body, { name, bodyLine, sqlTables }) {
1046
+ export function expandNoteTables(body, { name, bodyLine, sqlTables, pageLists }) {
1042
1047
  const { markdown, errors, lineMap } = expandContentTables(body ?? "", {
1043
1048
  source: name,
1044
1049
  sqlTables,
1050
+ pageLists,
1045
1051
  });
1046
1052
  if (errors.length) {
1047
1053
  const err = new Error(errors.map((e) => `content table — ${e.reason}`).join("; "));