@heroiclands/package-build 22.28.0 → 23.1.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 (156) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +1 -1
  3. package/bin/content-commands.mjs +1 -1
  4. package/ci/content-format-reference.mjs +46 -0
  5. package/docs/api.md +125 -142
  6. package/docs/authoring/assets.md +1 -1
  7. package/docs/authoring/dates-and-calendars.md +12 -6
  8. package/docs/authoring/first-note.md +1 -1
  9. package/docs/authoring/frontmatter.md +2 -2
  10. package/docs/authoring/links-and-markup.md +145 -29
  11. package/docs/commands.md +8 -10
  12. package/docs/configuration.md +13 -11
  13. package/docs/content-format.md +3 -3
  14. package/docs/guides/book.md +59 -4
  15. package/docs/guides/icons.md +4 -5
  16. package/docs/guides/packs.md +6 -0
  17. package/docs/guides/reference-pages.md +21 -1
  18. package/docs/guides/release-and-deploy.md +17 -1
  19. package/docs/guides/site.md +17 -18
  20. package/docs/reference/format-details.md +904 -275
  21. package/docs/reference/note-types.md +282 -91
  22. package/docs/reference/theme-pages.md +58 -66
  23. package/engine/address-values.mjs +124 -4
  24. package/engine/address.mjs +44 -3
  25. package/engine/anchors.mjs +103 -3
  26. package/engine/art-slots.mjs +2 -9
  27. package/engine/base-compiler.mjs +25 -6
  28. package/engine/calendar-artifacts.mjs +4 -1
  29. package/engine/calendar-notes.mjs +166 -102
  30. package/engine/content-format-check.mjs +2 -3
  31. package/engine/content-format.mjs +44 -1
  32. package/engine/content-format.yaml +84 -16
  33. package/engine/content-images.mjs +10 -1
  34. package/engine/content-index.mjs +37 -9
  35. package/engine/content-links.mjs +24 -5
  36. package/engine/content-tables.mjs +24 -3
  37. package/engine/data-keys.mjs +347 -0
  38. package/engine/derived-sections.mjs +322 -0
  39. package/engine/diagnostics.mjs +28 -0
  40. package/engine/event-fields.mjs +187 -0
  41. package/engine/event-rows.mjs +126 -0
  42. package/engine/event-views.mjs +305 -0
  43. package/engine/exported-scene.mjs +29 -53
  44. package/engine/frontmatter-lint.mjs +103 -77
  45. package/engine/frontmatter.mjs +7 -2
  46. package/engine/generate.mjs +53 -0
  47. package/engine/generated-sections.mjs +223 -0
  48. package/engine/heading-attributes.mjs +4 -2
  49. package/engine/helpers.mjs +12 -1
  50. package/engine/holdings.mjs +12 -33
  51. package/engine/ids.mjs +17 -11
  52. package/engine/index.mjs +4 -4
  53. package/engine/infobox-render.mjs +21 -6
  54. package/engine/infobox.mjs +217 -99
  55. package/engine/journals.mjs +104 -47
  56. package/engine/literature-notes.mjs +136 -0
  57. package/engine/literature-works.mjs +161 -0
  58. package/engine/map-graphviz.mjs +4 -18
  59. package/engine/markdown-expressions.mjs +116 -11
  60. package/engine/metadata-index.mjs +34 -0
  61. package/engine/near-miss.mjs +90 -0
  62. package/engine/note-addresses.mjs +75 -19
  63. package/engine/note-dates.mjs +5 -3
  64. package/engine/note-event-terms.mjs +364 -0
  65. package/engine/note-events.mjs +823 -0
  66. package/engine/note-vocabulary.mjs +195 -92
  67. package/engine/office-holders.mjs +20 -54
  68. package/engine/packagebuild-index.mjs +16 -1
  69. package/engine/pdf-build.mjs +97 -60
  70. package/engine/pdf-index.mjs +263 -0
  71. package/engine/pdf-render.mjs +47 -5
  72. package/engine/place-relation-terms.mjs +174 -0
  73. package/engine/place-relations.mjs +72 -160
  74. package/engine/rank-ladder.mjs +14 -41
  75. package/engine/reckoning-markers.mjs +2 -0
  76. package/engine/region-events.mjs +6 -12
  77. package/engine/scenes.mjs +73 -468
  78. package/engine/site-build.mjs +74 -85
  79. package/engine/site-index.mjs +3 -1
  80. package/engine/site-maps.mjs +43 -29
  81. package/engine/site-preview.mjs +40 -31
  82. package/engine/social-ties.mjs +2 -1
  83. package/engine/sql-tables.mjs +212 -9
  84. package/engine/standing-terms.mjs +145 -6
  85. package/engine/standings.mjs +37 -49
  86. package/engine/stub-lint.mjs +6 -0
  87. package/engine/web-wikilinks.mjs +21 -1
  88. package/engine/wikilink-syntax.mjs +34 -4
  89. package/engine/wikilinks.mjs +27 -2
  90. package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
  91. package/hugo-theme/layouts/partials/page-body.html +0 -2
  92. package/hugo-theme/static/css/style.css +25 -55
  93. package/package.json +1 -2
  94. package/packagebuild-metadata.jsonl +13 -13
  95. package/sohl/default-item-art.mjs +6 -6
  96. package/sohl/note-schemas.mjs +8 -73
  97. package/types/engine/address-values.d.mts +50 -3
  98. package/types/engine/address.d.mts +21 -0
  99. package/types/engine/anchors.d.mts +49 -2
  100. package/types/engine/art-slots.d.mts +1 -1
  101. package/types/engine/base-compiler.d.mts +13 -4
  102. package/types/engine/calendar-notes.d.mts +7 -0
  103. package/types/engine/content-format.d.mts +52 -0
  104. package/types/engine/content-images.d.mts +5 -1
  105. package/types/engine/content-tables.d.mts +9 -1
  106. package/types/engine/data-keys.d.mts +145 -0
  107. package/types/engine/derived-sections.d.mts +73 -0
  108. package/types/engine/diagnostics.d.mts +15 -0
  109. package/types/engine/event-fields.d.mts +99 -0
  110. package/types/engine/event-rows.d.mts +12 -0
  111. package/types/engine/event-views.d.mts +67 -0
  112. package/types/engine/exported-scene.d.mts +5 -2
  113. package/types/engine/frontmatter-lint.d.mts +3 -3
  114. package/types/engine/generated-sections.d.mts +143 -0
  115. package/types/engine/holdings.d.mts +6 -20
  116. package/types/engine/ids.d.mts +15 -12
  117. package/types/engine/index.d.mts +1 -1
  118. package/types/engine/infobox.d.mts +13 -3
  119. package/types/engine/journals.d.mts +24 -11
  120. package/types/engine/literature-notes.d.mts +29 -0
  121. package/types/engine/literature-works.d.mts +67 -0
  122. package/types/engine/map-graphviz.d.mts +2 -10
  123. package/types/engine/markdown-expressions.d.mts +12 -3
  124. package/types/engine/near-miss.d.mts +42 -0
  125. package/types/engine/note-addresses.d.mts +7 -1
  126. package/types/engine/note-event-terms.d.mts +68 -0
  127. package/types/engine/note-events.d.mts +71 -0
  128. package/types/engine/note-vocabulary.d.mts +37 -8
  129. package/types/engine/office-holders.d.mts +3 -0
  130. package/types/engine/pdf-index.d.mts +35 -0
  131. package/types/engine/pdf-render.d.mts +8 -2
  132. package/types/engine/place-relation-terms.d.mts +83 -0
  133. package/types/engine/place-relations.d.mts +11 -66
  134. package/types/engine/rank-ladder.d.mts +2 -0
  135. package/types/engine/region-events.d.mts +6 -12
  136. package/types/engine/scenes.d.mts +6 -32
  137. package/types/engine/site-build.d.mts +8 -22
  138. package/types/engine/site-maps.d.mts +15 -7
  139. package/types/engine/sql-tables.d.mts +77 -2
  140. package/types/engine/standing-terms.d.mts +37 -4
  141. package/types/engine/standings.d.mts +14 -9
  142. package/types/engine/wikilink-syntax.d.mts +20 -0
  143. package/types/engine/wikilinks.d.mts +3 -2
  144. package/docs/guides/government-migration.md +0 -52
  145. package/engine/itinerary-scenes.mjs +0 -166
  146. package/engine/lore-events.mjs +0 -197
  147. package/engine/map-notes.mjs +0 -1309
  148. package/engine/map-raster.mjs +0 -51
  149. package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
  150. package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
  151. package/hugo-theme/layouts/partials/holdings.html +0 -61
  152. package/hugo-theme/layouts/partials/place-map.html +0 -9
  153. package/types/engine/itinerary-scenes.d.mts +0 -29
  154. package/types/engine/lore-events.d.mts +0 -14
  155. package/types/engine/map-notes.d.mts +0 -297
  156. package/types/engine/map-raster.d.mts +0 -8
