@heroiclands/package-build 22.22.0 → 22.24.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.
- package/CHANGELOG.md +46 -0
- package/README.md +1 -0
- package/bin/content-commands.mjs +51 -14
- package/ci/content-format-reference.mjs +1 -1
- package/content-config.mjs +80 -2
- package/docs/api.md +85 -72
- package/docs/authoring/assets.md +29 -0
- package/docs/authoring/dates-and-calendars.md +37 -1
- package/docs/authoring/links-and-markup.md +112 -31
- package/docs/commands.md +30 -29
- package/docs/configuration.md +92 -19
- package/docs/content-format.md +1 -1
- package/docs/guides/book.md +5 -4
- package/docs/guides/module-project.md +2 -2
- package/docs/guides/site.md +41 -0
- package/docs/project-setup.md +4 -4
- package/docs/reference/format-details.md +323 -60
- package/docs/reference/note-types.md +9 -9
- package/docs/reference/theme-pages.md +496 -0
- package/engine/anchors.mjs +16 -10
- package/engine/art-fields.mjs +118 -0
- package/engine/art-slots.mjs +9 -2
- package/engine/asset-index.mjs +160 -10
- package/engine/asset-replacement.mjs +93 -0
- package/engine/asset-types.mjs +240 -0
- package/engine/base-compiler.mjs +54 -2
- package/engine/bundles.mjs +13 -1
- package/engine/content-blocks.mjs +4 -4
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +10 -2
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +36 -10
- package/engine/field-reference.mjs +31 -10
- package/engine/field-spec.mjs +10 -8
- package/engine/foreign-catalog.mjs +145 -19
- package/engine/frontmatter-lint.mjs +73 -1
- package/engine/heading-attributes.mjs +31 -18
- package/engine/helpers.mjs +102 -33
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox.mjs +47 -8
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +264 -39
- package/engine/lore-events.mjs +197 -0
- package/engine/markdown-expressions.mjs +257 -7
- package/engine/note-dates.mjs +305 -12
- package/engine/note-vocabulary.mjs +11 -5
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +357 -34
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +160 -36
- package/engine/scenes.mjs +16 -4
- package/engine/site-build.mjs +127 -13
- package/engine/site-config.mjs +19 -24
- package/engine/site-index.mjs +72 -1
- package/engine/site-preview.mjs +2 -0
- package/engine/system-block.mjs +44 -16
- package/hm3/actors.mjs +2 -2
- package/hugo-theme/data/banners.yaml +69 -0
- package/hugo-theme/layouts/404.html +85 -0
- package/hugo-theme/layouts/_default/baseof.html +64 -0
- package/hugo-theme/layouts/_default/list.html +139 -0
- package/hugo-theme/layouts/_default/single.html +4 -0
- package/hugo-theme/layouts/blog/list.html +54 -0
- package/hugo-theme/layouts/blog-post/single.html +36 -0
- package/hugo-theme/layouts/index.html +75 -0
- package/hugo-theme/layouts/partials/banner-url.html +117 -0
- package/hugo-theme/layouts/partials/cdn-url.html +36 -0
- package/hugo-theme/layouts/partials/draft-notice.html +41 -0
- package/hugo-theme/layouts/partials/draft-state.html +26 -0
- package/hugo-theme/layouts/partials/footer.html +29 -0
- package/hugo-theme/layouts/partials/header.html +35 -0
- package/hugo-theme/layouts/partials/hero-banner.html +86 -0
- package/hugo-theme/layouts/partials/holdings/entry.html +21 -0
- package/hugo-theme/layouts/partials/holdings/kind-groups.html +71 -0
- package/hugo-theme/layouts/partials/holdings.html +61 -0
- package/hugo-theme/layouts/partials/infobox/entry.html +15 -0
- package/hugo-theme/layouts/partials/infobox/items.html +26 -0
- package/hugo-theme/layouts/partials/infobox/section.html +56 -0
- package/hugo-theme/layouts/partials/infobox/value.html +43 -0
- package/hugo-theme/layouts/partials/infobox.html +57 -0
- package/hugo-theme/layouts/partials/page-body.html +154 -0
- package/hugo-theme/layouts/partials/place-map.html +9 -0
- package/hugo-theme/layouts/partials/related.html +121 -0
- package/hugo-theme/layouts/partials/search.html +84 -0
- package/hugo-theme/layouts/partials/site-url.html +32 -0
- package/hugo-theme/layouts/partials/tags.html +30 -0
- package/hugo-theme/layouts/partials/toc.html +28 -0
- package/hugo-theme/layouts/partials/warn-missing-banner.html +3 -0
- package/hugo-theme/layouts/shortcodes/cartouche.html +64 -0
- package/hugo-theme/layouts/shortcodes/exturl.html +12 -0
- package/hugo-theme/layouts/shortcodes/glyph.html +43 -0
- package/hugo-theme/layouts/shortcodes/pagelist.html +63 -0
- package/hugo-theme/static/css/sohl-icons.css +57 -0
- package/hugo-theme/static/css/style.css +2953 -0
- package/hugo-theme/static/fonts/game-icons-kb.woff2 +0 -0
- package/hugo-theme/static/img/banners/takheperu-hieroglyphs.svg +1 -0
- package/hugo-theme/static/img/cartouches/takheperu.svg +1 -0
- package/hugo-theme/theme.toml +11 -0
- package/manifest.mjs +30 -24
- package/package.json +2 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/item-fields.mjs +4 -3
- package/types/content-config.d.mts +48 -1
- package/types/engine/anchors.d.mts +9 -4
- package/types/engine/art-fields.d.mts +64 -0
- package/types/engine/art-slots.d.mts +2 -1
- package/types/engine/asset-index.d.mts +43 -0
- package/types/engine/asset-replacement.d.mts +22 -0
- package/types/engine/asset-types.d.mts +18 -0
- package/types/engine/base-compiler.d.mts +0 -17
- package/types/engine/content-embeds.d.mts +16 -0
- package/types/engine/content-figures.d.mts +101 -0
- package/types/engine/content-footnotes.d.mts +7 -0
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- package/types/engine/field-reference.d.mts +21 -0
- package/types/engine/field-spec.d.mts +20 -16
- package/types/engine/foreign-catalog.d.mts +39 -0
- package/types/engine/heading-attributes.d.mts +13 -8
- package/types/engine/helpers.d.mts +28 -10
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +31 -9
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +23 -2
- package/types/engine/note-dates.d.mts +64 -1
- package/types/engine/pathnames.d.mts +26 -0
- package/types/engine/pdf-build.d.mts +28 -6
- package/types/engine/pdf-images.d.mts +52 -0
- package/types/engine/pdf-render.d.mts +12 -0
- package/types/engine/site-build.d.mts +14 -3
- package/types/engine/site-config.d.mts +9 -9
- package/types/engine/site-index.d.mts +54 -0
- package/types/engine/system-block.d.mts +26 -2
- package/types/hm3/actors.d.mts +18 -0
- package/types/manifest.d.mts +19 -23
- package/engine/content-captions.mjs +0 -230
- package/types/engine/content-captions.d.mts +0 -58
|
@@ -112,11 +112,13 @@ export function compilesSystemDocument(fm, map, router) {
|
|
|
112
112
|
* @param {object} [options.router] - The pack router deciding which system
|
|
113
113
|
* compiles a document for this note. Defaults to the consuming repository's.
|
|
114
114
|
* @param {object} [options.dates] - Calendar conversion context.
|
|
115
|
+
* @param {string} [options.contentPackage] - This build's content package,
|
|
116
|
+
* the default a note box's short-form Address resolves against.
|
|
115
117
|
* @returns {object[]} The boxes, in the order every medium renders them.
|
|
116
118
|
* @throws {Error} When the built set disagrees with what the note's type maps
|
|
117
119
|
* to — see {@link module:engine/infobox.assertInfoboxSet}.
|
|
118
120
|
*/
|
|
119
|
-
export function noteInfoboxes(fm, { resolve, router = packRouter(), dates } = {}) {
|
|
121
|
+
export function noteInfoboxes(fm, { resolve, router = packRouter(), dates, contentPackage } = {}) {
|
|
120
122
|
const boxes = buildInfoboxes(fm, {
|
|
121
123
|
maps: KNOWN_DOCUMENT_SUBTYPE_MAPS,
|
|
122
124
|
providers: KNOWN_INFOBOXES,
|
|
@@ -124,6 +126,7 @@ export function noteInfoboxes(fm, { resolve, router = packRouter(), dates } = {}
|
|
|
124
126
|
resolveField: resolveFieldValue,
|
|
125
127
|
resolve,
|
|
126
128
|
dates,
|
|
129
|
+
contentPackage,
|
|
127
130
|
});
|
|
128
131
|
return /** @type {object[]} */ (
|
|
129
132
|
assertInfoboxSet(boxes, fm, { maps: KNOWN_DOCUMENT_SUBTYPE_MAPS })
|
package/engine/infobox.mjs
CHANGED
|
@@ -119,7 +119,12 @@ import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
|
|
|
119
119
|
import { readAliasedField } from "./retired-fields.mjs";
|
|
120
120
|
import { subtypeRow } from "./document-subtypes.mjs";
|
|
121
121
|
import { authoredKey } from "./system-block.mjs";
|
|
122
|
-
import {
|
|
122
|
+
import {
|
|
123
|
+
formatDateInCalendar,
|
|
124
|
+
formatNoteDate,
|
|
125
|
+
parseNoteDate,
|
|
126
|
+
resolvedDateFields,
|
|
127
|
+
} from "./note-dates.mjs";
|
|
123
128
|
import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
|
|
124
129
|
import {
|
|
125
130
|
officeAnchor,
|
|
@@ -290,7 +295,7 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
|
|
|
290
295
|
bgImage: Object.freeze({ withheld: "an image, which the box never carries" }),
|
|
291
296
|
banner: Object.freeze({ withheld: "an image, which the box never carries" }),
|
|
292
297
|
overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
|
|
293
|
-
"lore.
|
|
298
|
+
"lore.events": Object.freeze({ withheld: "chronology machinery, not a summary row" }),
|
|
294
299
|
|
|
295
300
|
assocSkill: Object.freeze({ label: "Skill" }),
|
|
296
301
|
assocAffiliation: Object.freeze({ label: "Affiliation" }),
|
|
@@ -695,8 +700,15 @@ function rankRows(raw, resolve) {
|
|
|
695
700
|
return rows;
|
|
696
701
|
}
|
|
697
702
|
|
|
698
|
-
/**
|
|
699
|
-
|
|
703
|
+
/**
|
|
704
|
+
* Expand structured references using the existing linked-row shape.
|
|
705
|
+
*
|
|
706
|
+
* @param {string} [contentPackage] - This build's content package, the
|
|
707
|
+
* default a short-form Address resolves against — the same value the site
|
|
708
|
+
* build and the pack compiler read `contentPackage()` for. A row naming a
|
|
709
|
+
* short Address with none handed to it skips rather than guesses.
|
|
710
|
+
*/
|
|
711
|
+
function structuredRows(field, raw, resolve, label, contentPackage) {
|
|
700
712
|
if (field.standings) return standingRows(field, raw, resolve, label);
|
|
701
713
|
if (field.roster) {
|
|
702
714
|
const roster = rosterRows(raw);
|
|
@@ -739,7 +751,7 @@ function structuredRows(field, raw, resolve, label, fm) {
|
|
|
739
751
|
const address = parseAddress(
|
|
740
752
|
target,
|
|
741
753
|
{
|
|
742
|
-
package:
|
|
754
|
+
package: contentPackage,
|
|
743
755
|
system: "note",
|
|
744
756
|
types: new Set(field.accepts),
|
|
745
757
|
},
|
|
@@ -832,6 +844,8 @@ export function linkValue(ref, resolve, hint) {
|
|
|
832
844
|
* @param {object} [options] - Options.
|
|
833
845
|
* @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
|
|
834
846
|
* reference to `{name, url?, uuid?, address?}`.
|
|
847
|
+
* @param {string} [options.contentPackage] - This build's content package,
|
|
848
|
+
* the default a relation row's short-form Address resolves against.
|
|
835
849
|
* @param {object} [options.vocabulary] - The note vocabulary to read.
|
|
836
850
|
* @param {object} [options.presentation] - The overlay to read.
|
|
837
851
|
* @returns {object} The box.
|
|
@@ -857,7 +871,13 @@ export function noteInfobox(fm, options = {}) {
|
|
|
857
871
|
*/
|
|
858
872
|
function noteBox(
|
|
859
873
|
fm,
|
|
860
|
-
{
|
|
874
|
+
{
|
|
875
|
+
resolve,
|
|
876
|
+
dates,
|
|
877
|
+
contentPackage,
|
|
878
|
+
vocabulary = NOTE_VOCABULARY,
|
|
879
|
+
presentation = NOTE_FIELD_PRESENTATION,
|
|
880
|
+
} = {},
|
|
861
881
|
) {
|
|
862
882
|
const rows = [];
|
|
863
883
|
/** @type {Set<string>} */
|
|
@@ -916,7 +936,7 @@ function noteBox(
|
|
|
916
936
|
raw,
|
|
917
937
|
resolve,
|
|
918
938
|
overlay.label ?? humanizeFieldName(field.name),
|
|
919
|
-
|
|
939
|
+
contentPackage,
|
|
920
940
|
);
|
|
921
941
|
if (structured) {
|
|
922
942
|
rows.push(...structured);
|
|
@@ -954,6 +974,22 @@ function noteBox(
|
|
|
954
974
|
shown.add(field.name);
|
|
955
975
|
}
|
|
956
976
|
|
|
977
|
+
// `events` itself stays withheld — the family's shape is chronology
|
|
978
|
+
// machinery, not a summary row — but a reader meets each occurrence's
|
|
979
|
+
// own next date, the way a being's computed `age` reaches the box.
|
|
980
|
+
if (fm?.type === "lore" && Array.isArray(data.events) && data.events.length && dates) {
|
|
981
|
+
const resolved = resolvedDateFields(fm, dates);
|
|
982
|
+
for (const eventEntry of resolved.events ?? []) {
|
|
983
|
+
if (!eventEntry.next) continue;
|
|
984
|
+
rows.push({
|
|
985
|
+
label: "Next occurrence",
|
|
986
|
+
kind: "text",
|
|
987
|
+
value: eventEntry.next.prose ?? eventEntry.next.text,
|
|
988
|
+
});
|
|
989
|
+
shown.add("events");
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
957
993
|
return {
|
|
958
994
|
box: {
|
|
959
995
|
id: NOTE_BOX_ID,
|
|
@@ -1136,6 +1172,8 @@ export function systemRowsSection(
|
|
|
1136
1172
|
* Resolves one declared field against the note.
|
|
1137
1173
|
* @param {(ref: unknown) => object|undefined} [options.resolve] - Resolves a
|
|
1138
1174
|
* reference to `{name, url?, uuid?, address?}`.
|
|
1175
|
+
* @param {string} [options.contentPackage] - This build's content package,
|
|
1176
|
+
* the default a note box's short-form Address resolves against.
|
|
1139
1177
|
* @param {object} [options.vocabulary] - The note vocabulary to read.
|
|
1140
1178
|
* @returns {object[]} The boxes.
|
|
1141
1179
|
*/
|
|
@@ -1146,11 +1184,12 @@ export function buildInfoboxes(fm, options) {
|
|
|
1146
1184
|
compilesDocument,
|
|
1147
1185
|
resolveField,
|
|
1148
1186
|
resolve,
|
|
1187
|
+
contentPackage,
|
|
1149
1188
|
vocabulary = NOTE_VOCABULARY,
|
|
1150
1189
|
dates,
|
|
1151
1190
|
} = options;
|
|
1152
1191
|
|
|
1153
|
-
const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary, dates });
|
|
1192
|
+
const { box: note, shown: taken } = noteBox(fm, { resolve, vocabulary, dates, contentPackage });
|
|
1154
1193
|
const boxes = [note];
|
|
1155
1194
|
|
|
1156
1195
|
for (const map of maps ?? []) {
|
|
@@ -15,18 +15,10 @@ import { loadForeignIndexes } from "./metadata-index.mjs";
|
|
|
15
15
|
import { authoredFrontmatter, isNoteRecord } from "./index-records.mjs";
|
|
16
16
|
import { buildScene } from "./map-notes.mjs";
|
|
17
17
|
import { buildJournalEntry } from "./journals.mjs";
|
|
18
|
+
import { imageDimensions } from "./asset-types.mjs";
|
|
18
19
|
import { makeId } from "./ids.mjs";
|
|
19
20
|
import { stripAdventureKeys } from "./bundle-notes.mjs";
|
|
20
21
|
|
|
21
|
-
/** The PNG's dimensions, from its fixed header. */
|
|
22
|
-
function pngDimensions(file) {
|
|
23
|
-
const bytes = fs.readFileSync(file);
|
|
24
|
-
if (bytes.toString("hex", 0, 8) !== "89504e470d0a1a0a") {
|
|
25
|
-
throw new Error(`${file} is not a PNG`);
|
|
26
|
-
}
|
|
27
|
-
return [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
22
|
/** GraphViz's plain coordinates, converted to pixel positions on its PNG. */
|
|
31
23
|
export function pinPositions(plain, width, height) {
|
|
32
24
|
const graph = /^graph\s+\S+\s+(\S+)\s+(\S+)/m.exec(plain);
|
|
@@ -111,7 +103,9 @@ export function buildItineraryScenes({
|
|
|
111
103
|
const plain = path.join(outDir, `from-${centre}.plain`);
|
|
112
104
|
renderDot(dot, png, { engine: "neato", format: "png", nop: 2, dpi: 150 });
|
|
113
105
|
renderDot(dot, plain, { engine: "neato", format: "plain", nop: 2 });
|
|
114
|
-
const
|
|
106
|
+
const size = imageDimensions(png, ".png");
|
|
107
|
+
if (!size) throw new Error(`${png} carries no readable PNG size`);
|
|
108
|
+
const { width, height } = size;
|
|
115
109
|
const positions = pinPositions(fs.readFileSync(plain, "utf8"), width, height);
|
|
116
110
|
const sceneId = makeId("itinerary-scene", `${config.contentPackage}:${centre}`);
|
|
117
111
|
const src = `${buildConfig.packageKind}/${buildConfig.packageId}/assets/maps/generated-itineraries/from-${centre}.png`;
|
package/engine/journals.mjs
CHANGED
|
@@ -51,16 +51,20 @@
|
|
|
51
51
|
|
|
52
52
|
import log from "loglevel";
|
|
53
53
|
|
|
54
|
+
import path from "node:path";
|
|
55
|
+
|
|
54
56
|
import {
|
|
55
57
|
sohlField,
|
|
56
58
|
makeId,
|
|
57
59
|
resolveName,
|
|
60
|
+
resolveImg,
|
|
58
61
|
defaultStats,
|
|
59
62
|
renderFoundryMarkdown,
|
|
60
63
|
md,
|
|
61
64
|
folderField,
|
|
62
65
|
} from "./helpers.mjs";
|
|
63
66
|
import { BasePackCompiler } from "./base-compiler.mjs";
|
|
67
|
+
import { scanBlocks } from "./content-blocks.mjs";
|
|
64
68
|
import { anchorPageId, resolveReference } from "./wikilinks.mjs";
|
|
65
69
|
import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
|
|
66
70
|
import { noteInfoboxes } from "./infobox-registry.mjs";
|
|
@@ -69,9 +73,12 @@ import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
|
69
73
|
import { JOURNAL_TYPES } from "./ids.mjs";
|
|
70
74
|
import { journalHasContent } from "./note-state.mjs";
|
|
71
75
|
import { draftNoticeFor } from "./draft-notice.mjs";
|
|
72
|
-
import {
|
|
76
|
+
import { scanFigures } from "./content-figures.mjs";
|
|
73
77
|
import { separateFootnotes } from "./content-footnotes.mjs";
|
|
74
78
|
import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
|
|
79
|
+
import { imagesIn } from "./content-images.mjs";
|
|
80
|
+
import { IMAGE_EXTENSIONS } from "./asset-types.mjs";
|
|
81
|
+
import { pathnameRoles } from "./art-fields.mjs";
|
|
75
82
|
|
|
76
83
|
/**
|
|
77
84
|
* `CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE` — the default ownership a withheld
|
|
@@ -80,11 +87,33 @@ import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
|
|
|
80
87
|
*/
|
|
81
88
|
const WITHHELD_OWNERSHIP = 0;
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Every line a named block's own markup occupies, from its `:::name` opener
|
|
92
|
+
* to its closing `:::` inclusive.
|
|
93
|
+
*
|
|
94
|
+
* Read from {@link module:engine/content-blocks.scanBlocks}'s own ranges
|
|
95
|
+
* rather than restated here, so a figure cannot disagree with the blocks pass
|
|
96
|
+
* about what counts as a named block or where one ends. `scanBlocks` already
|
|
97
|
+
* recognises every name in its registry, carrying an attribute block or not,
|
|
98
|
+
* and already tracks a nested construct's own closer by depth rather than by
|
|
99
|
+
* a flag a bare `:::` clears regardless of whose closer it is.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} markdown - A note's body, as {@link splitPages} receives it.
|
|
102
|
+
* @returns {Set<number>} 0-based line indices inside a named block.
|
|
103
|
+
*/
|
|
104
|
+
function namedBlockLines(markdown) {
|
|
105
|
+
const lines = new Set();
|
|
106
|
+
for (const { start, end } of scanBlocks(markdown).blocks) {
|
|
107
|
+
for (let i = start; i <= end; i++) lines.add(i);
|
|
108
|
+
}
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
83
112
|
/**
|
|
84
113
|
* Splits a markdown body into pages by top-level H1 headings. Fenced
|
|
85
|
-
* code blocks are respected so `# foo` inside ``` blocks doesn't
|
|
86
|
-
* a split. Content before the first H1 (if non-empty) becomes a
|
|
87
|
-
* page. Each H1 yields a page whose name is the heading
|
|
114
|
+
* code blocks are respected so `# foo` inside ``` or ~~~ blocks doesn't
|
|
115
|
+
* trigger a split. Content before the first H1 (if non-empty) becomes a
|
|
116
|
+
* leading page. Each H1 yields a page whose name is the heading
|
|
88
117
|
* text (with any `{#anchor-id}` suffix stripped out and surfaced as
|
|
89
118
|
* `anchorSlug`). The classes the heading declares come along as `classes`, so
|
|
90
119
|
* a surface that honours one has it without reading the suffix again.
|
|
@@ -95,27 +124,32 @@ const WITHHELD_OWNERSHIP = 0;
|
|
|
95
124
|
* calling that page "Introduction" would label the description as a preamble to
|
|
96
125
|
* nothing.
|
|
97
126
|
*
|
|
127
|
+
* A figure fence carries its own record along onto the page it starts, as
|
|
128
|
+
* `figure` — `null` for a page a heading started. {@link buildPages} reads it
|
|
129
|
+
* to build the `image` or `text` page a fence begins; nothing else sets it.
|
|
130
|
+
*
|
|
98
131
|
* @param {string} body - Markdown body to split.
|
|
99
132
|
* @param {string} [leadName] - Name of the page before the first heading.
|
|
133
|
+
* @param {(address: string) => string|undefined} [resolveRole] - From a
|
|
134
|
+
* picture's address to the role its asset declares, so a figure page's
|
|
135
|
+
* name reads `Map 1` rather than `Figure 1` where it is due — see
|
|
136
|
+
* {@link module:engine/content-figures.scanFigures}.
|
|
100
137
|
* @returns {Array<{name: string, anchorSlug: string|null, level: number,
|
|
101
|
-
* classes: string[], markdown: string}>} Pages in
|
|
138
|
+
* classes: string[], figure: object|null, markdown: string}>} Pages in
|
|
139
|
+
* document order.
|
|
102
140
|
*/
|
|
103
|
-
export function splitPages(body, leadName = "Introduction") {
|
|
141
|
+
export function splitPages(body, leadName = "Introduction", resolveRole) {
|
|
104
142
|
const { markdown, definitions } = separateFootnotes(body);
|
|
105
143
|
const lines = markdown.split("\n");
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
const captionedHeadings = new Set(
|
|
109
|
-
captions
|
|
110
|
-
.filter((caption) => /^\s*#{1,6}\s/.test(lines[caption.blockStart] ?? ""))
|
|
111
|
-
.map((caption) => caption.blockStart),
|
|
144
|
+
const figureStarts = new Map(
|
|
145
|
+
scanFigures(markdown, { resolveRole }).figures.map((figure) => [figure.line - 1, figure]),
|
|
112
146
|
);
|
|
113
|
-
const
|
|
147
|
+
const blockLines = namedBlockLines(markdown);
|
|
148
|
+
const openings = pageOpenings(markdown, blockLines);
|
|
114
149
|
const pages = [];
|
|
115
150
|
const beforeFirstH1 = [];
|
|
116
151
|
let current = null;
|
|
117
|
-
let
|
|
118
|
-
let inSecret = false;
|
|
152
|
+
let codeFence = null;
|
|
119
153
|
|
|
120
154
|
const closeCurrent = () => {
|
|
121
155
|
if (!current) return;
|
|
@@ -124,41 +158,54 @@ export function splitPages(body, leadName = "Introduction") {
|
|
|
124
158
|
anchorSlug: current.anchorSlug,
|
|
125
159
|
level: current.level,
|
|
126
160
|
classes: current.classes,
|
|
161
|
+
figure: current.figure ?? null,
|
|
127
162
|
markdown: current.lines.join("\n").trim(),
|
|
128
163
|
});
|
|
129
164
|
current = null;
|
|
130
165
|
};
|
|
131
166
|
|
|
132
167
|
for (const [lineIndex, line] of lines.entries()) {
|
|
133
|
-
|
|
134
|
-
|
|
168
|
+
// A fence's closer must carry the same character as its opener and
|
|
169
|
+
// be at least as long — a longer fence is what lets a fenced example
|
|
170
|
+
// carry a shorter fence of its own as literal content, same as
|
|
171
|
+
// {@link module:engine/content-blocks.scanBlocks} and
|
|
172
|
+
// {@link module:engine/content-figures.scanFigures} track it.
|
|
173
|
+
const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
174
|
+
if (codeFence) {
|
|
175
|
+
if (fence && fence[1][0] === codeFence[0] && fence[1].length >= codeFence.length) {
|
|
176
|
+
codeFence = null;
|
|
177
|
+
}
|
|
178
|
+
} else if (fence) {
|
|
179
|
+
codeFence = fence[1];
|
|
135
180
|
}
|
|
136
|
-
|
|
137
|
-
else if (!inCodeBlock && /^:::[ \t]*$/.test(line)) inSecret = false;
|
|
181
|
+
const inCodeBlock = codeFence !== null;
|
|
138
182
|
|
|
139
183
|
// An H1 starts a page, as does any heading carrying an `{#slug}`
|
|
140
184
|
// anchor: a Foundry UUID can only address a page, so a linkable
|
|
141
185
|
// section has to be one.
|
|
142
186
|
// {@link module:engine/heading-attributes.parseHeadingLine} is the one
|
|
143
|
-
// reading of that rule — `scanBlocks` and `
|
|
187
|
+
// reading of that rule — `scanBlocks` and `scanFigures` refuse such a
|
|
144
188
|
// heading written where it cannot become a page, from the same
|
|
145
189
|
// function, so none of them can disagree about what starts one. The
|
|
146
190
|
// lines it claims here are collected by `pageOpenings`, which also
|
|
147
|
-
// skips a fence and a named block.
|
|
191
|
+
// skips a fence and a named block. A figure fence opens a page of its
|
|
192
|
+
// own, named for the figure's label.
|
|
148
193
|
const opening = openings.get(lineIndex);
|
|
149
|
-
const
|
|
150
|
-
|
|
194
|
+
const figure =
|
|
195
|
+
!inCodeBlock && !blockLines.has(lineIndex) ? figureStarts.get(lineIndex) : null;
|
|
196
|
+
if (figure) {
|
|
151
197
|
closeCurrent();
|
|
152
198
|
current = {
|
|
153
|
-
name:
|
|
154
|
-
anchorSlug:
|
|
199
|
+
name: figure.label,
|
|
200
|
+
anchorSlug: figure.id || null,
|
|
155
201
|
level: 1,
|
|
156
202
|
classes: [],
|
|
203
|
+
figure,
|
|
157
204
|
lines: [line],
|
|
158
205
|
};
|
|
159
206
|
continue;
|
|
160
207
|
}
|
|
161
|
-
if (opening
|
|
208
|
+
if (opening) {
|
|
162
209
|
closeCurrent();
|
|
163
210
|
current = {
|
|
164
211
|
name: opening.text,
|
|
@@ -185,6 +232,7 @@ export function splitPages(body, leadName = "Introduction") {
|
|
|
185
232
|
anchorSlug: null,
|
|
186
233
|
level: 1,
|
|
187
234
|
classes: [],
|
|
235
|
+
figure: null,
|
|
188
236
|
markdown: intro,
|
|
189
237
|
});
|
|
190
238
|
}
|
|
@@ -283,6 +331,145 @@ export function journalPageId(entryId, page) {
|
|
|
283
331
|
: makeId("journal-page", `${entryId}:${page.name}`);
|
|
284
332
|
}
|
|
285
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Whether a caption carries markup an `image` page's `caption` field cannot
|
|
336
|
+
* hold.
|
|
337
|
+
*
|
|
338
|
+
* `image.caption` is a `StringField`; `text.content` is an `HTMLField`.
|
|
339
|
+
* Parsed as inline Markdown, plain prose tokenises as `text` alone, wrapping
|
|
340
|
+
* at most on a `softbreak` — anything else (emphasis, a code span, a link, an
|
|
341
|
+
* inline image) is a token an image page has nowhere to render.
|
|
342
|
+
*
|
|
343
|
+
* @param {string} caption - The figure's raw caption markdown.
|
|
344
|
+
* @returns {boolean} Whether the caption needs a `text` page.
|
|
345
|
+
*/
|
|
346
|
+
function captionCarriesMarkup(caption) {
|
|
347
|
+
if (!caption) return false;
|
|
348
|
+
const [inline] = md.parseInline(caption, {});
|
|
349
|
+
return (inline?.children ?? []).some(
|
|
350
|
+
(token) => token.type !== "text" && token.type !== "softbreak",
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The one picture a `figure`-kind fence holds, when it holds exactly one and
|
|
356
|
+
* nothing else — the shape an `image` page can draw on its own. A grouped
|
|
357
|
+
* fence, two pictures or more, keeps the `text` page a single `src` cannot
|
|
358
|
+
* hold, and an embed that resolves to a sound rather than a picture does too,
|
|
359
|
+
* there being no audio page type.
|
|
360
|
+
*
|
|
361
|
+
* By the time a journal note's markdown reaches this pass, an embed has
|
|
362
|
+
* already been rewritten into the ordinary `` image every surface
|
|
363
|
+
* renders ({@link module:engine/content-embeds}), so the file this needs is
|
|
364
|
+
* already sitting in the fence's own contents rather than behind an address
|
|
365
|
+
* this pass would have to resolve a second time.
|
|
366
|
+
*
|
|
367
|
+
* @param {object} figure - The figure record, `kind` already derived.
|
|
368
|
+
* @param {object} page - From {@link splitPages}; `page.markdown` starts on
|
|
369
|
+
* the figure's own opening line, so `figure`'s line-based offsets translate
|
|
370
|
+
* into it directly.
|
|
371
|
+
* @returns {string|null} The picture's `src`, or `null`.
|
|
372
|
+
*/
|
|
373
|
+
function soleFigurePicture(figure, page) {
|
|
374
|
+
if (figure.kind !== "figure" && figure.kind !== "map") return null;
|
|
375
|
+
const offset = figure.line - 1;
|
|
376
|
+
const lines = page.markdown.split("\n");
|
|
377
|
+
const contents = lines.slice(figure.bodyStart - offset, figure.bodyEnd - offset).join("\n");
|
|
378
|
+
const images = imagesIn(contents);
|
|
379
|
+
if (images.length !== 1) return null;
|
|
380
|
+
const extension = path.extname(images[0].src).toLowerCase();
|
|
381
|
+
return IMAGE_EXTENSIONS.includes(extension) ? images[0].src : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Whether a figure fence is the whole of the page {@link splitPages} gave it.
|
|
386
|
+
*
|
|
387
|
+
* A figure always starts a page of its own, but nothing closes one: trailing
|
|
388
|
+
* prose before the next heading or figure stays on it, exactly as it would
|
|
389
|
+
* behind an ordinary heading. An `image` page holds a `src` and a caption and
|
|
390
|
+
* nowhere else to put that prose, so it is refused the type and kept a `text`
|
|
391
|
+
* page, where the prose renders after the figure's own div as it always has.
|
|
392
|
+
*
|
|
393
|
+
* @param {object} figure - The figure record.
|
|
394
|
+
* @param {object} page - From {@link splitPages}.
|
|
395
|
+
* @returns {boolean} Whether nothing follows the fence's closing `:::`.
|
|
396
|
+
*/
|
|
397
|
+
function figureIsWholePage(figure, page) {
|
|
398
|
+
const offset = figure.line - 1;
|
|
399
|
+
const after = page.markdown
|
|
400
|
+
.split("\n")
|
|
401
|
+
.slice(figure.close - offset + 1)
|
|
402
|
+
.join("\n");
|
|
403
|
+
return after.trim() === "";
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The fields particular to the Foundry page a `:::figure` fence begins.
|
|
408
|
+
*
|
|
409
|
+
* A fence holding exactly one picture, captioned with no inline markup, and
|
|
410
|
+
* standing alone on its page becomes a page of type `image`: `src` the
|
|
411
|
+
* picture, `image.caption` the caption text, the page named for the figure's
|
|
412
|
+
* own number. Every other fence — grouped, an audio embed, a caption
|
|
413
|
+
* `image.caption` cannot hold, or one trailing prose keeps company with —
|
|
414
|
+
* becomes a page of type `text`.
|
|
415
|
+
*
|
|
416
|
+
* The `text` page's content is rendered and then relabeled. The render sees
|
|
417
|
+
* only this one fence — {@link scanFigures}, called again inside
|
|
418
|
+
* {@link module:engine/content-figures.renderFigureBlocks} on this page's own
|
|
419
|
+
* markdown, recounts it as the first of its kind regardless of its true,
|
|
420
|
+
* note-wide number. The fence is the whole of the figure on this page, so its
|
|
421
|
+
* label paragraph is the one element to correct, to the number the note-wide
|
|
422
|
+
* scan in {@link buildJournalEntry} already assigned.
|
|
423
|
+
*
|
|
424
|
+
* @param {object} figure - The figure record from {@link scanFigures},
|
|
425
|
+
* carried onto the page by {@link splitPages}.
|
|
426
|
+
* @param {object} page - From {@link splitPages}.
|
|
427
|
+
* @param {string} pageId - The page's `_id`, threaded into footnote anchors.
|
|
428
|
+
* @param {Map<string, number>} footnoteNumbers - Shared across the note.
|
|
429
|
+
* @param {(address: string) => string|undefined} [resolveRole] - See
|
|
430
|
+
* {@link splitPages}.
|
|
431
|
+
* @returns {{name: string, type: "image", src: string, image: {caption?: string}}
|
|
432
|
+
* |{name: string, type: "text", text: {format: number, content: string}}}
|
|
433
|
+
*/
|
|
434
|
+
function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole) {
|
|
435
|
+
const picture = soleFigurePicture(figure, page);
|
|
436
|
+
if (picture && figureIsWholePage(figure, page) && !captionCarriesMarkup(figure.caption)) {
|
|
437
|
+
let src = picture;
|
|
438
|
+
try {
|
|
439
|
+
src = resolveImg(picture) ?? picture;
|
|
440
|
+
} catch {
|
|
441
|
+
// Left as the authored pathname — the same fallback an inline
|
|
442
|
+
// image's own render takes when no configuration resolves it.
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
name: figure.label,
|
|
446
|
+
type: "image",
|
|
447
|
+
src,
|
|
448
|
+
image: figure.hasCaption ? { caption: figure.caption } : {},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const label =
|
|
452
|
+
figure.hasCaption ? `${figure.label}: ${md.renderInline(figure.caption)}` : figure.label;
|
|
453
|
+
const content = renderFoundryMarkdown(
|
|
454
|
+
page.markdown,
|
|
455
|
+
undefined,
|
|
456
|
+
footnoteNumbers,
|
|
457
|
+
pageId,
|
|
458
|
+
resolveRole,
|
|
459
|
+
);
|
|
460
|
+
return {
|
|
461
|
+
name: figure.label,
|
|
462
|
+
type: "text",
|
|
463
|
+
text: {
|
|
464
|
+
format: 1,
|
|
465
|
+
content: content.replace(
|
|
466
|
+
/<p class="content-figure-label">[\s\S]*?<\/p>/,
|
|
467
|
+
() => `<p class="content-figure-label">${label}</p>`,
|
|
468
|
+
),
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
286
473
|
/**
|
|
287
474
|
* Compile split pages into JournalEntryPage documents.
|
|
288
475
|
*
|
|
@@ -291,15 +478,22 @@ export function journalPageId(entryId, page) {
|
|
|
291
478
|
* @param {string} noteName - The note, for error messages.
|
|
292
479
|
* A page whose heading carries `.secret` is given to the GM alone, by the one
|
|
293
480
|
* ownership value Foundry reads as "nobody but a GM". Every other page states
|
|
294
|
-
* no ownership and inherits the journal's.
|
|
481
|
+
* no ownership and inherits the journal's. A figure-started page states none
|
|
482
|
+
* either: {@link scanFigures} validates a figure's classes against its own
|
|
483
|
+
* closed vocabulary, which carries no `.secret`.
|
|
295
484
|
*
|
|
485
|
+
* @param {Array<object>} figures - From {@link scanFigures}, numbered across
|
|
486
|
+
* the whole note. Read by a page a heading started; a page a figure started
|
|
487
|
+
* carries its own figure already, from {@link splitPages}.
|
|
488
|
+
* @param {(address: string) => string|undefined} [resolveRole] - See
|
|
489
|
+
* {@link splitPages}.
|
|
296
490
|
* @returns {Array<{_id: string, name: string, type: string,
|
|
297
491
|
* title: {show: boolean, level: number},
|
|
298
492
|
* text: {format: number, content: string}, _key: string,
|
|
299
493
|
* ownership?: {default: number}}>} The page documents, in order.
|
|
300
494
|
* @throws {Error} When the note has no content at all, or repeats an anchor.
|
|
301
495
|
*/
|
|
302
|
-
export function buildPages(rawPages, entryId, noteName,
|
|
496
|
+
export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
|
|
303
497
|
if (rawPages.length === 0) {
|
|
304
498
|
throw new Error(
|
|
305
499
|
`note "${noteName}" has no Introduction content and no H1 headings — nothing to compile`,
|
|
@@ -317,18 +511,30 @@ export function buildPages(rawPages, entryId, noteName, captions) {
|
|
|
317
511
|
return rawPages.map((page) => {
|
|
318
512
|
const pageId = journalPageId(entryId, page);
|
|
319
513
|
const withheld = (page.classes ?? []).includes(WITHHELD_CLASS);
|
|
514
|
+
const fields =
|
|
515
|
+
page.figure ?
|
|
516
|
+
buildFigurePageFields(page.figure, page, pageId, footnoteNumbers, resolveRole)
|
|
517
|
+
: {
|
|
518
|
+
name: page.name,
|
|
519
|
+
type: "text",
|
|
520
|
+
text: {
|
|
521
|
+
format: 1,
|
|
522
|
+
content:
|
|
523
|
+
page.markdown ?
|
|
524
|
+
renderFoundryMarkdown(
|
|
525
|
+
page.markdown,
|
|
526
|
+
figures,
|
|
527
|
+
footnoteNumbers,
|
|
528
|
+
pageId,
|
|
529
|
+
resolveRole,
|
|
530
|
+
)
|
|
531
|
+
: "",
|
|
532
|
+
},
|
|
533
|
+
};
|
|
320
534
|
return {
|
|
321
535
|
_id: pageId,
|
|
322
|
-
|
|
323
|
-
type: "text",
|
|
536
|
+
...fields,
|
|
324
537
|
title: { show: true, level: page.level ?? 1 },
|
|
325
|
-
text: {
|
|
326
|
-
format: 1,
|
|
327
|
-
content:
|
|
328
|
-
page.markdown ?
|
|
329
|
-
renderFoundryMarkdown(page.markdown, captions, footnoteNumbers, pageId)
|
|
330
|
-
: "",
|
|
331
|
-
},
|
|
332
538
|
...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
|
|
333
539
|
_key: `!journal.pages!${entryId}.${pageId}`,
|
|
334
540
|
};
|
|
@@ -365,6 +571,10 @@ export function buildPages(rawPages, entryId, noteName, captions) {
|
|
|
365
571
|
* module:engine/draft-notice}. It leads the first authored page,
|
|
366
572
|
* because a reader deciding whether to rely on the entry has to be told
|
|
367
573
|
* before they read it rather than after.
|
|
574
|
+
* @param {(address: string) => string|undefined} [params.resolveRole] - From
|
|
575
|
+
* a picture's address to the role its asset declares, so a figure page's
|
|
576
|
+
* name reads `Map 1` rather than `Figure 1` where it is due — see
|
|
577
|
+
* {@link module:engine/content-figures.scanFigures}.
|
|
368
578
|
* @returns {object} The JournalEntry document, keyed for the pack.
|
|
369
579
|
*/
|
|
370
580
|
export function buildJournalEntry({
|
|
@@ -376,9 +586,16 @@ export function buildJournalEntry({
|
|
|
376
586
|
stats = defaultStats(),
|
|
377
587
|
infoboxes = [],
|
|
378
588
|
notice = "",
|
|
589
|
+
resolveRole,
|
|
379
590
|
}) {
|
|
380
|
-
const rawPages = splitPages(markdown, leadName);
|
|
381
|
-
const pages = buildPages(
|
|
591
|
+
const rawPages = splitPages(markdown, leadName, resolveRole);
|
|
592
|
+
const pages = buildPages(
|
|
593
|
+
rawPages,
|
|
594
|
+
id,
|
|
595
|
+
name,
|
|
596
|
+
scanFigures(markdown, { resolveRole }).figures,
|
|
597
|
+
resolveRole,
|
|
598
|
+
);
|
|
382
599
|
for (const box of infoboxes) {
|
|
383
600
|
const pageId = makeId("journal-infobox-page", `${id}:${box.id}`);
|
|
384
601
|
pages.push({
|
|
@@ -541,12 +758,20 @@ export class Journals extends BasePackCompiler {
|
|
|
541
758
|
// by the pack list this build is being driven by.
|
|
542
759
|
router: this.router,
|
|
543
760
|
dates: reckoningContext(this.linkIndex),
|
|
761
|
+
contentPackage: this.linkIndex?.contentPackage,
|
|
544
762
|
});
|
|
545
763
|
|
|
764
|
+
// The markdown this pass reads has already had its embeds rewritten
|
|
765
|
+
// into ordinary images, each `src` the pathname `pathnameRoles` keys
|
|
766
|
+
// its roles by — so a `:::figure` of a map names itself `Map 1` here
|
|
767
|
+
// exactly as the website and the book name the same picture.
|
|
768
|
+
const roles = pathnameRoles(this.linkIndex);
|
|
769
|
+
|
|
546
770
|
return buildJournalEntry({
|
|
547
771
|
id,
|
|
548
772
|
name,
|
|
549
773
|
markdown,
|
|
774
|
+
resolveRole: (pathname) => roles.get(pathname),
|
|
550
775
|
infoboxes: boxes.map((box) => ({
|
|
551
776
|
id: box.id,
|
|
552
777
|
name: `${
|