@heroiclands/package-build 23.0.0 → 23.1.1
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.
- package/CHANGELOG.md +66 -0
- package/README.md +1 -1
- package/bin/content-commands.mjs +1 -1
- package/ci/content-format-reference.mjs +46 -0
- package/docs/api.md +124 -141
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/dates-and-calendars.md +12 -6
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +145 -29
- package/docs/commands.md +8 -10
- package/docs/configuration.md +13 -11
- package/docs/content-format.md +2 -2
- package/docs/guides/book.md +59 -4
- package/docs/guides/icons.md +4 -5
- package/docs/guides/packs.md +6 -0
- package/docs/guides/reference-pages.md +21 -1
- package/docs/guides/release-and-deploy.md +39 -7
- package/docs/guides/site.md +17 -18
- package/docs/reference/format-details.md +877 -276
- package/docs/reference/note-types.md +278 -91
- package/docs/reference/theme-pages.md +58 -97
- package/engine/address-values.mjs +124 -4
- package/engine/address.mjs +44 -3
- package/engine/anchors.mjs +103 -3
- package/engine/art-slots.mjs +2 -9
- package/engine/base-compiler.mjs +25 -6
- package/engine/calendar-artifacts.mjs +4 -1
- package/engine/calendar-notes.mjs +166 -102
- package/engine/content-format-check.mjs +2 -3
- package/engine/content-format.mjs +44 -1
- package/engine/content-format.yaml +79 -16
- package/engine/content-images.mjs +10 -1
- package/engine/content-index.mjs +37 -9
- package/engine/content-links.mjs +24 -5
- package/engine/content-tables.mjs +24 -3
- package/engine/data-keys.mjs +347 -0
- package/engine/derived-sections.mjs +322 -0
- package/engine/diagnostics.mjs +28 -0
- package/engine/event-fields.mjs +187 -0
- package/engine/event-rows.mjs +126 -0
- package/engine/event-views.mjs +305 -0
- package/engine/exported-scene.mjs +29 -53
- package/engine/frontmatter-lint.mjs +103 -77
- package/engine/frontmatter.mjs +7 -2
- package/engine/generate.mjs +53 -0
- package/engine/generated-sections.mjs +223 -0
- package/engine/heading-attributes.mjs +4 -2
- package/engine/helpers.mjs +12 -1
- package/engine/holdings.mjs +12 -33
- package/engine/ids.mjs +17 -11
- package/engine/index.mjs +4 -4
- package/engine/infobox-render.mjs +21 -6
- package/engine/infobox.mjs +217 -99
- package/engine/journals.mjs +104 -47
- package/engine/literature-notes.mjs +4 -1
- package/engine/literature-works.mjs +21 -13
- package/engine/map-graphviz.mjs +4 -18
- package/engine/markdown-expressions.mjs +116 -11
- package/engine/metadata-index.mjs +26 -0
- package/engine/near-miss.mjs +90 -0
- package/engine/note-addresses.mjs +75 -19
- package/engine/note-dates.mjs +5 -3
- package/engine/note-event-terms.mjs +364 -0
- package/engine/note-events.mjs +823 -0
- package/engine/note-vocabulary.mjs +184 -88
- package/engine/office-holders.mjs +20 -54
- package/engine/packagebuild-index.mjs +16 -1
- package/engine/pdf-build.mjs +97 -60
- package/engine/pdf-index.mjs +263 -0
- package/engine/pdf-render.mjs +47 -5
- package/engine/place-relation-terms.mjs +174 -0
- package/engine/place-relations.mjs +72 -160
- package/engine/rank-ladder.mjs +14 -41
- package/engine/reckoning-markers.mjs +2 -0
- package/engine/region-events.mjs +6 -12
- package/engine/scenes.mjs +73 -468
- package/engine/site-build.mjs +75 -101
- package/engine/site-index.mjs +3 -1
- package/engine/site-maps.mjs +43 -29
- package/engine/site-preview.mjs +40 -31
- package/engine/social-ties.mjs +2 -1
- package/engine/sql-tables.mjs +212 -9
- package/engine/standing-terms.mjs +145 -6
- package/engine/standings.mjs +37 -49
- package/engine/web-wikilinks.mjs +21 -1
- package/engine/wikilink-syntax.mjs +34 -4
- package/engine/wikilinks.mjs +27 -2
- package/engine/yaml-lint.mjs +7 -4
- package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
- package/hugo-theme/layouts/partials/page-body.html +0 -3
- package/hugo-theme/static/css/style.css +25 -55
- package/package.json +1 -2
- package/packagebuild-metadata.jsonl +13 -13
- package/release.mjs +62 -4
- package/sohl/note-schemas.mjs +8 -73
- package/types/engine/address-values.d.mts +50 -3
- package/types/engine/address.d.mts +21 -0
- package/types/engine/anchors.d.mts +49 -2
- package/types/engine/art-slots.d.mts +1 -1
- package/types/engine/base-compiler.d.mts +13 -4
- package/types/engine/calendar-notes.d.mts +7 -0
- package/types/engine/content-format.d.mts +52 -0
- package/types/engine/content-images.d.mts +5 -1
- package/types/engine/content-tables.d.mts +9 -1
- package/types/engine/data-keys.d.mts +145 -0
- package/types/engine/derived-sections.d.mts +73 -0
- package/types/engine/diagnostics.d.mts +15 -0
- package/types/engine/event-fields.d.mts +99 -0
- package/types/engine/event-rows.d.mts +12 -0
- package/types/engine/event-views.d.mts +67 -0
- package/types/engine/exported-scene.d.mts +5 -2
- package/types/engine/frontmatter-lint.d.mts +3 -3
- package/types/engine/generated-sections.d.mts +143 -0
- package/types/engine/holdings.d.mts +6 -20
- package/types/engine/ids.d.mts +15 -12
- package/types/engine/index.d.mts +1 -1
- package/types/engine/infobox.d.mts +13 -3
- package/types/engine/journals.d.mts +24 -11
- package/types/engine/literature-works.d.mts +1 -2
- package/types/engine/map-graphviz.d.mts +2 -10
- package/types/engine/markdown-expressions.d.mts +12 -3
- package/types/engine/near-miss.d.mts +42 -0
- package/types/engine/note-addresses.d.mts +7 -1
- package/types/engine/note-event-terms.d.mts +68 -0
- package/types/engine/note-events.d.mts +71 -0
- package/types/engine/note-vocabulary.d.mts +37 -8
- package/types/engine/office-holders.d.mts +3 -0
- package/types/engine/pdf-index.d.mts +35 -0
- package/types/engine/pdf-render.d.mts +8 -2
- package/types/engine/place-relation-terms.d.mts +83 -0
- package/types/engine/place-relations.d.mts +11 -66
- package/types/engine/rank-ladder.d.mts +2 -0
- package/types/engine/region-events.d.mts +6 -12
- package/types/engine/scenes.d.mts +6 -32
- package/types/engine/site-build.d.mts +8 -22
- package/types/engine/site-maps.d.mts +15 -7
- package/types/engine/sql-tables.d.mts +77 -2
- package/types/engine/standing-terms.d.mts +37 -4
- package/types/engine/standings.d.mts +14 -9
- package/types/engine/wikilink-syntax.d.mts +20 -0
- package/types/engine/wikilinks.d.mts +3 -2
- package/types/engine/yaml-lint.d.mts +5 -4
- package/types/release.d.mts +4 -0
- package/docs/guides/government-migration.md +0 -52
- package/engine/itinerary-scenes.mjs +0 -166
- package/engine/lore-events.mjs +0 -197
- package/engine/map-notes.mjs +0 -1309
- package/engine/map-raster.mjs +0 -51
- package/hugo-theme/layouts/partials/holdings/entry.html +0 -21
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +0 -71
- package/hugo-theme/layouts/partials/holdings.html +0 -61
- package/hugo-theme/layouts/partials/literature-works.html +0 -29
- package/hugo-theme/layouts/partials/place-map.html +0 -9
- package/types/engine/itinerary-scenes.d.mts +0 -29
- package/types/engine/lore-events.d.mts +0 -14
- package/types/engine/map-notes.d.mts +0 -297
- package/types/engine/map-raster.d.mts +0 -8
package/engine/pdf-build.mjs
CHANGED
|
@@ -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 {
|
|
145
|
-
import {
|
|
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, {
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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 ?
|
|
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
|
|
1185
|
-
|
|
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
|
+
}
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2096
|
-
* label appears
|
|
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))
|
|
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;
|