@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.
- package/CHANGELOG.md +72 -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 +125 -142
- package/docs/authoring/assets.md +1 -1
- package/docs/authoring/dates-and-calendars.md +12 -6
- package/docs/authoring/first-note.md +1 -1
- 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 +3 -3
- 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 +17 -1
- package/docs/guides/site.md +17 -18
- package/docs/reference/format-details.md +904 -275
- package/docs/reference/note-types.md +282 -91
- package/docs/reference/theme-pages.md +58 -66
- 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 +84 -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 +136 -0
- package/engine/literature-works.mjs +161 -0
- package/engine/map-graphviz.mjs +4 -18
- package/engine/markdown-expressions.mjs +116 -11
- package/engine/metadata-index.mjs +34 -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 +195 -92
- 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 +74 -85
- 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/stub-lint.mjs +6 -0
- package/engine/web-wikilinks.mjs +21 -1
- package/engine/wikilink-syntax.mjs +34 -4
- package/engine/wikilinks.mjs +27 -2
- package/hugo-theme/layouts/partials/infobox/entry.html +4 -1
- package/hugo-theme/layouts/partials/page-body.html +0 -2
- package/hugo-theme/static/css/style.css +25 -55
- package/package.json +1 -2
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/default-item-art.mjs +6 -6
- 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-notes.d.mts +29 -0
- package/types/engine/literature-works.d.mts +67 -0
- 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/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/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/journals.mjs
CHANGED
|
@@ -124,9 +124,15 @@ function namedBlockLines(markdown) {
|
|
|
124
124
|
* calling that page "Introduction" would label the description as a preamble to
|
|
125
125
|
* nothing.
|
|
126
126
|
*
|
|
127
|
-
* A
|
|
128
|
-
* `figure` — `null` for
|
|
129
|
-
*
|
|
127
|
+
* A captioned item's page holds the item alone, and carries the item's record
|
|
128
|
+
* as `figure` — `null` for every other page. {@link buildPages} reads it to
|
|
129
|
+
* build the `image` or `text` page; nothing else sets it. The page is named
|
|
130
|
+
* for the item's number, or else its caption's visible text.
|
|
131
|
+
*
|
|
132
|
+
* Body that follows an item, up to the next page opening, resumes in a
|
|
133
|
+
* **continuation** of the section the item interrupted: the section's name,
|
|
134
|
+
* level and classes, with `continues` naming the item it follows. A
|
|
135
|
+
* continuation holding nothing is not emitted.
|
|
130
136
|
*
|
|
131
137
|
* @param {string} body - Markdown body to split.
|
|
132
138
|
* @param {string} [leadName] - Name of the page before the first heading.
|
|
@@ -135,8 +141,8 @@ function namedBlockLines(markdown) {
|
|
|
135
141
|
* name reads `Map 1` rather than `Figure 1` where it is due — see
|
|
136
142
|
* {@link module:engine/content-figures.scanFigures}.
|
|
137
143
|
* @returns {Array<{name: string, anchorSlug: string|null, level: number,
|
|
138
|
-
* classes: string[], figure: object|null,
|
|
139
|
-
* document order.
|
|
144
|
+
* classes: string[], figure: object|null, continues?: string,
|
|
145
|
+
* markdown: string}>} Pages in document order.
|
|
140
146
|
*/
|
|
141
147
|
export function splitPages(body, leadName = "Introduction", resolveRole) {
|
|
142
148
|
const { markdown, definitions } = separateFootnotes(body);
|
|
@@ -150,16 +156,26 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
|
|
|
150
156
|
const beforeFirstH1 = [];
|
|
151
157
|
let current = null;
|
|
152
158
|
let codeFence = null;
|
|
159
|
+
/** The page a heading last opened, which a captioned item interrupts. */
|
|
160
|
+
let section = { name: leadName, level: 1, classes: [] };
|
|
153
161
|
|
|
154
162
|
const closeCurrent = () => {
|
|
155
163
|
if (!current) return;
|
|
164
|
+
const markdown = current.lines.join("\n").trim();
|
|
165
|
+
// A continuation exists only to carry what follows its item; one
|
|
166
|
+
// holding nothing is not a page.
|
|
167
|
+
if (current.continues && !markdown) {
|
|
168
|
+
current = null;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
156
171
|
pages.push({
|
|
157
172
|
name: current.name,
|
|
158
173
|
anchorSlug: current.anchorSlug,
|
|
159
174
|
level: current.level,
|
|
160
175
|
classes: current.classes,
|
|
161
176
|
figure: current.figure ?? null,
|
|
162
|
-
|
|
177
|
+
...(current.continues ? { continues: current.continues } : {}),
|
|
178
|
+
markdown,
|
|
163
179
|
});
|
|
164
180
|
current = null;
|
|
165
181
|
};
|
|
@@ -190,16 +206,32 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
|
|
|
190
206
|
// lines it claims here are collected by `pageOpenings`, which also
|
|
191
207
|
// skips a fence and a named block. A figure fence opens a page of its
|
|
192
208
|
// own, named for the figure's label.
|
|
209
|
+
// A captioned item's page holds the item alone. The body after it
|
|
210
|
+
// resumes in a continuation of the section it interrupted, keyed on
|
|
211
|
+
// the item so its id holds still when pages move around it.
|
|
212
|
+
if (current?.figure && lineIndex > current.figure.close) {
|
|
213
|
+
const item = current.figure;
|
|
214
|
+
closeCurrent();
|
|
215
|
+
current = {
|
|
216
|
+
name: section.name,
|
|
217
|
+
anchorSlug: null,
|
|
218
|
+
level: section.level,
|
|
219
|
+
classes: section.classes,
|
|
220
|
+
continues: item.slug || item.label || captionPageName(item.caption),
|
|
221
|
+
lines: [],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
193
225
|
const opening = openings.get(lineIndex);
|
|
194
226
|
const figure =
|
|
195
227
|
!inCodeBlock && !blockLines.has(lineIndex) ? figureStarts.get(lineIndex) : null;
|
|
196
228
|
if (figure) {
|
|
197
229
|
closeCurrent();
|
|
198
230
|
current = {
|
|
199
|
-
name: figure.label || figure.caption,
|
|
231
|
+
name: figure.label || captionPageName(figure.caption),
|
|
200
232
|
anchorSlug: figure.id || null,
|
|
201
233
|
level: 1,
|
|
202
|
-
classes: [],
|
|
234
|
+
classes: section.classes.includes(WITHHELD_CLASS) ? [WITHHELD_CLASS] : [],
|
|
203
235
|
figure,
|
|
204
236
|
lines: [line],
|
|
205
237
|
};
|
|
@@ -214,6 +246,7 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
|
|
|
214
246
|
classes: opening.classes,
|
|
215
247
|
lines: [],
|
|
216
248
|
};
|
|
249
|
+
section = current;
|
|
217
250
|
continue;
|
|
218
251
|
}
|
|
219
252
|
|
|
@@ -270,7 +303,18 @@ export function splitPages(body, leadName = "Introduction", resolveRole) {
|
|
|
270
303
|
export function assertUniquePages(rawPages, noteName) {
|
|
271
304
|
const anchors = new Set();
|
|
272
305
|
const names = new Set();
|
|
306
|
+
const continuations = new Set();
|
|
273
307
|
for (const page of rawPages) {
|
|
308
|
+
if (page.continues) {
|
|
309
|
+
const key = `${page.name}\u0000${page.continues}`;
|
|
310
|
+
if (continuations.has(key)) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`note "${noteName}" has two captioned items both captioned "${page.continues}" in "${page.name}"; give one an {#anchor}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
continuations.add(key);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
274
318
|
if (page.anchorSlug) {
|
|
275
319
|
if (anchors.has(page.anchorSlug)) {
|
|
276
320
|
throw new Error(
|
|
@@ -321,16 +365,49 @@ export function assertUniqueAnchors(rawPages, noteName) {
|
|
|
321
365
|
* a lint error. {@link assertUniquePages} states the same thing at compile
|
|
322
366
|
* time, where the packer would otherwise report only an opaque duplicate key.
|
|
323
367
|
*
|
|
368
|
+
* A continuation page shares its section's name, so it is keyed by that name
|
|
369
|
+
* and the captioned item it follows.
|
|
370
|
+
*
|
|
324
371
|
* @param {string} entryId - The owning JournalEntry's `_id`.
|
|
325
|
-
* @param {{anchorSlug: string|null, name: string}} page -
|
|
372
|
+
* @param {{anchorSlug: string|null, name: string, continues?: string}} page -
|
|
373
|
+
* From {@link splitPages}.
|
|
326
374
|
* @returns {string} A 16-character Foundry id.
|
|
327
375
|
*/
|
|
328
376
|
export function journalPageId(entryId, page) {
|
|
329
|
-
return page.anchorSlug
|
|
330
|
-
|
|
377
|
+
if (page.anchorSlug) return anchorPageId(entryId, page.anchorSlug);
|
|
378
|
+
return page.continues ?
|
|
379
|
+
makeId("journal-page", `${entryId}:${page.name}:after:${page.continues}`)
|
|
331
380
|
: makeId("journal-page", `${entryId}:${page.name}`);
|
|
332
381
|
}
|
|
333
382
|
|
|
383
|
+
/**
|
|
384
|
+
* The name of the page a caption begins: the caption's visible text.
|
|
385
|
+
*
|
|
386
|
+
* Foundry shows a page name as plain text, so a caption's markup — emphasis, a
|
|
387
|
+
* code span, a Markdown link, or the `@UUID[...]{label}` a resolved wikilink
|
|
388
|
+
* has already become, inside its wrapping `<span>` — reduces to the text a
|
|
389
|
+
* reader sees, the same text a reference to the caption displays. The markup
|
|
390
|
+
* itself stays in the page's content, where it renders.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} caption - The figure's caption, as the journal pass sees it.
|
|
393
|
+
* @returns {string} Its visible text, whitespace collapsed.
|
|
394
|
+
*/
|
|
395
|
+
function captionPageName(caption) {
|
|
396
|
+
if (!caption) return "";
|
|
397
|
+
const [inline] = md.parseInline(caption, {});
|
|
398
|
+
const text = (inline?.children ?? [])
|
|
399
|
+
.map((token) =>
|
|
400
|
+
token.type === "text" || token.type === "code_inline" ? token.content
|
|
401
|
+
: token.type === "softbreak" || token.type === "hardbreak" ? " "
|
|
402
|
+
: "",
|
|
403
|
+
)
|
|
404
|
+
.join("");
|
|
405
|
+
return text
|
|
406
|
+
.replace(/@UUID\[[^\]]*\]\{([^}]*)\}/g, "$1")
|
|
407
|
+
.replace(/\s+/g, " ")
|
|
408
|
+
.trim();
|
|
409
|
+
}
|
|
410
|
+
|
|
334
411
|
/**
|
|
335
412
|
* Whether a caption carries markup an `image` page's `caption` field cannot
|
|
336
413
|
* hold.
|
|
@@ -381,36 +458,13 @@ function soleFigurePicture(figure, page) {
|
|
|
381
458
|
return IMAGE_EXTENSIONS.includes(extension) ? images[0].src : null;
|
|
382
459
|
}
|
|
383
460
|
|
|
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
461
|
/**
|
|
407
462
|
* The fields particular to the Foundry page a captioned item begins.
|
|
408
463
|
*
|
|
409
|
-
* A
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* `image.caption` cannot hold, or one trailing prose keeps company with —
|
|
464
|
+
* A captioned item holding exactly one picture, captioned with no inline
|
|
465
|
+
* markup, becomes a page of type `image`: `src` the picture, `image.caption`
|
|
466
|
+
* the caption text, the page named for the figure's own number. Every other
|
|
467
|
+
* item — grouped, an audio embed, or a caption `image.caption` cannot hold —
|
|
414
468
|
* becomes a page of type `text`.
|
|
415
469
|
*
|
|
416
470
|
* The `text` page's content is rendered and then relabeled. The render sees
|
|
@@ -433,7 +487,7 @@ function figureIsWholePage(figure, page) {
|
|
|
433
487
|
*/
|
|
434
488
|
function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRole) {
|
|
435
489
|
const picture = soleFigurePicture(figure, page);
|
|
436
|
-
if (picture &&
|
|
490
|
+
if (picture && !captionCarriesMarkup(figure.caption)) {
|
|
437
491
|
let src = picture;
|
|
438
492
|
try {
|
|
439
493
|
src = resolveImg(picture) ?? picture;
|
|
@@ -442,7 +496,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
|
|
|
442
496
|
// image's own render takes when no configuration resolves it.
|
|
443
497
|
}
|
|
444
498
|
return {
|
|
445
|
-
name: figure.label || figure.caption,
|
|
499
|
+
name: figure.label || captionPageName(figure.caption),
|
|
446
500
|
type: "image",
|
|
447
501
|
src,
|
|
448
502
|
image: figure.hasCaption ? { caption: figure.caption } : {},
|
|
@@ -460,7 +514,7 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
|
|
|
460
514
|
resolveRole,
|
|
461
515
|
);
|
|
462
516
|
return {
|
|
463
|
-
name: figure.label || figure.caption,
|
|
517
|
+
name: figure.label || captionPageName(figure.caption),
|
|
464
518
|
type: "text",
|
|
465
519
|
text: {
|
|
466
520
|
format: 1,
|
|
@@ -478,11 +532,12 @@ function buildFigurePageFields(figure, page, pageId, footnoteNumbers, resolveRol
|
|
|
478
532
|
* @param {Array<object>} rawPages - From {@link splitPages}.
|
|
479
533
|
* @param {string} entryId - The owning JournalEntry's `_id`.
|
|
480
534
|
* @param {string} noteName - The note, for error messages.
|
|
481
|
-
* A page whose heading carries `.secret` is given to the GM alone, by
|
|
482
|
-
* ownership value Foundry reads as "nobody but a GM"
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
535
|
+
* A page whose opening heading carries `.secret` is given to the GM alone, by
|
|
536
|
+
* the one ownership value Foundry reads as "nobody but a GM"; so is a captioned
|
|
537
|
+
* item's page and a continuation page inside such a section. Every other page
|
|
538
|
+
* states no ownership and inherits the journal's. A caption's own classes carry
|
|
539
|
+
* no `.secret`, because {@link scanFigures} validates them against a closed
|
|
540
|
+
* vocabulary without it.
|
|
486
541
|
*
|
|
487
542
|
* @param {Array<object>} figures - From {@link scanFigures}, numbered across
|
|
488
543
|
* the whole note. Read by a page a heading started; a page a figure started
|
|
@@ -536,7 +591,9 @@ export function buildPages(rawPages, entryId, noteName, figures, resolveRole) {
|
|
|
536
591
|
return {
|
|
537
592
|
_id: pageId,
|
|
538
593
|
...fields,
|
|
539
|
-
|
|
594
|
+
// A continuation resumes its section, whose title its first
|
|
595
|
+
// page already shows.
|
|
596
|
+
title: { show: !page.continues, level: page.level ?? 1 },
|
|
540
597
|
...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
|
|
541
598
|
_key: `!journal.pages!${entryId}.${pageId}`,
|
|
542
599
|
};
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
* A work a people tells, sings or writes: the `literature` lore subType and the
|
|
16
|
+
* four optional facts such a note may state about itself.
|
|
17
|
+
*
|
|
18
|
+
* `DataFieldSpec` declares the keys a type accepts, not the subType that may
|
|
19
|
+
* write them, so {@link checkLiteratureNote} scopes these keys to `literature`
|
|
20
|
+
* the way the calendar check scopes its own to `calendar`.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
|
|
26
|
+
import { checkCultureChoice } from "./culture-choice.mjs";
|
|
27
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
28
|
+
|
|
29
|
+
/** The `lore` subType whose notes are works of literature. */
|
|
30
|
+
export const LITERATURE_SUBTYPE = "literature";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Require `data.language` to address a `skill` note whose subType is
|
|
34
|
+
* `language`.
|
|
35
|
+
*
|
|
36
|
+
* An Address that does not resolve is the reference check's to report, so
|
|
37
|
+
* this speaks only about a target that exists and is the wrong kind.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} note
|
|
40
|
+
* @param {{index?: object}} [options]
|
|
41
|
+
* @returns {object[]} The findings.
|
|
42
|
+
*/
|
|
43
|
+
export function checkLanguageChoice(note, { index } = {}) {
|
|
44
|
+
const value = note.fm?.data?.language;
|
|
45
|
+
if (value === undefined || value === null || !index?.addressHit) return [];
|
|
46
|
+
const tuple = parseAddress(value, {
|
|
47
|
+
package: index.contentPackage,
|
|
48
|
+
system: "note",
|
|
49
|
+
type: "skill",
|
|
50
|
+
types: index.types,
|
|
51
|
+
packages: index.packages,
|
|
52
|
+
});
|
|
53
|
+
if (tuple.reason) return [];
|
|
54
|
+
const target = index.addressHit(renderAddress(tuple));
|
|
55
|
+
if (!target) return [];
|
|
56
|
+
const fm = target.fm ?? target;
|
|
57
|
+
if (tuple.type === "skill" && fm.subType === "language") return [];
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
file: note.file,
|
|
61
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "language"]),
|
|
62
|
+
severity: "error",
|
|
63
|
+
message: `data.language ${isAddressTuple(value) ? renderAddress(value) : value} names a note that is not a skill whose subType is language`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The fields a `literature` note may state. Every one is optional.
|
|
70
|
+
*
|
|
71
|
+
* @type {ReadonlyArray<object>}
|
|
72
|
+
*/
|
|
73
|
+
export const LITERATURE_FIELDS = Object.freeze([
|
|
74
|
+
{
|
|
75
|
+
name: "culture",
|
|
76
|
+
shape: "an Address",
|
|
77
|
+
kind: "address",
|
|
78
|
+
ref: "lore",
|
|
79
|
+
accepts: ["lore"],
|
|
80
|
+
check: checkCultureChoice,
|
|
81
|
+
describe: "The people whose work this is, as a culture lore note.",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "form",
|
|
85
|
+
shape: "string",
|
|
86
|
+
kind: "string",
|
|
87
|
+
describe:
|
|
88
|
+
"The kind of work in its people's own terms — an epic, a saga, a " +
|
|
89
|
+
"praise-song, an elegy. Free text.",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "subjects",
|
|
93
|
+
shape: "list of Addresses",
|
|
94
|
+
kind: "list",
|
|
95
|
+
entryKind: "address",
|
|
96
|
+
anchors: Object.freeze(["event"]),
|
|
97
|
+
describe:
|
|
98
|
+
"The beings, places, gods and other notes the work concerns, and each " +
|
|
99
|
+
"event it concerns, named as `place-ironfells#sack`.",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "language",
|
|
103
|
+
shape: "an Address",
|
|
104
|
+
kind: "address",
|
|
105
|
+
ref: "skill",
|
|
106
|
+
accepts: ["skill"],
|
|
107
|
+
check: checkLanguageChoice,
|
|
108
|
+
describe: "The tongue the work is composed in, as a language skill note.",
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const LITERATURE_KEYS = Object.freeze(LITERATURE_FIELDS.map((field) => field.name));
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Refuse a literature field on a lore note of any other subType.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} note
|
|
118
|
+
* @returns {object[]} One error per literature key written off `literature`.
|
|
119
|
+
*/
|
|
120
|
+
export function checkLiteratureNote(note) {
|
|
121
|
+
const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
|
|
122
|
+
const subType = String(note?.fm?.subType ?? "").toLowerCase();
|
|
123
|
+
if (type !== "lore" || subType === LITERATURE_SUBTYPE) return [];
|
|
124
|
+
const data = note?.fm?.data;
|
|
125
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return [];
|
|
126
|
+
return LITERATURE_KEYS.filter((key) => data[key] !== undefined && data[key] !== null).map(
|
|
127
|
+
(key) => ({
|
|
128
|
+
file: note.file,
|
|
129
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", key], { key: true }),
|
|
130
|
+
severity: "error",
|
|
131
|
+
message:
|
|
132
|
+
`\`data.${key}\` describes a work of literature, and this note's ` +
|
|
133
|
+
`\`subType\` is not \`literature\``,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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 works of literature that concern each page, derived from what the works
|
|
16
|
+
* say. A `literature` lore note names its subjects in `data.subjects`; the build
|
|
17
|
+
* inverts that into the **In song and story** section of every subject — see
|
|
18
|
+
* {@link module:engine/derived-sections} — so a reader of a hero, a place or a
|
|
19
|
+
* god reaches the epics and legends about it. The subject note writes nothing.
|
|
20
|
+
*
|
|
21
|
+
* A subject naming an event lists the work on the page of the note holding
|
|
22
|
+
* that event. A work in a fetched index lists on this package's subject pages
|
|
23
|
+
* when that index carries its `subjects`. A subject in another package has no page here,
|
|
24
|
+
* so a local work naming one lists nowhere.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { parseAddress, renderAddress, splitAnchor } from "./address.mjs";
|
|
30
|
+
import { AddressLink } from "./address-values.mjs";
|
|
31
|
+
import { readCanonicalKey } from "./content-address.mjs";
|
|
32
|
+
import { LITERATURE_SUBTYPE } from "./literature-notes.mjs";
|
|
33
|
+
|
|
34
|
+
/** @typedef {{title: string, url?: string, address: string, form?: string}} WorkEntry */
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} WorksNode
|
|
37
|
+
* @property {string} key Own Address, normalized to the `note` system.
|
|
38
|
+
* @property {string} title
|
|
39
|
+
* @property {string} [url] Absent for a note that publishes no page.
|
|
40
|
+
* @property {string} [package] Owning content package.
|
|
41
|
+
* @property {string} [form] A work's `data.form`.
|
|
42
|
+
* @property {unknown[]} subjects A work's `data.subjects`; empty for any other note.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The note-system key of one Address, or "" where it names nothing. A subject
|
|
47
|
+
* naming an event — `place-ironfells#sack`, or an {@link AddressLink} read
|
|
48
|
+
* from one — keys the note that holds the event.
|
|
49
|
+
*/
|
|
50
|
+
function addressKey(value, pkg, types) {
|
|
51
|
+
if (value == null) return "";
|
|
52
|
+
const note =
|
|
53
|
+
value instanceof AddressLink ? value.target
|
|
54
|
+
: typeof value === "string" ? splitAnchor(value).address
|
|
55
|
+
: value;
|
|
56
|
+
const tuple = parseAddress(note, { package: pkg ?? "local", system: "note", types });
|
|
57
|
+
if (tuple.reason) return "";
|
|
58
|
+
return renderAddress({ ...tuple, system: "note" }).toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether a note is a work of literature. */
|
|
62
|
+
function isWork(type, subType) {
|
|
63
|
+
return type === "lore" && String(subType ?? "").toLowerCase() === LITERATURE_SUBTYPE;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One local page's node.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} fm - The note's frontmatter.
|
|
70
|
+
* @param {{title: string, url?: string, package?: string}} page
|
|
71
|
+
* @returns {WorksNode|null} Null for a note with no type or shortcode.
|
|
72
|
+
*/
|
|
73
|
+
export function worksNode(fm, { title, url, package: pkg }) {
|
|
74
|
+
const type = String(fm?.type ?? "");
|
|
75
|
+
const shortcode = String(fm?.shortcode ?? "").toLowerCase();
|
|
76
|
+
if (!type || !shortcode) return null;
|
|
77
|
+
const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
|
|
78
|
+
const work = isWork(type, fm.subType);
|
|
79
|
+
return {
|
|
80
|
+
key: renderAddress({ package: pkg ?? "local", system: "note", type, shortcode }),
|
|
81
|
+
title,
|
|
82
|
+
...(url ? { url } : {}),
|
|
83
|
+
...(pkg ? { package: pkg } : {}),
|
|
84
|
+
...(work && typeof data.form === "string" && data.form ? { form: data.form } : {}),
|
|
85
|
+
subjects:
|
|
86
|
+
!work ? []
|
|
87
|
+
: Array.isArray(data.subjects) ? data.subjects
|
|
88
|
+
: data.subjects ? [data.subjects]
|
|
89
|
+
: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The works a fetched index carries, as nodes. Only a work can list itself on
|
|
95
|
+
* a page, and only a page of this package can carry the list, so a foreign
|
|
96
|
+
* entry that is not a work is never needed here.
|
|
97
|
+
*
|
|
98
|
+
* @param {Map<string, object>|undefined} foreignIndex
|
|
99
|
+
* @returns {WorksNode[]}
|
|
100
|
+
*/
|
|
101
|
+
export function foreignWorksNodes(foreignIndex) {
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const [canonical, entry] of foreignIndex ?? []) {
|
|
104
|
+
const tuple = readCanonicalKey(canonical);
|
|
105
|
+
if (!tuple || !isWork(entry?.type, entry?.subType)) continue;
|
|
106
|
+
if (!Array.isArray(entry.subjects) || !entry.subjects.length) continue;
|
|
107
|
+
out.push({
|
|
108
|
+
key: renderAddress({ ...tuple, system: "note" }),
|
|
109
|
+
title: String(entry.name ?? tuple.shortcode),
|
|
110
|
+
...(entry.url ? { url: entry.url } : {}),
|
|
111
|
+
package: tuple.package,
|
|
112
|
+
...(typeof entry.form === "string" && entry.form ? { form: entry.form } : {}),
|
|
113
|
+
subjects: entry.subjects,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Invert every work's subjects into the list each subject's page carries.
|
|
121
|
+
*
|
|
122
|
+
* A subject is resolved in its work's own package. A work naming one subject
|
|
123
|
+
* twice lists once; a work naming itself is not listed on its own page.
|
|
124
|
+
* Entries sort by title.
|
|
125
|
+
*
|
|
126
|
+
* @param {Iterable<WorksNode|null>} nodes - Every local page, then any foreign work.
|
|
127
|
+
* @param {object} options
|
|
128
|
+
* @param {ReadonlySet<string>} options.types - Note types an Address may name.
|
|
129
|
+
* @returns {Map<string, {works: WorkEntry[]}>} Keyed by the subject page's URL.
|
|
130
|
+
*/
|
|
131
|
+
export function worksPages(nodes, { types }) {
|
|
132
|
+
const all = [...nodes].filter(Boolean);
|
|
133
|
+
/** @type {Map<string, WorksNode>} */
|
|
134
|
+
const pages = new Map();
|
|
135
|
+
for (const node of all) {
|
|
136
|
+
if (!node.url) continue;
|
|
137
|
+
const key = node.key.toLowerCase();
|
|
138
|
+
if (!pages.has(key)) pages.set(key, node);
|
|
139
|
+
}
|
|
140
|
+
/** @type {Map<string, {works: WorkEntry[]}>} */
|
|
141
|
+
const lists = new Map();
|
|
142
|
+
for (const work of all) {
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
for (const subject of work.subjects) {
|
|
145
|
+
const target = pages.get(addressKey(subject, work.package, types));
|
|
146
|
+
if (!target || target.key === work.key || seen.has(target.url)) continue;
|
|
147
|
+
seen.add(target.url);
|
|
148
|
+
let block = lists.get(target.url);
|
|
149
|
+
if (!block) lists.set(target.url, (block = { works: [] }));
|
|
150
|
+
block.works.push({
|
|
151
|
+
title: work.title,
|
|
152
|
+
...(work.url ? { url: work.url } : {}),
|
|
153
|
+
address: work.key,
|
|
154
|
+
...(work.form ? { form: work.form } : {}),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const block of lists.values())
|
|
159
|
+
block.works.sort((a, b) => a.title.localeCompare(b.title, "en"));
|
|
160
|
+
return lists;
|
|
161
|
+
}
|
package/engine/map-graphviz.mjs
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import { instance } from "@viz-js/viz";
|
|
7
|
-
import { Resvg } from "@resvg/resvg-js";
|
|
8
7
|
|
|
9
8
|
/** The layout engines accepted by the map command. */
|
|
10
9
|
export const GRAPHVIZ_ENGINES = Object.freeze(["dot", "twopi", "neato"]);
|
|
@@ -12,22 +11,16 @@ export const GRAPHVIZ_ENGINES = Object.freeze(["dot", "twopi", "neato"]);
|
|
|
12
11
|
const graphviz = await instance();
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
|
-
* Render a DOT source file to SVG
|
|
16
|
-
*
|
|
17
|
-
* A PNG uses the same Graphviz SVG layout as the vector map. SVG dimensions
|
|
18
|
-
* are in points; resvg starts at 96 pixels per inch, so the zoom converts
|
|
19
|
-
* the requested DPI to the same physical size as Graphviz's PNG output.
|
|
14
|
+
* Render a DOT source file to SVG.
|
|
20
15
|
*
|
|
21
16
|
* @param {string} dotPath - The saved DOT source.
|
|
22
17
|
* @param {string} outPath - The output file.
|
|
23
18
|
* @param {object} opts
|
|
24
19
|
* @param {"dot"|"twopi"|"neato"} opts.engine - Layout engine.
|
|
25
|
-
* @param {"svg"|"plain"|"png"} [opts.format="svg"] - Output format.
|
|
26
20
|
* @param {2} [opts.nop] - Preserve positioned nodes as `neato -n2` does.
|
|
27
|
-
* @param {number} [opts.dpi=96] - PNG resolution.
|
|
28
21
|
* @returns {{warnings: string}} Renderer warnings.
|
|
29
22
|
*/
|
|
30
|
-
export function renderDot(dotPath, outPath, { engine,
|
|
23
|
+
export function renderDot(dotPath, outPath, { engine, nop }) {
|
|
31
24
|
if (!GRAPHVIZ_ENGINES.includes(engine)) {
|
|
32
25
|
throw new Error(`Unsupported Graphviz engine "${engine}" for ${dotPath}`);
|
|
33
26
|
}
|
|
@@ -35,19 +28,12 @@ export function renderDot(dotPath, outPath, { engine, format = "svg", nop, dpi =
|
|
|
35
28
|
try {
|
|
36
29
|
const result = graphviz.render(dot, {
|
|
37
30
|
engine: nop === 2 ? "nop2" : engine,
|
|
38
|
-
format:
|
|
31
|
+
format: "svg",
|
|
39
32
|
});
|
|
40
33
|
if (result.status !== "success" || !result.output) {
|
|
41
34
|
throw new Error(result.errors.map((error) => error.message).join("; ") || "no output");
|
|
42
35
|
}
|
|
43
|
-
|
|
44
|
-
const image = new Resvg(result.output, {
|
|
45
|
-
fitTo: { mode: "zoom", value: dpi / 96 },
|
|
46
|
-
}).render();
|
|
47
|
-
fs.writeFileSync(outPath, image.asPng());
|
|
48
|
-
} else {
|
|
49
|
-
fs.writeFileSync(outPath, result.output);
|
|
50
|
-
}
|
|
36
|
+
fs.writeFileSync(outPath, result.output);
|
|
51
37
|
// The embedded renderer uses Times metrics for the generated bold
|
|
52
38
|
// headings; its warning repeats for every map and needs no action.
|
|
53
39
|
return {
|