@@ -107,6 +107,7 @@ import { artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs"
107
107
  import { embedRole } from "./content-embeds.mjs";
108
108
  import { expandContentTables } from "./content-tables.mjs";
109
109
  import { renderMarkdownExpressions } from "./markdown-expressions.mjs";
110
+ import { eventNoteIndex } from "./event-fields.mjs";
110
111
  import { numberFigures, scanFigures } from "./content-figures.mjs";
111
112
  import { collectAnchors } from "./anchors.mjs";
112
113
  import { protectCode } from "./code-fences.mjs";
@@ -141,8 +142,8 @@ import { isDraftNote, isGmNote } from "./note-vocabulary.mjs";
141
142
  import { infoboxTypstPreamble, infoboxesToTypst, linkToTypst } from "./infobox-render.mjs";
142
143
  import { noteInfoboxes } from "./infobox-registry.mjs";
143
144
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
144
- import { buildMaps, relatedPlaces } from "./map-build.mjs";
145
- import { mapWorld } from "./map-places.mjs";
145
+ import { drawMapsFrom } from "./derived-sections.mjs";
146
+ import { generatedDrawing, markGenerated, splitGenerated } from "./generated-sections.mjs";
146
147
  import { resolveAssetReplacement } from "./asset-replacement.mjs";
147
148
 
148
149
  /**
@@ -340,6 +341,9 @@ function stagedReplacementPath(forms, config, { foreignIndex = new Map(), resolv
340
341
  */
341
342
  const STAGED_PLATES = "plates";
342
343
 
344
+ /** Where the book draws the map from a place, beside its Typst source. */
345
+ const BOOK_MAP_DIR = "maps";
346
+
343
347
  /**
344
348
  * Copy every banner the document tree names into the output directory.
345
349
  *
@@ -657,6 +661,14 @@ export async function buildPdf({
657
661
  const stageImages = (body, file, columns = 2) => {
658
662
  for (const image of imagesIn(body)) {
659
663
  const src = image.src;
664
+ // A generated section's drawing is the one this build drew beside
665
+ // its source — see `drawn` below — and is vector, so nothing
666
+ // resamples it.
667
+ const drawing = generatedDrawing(src);
668
+ if (drawing) {
669
+ if (drawn.maps.has(drawing)) images.set(src, `${BOOK_MAP_DIR}/${drawing}`);
670
+ continue;
671
+ }
660
672
  const directive = parseImageDirective(image.directive);
661
673
  const wide =
662
674
  directive.size === "full-width" || directive.classes.includes("full-width");
@@ -786,6 +798,25 @@ export async function buildPdf({
786
798
  * lineMap?: object[], numberedFigures: object[]}>}
787
799
  */
788
800
  const prepared = new Map();
801
+ // The map from each selected place given a From here section, drawn before
802
+ // any body is rendered so the section's image names a file the book holds.
803
+ // A place's map is printed there, inside its entry, and nowhere else.
804
+ const drawn = drawMapsFrom(records, {
805
+ outDir: path.join(outDir, BOOK_MAP_DIR),
806
+ foreignIndex: gates.foreign.index,
807
+ config: resolved,
808
+ only: plan.entries
809
+ .filter((entry) => entry.kind === "note" && entry.record?.type === "place")
810
+ .map((entry) => String(entry.record.shortcode ?? "")),
811
+ });
812
+ findings.push(...drawn.findings);
813
+ // Every note's events by Address, for an inline reference to an event's
814
+ // field — the same lookup the website and the Foundry compile build.
815
+ const events = eventNoteIndex(records, {
816
+ contentPackage: gates.index.contentPackage,
817
+ foreignIndex: gates.foreign?.index,
818
+ dates: gates.index.dateContext,
819
+ });
789
820
  // Every note's figures, by the address a `ref` crossing into it would
790
821
  // write — see `engine/site-index.mjs`'s `figureIndexKeys`.
791
822
  const figuresByAddress = new Map();
@@ -795,7 +826,7 @@ export async function buildPdf({
795
826
  const page = byFile.get(file);
796
827
  if (!page) continue; // reported as `missing` by the render loop below
797
828
  const src = page.relPath ?? page.base;
798
- const { markdown, errors, lineMap } = expandContentTables(page.body, {
829
+ const { markdown, errors, lineMap, generatedFrom } = expandContentTables(page.body, {
799
830
  docs: universe.get(page.pkg) ?? [],
800
831
  linkable: (d) => Boolean(d.fm.shortcode),
801
832
  source: src,
@@ -807,7 +838,13 @@ export async function buildPdf({
807
838
  scanFigures(markdown, { resolveRole }).figures,
808
839
  figureCounts,
809
840
  );
810
- prepared.set(entry.anchor, { markdown, errors, lineMap, numberedFigures });
841
+ prepared.set(entry.anchor, {
842
+ markdown,
843
+ errors,
844
+ lineMap,
845
+ numberedFigures,
846
+ generatedFrom,
847
+ });
811
848
  const shortcode = page.fm.shortcode;
812
849
  if (typeof shortcode === "string" && shortcode) {
813
850
  const byId = new Map(
@@ -848,7 +885,8 @@ export async function buildPdf({
848
885
  const renderPage = (page, headingOffset, anchorPrefix, columns) => {
849
886
  const src = page.relPath ?? page.base;
850
887
  const wikiErrors = [];
851
- const { markdown, errors, lineMap, numberedFigures } = prepared.get(anchorPrefix);
888
+ const { markdown, errors, lineMap, numberedFigures, generatedFrom } =
889
+ prepared.get(anchorPrefix);
852
890
  // A content-table finding states its `reason`, its 0-based line within
853
891
  // the body and its column, exactly as the site build reports them.
854
892
  // Reading it as `message` yields `[object Object]` and discards the
@@ -899,12 +937,17 @@ export async function buildPdf({
899
937
  // Code fences are protected for the same reason every other pass
900
938
  // protects them: a wikilink shown as an example is prose about a
901
939
  // wikilink, and resolving it would make the example impossible to write.
902
- const expressions = renderMarkdownExpressions(markdown, {
940
+ // Where the generated sections begin is marked before any pass can move
941
+ // a line, and the body is split there once its links resolve: the
942
+ // sections are set by the rules for generated links — see
943
+ // `renderLink` in `engine/pdf-render.mjs`.
944
+ const expressions = renderMarkdownExpressions(markGenerated(markdown, generatedFrom), {
903
945
  fm: page.fm,
904
946
  dates: gates.index.dateContext,
905
947
  sqlResults: sqlTables?.inline?.get(page.file),
906
948
  file: page.file,
907
949
  bodyLine: page.bodyLine,
950
+ events,
908
951
  figures: {
909
952
  get: (id) => figuresById.get(id),
910
953
  // A cross-note `ref` resolves the written address exactly as
@@ -964,23 +1007,36 @@ export async function buildPdf({
964
1007
  });
965
1008
  }
966
1009
  stageImages(resolvedBody, page.file, columns);
967
- const prose = markdownToTypst(resolvedBody, {
968
- md,
969
- links: plan.links,
970
- glyphs,
971
- images,
972
- assets: imageAssets,
973
- headingOffset,
974
- anchorPrefix,
975
- captions: numberedFigures,
976
- url: site,
977
- findings,
978
- file: page.file,
979
- bodyLine: page.bodyLine,
980
- lineMap,
981
- prepared: true,
982
- resolveRole: (pathname) => pathRoles.get(pathname),
983
- });
1010
+ const { authored, generated } = splitGenerated(resolvedBody);
1011
+ const typst = (text, options) =>
1012
+ markdownToTypst(text, {
1013
+ md,
1014
+ links: plan.links,
1015
+ glyphs,
1016
+ images,
1017
+ assets: imageAssets,
1018
+ headingOffset,
1019
+ anchorPrefix,
1020
+ url: site,
1021
+ findings,
1022
+ file: page.file,
1023
+ bodyLine: page.bodyLine,
1024
+ prepared: true,
1025
+ resolveRole: (pathname) => pathRoles.get(pathname),
1026
+ ...options,
1027
+ });
1028
+ const prose = [
1029
+ typst(authored, { captions: numberedFigures, lineMap, indexMentions: true }),
1030
+ ...(generated ?
1031
+ [
1032
+ typst(generated, {
1033
+ captions: [],
1034
+ lineMap: lineMap.slice(generatedFrom),
1035
+ generated: true,
1036
+ }),
1037
+ ]
1038
+ : []),
1039
+ ].join("\n");
984
1040
  // Infoboxes follow the authored body within this note's entry.
985
1041
  const boxes = noteInfoboxes(page.fm, {
986
1042
  resolve: (ref, hint) => resolveInfoboxRef(gates.index, ref, hint),
@@ -1044,6 +1100,7 @@ export async function buildPdf({
1044
1100
  anchorPrefix: entry.anchor,
1045
1101
  captions: prepared.get(entry.anchor).numberedFigures,
1046
1102
  url: site,
1103
+ indexMentions: true,
1047
1104
  findings,
1048
1105
  file,
1049
1106
  }),
@@ -1073,6 +1130,7 @@ export async function buildPdf({
1073
1130
  footnotePrefix: `footnote-front-${index + 1}`,
1074
1131
  captions: frontFigures.get(file),
1075
1132
  url: site,
1133
+ indexMentions: true,
1076
1134
  findings,
1077
1135
  file,
1078
1136
  });
@@ -1140,49 +1198,28 @@ export async function buildPdf({
1140
1198
  uses: [use],
1141
1199
  });
1142
1200
  };
1143
- const world = mapWorld({
1144
- records,
1145
- foreignIndex: gates.foreign.index,
1146
- contentBase,
1147
- config: resolved,
1148
- });
1149
- const related = new Set(relatedPlaces(world.places));
1150
- const mapDir = path.join(outDir, "maps");
1151
- fs.rmSync(mapDir, { recursive: true, force: true });
1152
- const mappedEntries = plan.entries.filter(
1153
- (entry) =>
1154
- entry.kind === "note" &&
1155
- entry.record?.type === "place" &&
1156
- related.has(String(entry.record.shortcode ?? "").toLowerCase()),
1157
- );
1158
- if (mappedEntries.length) {
1159
- const centres = [...new Set(mappedEntries.map((entry) => entry.record.shortcode))];
1160
- const drawn = buildMaps({ world, outDir: mapDir, from: centres });
1161
- findings.push(...drawn.findings);
1162
- for (const entry of mappedEntries) {
1163
- addMapPage(entry.anchor, {
1164
- path: `maps/from-${entry.record.shortcode}.svg`,
1165
- title: `From here: ${entry.record.name?.full ?? entry.record.shortcode}`,
1166
- });
1167
- }
1168
- }
1169
1201
  for (const entry of plan.entries) {
1170
1202
  if (entry.kind !== "note" || entry.record?.type !== "map") continue;
1171
1203
  const title = entry.record.name?.full ?? entry.record.shortcode;
1172
- if (["regionalmap", "totm"].includes(entry.record.subType)) {
1173
- if (entry.record.data?.bgImage) {
1174
- stageMapBackground(entry, entry.record.data.bgImage, title);
1175
- }
1176
- continue;
1177
- }
1178
- if (!["battlemap", "localmap"].includes(entry.record.subType)) continue;
1179
- for (const [index, level] of (entry.record.data?.scene?.levels ?? []).entries()) {
1204
+ // Every subtype's picture is its exported Scene's level backgrounds,
1205
+ // each with `data.fixup` applied. Nothing else in the Scene is read.
1206
+ const levels = entry.record.data?.scene?.levels;
1207
+ for (const [index, level] of (Array.isArray(levels) ? levels : []).entries()) {
1208
+ if (!level || typeof level !== "object") continue;
1180
1209
  const paths = [
1181
1210
  `.levels[${index}].background.src`,
1182
- ...(level._id ? [`.levels[${level._id}].background.src`] : []),
1211
+ ...(level._id ?
1212
+ [
1213
+ `.levels[${level._id}].background.src`,
1214
+ `.levels[_id=${level._id}].background.src`,
1215
+ `.levels[_id="${level._id}"].background.src`,
1216
+ `.levels[_id='${level._id}'].background.src`,
1217
+ ]
1218
+ : []),
1183
1219
  ];
1184
- const fixup = (entry.record.data?.fixup ?? []).find((item) =>
1185
- paths.includes(item.path),
1220
+ const fixups = entry.record.data?.fixup;
1221
+ const fixup = (Array.isArray(fixups) ? fixups : []).find((item) =>
1222
+ paths.includes(item?.path),
1186
1223
  );
1187
1224
  const value = fixup?.value ?? level.background?.src;
1188
1225
  if (value)
@@ -0,0 +1,263 @@
1
+ /*
2
+ * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
+ * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
+ *
5
+ * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
+ * You may copy, modify, and distribute it under the terms of that license.
7
+ *
8
+ * For full terms, see the LICENSE.md file in the project root or visit:
9
+ * https://www.gnu.org/licenses/gpl-3.0.html
10
+ *
11
+ * SPDX-License-Identifier: GPL-3.0-or-later
12
+ */
13
+
14
+ /**
15
+ * The book's index: which terms it lists, in what order, and the Typst that
16
+ * finds their pages.
17
+ *
18
+ * **Terms are decided here, pages are decided by layout.** The set of terms and
19
+ * their order are known from the plan alone, so they are computed in this
20
+ * module and written into the source as data. Which page a term lands on is
21
+ * known only once the compiler has laid the book out, so every entry heading
22
+ * and every authored mention carries an invisible `metadata` marker and the
23
+ * chapter queries them from inside a `context`. The page numbers are therefore
24
+ * right however the book reflows.
25
+ *
26
+ * - A note's own entry lists its name, with the entry's page in bold.
27
+ * - An authored mention of a note in the book adds the page it appears on.
28
+ * - An alias (`name.aliases`) and an event's in-world name
29
+ * (`data.events[].names`) list a cross-reference to the note's name.
30
+ *
31
+ * Terms sort on the ASCII fold the content index derives for names
32
+ * ({@link module:engine/content-index.asciiName}), ignoring case, so an
33
+ * accented name files under its base letter.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import { asciiName } from "./content-index.mjs";
39
+
40
+ /**
41
+ * The marker an entry heading or an authored mention carries.
42
+ *
43
+ * @param {string} slug - The address slug of the note the marker is about.
44
+ * @param {boolean} [main] - Whether this is the note's own entry.
45
+ * @returns {string} Typst markup occupying no space.
46
+ */
47
+ export function indexMarker(slug, main = false) {
48
+ return `#book-ix("${escapeString(slug)}"${main ? ", main: true" : ""})`;
49
+ }
50
+
51
+ /** @param {string} text @returns {string} The text inside a Typst string literal. */
52
+ function escapeString(text) {
53
+ return String(text).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
54
+ }
55
+
56
+ /**
57
+ * The slug a note is indexed under.
58
+ *
59
+ * @param {object} record - A plan entry's record.
60
+ * @returns {string} The slug, or "".
61
+ */
62
+ function slugOf(record) {
63
+ return String(record?.address?.slug ?? record?.shortcode ?? "");
64
+ }
65
+
66
+ /**
67
+ * The folded sort key of a term.
68
+ *
69
+ * @param {string} name - The term as printed.
70
+ * @returns {string} Lower-case printable ASCII.
71
+ */
72
+ function sortKey(name) {
73
+ return (asciiName(name) ?? name).toLowerCase();
74
+ }
75
+
76
+ /**
77
+ * The letter heading a term files under: `#` for anything that does not begin
78
+ * with a letter.
79
+ *
80
+ * @param {string} key - The term's sort key.
81
+ * @returns {string} A capital letter or `#`.
82
+ */
83
+ function letterOf(key) {
84
+ const first = key.charAt(0).toUpperCase();
85
+ return first >= "A" && first <= "Z" ? first : "#";
86
+ }
87
+
88
+ /**
89
+ * Every term the book's index lists, in index order.
90
+ *
91
+ * @param {object[]} entries - The plan's entries.
92
+ * @returns {Array<{letter: string, name: string, slug: string, see?: string}>}
93
+ * Terms with `see` set for a cross-reference to the main name it points at.
94
+ */
95
+ export function indexTerms(entries) {
96
+ const terms = [];
97
+ const info = new Map();
98
+ const nameByCanonical = new Map();
99
+ for (const entry of entries ?? []) {
100
+ if (entry?.kind !== "note") continue;
101
+ const canonical = entry.record?.address?.canonical;
102
+ const name = String(entry.record?.name?.full ?? "").trim();
103
+ if (canonical && name) nameByCanonical.set(String(canonical), name);
104
+ }
105
+ for (const entry of entries ?? []) {
106
+ if (entry?.kind !== "note") continue;
107
+ const slug = slugOf(entry.record);
108
+ const name = String(entry.record?.name?.full ?? "").trim();
109
+ if (!slug || !name || info.has(slug)) continue;
110
+ const parents = Array.isArray(entry.record?.data?.parents) ? entry.record.data.parents : [];
111
+ const parent = parents
112
+ .map((one) =>
113
+ nameByCanonical.get(String(typeof one === "string" ? one : one?.canonical)),
114
+ )
115
+ .find(Boolean);
116
+ info.set(slug, {
117
+ kind: String(entry.record?.subType || entry.record?.type || ""),
118
+ parent: parent ?? "",
119
+ });
120
+ terms.push({ name, slug, main: true });
121
+ const taken = new Set([sortKey(name)]);
122
+ const names = [
123
+ ...(Array.isArray(entry.record?.name?.aliases) ? entry.record.name.aliases : []),
124
+ ...(Array.isArray(entry.record?.data?.events) ? entry.record.data.events : []).flatMap(
125
+ (event) => (Array.isArray(event?.names) ? event.names.map((n) => n?.name) : []),
126
+ ),
127
+ ];
128
+ for (const other of names) {
129
+ if (typeof other !== "string" || !other.trim()) continue;
130
+ const key = sortKey(other.trim());
131
+ if (taken.has(key)) continue;
132
+ taken.add(key);
133
+ terms.push({ name: other.trim(), slug, main: false });
134
+ }
135
+ }
136
+ // Terms printing one name for different notes are told apart: first by the
137
+ // note's subtype (or its type), then, where that still collides, by the
138
+ // place the note is within.
139
+ const clashing = (shown) => {
140
+ const owners = new Map();
141
+ terms.forEach((term, i) => {
142
+ const key = sortKey(shown[i]);
143
+ if (!owners.has(key)) owners.set(key, new Set());
144
+ owners.get(key).add(term.slug);
145
+ });
146
+ return terms.map((_, i) => owners.get(sortKey(shown[i])).size > 1);
147
+ };
148
+ const qualified = (term, withParent) => {
149
+ const { kind, parent } = info.get(term.slug);
150
+ const qualifier = withParent && parent ? `${kind}, ${parent}` : kind;
151
+ return qualifier ? `${term.name} (${qualifier})` : term.name;
152
+ };
153
+ let shown = terms.map((term) => term.name);
154
+ for (const withParent of [false, true]) {
155
+ const clash = clashing(shown);
156
+ if (!clash.some(Boolean)) break;
157
+ shown = terms.map((term, i) => (clash[i] ? qualified(term, withParent) : shown[i]));
158
+ }
159
+ const mainShown = new Map();
160
+ terms.forEach((term, i) => {
161
+ if (term.main) mainShown.set(term.slug, shown[i]);
162
+ });
163
+ const keyed = terms.map((term, i) => ({
164
+ name: shown[i],
165
+ slug: term.slug,
166
+ ...(term.main ? {} : { see: mainShown.get(term.slug) }),
167
+ key: sortKey(shown[i]),
168
+ }));
169
+ keyed.sort((a, b) => {
170
+ const la = letterOf(a.key);
171
+ const lb = letterOf(b.key);
172
+ if (la !== lb)
173
+ return (
174
+ la === "#" ? -1
175
+ : lb === "#" ? 1
176
+ : la < lb ? -1
177
+ : 1
178
+ );
179
+ if (a.key !== b.key) return a.key < b.key ? -1 : 1;
180
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1;
181
+ return (
182
+ a.slug < b.slug ? -1
183
+ : a.slug > b.slug ? 1
184
+ : 0
185
+ );
186
+ });
187
+ return keyed.map(({ key, ...term }) => ({ letter: letterOf(key), ...term }));
188
+ }
189
+
190
+ /**
191
+ * The Typst function definitions the markers and the chapter rely on.
192
+ *
193
+ * @returns {string} Typst source.
194
+ */
195
+ export function indexPreamble() {
196
+ return [
197
+ // Zero-size and invisible; the label is what the chapter queries.
198
+ "#let book-ix(key, main: false) = [#metadata((key: key, main: main)) <book-index>]",
199
+ "#let book-index(terms) = context {",
200
+ " set par(justify: false, first-line-indent: 0em, hanging-indent: 1em, spacing: 0.45em)",
201
+ " set text(size: 8.8pt)",
202
+ " let by = (:)",
203
+ " for m in query(<book-index>) {",
204
+ " let loc = m.location()",
205
+ " let n = counter(page).at(loc).first()",
206
+ " let pages = by.at(m.value.key, default: ())",
207
+ " pages.push((n: n, main: m.value.main, loc: loc))",
208
+ " by.insert(m.value.key, pages)",
209
+ " }",
210
+ " let merged(key) = {",
211
+ " let out = (:)",
212
+ " for p in by.at(key, default: ()) {",
213
+ " let at = str(p.n)",
214
+ " let seen = out.at(at, default: none)",
215
+ " if seen == none or (p.main and not seen.main) { out.insert(at, p) }",
216
+ " }",
217
+ " out.values().sorted(key: p => p.n)",
218
+ " }",
219
+ " let letter = none",
220
+ " for term in terms {",
221
+ " if term.letter != letter {",
222
+ " letter = term.letter",
223
+ " heading(level: 5, outlined: false, bookmarked: false)[#letter]",
224
+ " }",
225
+ " if term.see != none {",
226
+ " let main = merged(term.slug).filter(p => p.main)",
227
+ " let shown = if main.len() > 0 { link(main.first().loc)[#term.see] } else { term.see }",
228
+ " par[#term.name, #emph[see] #shown]",
229
+ " } else {",
230
+ " let numbers = merged(term.slug).map(p => link(p.loc)[#text(",
231
+ ' weight: if p.main { "bold" } else { "regular" })[#numbering("1", p.n)]])',
232
+ " par[#term.name #h(0.35em) #numbers.join[, ]]",
233
+ " }",
234
+ " }",
235
+ "}",
236
+ ].join("\n");
237
+ }
238
+
239
+ /**
240
+ * The index chapter, ready to follow the last entry.
241
+ *
242
+ * @param {object[]} entries - The plan's entries.
243
+ * @param {string} title - The book's title, which kicks the chapter.
244
+ * @returns {string} Typst source, or "" when the book has nothing to index.
245
+ */
246
+ export function indexChapter(entries, title) {
247
+ const terms = indexTerms(entries);
248
+ if (!terms.length) return "";
249
+ const rows = terms.map(
250
+ (term) =>
251
+ `(letter: "${escapeString(term.letter)}", name: "${escapeString(term.name)}", ` +
252
+ `slug: "${escapeString(term.slug)}", see: ${
253
+ term.see === undefined ? "none" : `"${escapeString(term.see)}"`
254
+ })`,
255
+ );
256
+ return [
257
+ "#set page(columns: 2, footer: book-footer[Index])",
258
+ `#book-section("${escapeString(title)}", "Index", none)[` +
259
+ "#heading(level: 1, outlined: true, bookmarked: true)[Index] <book-index-chapter>]",
260
+ `#book-index((${rows.join(",\n ")},))`,
261
+ "",
262
+ ].join("\n");
263
+ }
@@ -86,6 +86,7 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
86
86
  "full-width": '"full-width"',
87
87
  });
88
88
  import { markupAnchorFindings } from "./anchors.mjs";
89
+ import { indexChapter, indexMarker, indexPreamble } from "./pdf-index.mjs";
89
90
  import { slugify } from "./content-slug.mjs";
90
91
  import { alertMarkdownPlugin, scanAlerts, ALERT_TYPES } from "./content-alerts.mjs";
91
92
  import { spanMarkdownPlugin, scanSpans } from "./content-spans.mjs";
@@ -474,6 +475,10 @@ function reportUnrenderable(source, definitions, opts, original) {
474
475
  * at. Every link the book sets as a URL is resolved against it, because a
475
476
  * reader holding a PDF has no page to resolve a path against — see
476
477
  * {@link renderLink}.
478
+ * @param {boolean} [opts.generated] - Whether the Markdown is a note's
479
+ * generated sections rather than its author's text — see
480
+ * {@link module:engine/generated-sections}. Their links follow the rules
481
+ * {@link renderLink} states for generated links.
477
482
  * @returns {string} Typst markup.
478
483
  */
479
484
  export function markdownToTypst(markdown, opts = {}) {
@@ -513,6 +518,8 @@ export function markdownToTypst(markdown, opts = {}) {
513
518
  headingOffset,
514
519
  anchorPrefix,
515
520
  url: opts.url,
521
+ indexMentions: opts.indexMentions === true,
522
+ generated: Boolean(opts.generated),
516
523
  seen: new Map(),
517
524
  footnoteState,
518
525
  footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
@@ -1330,9 +1337,30 @@ export function absolutePageUrl(url, site) {
1330
1337
  }
1331
1338
  }
1332
1339
 
1340
+ /**
1341
+ * The page a label's element starts on, printed in that page's own numbering
1342
+ * once the book is laid out — the number the contents prints for it.
1343
+ *
1344
+ * @param {string} label - A label the book declares.
1345
+ * @returns {string} Typst markup.
1346
+ */
1347
+ function pageOfLabel(label) {
1348
+ return (
1349
+ `#context { let here = locate(<${label}>); numbering(if here.page-numbering() == none ` +
1350
+ `{ "1" } else { here.page-numbering() }, ..counter(page).at(here)) }`
1351
+ );
1352
+ }
1353
+
1333
1354
  /**
1334
1355
  * A link, internal when the book prints its destination and external otherwise.
1335
1356
  *
1357
+ * **A link in a generated section** — `ctx.generated` — names a note the build
1358
+ * derived rather than one an author chose, and a book is often a selection of
1359
+ * notes. So one whose target the book prints is an internal link followed by
1360
+ * the page the target starts on, `Ford (p. 12)`, which keeps a printed copy
1361
+ * usable; one whose target the book leaves out is its words alone, with no
1362
+ * link and no finding.
1363
+ *
1336
1364
  * The address slug is read from the tail of the URL, which is where every
1337
1365
  * address this toolchain publishes puts it — `…/<type>-<shortcode>/`. A
1338
1366
  * cross-package URL resolved through the link manifest is on another package's
@@ -1360,9 +1388,14 @@ function renderLink(href, inner, ctx) {
1360
1388
  // `[[note#appearance]]` reaches the section, not just the entry — the
1361
1389
  // same namespaced label the heading declared.
1362
1390
  const target = fragment ? sectionLabel(anchor, fragment) : labelFor(anchor);
1363
- return `#link(<${target}>)[${inner}]`;
1391
+ const link = `#link(<${target}>)[${inner}]`;
1392
+ // Generated text prints its page and adds nothing to the index; only
1393
+ // authored prose that sets `indexMentions` marks a mention.
1394
+ if (ctx.generated) return fragment ? link : `${link}~(p.~${pageOfLabel(target)})`;
1395
+ const marker = ctx.indexMentions && slug ? indexMarker(slug) : "";
1396
+ return `${link}${marker}`;
1364
1397
  }
1365
- if (!url) return inner;
1398
+ if (!url || ctx.generated) return inner;
1366
1399
  return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
1367
1400
  }
1368
1401
 
@@ -1850,6 +1883,7 @@ export function renderBook({
1850
1883
  const out = [];
1851
1884
 
1852
1885
  out.push(bookTypstPreamble());
1886
+ out.push(indexPreamble());
1853
1887
  out.push("");
1854
1888
  out.push(`#set document(title: "${escapeTypstString(title)}")`);
1855
1889
  // Cream stock and dark ink rather than a dark screen theme: 2,000 pages of
@@ -1980,6 +2014,9 @@ export function renderBook({
1980
2014
  `#heading(level: ${Math.min(6, depth + 1)}, outlined: false, bookmarked: true)` +
1981
2015
  `[${escapeTypst(name)}] <${label}>]`,
1982
2016
  );
2017
+ out.push(
2018
+ indexMarker(String(entry.record?.address?.slug ?? entry.record?.shortcode ?? ""), true),
2019
+ );
1983
2020
  out.push("");
1984
2021
  const body = bodies.get(entry.anchor);
1985
2022
  if (body) {
@@ -1994,6 +2031,9 @@ export function renderBook({
1994
2031
  }
1995
2032
  }
1996
2033
 
2034
+ const index = indexChapter(plan?.entries, title);
2035
+ if (index) out.push(index);
2036
+
1997
2037
  return `${out.join("\n")}\n`;
1998
2038
  }
1999
2039
 
@@ -2092,8 +2132,9 @@ function footerName(entry) {
2092
2132
  * that would have contained it, which is where a reader wants to end up anyway;
2093
2133
  * a link with no entry to fall back to becomes plain text. Both are reported.
2094
2134
  *
2095
- * A declaration is a label not preceded by `#link(` — the only two places a
2096
- * label appears are the heading that declares one and the link that uses one.
2135
+ * A declaration is a label preceded by neither `#link(` nor `locate(` — a
2136
+ * label appears in the heading that declares it, the link that uses it, and
2137
+ * the page lookup a generated link prints after itself.
2097
2138
  *
2098
2139
  * @param {string} source - The assembled Typst document.
2099
2140
  * @param {object[]} [findings] - Collected here rather than thrown.
@@ -2102,7 +2143,8 @@ function footerName(entry) {
2102
2143
  export function resolveDanglingLabels(source, findings = []) {
2103
2144
  const text = String(source ?? "");
2104
2145
  const declared = new Set();
2105
- for (const match of text.matchAll(/(?<!#link\()<([A-Za-z0-9_-]+)>/g)) declared.add(match[1]);
2146
+ for (const match of text.matchAll(/(?<!#link\(|locate\()<([A-Za-z0-9_-]+)>/g))
2147
+ declared.add(match[1]);
2106
2148
 
2107
2149
  return text.replace(/#link\(<([A-Za-z0-9_-]+)>\)/g, (whole, label) => {
2108
2150
  if (declared.has(label)) return whole;