@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/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
|
};
|
|
@@ -93,7 +93,10 @@ export const LITERATURE_FIELDS = Object.freeze([
|
|
|
93
93
|
shape: "list of Addresses",
|
|
94
94
|
kind: "list",
|
|
95
95
|
entryKind: "address",
|
|
96
|
-
|
|
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`.",
|
|
97
100
|
},
|
|
98
101
|
{
|
|
99
102
|
name: "language",
|
|
@@ -13,26 +13,25 @@
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* The subject note writes nothing.
|
|
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
20
|
*
|
|
21
|
-
* A
|
|
22
|
-
*
|
|
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,
|
|
23
24
|
* so a local work naming one lists nowhere.
|
|
24
25
|
*
|
|
25
26
|
* @module
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
|
-
import { parseAddress, renderAddress } from "./address.mjs";
|
|
29
|
+
import { parseAddress, renderAddress, splitAnchor } from "./address.mjs";
|
|
30
|
+
import { AddressLink } from "./address-values.mjs";
|
|
29
31
|
import { readCanonicalKey } from "./content-address.mjs";
|
|
30
32
|
import { LITERATURE_SUBTYPE } from "./literature-notes.mjs";
|
|
31
33
|
|
|
32
|
-
/**
|
|
33
|
-
export const WORKS_KEY = "works";
|
|
34
|
-
|
|
35
|
-
/** @typedef {{title: string, url?: string, form?: string}} WorkEntry */
|
|
34
|
+
/** @typedef {{title: string, url?: string, address: string, form?: string}} WorkEntry */
|
|
36
35
|
/**
|
|
37
36
|
* @typedef {object} WorksNode
|
|
38
37
|
* @property {string} key Own Address, normalized to the `note` system.
|
|
@@ -43,10 +42,18 @@ export const WORKS_KEY = "works";
|
|
|
43
42
|
* @property {unknown[]} subjects A work's `data.subjects`; empty for any other note.
|
|
44
43
|
*/
|
|
45
44
|
|
|
46
|
-
/**
|
|
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
|
+
*/
|
|
47
50
|
function addressKey(value, pkg, types) {
|
|
48
51
|
if (value == null) return "";
|
|
49
|
-
const
|
|
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 });
|
|
50
57
|
if (tuple.reason) return "";
|
|
51
58
|
return renderAddress({ ...tuple, system: "note" }).toLowerCase();
|
|
52
59
|
}
|
|
@@ -143,6 +150,7 @@ export function worksPages(nodes, { types }) {
|
|
|
143
150
|
block.works.push({
|
|
144
151
|
title: work.title,
|
|
145
152
|
...(work.url ? { url: work.url } : {}),
|
|
153
|
+
address: work.key,
|
|
146
154
|
...(work.form ? { form: work.form } : {}),
|
|
147
155
|
});
|
|
148
156
|
}
|
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 {
|
|
@@ -8,6 +8,7 @@ import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
|
|
|
8
8
|
import { numberWords, numberDigits } from "./number-words.mjs";
|
|
9
9
|
import { slugify } from "./content-slug.mjs";
|
|
10
10
|
import { WIKILINK, authoredLabel, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
|
|
11
|
+
import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* An inline expression, as an author writes one.
|
|
@@ -152,7 +153,9 @@ const HELPERS = Object.freeze([
|
|
|
152
153
|
}),
|
|
153
154
|
Object.freeze({
|
|
154
155
|
name: "ref",
|
|
155
|
-
params:
|
|
156
|
+
params:
|
|
157
|
+
'address [form="number"|"full"|"title"] ' +
|
|
158
|
+
'| address field="when"|"until"|"kind"|"summary"|"name"',
|
|
156
159
|
summary:
|
|
157
160
|
'A link to a captioned item, by its anchor — `"#thorn"` on this note, ' +
|
|
158
161
|
'`"note-address#thorn"` on another. `form` is `number` (the default, and ' +
|
|
@@ -160,11 +163,21 @@ const HELPERS = Object.freeze([
|
|
|
160
163
|
"caption; or `title`, the caption alone. Always renders as a link to the " +
|
|
161
164
|
"figure, and a link inside the caption contributes only its label text. " +
|
|
162
165
|
"The number rendered is the one the figure carries on the surface doing " +
|
|
163
|
-
"the rendering, whichever note names it."
|
|
166
|
+
"the rendering, whichever note names it. With `field` in place of `form`, " +
|
|
167
|
+
'the address names an event — `"place-ironfells#sack"`, `"#sack"` on this ' +
|
|
168
|
+
'note, or `"lore-founding"` for a note holding one event — and the reference ' +
|
|
169
|
+
"prints that event's `when`, `until`, `kind`, `summary` or `name` as plain " +
|
|
170
|
+
"text, never a link; a date prints as the note format prints any date.",
|
|
164
171
|
make:
|
|
165
|
-
({ figures }) =>
|
|
172
|
+
({ figures, events, fm, dates }) =>
|
|
166
173
|
(address, options) => {
|
|
167
174
|
const hash = options?.hash ?? {};
|
|
175
|
+
if (Object.hasOwn(hash, "field")) {
|
|
176
|
+
if (Object.hasOwn(hash, "form")) throw new RangeError(REF_BOTH_FAULT);
|
|
177
|
+
const result = eventReference(address, hash.field, { events, fm, dates });
|
|
178
|
+
if (result.problem) throw new RangeError(result.problem);
|
|
179
|
+
return result.text;
|
|
180
|
+
}
|
|
168
181
|
const form = Object.hasOwn(hash, "form") ? hash.form : "number";
|
|
169
182
|
const formFault = refFormFault(form);
|
|
170
183
|
if (formFault) throw new RangeError(formFault);
|
|
@@ -184,6 +197,74 @@ const HELPERS = Object.freeze([
|
|
|
184
197
|
}),
|
|
185
198
|
]);
|
|
186
199
|
|
|
200
|
+
/** What a reference writing both `form` and `field` is told. */
|
|
201
|
+
const REF_BOTH_FAULT = "ref takes form= for a figure or field= for an event, not both";
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The text one event reference prints, or why it prints nothing.
|
|
205
|
+
*
|
|
206
|
+
* The address is read through the wikilink grammar, as a figure reference's
|
|
207
|
+
* is: `"#sack"` names an event of this note, `"place-ironfells#sack"` one of
|
|
208
|
+
* another, and `"lore-founding"` a note holding exactly one event. The anchor
|
|
209
|
+
* must be one the note declares, and an `event` one.
|
|
210
|
+
*
|
|
211
|
+
* @param {unknown} address - The reference's address.
|
|
212
|
+
* @param {unknown} field - The field to print, one of {@link EVENT_REFERENCE_FIELDS}.
|
|
213
|
+
* @param {{events?: {note: (written: string) => object|undefined,
|
|
214
|
+
* own?: (fm: object) => object|undefined}, fm?: object,
|
|
215
|
+
* dates?: object}} context - Every note's events, this note's frontmatter,
|
|
216
|
+
* and the reckoning context.
|
|
217
|
+
* @returns {{text: string}|{problem: string}} The text, with any link reduced to
|
|
218
|
+
* its label, or the finding's message.
|
|
219
|
+
*/
|
|
220
|
+
function eventReference(address, field, { events, fm, dates } = {}) {
|
|
221
|
+
if (!EVENT_REFERENCE_FIELDS.includes(field))
|
|
222
|
+
return { problem: `ref's field must be one of ${EVENT_REFERENCE_FIELDS.join(", ")}` };
|
|
223
|
+
if (typeof address !== "string" || !address.trim())
|
|
224
|
+
return { problem: "ref needs an address naming an event" };
|
|
225
|
+
if (!events?.note)
|
|
226
|
+
return { problem: `ref "${address}" names an event, and this build resolves none` };
|
|
227
|
+
const parsed = parseWikilink(address);
|
|
228
|
+
const same = isSamePage(parsed);
|
|
229
|
+
const note = same ? events.own?.(fm) : events.note(parsed.target);
|
|
230
|
+
if (!note)
|
|
231
|
+
return {
|
|
232
|
+
problem:
|
|
233
|
+
same ?
|
|
234
|
+
`ref "${address}" names an event of this note, which this build does not resolve`
|
|
235
|
+
: `ref "${address}" addresses "${parsed.target}", which names no note this build resolves`,
|
|
236
|
+
};
|
|
237
|
+
let event;
|
|
238
|
+
if (parsed.anchor) {
|
|
239
|
+
const anchor = note.anchors.find((one) => one?.slug === parsed.anchor);
|
|
240
|
+
event = note.events.find((one) => one.id === parsed.anchor);
|
|
241
|
+
if (anchor && anchor.kind !== "event")
|
|
242
|
+
return {
|
|
243
|
+
problem:
|
|
244
|
+
`ref "${address}" names "#${parsed.anchor}", a ${anchor.kind ?? "prose"} ` +
|
|
245
|
+
"anchor — field= reads an event",
|
|
246
|
+
};
|
|
247
|
+
if (!event)
|
|
248
|
+
return {
|
|
249
|
+
problem: `ref "${address}" names no anchor "#${parsed.anchor}" in "${note.target}"`,
|
|
250
|
+
};
|
|
251
|
+
} else {
|
|
252
|
+
if (note.events.length !== 1)
|
|
253
|
+
return {
|
|
254
|
+
problem:
|
|
255
|
+
note.events.length === 0 ?
|
|
256
|
+
`ref "${address}" names "${note.target}", which holds no events`
|
|
257
|
+
: `ref "${address}" names "${note.target}", which holds ` +
|
|
258
|
+
`${note.events.length} events — name one as "${parsed.target}#<id>"`,
|
|
259
|
+
};
|
|
260
|
+
event = note.events[0];
|
|
261
|
+
}
|
|
262
|
+
const value = eventFieldText(event, field, dates);
|
|
263
|
+
if (value.missing)
|
|
264
|
+
return { problem: `ref "${address}" field="${field}": that event states no ${field}` };
|
|
265
|
+
return { text: flattenCaptionLinks(value.text) };
|
|
266
|
+
}
|
|
267
|
+
|
|
187
268
|
/** The closed set of forms a figure reference may render as; `number` is the default. */
|
|
188
269
|
const REF_FORMS = Object.freeze(["number", "full", "title"]);
|
|
189
270
|
|
|
@@ -382,10 +463,25 @@ export function sqlQueriesInMarkdown(body, fm = {}) {
|
|
|
382
463
|
* @param {object} node - A `MustacheStatement`/`SubExpression` AST node.
|
|
383
464
|
* @param {object} [figures] - This note's figures, and how to reach another
|
|
384
465
|
* note's — see {@link refFigure}.
|
|
466
|
+
* @param {object} [eventContext] - What an event reference reads — see
|
|
467
|
+
* {@link eventReference}.
|
|
385
468
|
* @returns {string|null} A finding message, or `null` when the call is sound.
|
|
386
469
|
*/
|
|
387
|
-
function refCallFault(node, figures) {
|
|
470
|
+
function refCallFault(node, figures, eventContext) {
|
|
388
471
|
const pair = node.hash?.pairs?.find((entry) => entry.key === "form");
|
|
472
|
+
const fieldPair = node.hash?.pairs?.find((entry) => entry.key === "field");
|
|
473
|
+
if (fieldPair) {
|
|
474
|
+
if (fieldPair.value.type !== "StringLiteral")
|
|
475
|
+
return (
|
|
476
|
+
"ref's field must be a quoted string — one of " + EVENT_REFERENCE_FIELDS.join(", ")
|
|
477
|
+
);
|
|
478
|
+
if (pair) return REF_BOTH_FAULT;
|
|
479
|
+
const addressParam = node.params?.[0];
|
|
480
|
+
if (addressParam?.type !== "StringLiteral") return null;
|
|
481
|
+
return (
|
|
482
|
+
eventReference(addressParam.value, fieldPair.value.value, eventContext).problem ?? null
|
|
483
|
+
);
|
|
484
|
+
}
|
|
389
485
|
if (pair && pair.value.type !== "StringLiteral")
|
|
390
486
|
return `ref's form must be a quoted string — one of ${REF_FORMS.join(", ")}`;
|
|
391
487
|
const form = pair ? pair.value.value : "number";
|
|
@@ -413,10 +509,12 @@ function refCallFault(node, figures) {
|
|
|
413
509
|
* @param {string} body
|
|
414
510
|
* @param {object} [figures] - This note's figures, and how to reach another
|
|
415
511
|
* note's — see {@link refFigure}.
|
|
512
|
+
* @param {object} [eventContext] - What an event reference reads — see
|
|
513
|
+
* {@link eventReference}.
|
|
416
514
|
* @returns {Array<{offset: number, message: string}>} One entry per faulty
|
|
417
515
|
* `{{...}}` expression, `offset` into `body`.
|
|
418
516
|
*/
|
|
419
|
-
function refFaults(body, figures) {
|
|
517
|
+
function refFaults(body, figures, eventContext) {
|
|
420
518
|
const faults = [];
|
|
421
519
|
for (const match of matchAllOutsideCode(body, EXPRESSION)) {
|
|
422
520
|
let program;
|
|
@@ -428,7 +526,7 @@ function refFaults(body, figures) {
|
|
|
428
526
|
const visit = (node) => {
|
|
429
527
|
if (!node || typeof node !== "object") return;
|
|
430
528
|
if (node.path?.original === "ref") {
|
|
431
|
-
const message = refCallFault(node, figures);
|
|
529
|
+
const message = refCallFault(node, figures, eventContext);
|
|
432
530
|
if (message) faults.push({ offset: match.index, message });
|
|
433
531
|
}
|
|
434
532
|
for (const parameter of node.params ?? []) visit(parameter);
|
|
@@ -452,22 +550,29 @@ function positionAt(body, offset, bodyLine) {
|
|
|
452
550
|
* figures?: {get: (id: string) => {label: string, caption: string,
|
|
453
551
|
* hasCaption: boolean}|undefined, note?: (address: string) =>
|
|
454
552
|
* {url: string|null, figures: Map<string, object>}|undefined,
|
|
455
|
-
* link?: Function},
|
|
553
|
+
* link?: Function}, events?: {note: (written: string) => object|undefined,
|
|
554
|
+
* own?: (fm: object) => object|undefined},
|
|
555
|
+
* file?: string, bodyLine?: number}} [options] -
|
|
456
556
|
* `figures` is the `ref` helper's own view of the corpus: `get` reads this
|
|
457
557
|
* note's captioned items by id, and `note` resolves another note's the
|
|
458
558
|
* way a wikilink would — omitted, a cross-note address is refused rather
|
|
459
559
|
* than looked up. `link` overrides how a resolved reference renders; the
|
|
460
|
-
* default is a Markdown link to the target's own anchor.
|
|
560
|
+
* default is a Markdown link to the target's own anchor. `events` is what an
|
|
561
|
+
* event reference reads: every note's events by Address, from
|
|
562
|
+
* {@link module:engine/event-fields.eventNoteIndex}; omitted, an event
|
|
563
|
+
* reference is refused.
|
|
461
564
|
*/
|
|
462
565
|
export function renderMarkdownExpressions(
|
|
463
566
|
body,
|
|
464
|
-
{ fm, dates, sqlResults, figures, file, bodyLine = 1 } = {},
|
|
567
|
+
{ fm, dates, sqlResults, figures, events, file, bodyLine = 1 } = {},
|
|
465
568
|
) {
|
|
466
569
|
const findings = [];
|
|
467
570
|
const engine = Handlebars.create();
|
|
468
571
|
for (const helper of HELPERS)
|
|
469
|
-
engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures }));
|
|
470
|
-
const staticFaults = new Map(
|
|
572
|
+
engine.registerHelper(helper.name, helper.make({ sqlResults, dates, figures, events, fm }));
|
|
573
|
+
const staticFaults = new Map(
|
|
574
|
+
refFaults(body, figures, { events, fm, dates }).map((f) => [f.offset, f.message]),
|
|
575
|
+
);
|
|
471
576
|
const markdown = replaceOutsideCode(body, EXPRESSION, (source, _inside, offset) => {
|
|
472
577
|
const fault = staticFaults.get(offset);
|
|
473
578
|
if (fault) {
|
|
@@ -307,6 +307,27 @@ function newestIndex(dirs) {
|
|
|
307
307
|
return path.join(dir, entries[0]);
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/**
|
|
311
|
+
* A published record's events, each with its `id`, the dates the publishing
|
|
312
|
+
* package resolved for it, and the entry as published, which an inline
|
|
313
|
+
* reference to one of its fields reads.
|
|
314
|
+
*
|
|
315
|
+
* @param {Record<string, any>} record - An index record.
|
|
316
|
+
* @returns {Array<{id?: string, when?: object, until?: object, entry: object}>|undefined}
|
|
317
|
+
* In entry order, or `undefined` for a note with none.
|
|
318
|
+
*/
|
|
319
|
+
function publishedEvents(record) {
|
|
320
|
+
const events = record.data?.events;
|
|
321
|
+
if (!Array.isArray(events) || events.length === 0) return undefined;
|
|
322
|
+
const resolved = record.resolvedDates?.events ?? [];
|
|
323
|
+
return events.map((entry, position) => ({
|
|
324
|
+
...(typeof entry?.id === "string" ? { id: entry.id } : {}),
|
|
325
|
+
...(resolved[position]?.when ? { when: resolved[position].when } : {}),
|
|
326
|
+
...(resolved[position]?.until ? { until: resolved[position].until } : {}),
|
|
327
|
+
entry,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
|
|
310
331
|
/**
|
|
311
332
|
* Resolve every foreign address this build can cite, from the fetched indexes.
|
|
312
333
|
*
|
|
@@ -437,6 +458,11 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
|
|
|
437
458
|
...(Object.hasOwn(record.data ?? {}, "government") ?
|
|
438
459
|
{ government: record.data.government }
|
|
439
460
|
: {}),
|
|
461
|
+
// Every anchor the note declares, with its kind, and each of its
|
|
462
|
+
// events with its id and resolved date, so a consumer's
|
|
463
|
+
// `follows` edge into this package is resolved and ordered here.
|
|
464
|
+
noteAnchors: record.anchors ?? undefined,
|
|
465
|
+
events: publishedEvents(record),
|
|
440
466
|
// What a work of literature concerns and what kind of work it
|
|
441
467
|
// is, so a consumer's subject pages list a dependency's works.
|
|
442
468
|
...(parts.type === "lore" && record.subType === "literature" ?
|