@heroiclands/package-build 22.21.0 → 22.22.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 +191 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +36 -18
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +224 -46
- package/docs/reference/note-types.md +21 -4
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/frontmatter-lint.mjs +47 -64
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +14 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +83 -8
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +172 -37
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +18 -2
- package/engine/site-preview.mjs +18 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standings.mjs +89 -6
- package/engine/system-block.mjs +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +82 -6
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pdf-render.d.mts +72 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standings.d.mts +21 -3
- package/types/engine/system-block.d.mts +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-secrets.d.mts +0 -17
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import { encodeAddresses } from "./address-values.mjs";
|
|
38
38
|
import { sectionHolds } from "./infobox.mjs";
|
|
39
|
-
import { escapeTypst, escapeTypstString } from "./pdf-render.mjs";
|
|
39
|
+
import { absolutePageUrl, escapeTypst, escapeTypstString } from "./pdf-render.mjs";
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* How many cells an attribute grid packs into a row.
|
|
@@ -226,16 +226,22 @@ export function sectionHasContent(section) {
|
|
|
226
226
|
* reference, so the link works on paper as a cross-reference and in a PDF
|
|
227
227
|
* viewer as a jump. Everything else is set as its own words.
|
|
228
228
|
*
|
|
229
|
+
* A panel's rows address pages the same way prose does, so a page the book does
|
|
230
|
+
* not print is set as the absolute URL a reader can follow — see
|
|
231
|
+
* {@link module:engine/pdf-render.absolutePageUrl}.
|
|
232
|
+
*
|
|
229
233
|
* @param {object} value - A `link` value.
|
|
230
234
|
* @param {Map<string, string>} links - Address slug → the book's anchor.
|
|
231
235
|
* @param {(anchor: string) => string} labelFor - The anchor's Typst label.
|
|
236
|
+
* @param {string} [site] - The absolute address this book's pages are served at.
|
|
232
237
|
* @returns {string} Typst markup.
|
|
233
238
|
*/
|
|
234
|
-
export function linkToTypst(value, links, labelFor) {
|
|
239
|
+
export function linkToTypst(value, links, labelFor, site) {
|
|
235
240
|
const text = escapeTypst(value?.text ?? "");
|
|
236
241
|
const anchor = value?.address ? links?.get(encodeAddresses(value.address)) : undefined;
|
|
237
242
|
if (anchor) return `#link(<${labelFor(anchor)}>)[${text}]`;
|
|
238
|
-
if (value?.url)
|
|
243
|
+
if (value?.url)
|
|
244
|
+
return `#link("${escapeTypstString(absolutePageUrl(value.url, site))}")[${text}]`;
|
|
239
245
|
return text;
|
|
240
246
|
}
|
|
241
247
|
|
|
@@ -375,7 +381,13 @@ export function infoboxTypstPreamble() {
|
|
|
375
381
|
"fill: infobox-rule)[#upper(t)]; v(0.20em) }",
|
|
376
382
|
'#let infobox-head(t) = { v(0.40em); text(size: 7.6pt, weight: "bold", ' +
|
|
377
383
|
"tracking: 1.3pt, fill: infobox-rule)[#upper(t)]; v(0.16em) }",
|
|
384
|
+
// One definition-list style across the three surfaces: the term right
|
|
385
|
+
// against a fixed column so every term in the panel shares one edge,
|
|
386
|
+
// and space below the row rather than rows run together. A page has one
|
|
387
|
+
// width, so the stacked state the screen surfaces fall back to has
|
|
388
|
+
// nothing to answer here and is not drawn.
|
|
378
389
|
"#let infobox-row(k, v) = grid(columns: (2.05cm, 1fr), column-gutter: 4pt, " +
|
|
390
|
+
"inset: (bottom: 2pt), align: (right + top, left + top), " +
|
|
379
391
|
"text(size: 7pt, tracking: 0.5pt, fill: infobox-faint)[#upper(k)], text(size: 8pt)[#v])",
|
|
380
392
|
"#let infobox-cell(lab, val) = align(center)[" +
|
|
381
393
|
"#text(size: 7pt, tracking: 0.6pt, fill: infobox-faint)[#lab]#h(2.5pt)" +
|
package/engine/infobox.mjs
CHANGED
|
@@ -121,7 +121,13 @@ import { subtypeRow } from "./document-subtypes.mjs";
|
|
|
121
121
|
import { authoredKey } from "./system-block.mjs";
|
|
122
122
|
import { formatDateInCalendar, formatNoteDate, parseNoteDate } from "./note-dates.mjs";
|
|
123
123
|
import { displayBeingHeight, displayBeingWeight } from "./being-measurements.mjs";
|
|
124
|
-
import {
|
|
124
|
+
import {
|
|
125
|
+
officeAnchor,
|
|
126
|
+
officeRoster,
|
|
127
|
+
rankAnchor,
|
|
128
|
+
readStandings,
|
|
129
|
+
standingPhrase,
|
|
130
|
+
} from "./standings.mjs";
|
|
125
131
|
|
|
126
132
|
/**
|
|
127
133
|
* How a section arranges what it holds.
|
|
@@ -327,7 +333,8 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
|
|
|
327
333
|
}),
|
|
328
334
|
"being.appearance.complexion": Object.freeze({
|
|
329
335
|
group: "appearance",
|
|
330
|
-
|
|
336
|
+
whole: true,
|
|
337
|
+
phrase: (v) => `${humanizedList(v)} complexion`,
|
|
331
338
|
}),
|
|
332
339
|
"being.appearance.extra_features": Object.freeze({ group: "appearance" }),
|
|
333
340
|
});
|
|
@@ -469,6 +476,24 @@ export function humanizeValue(value) {
|
|
|
469
476
|
.trim();
|
|
470
477
|
}
|
|
471
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Several enumerated values as one clause — `weathered, ruddy and scarred`.
|
|
481
|
+
*
|
|
482
|
+
* A field that admits one value or a list of them reads as a single phrase, so
|
|
483
|
+
* the noun it qualifies is said once rather than once per value. Internal: the
|
|
484
|
+
* phrase that wants it is declared in this module.
|
|
485
|
+
*
|
|
486
|
+
* @param {unknown} value - One value, or a list of them.
|
|
487
|
+
* @returns {string} The text.
|
|
488
|
+
*/
|
|
489
|
+
function humanizedList(value) {
|
|
490
|
+
const parts = (Array.isArray(value) ? value : [value])
|
|
491
|
+
.filter((entry) => entry !== undefined && entry !== null && entry !== "")
|
|
492
|
+
.map((entry) => humanizeValue(entry));
|
|
493
|
+
if (parts.length < 2) return parts[0] ?? "";
|
|
494
|
+
return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
472
497
|
/**
|
|
473
498
|
* A value as a row shows it.
|
|
474
499
|
*
|
|
@@ -633,6 +658,43 @@ function rosterRows(raw) {
|
|
|
633
658
|
return rows;
|
|
634
659
|
}
|
|
635
660
|
|
|
661
|
+
/**
|
|
662
|
+
* A ladder of named ranks, one row each, ordered by the level a body confers.
|
|
663
|
+
*
|
|
664
|
+
* The **title is the label and its description is the value**, the same
|
|
665
|
+
* convention {@link rosterRows} draws a body's offices with, so a reader
|
|
666
|
+
* meets the two lists the same way. A rung's optional `lore` — already
|
|
667
|
+
* resolved to an Address by the time this runs — reads alongside the
|
|
668
|
+
* description rather than replacing it, since the description is what the
|
|
669
|
+
* rung itself states and the lore is a pointer to more.
|
|
670
|
+
*
|
|
671
|
+
* @param {unknown} raw - The authored `governance.ranks` list.
|
|
672
|
+
* @param {(ref: unknown, hint?: object) => object|undefined} resolve - The
|
|
673
|
+
* medium's resolver.
|
|
674
|
+
* @returns {object[]|null} One row per rung, in ascending level, or `null`
|
|
675
|
+
* where the value is not a list of them.
|
|
676
|
+
*/
|
|
677
|
+
function rankRows(raw, resolve) {
|
|
678
|
+
if (!Array.isArray(raw)) return null;
|
|
679
|
+
const rungs = raw
|
|
680
|
+
.filter((rung) => isMapping(rung) && hasValue(rung.title))
|
|
681
|
+
.toSorted((a, b) => Number(a.level ?? 0) - Number(b.level ?? 0));
|
|
682
|
+
const rows = [];
|
|
683
|
+
for (const rung of rungs) {
|
|
684
|
+
const anchor = rankAnchor(String(rung.title));
|
|
685
|
+
const description =
|
|
686
|
+
hasValue(rung.description) ? String(rung.description) : String(rung.title);
|
|
687
|
+
const lore = hasValue(rung.lore) ? linkValue(rung.lore, resolve, { type: "lore" }) : null;
|
|
688
|
+
rows.push({
|
|
689
|
+
...(anchor ? { id: anchor } : {}),
|
|
690
|
+
label: String(rung.title),
|
|
691
|
+
kind: "text",
|
|
692
|
+
value: lore?.text ? `${description} (Lore: ${lore.text})` : description,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return rows;
|
|
696
|
+
}
|
|
697
|
+
|
|
636
698
|
/** Expand structured references using the existing linked-row shape. */
|
|
637
699
|
function structuredRows(field, raw, resolve, label, fm) {
|
|
638
700
|
if (field.standings) return standingRows(field, raw, resolve, label);
|
|
@@ -640,9 +702,20 @@ function structuredRows(field, raw, resolve, label, fm) {
|
|
|
640
702
|
const roster = rosterRows(raw);
|
|
641
703
|
if (roster) return roster;
|
|
642
704
|
}
|
|
643
|
-
if (
|
|
644
|
-
const
|
|
645
|
-
|
|
705
|
+
if (field.ranks) {
|
|
706
|
+
const ranks = rankRows(raw, resolve);
|
|
707
|
+
if (ranks) return ranks;
|
|
708
|
+
}
|
|
709
|
+
// A relationship entry — `{to, ...}` — is a mapping that is not itself an
|
|
710
|
+
// Address: an authored plain list of addresses normalises to a list of
|
|
711
|
+
// Address tuples, which `isMapping` also reports true for, and treating
|
|
712
|
+
// one as a relationship keyed off a `to` it never carries is what emptied
|
|
713
|
+
// `lore`, `parents`, `domains` and `economy` silently.
|
|
714
|
+
const relations =
|
|
715
|
+
Array.isArray(raw) ? raw.filter((entry) => isMapping(entry) && !isAddressTuple(entry)) : [];
|
|
716
|
+
if (relations.length) {
|
|
717
|
+
const links = relations
|
|
718
|
+
.filter((entry) => hasValue(entry.to))
|
|
646
719
|
.map((entry) => {
|
|
647
720
|
const link = linkValue(entry.to, resolve, { type: field.ref ?? "place" });
|
|
648
721
|
const details = Object.entries(entry)
|
|
@@ -666,7 +739,7 @@ function structuredRows(field, raw, resolve, label, fm) {
|
|
|
666
739
|
const address = parseAddress(
|
|
667
740
|
target,
|
|
668
741
|
{
|
|
669
|
-
package:
|
|
742
|
+
package: "local",
|
|
670
743
|
system: "note",
|
|
671
744
|
types: new Set(field.accepts),
|
|
672
745
|
},
|
|
@@ -789,7 +862,7 @@ function noteBox(
|
|
|
789
862
|
const rows = [];
|
|
790
863
|
/** @type {Set<string>} */
|
|
791
864
|
const shown = new Set();
|
|
792
|
-
const name = fm?.name?.full
|
|
865
|
+
const name = fm?.name?.full;
|
|
793
866
|
if (hasValue(name)) rows.push({ label: "Name", kind: "text", value: String(name) });
|
|
794
867
|
if (Array.isArray(fm?.name?.aliases) && fm.name.aliases.length) {
|
|
795
868
|
rows.push({ label: "Aliases", kind: "list", value: fm.name.aliases });
|
|
@@ -857,7 +930,9 @@ function noteBox(
|
|
|
857
930
|
label: GROUP_LABELS[overlay.group] ?? humanizeFieldName(overlay.group),
|
|
858
931
|
entries: [],
|
|
859
932
|
};
|
|
860
|
-
|
|
933
|
+
// A `whole` phrase reads the value entire, so several values compose
|
|
934
|
+
// into one clause rather than repeating the noun once each.
|
|
935
|
+
const parts = overlay.whole || !Array.isArray(raw) ? [raw] : raw.filter(hasValue);
|
|
861
936
|
for (const part of parts) {
|
|
862
937
|
group.entries.push(overlay.phrase ? overlay.phrase(part) : humanizeValue(part));
|
|
863
938
|
}
|
package/engine/journals.mjs
CHANGED
|
@@ -71,6 +71,14 @@ import { journalHasContent } from "./note-state.mjs";
|
|
|
71
71
|
import { draftNoticeFor } from "./draft-notice.mjs";
|
|
72
72
|
import { scanCaptions } from "./content-captions.mjs";
|
|
73
73
|
import { separateFootnotes } from "./content-footnotes.mjs";
|
|
74
|
+
import { WITHHELD_CLASS, pageOpenings } from "./heading-attributes.mjs";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE` — the default ownership a withheld
|
|
78
|
+
* page states. A GM reads every document whatever it says; everyone else reads
|
|
79
|
+
* this one not at all.
|
|
80
|
+
*/
|
|
81
|
+
const WITHHELD_OWNERSHIP = 0;
|
|
74
82
|
|
|
75
83
|
/**
|
|
76
84
|
* Splits a markdown body into pages by top-level H1 headings. Fenced
|
|
@@ -78,7 +86,8 @@ import { separateFootnotes } from "./content-footnotes.mjs";
|
|
|
78
86
|
* a split. Content before the first H1 (if non-empty) becomes a leading
|
|
79
87
|
* page. Each H1 yields a page whose name is the heading
|
|
80
88
|
* text (with any `{#anchor-id}` suffix stripped out and surfaced as
|
|
81
|
-
* `anchorSlug`).
|
|
89
|
+
* `anchorSlug`). The classes the heading declares come along as `classes`, so
|
|
90
|
+
* a surface that honours one has it without reading the suffix again.
|
|
82
91
|
*
|
|
83
92
|
* `leadName` names that leading page. A journal note's is "Introduction",
|
|
84
93
|
* because it introduces the pages that follow. An item doc's is the item — a
|
|
@@ -89,7 +98,7 @@ import { separateFootnotes } from "./content-footnotes.mjs";
|
|
|
89
98
|
* @param {string} body - Markdown body to split.
|
|
90
99
|
* @param {string} [leadName] - Name of the page before the first heading.
|
|
91
100
|
* @returns {Array<{name: string, anchorSlug: string|null, level: number,
|
|
92
|
-
* markdown: string}>} Pages in document order.
|
|
101
|
+
* classes: string[], markdown: string}>} Pages in document order.
|
|
93
102
|
*/
|
|
94
103
|
export function splitPages(body, leadName = "Introduction") {
|
|
95
104
|
const { markdown, definitions } = separateFootnotes(body);
|
|
@@ -101,6 +110,7 @@ export function splitPages(body, leadName = "Introduction") {
|
|
|
101
110
|
.filter((caption) => /^\s*#{1,6}\s/.test(lines[caption.blockStart] ?? ""))
|
|
102
111
|
.map((caption) => caption.blockStart),
|
|
103
112
|
);
|
|
113
|
+
const openings = pageOpenings(markdown);
|
|
104
114
|
const pages = [];
|
|
105
115
|
const beforeFirstH1 = [];
|
|
106
116
|
let current = null;
|
|
@@ -113,6 +123,7 @@ export function splitPages(body, leadName = "Introduction") {
|
|
|
113
123
|
name: current.name,
|
|
114
124
|
anchorSlug: current.anchorSlug,
|
|
115
125
|
level: current.level,
|
|
126
|
+
classes: current.classes,
|
|
116
127
|
markdown: current.lines.join("\n").trim(),
|
|
117
128
|
});
|
|
118
129
|
current = null;
|
|
@@ -128,11 +139,13 @@ export function splitPages(body, leadName = "Introduction") {
|
|
|
128
139
|
// An H1 starts a page, as does any heading carrying an `{#slug}`
|
|
129
140
|
// anchor: a Foundry UUID can only address a page, so a linkable
|
|
130
141
|
// section has to be one.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
// {@link module:engine/heading-attributes.parseHeadingLine} is the one
|
|
143
|
+
// reading of that rule — `scanBlocks` and `scanCaptions` refuse such a
|
|
144
|
+
// heading written where it cannot become a page, from the same
|
|
145
|
+
// function, so none of them can disagree about what starts one. The
|
|
146
|
+
// lines it claims here are collected by `pageOpenings`, which also
|
|
147
|
+
// skips a fence and a named block.
|
|
148
|
+
const opening = openings.get(lineIndex);
|
|
136
149
|
const caption = !inCodeBlock && !inSecret ? captionStarts.get(lineIndex) : null;
|
|
137
150
|
if (caption) {
|
|
138
151
|
closeCurrent();
|
|
@@ -140,16 +153,18 @@ export function splitPages(body, leadName = "Introduction") {
|
|
|
140
153
|
name: caption.label,
|
|
141
154
|
anchorSlug: caption.id,
|
|
142
155
|
level: 1,
|
|
156
|
+
classes: [],
|
|
143
157
|
lines: [line],
|
|
144
158
|
};
|
|
145
159
|
continue;
|
|
146
160
|
}
|
|
147
|
-
if (
|
|
161
|
+
if (opening && !captionedHeadings.has(lineIndex)) {
|
|
148
162
|
closeCurrent();
|
|
149
163
|
current = {
|
|
150
|
-
name:
|
|
151
|
-
anchorSlug:
|
|
152
|
-
level:
|
|
164
|
+
name: opening.text,
|
|
165
|
+
anchorSlug: opening.id || null,
|
|
166
|
+
level: opening.level,
|
|
167
|
+
classes: opening.classes,
|
|
153
168
|
lines: [],
|
|
154
169
|
};
|
|
155
170
|
continue;
|
|
@@ -169,6 +184,7 @@ export function splitPages(body, leadName = "Introduction") {
|
|
|
169
184
|
name: leadName,
|
|
170
185
|
anchorSlug: null,
|
|
171
186
|
level: 1,
|
|
187
|
+
classes: [],
|
|
172
188
|
markdown: intro,
|
|
173
189
|
});
|
|
174
190
|
}
|
|
@@ -273,10 +289,14 @@ export function journalPageId(entryId, page) {
|
|
|
273
289
|
* @param {Array<object>} rawPages - From {@link splitPages}.
|
|
274
290
|
* @param {string} entryId - The owning JournalEntry's `_id`.
|
|
275
291
|
* @param {string} noteName - The note, for error messages.
|
|
292
|
+
* A page whose heading carries `.secret` is given to the GM alone, by the one
|
|
293
|
+
* ownership value Foundry reads as "nobody but a GM". Every other page states
|
|
294
|
+
* no ownership and inherits the journal's.
|
|
295
|
+
*
|
|
276
296
|
* @returns {Array<{_id: string, name: string, type: string,
|
|
277
297
|
* title: {show: boolean, level: number},
|
|
278
|
-
* text: {format: number, content: string}, _key: string
|
|
279
|
-
* documents, in order.
|
|
298
|
+
* text: {format: number, content: string}, _key: string,
|
|
299
|
+
* ownership?: {default: number}}>} The page documents, in order.
|
|
280
300
|
* @throws {Error} When the note has no content at all, or repeats an anchor.
|
|
281
301
|
*/
|
|
282
302
|
export function buildPages(rawPages, entryId, noteName, captions) {
|
|
@@ -296,6 +316,7 @@ export function buildPages(rawPages, entryId, noteName, captions) {
|
|
|
296
316
|
}
|
|
297
317
|
return rawPages.map((page) => {
|
|
298
318
|
const pageId = journalPageId(entryId, page);
|
|
319
|
+
const withheld = (page.classes ?? []).includes(WITHHELD_CLASS);
|
|
299
320
|
return {
|
|
300
321
|
_id: pageId,
|
|
301
322
|
name: page.name,
|
|
@@ -308,6 +329,7 @@ export function buildPages(rawPages, entryId, noteName, captions) {
|
|
|
308
329
|
renderFoundryMarkdown(page.markdown, captions, footnoteNumbers, pageId)
|
|
309
330
|
: "",
|
|
310
331
|
},
|
|
332
|
+
...(withheld ? { ownership: { default: WITHHELD_OWNERSHIP } } : {}),
|
|
311
333
|
_key: `!journal.pages!${entryId}.${pageId}`,
|
|
312
334
|
};
|
|
313
335
|
});
|
|
@@ -329,7 +351,6 @@ export function buildPages(rawPages, entryId, noteName, captions) {
|
|
|
329
351
|
* @param {string} [params.leadName] - Name for the page before the first
|
|
330
352
|
* heading; see {@link splitPages}.
|
|
331
353
|
* @param {string|null} [params.folder] - The folder id, or `null`.
|
|
332
|
-
* @param {object} [params.flags] - Document flags.
|
|
333
354
|
* @param {object} [params.stats] - The `_stats` block to stamp. Passed by the
|
|
334
355
|
* caller because it is a property of the *pack* being written, not of the
|
|
335
356
|
* entry: a module may ship the same content for two systems, and each pack's
|
|
@@ -352,7 +373,6 @@ export function buildJournalEntry({
|
|
|
352
373
|
markdown,
|
|
353
374
|
leadName,
|
|
354
375
|
folder = null,
|
|
355
|
-
flags,
|
|
356
376
|
stats = defaultStats(),
|
|
357
377
|
infoboxes = [],
|
|
358
378
|
notice = "",
|
|
@@ -379,7 +399,7 @@ export function buildJournalEntry({
|
|
|
379
399
|
folder,
|
|
380
400
|
sort: 0,
|
|
381
401
|
ownership: { default: 0 },
|
|
382
|
-
flags:
|
|
402
|
+
flags: {},
|
|
383
403
|
_id: id,
|
|
384
404
|
_stats: stats,
|
|
385
405
|
_key: `!journal!${id}`,
|
|
@@ -546,7 +566,6 @@ export class Journals extends BasePackCompiler {
|
|
|
546
566
|
// "Introduction" — see {@link splitPages}.
|
|
547
567
|
leadName: ownsDoc ? name : undefined,
|
|
548
568
|
folder,
|
|
549
|
-
flags: fm.flags,
|
|
550
569
|
stats: this.stats,
|
|
551
570
|
});
|
|
552
571
|
}
|
package/engine/macros.mjs
CHANGED
|
@@ -7,8 +7,182 @@ import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
|
|
|
7
7
|
import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
|
|
8
8
|
import { numberWords, numberDigits } from "./number-words.mjs";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* An inline expression, as an author writes one.
|
|
12
|
+
*
|
|
13
|
+
* Hugo shortcodes begin `{{<` or `{{%` and stay in the source for Hugo.
|
|
14
|
+
*
|
|
15
|
+
* Exported because a renderer that meets one has met a body this pass never ran
|
|
16
|
+
* over, and has to be able to say so.
|
|
17
|
+
*
|
|
18
|
+
* @type {RegExp}
|
|
19
|
+
*/
|
|
20
|
+
export const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every helper an expression may call, with the parameters it takes.
|
|
24
|
+
*
|
|
25
|
+
* **This is the one list.** The engine registers from it and the authoring
|
|
26
|
+
* document is checked against it, so a helper added here is a helper the
|
|
27
|
+
* document must describe, and a helper the document describes but the engine
|
|
28
|
+
* does not register fails the same test.
|
|
29
|
+
*
|
|
30
|
+
* `make` receives the render's context — the prepared SQL results and the date
|
|
31
|
+
* rules — and returns the function Handlebars calls. A helper that needs
|
|
32
|
+
* neither ignores it.
|
|
33
|
+
*/
|
|
34
|
+
const HELPERS = Object.freeze([
|
|
35
|
+
Object.freeze({
|
|
36
|
+
name: "eq",
|
|
37
|
+
params: "a b",
|
|
38
|
+
summary: 'True when both are the same value. Compares exactly, so 1 and "1" differ.',
|
|
39
|
+
make: () => (left, right) => left === right,
|
|
40
|
+
}),
|
|
41
|
+
Object.freeze({
|
|
42
|
+
name: "gt",
|
|
43
|
+
params: "a b",
|
|
44
|
+
summary: "True when a is greater than b. Both must be finite numbers.",
|
|
45
|
+
make: () => numericCompare("gt", (a, b) => a > b),
|
|
46
|
+
}),
|
|
47
|
+
Object.freeze({
|
|
48
|
+
name: "gte",
|
|
49
|
+
params: "a b",
|
|
50
|
+
summary: "True when a is greater than or equal to b. Both must be finite numbers.",
|
|
51
|
+
make: () => numericCompare("gte", (a, b) => a >= b),
|
|
52
|
+
}),
|
|
53
|
+
Object.freeze({
|
|
54
|
+
name: "lt",
|
|
55
|
+
params: "a b",
|
|
56
|
+
summary: "True when a is less than b. Both must be finite numbers.",
|
|
57
|
+
make: () => numericCompare("lt", (a, b) => a < b),
|
|
58
|
+
}),
|
|
59
|
+
Object.freeze({
|
|
60
|
+
name: "lte",
|
|
61
|
+
params: "a b",
|
|
62
|
+
summary: "True when a is less than or equal to b. Both must be finite numbers.",
|
|
63
|
+
make: () => numericCompare("lte", (a, b) => a <= b),
|
|
64
|
+
}),
|
|
65
|
+
Object.freeze({
|
|
66
|
+
name: "not",
|
|
67
|
+
params: "value",
|
|
68
|
+
summary: "True when the value is false, zero, empty or absent.",
|
|
69
|
+
make: () => (value) => !value,
|
|
70
|
+
}),
|
|
71
|
+
Object.freeze({
|
|
72
|
+
name: "and",
|
|
73
|
+
params: "value …",
|
|
74
|
+
summary: "True when every value given is true. Takes two or more.",
|
|
75
|
+
make:
|
|
76
|
+
() =>
|
|
77
|
+
(...args) =>
|
|
78
|
+
args.slice(0, -1).every(Boolean),
|
|
79
|
+
}),
|
|
80
|
+
Object.freeze({
|
|
81
|
+
name: "or",
|
|
82
|
+
params: "value …",
|
|
83
|
+
summary: "True when any value given is true. Takes two or more.",
|
|
84
|
+
make:
|
|
85
|
+
() =>
|
|
86
|
+
(...args) =>
|
|
87
|
+
args.slice(0, -1).some(Boolean),
|
|
88
|
+
}),
|
|
89
|
+
Object.freeze({
|
|
90
|
+
name: "words",
|
|
91
|
+
params: "number",
|
|
92
|
+
summary:
|
|
93
|
+
"The number spelled out for running prose — `1200` becomes one thousand two hundred. " +
|
|
94
|
+
"Whole numbers only; a decimal is a finding, and `digits` takes one.",
|
|
95
|
+
make: () => (value) => numberWords(value),
|
|
96
|
+
}),
|
|
97
|
+
Object.freeze({
|
|
98
|
+
name: "digits",
|
|
99
|
+
params: "number",
|
|
100
|
+
summary:
|
|
101
|
+
"The number as a grouped numeral — `1200` becomes 1,200, and a fractional part is kept.",
|
|
102
|
+
make: () => (value) => numberDigits(value),
|
|
103
|
+
}),
|
|
104
|
+
Object.freeze({
|
|
105
|
+
name: "sql",
|
|
106
|
+
params: '"query"',
|
|
107
|
+
summary:
|
|
108
|
+
"The single value a query returns, for use inside a sentence. The query is a quoted " +
|
|
109
|
+
"string, the same SQL a table fence takes.",
|
|
110
|
+
make:
|
|
111
|
+
({ sqlResults }) =>
|
|
112
|
+
(query) => {
|
|
113
|
+
if (typeof query !== "string" || !query.trim())
|
|
114
|
+
throw new TypeError("sql needs a nonempty query string");
|
|
115
|
+
const result = sqlResults?.get(query);
|
|
116
|
+
if (!result)
|
|
117
|
+
throw new RangeError(`SQL expression has no prepared result: ${query}`);
|
|
118
|
+
if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
|
|
119
|
+
return result.value;
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
Object.freeze({
|
|
123
|
+
name: "dateformat",
|
|
124
|
+
params: 'calendar date ["format"]',
|
|
125
|
+
summary:
|
|
126
|
+
"A date written in a calendar. The calendar is an Address or its shortcode, the date " +
|
|
127
|
+
"is the value a note carries, and the optional third argument names an output pattern.",
|
|
128
|
+
make:
|
|
129
|
+
({ dates }) =>
|
|
130
|
+
(calendar, authored, formatOrOptions) => {
|
|
131
|
+
if (
|
|
132
|
+
!(typeof calendar === "string" || isAddressTuple(calendar)) ||
|
|
133
|
+
!["string", "number"].includes(typeof authored)
|
|
134
|
+
)
|
|
135
|
+
throw new TypeError("dateformat needs a calendar Address and a date");
|
|
136
|
+
const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
|
|
137
|
+
const error = parsed.findings.find((item) => item.severity === "error");
|
|
138
|
+
if (error || !parsed.date)
|
|
139
|
+
throw new RangeError(error?.message ?? "date does not resolve");
|
|
140
|
+
const printable = formatDateInCalendar(
|
|
141
|
+
parsed.date,
|
|
142
|
+
isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
|
|
143
|
+
dates,
|
|
144
|
+
typeof formatOrOptions === "string" ? formatOrOptions : undefined,
|
|
145
|
+
);
|
|
146
|
+
if (!printable)
|
|
147
|
+
throw new RangeError(`date cannot be printed in calendar ${calendar}`);
|
|
148
|
+
return printable.prose ?? printable.text;
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A comparison of two numbers, which refuses anything else.
|
|
155
|
+
*
|
|
156
|
+
* Four helpers differ only in the comparison, and a value that is not a finite
|
|
157
|
+
* number is a finding rather than a silent `false`.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} name - The helper's name, for the message.
|
|
160
|
+
* @param {(a: number, b: number) => boolean} compare - The comparison.
|
|
161
|
+
* @returns {(left: unknown, right: unknown) => boolean} The helper.
|
|
162
|
+
*/
|
|
163
|
+
function numericCompare(name, compare) {
|
|
164
|
+
return (left, right) => {
|
|
165
|
+
const finite = (value) =>
|
|
166
|
+
typeof value === "bigint" || (typeof value === "number" && Number.isFinite(value));
|
|
167
|
+
if (!finite(left) || !finite(right))
|
|
168
|
+
throw new TypeError(`${name} needs two finite numbers`);
|
|
169
|
+
return compare(left, right);
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Each helper's parameters and what it does, keyed by name.
|
|
175
|
+
*
|
|
176
|
+
* The authoring document describes this surface, and a test holds the two
|
|
177
|
+
* together. A consumer reads it to list what an expression may call.
|
|
178
|
+
*
|
|
179
|
+
* @type {Readonly<Record<string, Readonly<{params: string, summary: string}>>>}
|
|
180
|
+
*/
|
|
181
|
+
export const EXPRESSION_HELPERS = Object.freeze(
|
|
182
|
+
Object.fromEntries(
|
|
183
|
+
HELPERS.map(({ name, params, summary }) => [name, Object.freeze({ params, summary })]),
|
|
184
|
+
),
|
|
185
|
+
);
|
|
12
186
|
|
|
13
187
|
/** Find SQL helper calls, including calls nested inside Boolean expressions. */
|
|
14
188
|
export function sqlQueriesInMarkdown(body, fm = {}) {
|
|
@@ -47,57 +221,8 @@ export function renderMarkdownExpressions(
|
|
|
47
221
|
) {
|
|
48
222
|
const findings = [];
|
|
49
223
|
const engine = Handlebars.create();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
!["number", "bigint"].includes(typeof left) ||
|
|
53
|
-
!["number", "bigint"].includes(typeof right) ||
|
|
54
|
-
(typeof left === "number" && !Number.isFinite(left)) ||
|
|
55
|
-
(typeof right === "number" && !Number.isFinite(right))
|
|
56
|
-
)
|
|
57
|
-
throw new TypeError(`${name} needs two finite numbers`);
|
|
58
|
-
};
|
|
59
|
-
for (const [name, compare] of Object.entries({
|
|
60
|
-
gt: (a, b) => a > b,
|
|
61
|
-
gte: (a, b) => a >= b,
|
|
62
|
-
lt: (a, b) => a < b,
|
|
63
|
-
lte: (a, b) => a <= b,
|
|
64
|
-
}))
|
|
65
|
-
engine.registerHelper(name, (left, right) => {
|
|
66
|
-
numeric(name, left, right);
|
|
67
|
-
return compare(left, right);
|
|
68
|
-
});
|
|
69
|
-
engine.registerHelper("eq", (left, right) => left === right);
|
|
70
|
-
engine.registerHelper("not", (value) => !value);
|
|
71
|
-
engine.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
|
|
72
|
-
engine.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
|
|
73
|
-
engine.registerHelper("words", (value) => numberWords(value));
|
|
74
|
-
engine.registerHelper("digits", (value) => numberDigits(value));
|
|
75
|
-
engine.registerHelper("sql", (query) => {
|
|
76
|
-
if (typeof query !== "string" || !query.trim())
|
|
77
|
-
throw new TypeError("sql needs a nonempty query string");
|
|
78
|
-
const result = sqlResults?.get(query);
|
|
79
|
-
if (!result) throw new RangeError(`SQL expression has no prepared result: ${query}`);
|
|
80
|
-
if (result.error) throw new RangeError(`SQL expression failed: ${result.error}`);
|
|
81
|
-
return result.value;
|
|
82
|
-
});
|
|
83
|
-
engine.registerHelper("dateformat", (calendar, authored, formatOrOptions) => {
|
|
84
|
-
if (
|
|
85
|
-
!(typeof calendar === "string" || isAddressTuple(calendar)) ||
|
|
86
|
-
!["string", "number"].includes(typeof authored)
|
|
87
|
-
)
|
|
88
|
-
throw new TypeError("dateformat needs a calendar Address and a date");
|
|
89
|
-
const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
|
|
90
|
-
const error = parsed.findings.find((item) => item.severity === "error");
|
|
91
|
-
if (error || !parsed.date) throw new RangeError(error?.message ?? "date does not resolve");
|
|
92
|
-
const printable = formatDateInCalendar(
|
|
93
|
-
parsed.date,
|
|
94
|
-
isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
|
|
95
|
-
dates,
|
|
96
|
-
typeof formatOrOptions === "string" ? formatOrOptions : undefined,
|
|
97
|
-
);
|
|
98
|
-
if (!printable) throw new RangeError(`date cannot be printed in calendar ${calendar}`);
|
|
99
|
-
return printable.prose ?? printable.text;
|
|
100
|
-
});
|
|
224
|
+
for (const helper of HELPERS)
|
|
225
|
+
engine.registerHelper(helper.name, helper.make({ sqlResults, dates }));
|
|
101
226
|
const markdown = replaceOutsideCode(body, EXPRESSION, (source, _inside, offset) => {
|
|
102
227
|
try {
|
|
103
228
|
return engine.compile(source, { strict: true })(fm ?? {});
|
package/engine/note-dates.mjs
CHANGED
|
@@ -117,6 +117,39 @@ function unknownRecord() {
|
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Whether a number-valued date may have lost a trailing zero off its day.
|
|
122
|
+
*
|
|
123
|
+
* YAML reads an unquoted `<year>.<day>` as a float, and a float carries no
|
|
124
|
+
* trailing zero: `born: 667.130` and `born: 667.13` both arrive as `667.13`.
|
|
125
|
+
* The authored digits are unrecoverable by then, so the question a reader needs
|
|
126
|
+
* answered is whether a longer day was *possible* — and it was exactly when the
|
|
127
|
+
* value with one more zero on the end still names a day the world's year holds.
|
|
128
|
+
*
|
|
129
|
+
* One zero settles it. A second only makes the day ten times longer again, so a
|
|
130
|
+
* value whose single-zero expansion already falls outside the year has no
|
|
131
|
+
* reading but the one it was handed.
|
|
132
|
+
*
|
|
133
|
+
* **The world's year length is what makes the question answerable**, so a tree
|
|
134
|
+
* whose notes declare none gets no claim either way — the same rule the upper
|
|
135
|
+
* bound on a day follows. Refusing every float there would red a whole corpus
|
|
136
|
+
* over a test nothing could evaluate.
|
|
137
|
+
*
|
|
138
|
+
* @param {number} value - The authored value, as YAML parsed it.
|
|
139
|
+
* @param {number|undefined} daysPerYear - The world's year length.
|
|
140
|
+
* @returns {boolean} True when the authored day is ambiguous.
|
|
141
|
+
*/
|
|
142
|
+
function dayCouldHaveLostAZero(value, daysPerYear) {
|
|
143
|
+
if (Number.isInteger(value)) return false;
|
|
144
|
+
const fraction = String(Math.abs(value)).split(".")[1] ?? "";
|
|
145
|
+
// An exponent-form string carries no day at all.
|
|
146
|
+
if (!/^\d+$/.test(fraction)) return false;
|
|
147
|
+
const tenfold = Number(`${fraction}0`);
|
|
148
|
+
if (!Number.isSafeInteger(tenfold)) return false;
|
|
149
|
+
if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1) return false;
|
|
150
|
+
return tenfold <= daysPerYear;
|
|
151
|
+
}
|
|
152
|
+
|
|
120
153
|
/**
|
|
121
154
|
* Parse one authored date.
|
|
122
155
|
*
|
|
@@ -194,6 +227,22 @@ export function parseNoteDate(value, options) {
|
|
|
194
227
|
return { date: null, findings };
|
|
195
228
|
};
|
|
196
229
|
|
|
230
|
+
// An unquoted `<year>.<day>` reaches here as a float, and YAML has already
|
|
231
|
+
// dropped a trailing zero off the day: `667.13` and `667.130` arrive as one
|
|
232
|
+
// value, so nothing downstream can tell day 13 from day 130. Refused where
|
|
233
|
+
// the lost zero would still name a day inside the world's year, and read as
|
|
234
|
+
// written where no such day exists — `675.281` cannot have been `675.2810`.
|
|
235
|
+
if (typeof value === "number" && dayCouldHaveLostAZero(value, daysPerYear)) {
|
|
236
|
+
const grown = `${text}0`;
|
|
237
|
+
return refuse(
|
|
238
|
+
`${subject} is a number, and a day's trailing zero is lost before a date ` +
|
|
239
|
+
`is read — \`${text}\` and \`${grown}\` reach this check as one value. ` +
|
|
240
|
+
`Quote a canonical date that states a day, \`"${text}"\` or ` +
|
|
241
|
+
`\`"${grown}"\`, so the digits the note states are the digits the ` +
|
|
242
|
+
`build reads`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
197
246
|
const resolveEra = (parsed, reckoning, label) => {
|
|
198
247
|
if (parsed.year < 1) return refuse(`${subject} needs a positive year in ${label}`);
|
|
199
248
|
if (!Number.isSafeInteger(daysPerYear) || daysPerYear < 1)
|