@heroiclands/package-build 23.11.0 → 23.12.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 +27 -0
- package/README.md +1 -0
- package/assets/preview/check-sanitizer.go +68 -0
- package/assets/preview/gitea-theme.js +36 -0
- package/assets/preview/gitea.ini +127 -0
- package/assets/styles/content-base.css +119 -16
- package/bin/content-commands.mjs +2 -2
- package/bin/package-build-preview.mjs +75 -0
- package/content-config.mjs +11 -0
- package/docs/api.md +105 -46
- package/docs/authoring/links-and-markup.md +52 -14
- package/docs/commands.md +46 -2
- package/docs/configuration.md +21 -4
- package/docs/design.md +4 -3
- package/docs/guides/book.md +10 -6
- package/docs/reference/format-details.md +44 -40
- package/docs/reference/note-types.md +0 -1
- package/docs/reference/theme-pages.md +1 -1
- package/docs/source-preview.md +172 -0
- package/engine/base-compiler.mjs +7 -8
- package/engine/being-age.mjs +19 -105
- package/engine/calendar-notes.mjs +3 -25
- package/engine/compile-corpus.mjs +2 -1
- package/engine/content-format.yaml +0 -1
- package/engine/content-index.mjs +9 -9
- package/engine/content-spans.mjs +28 -6
- package/engine/current-date.mjs +37 -0
- package/engine/definition-list.mjs +58 -0
- package/engine/first-use.mjs +4 -2
- package/engine/frontmatter-lint.mjs +7 -2
- package/engine/helpers.mjs +2 -0
- package/engine/index.mjs +2 -0
- package/engine/infobox.mjs +1 -5
- package/engine/note-vocabulary.mjs +2 -2
- package/engine/npc-generate.mjs +7 -6
- package/engine/pdf-render.mjs +37 -4
- package/engine/preview-current-date.mjs +60 -0
- package/engine/site-build.mjs +5 -5
- package/engine/site-index.mjs +5 -2
- package/engine/site-preview.mjs +641 -325
- package/hugo-theme/layouts/_default/list.html +1 -1
- package/hugo-theme/layouts/blog/list.html +1 -1
- package/hugo-theme/layouts/blog-post/single.html +1 -1
- package/hugo-theme/layouts/partials/dictionary-lists.html +53 -0
- package/hugo-theme/layouts/partials/page-body.html +1 -1
- package/hugo-theme/static/css/style.css +22 -8
- package/package.json +8 -2
- package/types/content-config.d.mts +8 -0
- package/types/engine/base-compiler.d.mts +1 -1
- package/types/engine/being-age.d.mts +10 -44
- package/types/engine/calendar-notes.d.mts +2 -6
- package/types/engine/compile-corpus.d.mts +2 -1
- package/types/engine/content-index.d.mts +3 -1
- package/types/engine/content-spans.d.mts +4 -2
- package/types/engine/current-date.d.mts +9 -0
- package/types/engine/definition-list.d.mts +8 -0
- package/types/engine/frontmatter-lint.d.mts +6 -2
- package/types/engine/index.d.mts +2 -0
- package/types/engine/npc-generate.d.mts +2 -2
- package/types/engine/preview-current-date.d.mts +8 -0
- package/types/engine/site-index.d.mts +5 -1
- package/types/engine/site-preview.d.mts +40 -33
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* A **world fact** is how long the year is, how the day divides, and how the
|
|
21
21
|
* moon moves. Every people on the world lives under the same ones, so no
|
|
22
22
|
* calendar may restate them: they are read from the package's `place` notes —
|
|
23
|
-
* `data.year`
|
|
23
|
+
* `data.year` from the world, `data.moon` from the moon —
|
|
24
24
|
* and written by the emitters.
|
|
25
25
|
*
|
|
26
26
|
* A **calendar** is one people's division of those days: the months they name,
|
|
@@ -301,7 +301,7 @@ export const CALENDAR_FIELDS = Object.freeze([
|
|
|
301
301
|
* The `data:` keys a world or a celestial body may write.
|
|
302
302
|
*
|
|
303
303
|
* Four groups, and which note carries which is a fact about the body rather
|
|
304
|
-
* than a convention: a planet states its own size, tilt
|
|
304
|
+
* than a convention: a planet states its own size, tilt and year; a
|
|
305
305
|
* moon states its own size, orbit and cycle.
|
|
306
306
|
*
|
|
307
307
|
* @type {readonly object[]}
|
|
@@ -349,12 +349,6 @@ export const INVARIANT_FIELDS = Object.freeze([
|
|
|
349
349
|
kind: "number",
|
|
350
350
|
describe: "How many seconds the minute divides into.",
|
|
351
351
|
},
|
|
352
|
-
{
|
|
353
|
-
name: "present",
|
|
354
|
-
shape: "a date",
|
|
355
|
-
kind: "string",
|
|
356
|
-
describe: "The day the setting stops and play begins — what an age is computed against.",
|
|
357
|
-
},
|
|
358
352
|
{
|
|
359
353
|
name: "body.diameterKm",
|
|
360
354
|
shape: "number",
|
|
@@ -438,14 +432,13 @@ function keysWritten(note, keys) {
|
|
|
438
432
|
* note has no year, and every question that depends on one goes unasked.
|
|
439
433
|
*
|
|
440
434
|
* @param {object} [index] - The link index, holding every note in the package.
|
|
441
|
-
* @returns {{year: object|null,
|
|
435
|
+
* @returns {{year: object|null, moon: object|null,
|
|
442
436
|
* body: object|null, moonName: string|null, yearNote: object|null,
|
|
443
437
|
* moonNote: object|null}} What the package's notes state.
|
|
444
438
|
*/
|
|
445
439
|
export function worldInvariants(index) {
|
|
446
440
|
const empty = {
|
|
447
441
|
year: null,
|
|
448
|
-
present: null,
|
|
449
442
|
moon: null,
|
|
450
443
|
body: null,
|
|
451
444
|
moonName: null,
|
|
@@ -459,7 +452,6 @@ export function worldInvariants(index) {
|
|
|
459
452
|
const data = dataOf(note);
|
|
460
453
|
if (!found.yearNote && data.year && typeof data.year === "object") {
|
|
461
454
|
found.year = data.year;
|
|
462
|
-
found.present = data.present ?? null;
|
|
463
455
|
found.yearNote = note;
|
|
464
456
|
}
|
|
465
457
|
if (!found.moonNote && data.moon && typeof data.moon === "object") {
|
|
@@ -619,9 +611,6 @@ function checkCalendarDayNames(note, daysPerYear) {
|
|
|
619
611
|
* - a moon with no year is refused, because a cycle measured in days has
|
|
620
612
|
* nothing to be measured against.
|
|
621
613
|
*
|
|
622
|
-
* `data.present` is read through the parser a note's dates go through, with the
|
|
623
|
-
* corpus in hand, so a present written in an era resolves where it is written.
|
|
624
|
-
*
|
|
625
614
|
* @param {object} note - The note, as the link index hands it over.
|
|
626
615
|
* @param {object} [opts]
|
|
627
616
|
* @param {object} [opts.index] - The link index, holding every note.
|
|
@@ -650,17 +639,6 @@ export function checkWorldFacts(note, { index } = {}) {
|
|
|
650
639
|
return findings;
|
|
651
640
|
}
|
|
652
641
|
|
|
653
|
-
if (written.includes("present")) {
|
|
654
|
-
const { findings: dated } = parseNoteDate(dataOf(note).present, {
|
|
655
|
-
...reckoningContext(index),
|
|
656
|
-
field: "data.present",
|
|
657
|
-
allowUnknown: false,
|
|
658
|
-
file: note.file,
|
|
659
|
-
raw: note.raw,
|
|
660
|
-
keyPath: ["data", "present"],
|
|
661
|
-
});
|
|
662
|
-
findings.push(...dated);
|
|
663
|
-
}
|
|
664
642
|
if (written.includes("moon") && dataOf(note).moon?.newOn !== undefined) {
|
|
665
643
|
const parsed = parseNoteDate(dataOf(note).moon.newOn, {
|
|
666
644
|
...reckoningContext(index),
|
|
@@ -61,7 +61,7 @@ import { prepareTreeSqlTables } from "./sql-tables.mjs";
|
|
|
61
61
|
* the index refuses is a note the compile must still *report*, exactly as the
|
|
62
62
|
* compile loop reported it when the loop was the first to see it.
|
|
63
63
|
* @param {import("./content-session.mjs").ContentSession} [opts.session] - Captured context shared with other operations.
|
|
64
|
-
* @returns {Promise<{records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[], syntax?: import("./content-session.mjs").ContentSyntax}>} The corpus,
|
|
64
|
+
* @returns {Promise<{currentDate: string, records: object[], parsed: Map<string, object>, linkIndex: object, sqlTables: Map<string, object[]>|undefined, problems: object[], syntax?: import("./content-session.mjs").ContentSyntax}>} The corpus,
|
|
65
65
|
* its indexes, and the notes it could not record.
|
|
66
66
|
*/
|
|
67
67
|
export async function buildCompileCorpus({
|
|
@@ -99,6 +99,7 @@ export async function buildCompileCorpus({
|
|
|
99
99
|
const sqlOptions = { ...corpusOptions, skipDirectories, parsed };
|
|
100
100
|
const sqlTables = await prepareTreeSqlTables(contentBase, sqlOptions);
|
|
101
101
|
return {
|
|
102
|
+
currentDate: resolved.currentDate,
|
|
102
103
|
records,
|
|
103
104
|
parsed,
|
|
104
105
|
problems: collected,
|
package/engine/content-index.mjs
CHANGED
|
@@ -179,7 +179,8 @@ import { isDraftNote } from "./note-vocabulary.mjs";
|
|
|
179
179
|
// A being's computed `age` — the middle state between an authored override and
|
|
180
180
|
// an unknown one — filled in against the package's own declared present before
|
|
181
181
|
// a record is built, so the index carries it without the note being written to.
|
|
182
|
-
import { applyComputedBeingAge
|
|
182
|
+
import { applyComputedBeingAge } from "./being-age.mjs";
|
|
183
|
+
import { normalizeCurrentDate } from "./current-date.mjs";
|
|
183
184
|
import { reckoningContext } from "./reckoning-markers.mjs";
|
|
184
185
|
import { resolvedDateFields } from "./note-dates.mjs";
|
|
185
186
|
|
|
@@ -1097,6 +1098,7 @@ export function resolveCultures(
|
|
|
1097
1098
|
* asserts that it does not exist. The same holds for a culture conflict: a
|
|
1098
1099
|
* reader receives each as a finding, and the emitter throws once, listing
|
|
1099
1100
|
* every one.
|
|
1101
|
+
* @param {string} [options.currentDate="720.1"] - Setting-wide neutral current date.
|
|
1100
1102
|
* @param {(key: string) => ({culture?: unknown}|undefined)} [options.foreignRecord] -
|
|
1101
1103
|
* Another package's entry for a canonical address, read for a parent that
|
|
1102
1104
|
* package publishes; see {@link resolveCultures}.
|
|
@@ -1113,6 +1115,7 @@ export function collectContentIndex(
|
|
|
1113
1115
|
contentBase,
|
|
1114
1116
|
{
|
|
1115
1117
|
contentPackage,
|
|
1118
|
+
currentDate,
|
|
1116
1119
|
skipDirectories,
|
|
1117
1120
|
assetsBase,
|
|
1118
1121
|
manifest,
|
|
@@ -1134,10 +1137,7 @@ export function collectContentIndex(
|
|
|
1134
1137
|
// tree to walk and the index is its asset records. The caller decides
|
|
1135
1138
|
// whether an absent tree is a mistake; by the time the walk is reached it
|
|
1136
1139
|
// is simply a package with nothing to compile.
|
|
1137
|
-
//
|
|
1138
|
-
// same walk before the loop below reads it a second time, and
|
|
1139
|
-
// `walkMarkdownTree` is a generator — a single-use one, exhausted the
|
|
1140
|
-
// moment anything else iterates it first.
|
|
1140
|
+
// Materialize parsed notes once for shared calendar and source context.
|
|
1141
1141
|
const notes = fs.existsSync(contentBase) ? [...walkMarkdownTree(contentBase, walkOpts)] : [];
|
|
1142
1142
|
const parsedNotes = [];
|
|
1143
1143
|
for (const note of notes) {
|
|
@@ -1165,10 +1165,8 @@ export function collectContentIndex(
|
|
|
1165
1165
|
problems.push(problem);
|
|
1166
1166
|
}
|
|
1167
1167
|
|
|
1168
|
-
//
|
|
1169
|
-
|
|
1170
|
-
// second read is needed to answer a question about all of it.
|
|
1171
|
-
const present = presentAmongFrontmatters(parsedNotes.map((n) => n.frontmatter));
|
|
1168
|
+
// Every derived age and recurring event uses the same configured date.
|
|
1169
|
+
const present = normalizeCurrentDate(currentDate);
|
|
1172
1170
|
// Carried on the same context `resolvedDateFields` reads: a recurring
|
|
1173
1171
|
// event's `next` is computed against this present exactly as a being's
|
|
1174
1172
|
// `age` already is, above.
|
|
@@ -1497,6 +1495,7 @@ export function indexRecordsFor({
|
|
|
1497
1495
|
};
|
|
1498
1496
|
return collectContentIndex(tree, {
|
|
1499
1497
|
contentPackage: resolved.contentPackage,
|
|
1498
|
+
currentDate: resolved.currentDate,
|
|
1500
1499
|
addressContext: {
|
|
1501
1500
|
...noteAddressContext(resolved),
|
|
1502
1501
|
declaresAddress: dependencyAddresses(resolved),
|
|
@@ -1680,6 +1679,7 @@ export async function prepareContentIndex({ contentBase, config, session } = {})
|
|
|
1680
1679
|
});
|
|
1681
1680
|
const index = buildSiteIndex(pages, {
|
|
1682
1681
|
package: resolved.contentPackage,
|
|
1682
|
+
currentDate: resolved.currentDate,
|
|
1683
1683
|
records: visible,
|
|
1684
1684
|
foreignIndex: foreign.index,
|
|
1685
1685
|
syntax: session?.syntax,
|
package/engine/content-spans.mjs
CHANGED
|
@@ -22,17 +22,23 @@ import { inlineText } from "./name-markup.mjs";
|
|
|
22
22
|
/** Add bracket spans after recognized Markdown links and footnotes.
|
|
23
23
|
* @param {import("markdown-it")} md - Markdown parser to extend.
|
|
24
24
|
* @param {object} [options] - Surface presentation.
|
|
25
|
-
* @param {boolean} [options.
|
|
25
|
+
* @param {boolean} [options.source] - Emit opaque authored source tokens for buffer preview.
|
|
26
|
+
* @param {boolean} [options.spoilers] - Add focusable CSS spoiler wrappers (Foundry).
|
|
26
27
|
*/
|
|
27
|
-
export function spanMarkdownPlugin(md, { spoilers = false } = {}) {
|
|
28
|
+
export function spanMarkdownPlugin(md, { spoilers = false, source = false } = {}) {
|
|
28
29
|
if (spoilers) {
|
|
29
30
|
md.renderer.rules.span_open = (tokens, index, options, env, renderer) => {
|
|
30
31
|
const token = tokens[index];
|
|
31
32
|
const secret = (token.attrGet("class") ?? "").split(/\s+/).includes("secret");
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
(
|
|
35
|
-
|
|
33
|
+
if (secret) {
|
|
34
|
+
token.attrSet("data-inline-spoiler", "");
|
|
35
|
+
token.attrSet("tabindex", "0");
|
|
36
|
+
if (!token.attrGet("title"))
|
|
37
|
+
token.attrSet("title", "Spoiler: hover or focus to reveal");
|
|
38
|
+
if (!token.attrGet("aria-label"))
|
|
39
|
+
token.attrSet("aria-label", "Spoiler; focus to reveal");
|
|
40
|
+
}
|
|
41
|
+
return renderer.renderToken(tokens, index, options);
|
|
36
42
|
};
|
|
37
43
|
}
|
|
38
44
|
md.inline.ruler.after("link", "bracket_span", (state, silent) => {
|
|
@@ -89,6 +95,22 @@ export function spanMarkdownPlugin(md, { spoilers = false } = {}) {
|
|
|
89
95
|
state.pos = finish;
|
|
90
96
|
return true;
|
|
91
97
|
}
|
|
98
|
+
if (source) {
|
|
99
|
+
// An incomplete attribute stays opaque through the end of its line.
|
|
100
|
+
if (state.src[finish] === "{") {
|
|
101
|
+
const newline = state.src.indexOf("\n", finish);
|
|
102
|
+
finish = newline < 0 ? state.posMax : newline;
|
|
103
|
+
}
|
|
104
|
+
const token = state.push("span_source", "", 0);
|
|
105
|
+
token.content = state.src.slice(start, finish);
|
|
106
|
+
token.meta = {
|
|
107
|
+
text: state.src.slice(start + 1, end),
|
|
108
|
+
labelEnd: end - start,
|
|
109
|
+
secret: parsed.classes.includes("secret"),
|
|
110
|
+
};
|
|
111
|
+
state.pos = finish;
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
92
114
|
const token = state.push("span_open", "span", 1);
|
|
93
115
|
token.meta = {
|
|
94
116
|
span: true,
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
/** Setting-wide neutral current date, independent of configuration loading. */
|
|
15
|
+
import { parseNoteDate } from "./note-dates.mjs";
|
|
16
|
+
|
|
17
|
+
/** Default day on the neutral setting timeline. */
|
|
18
|
+
export const DEFAULT_CURRENT_DATE = "720.1";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate a quoted neutral date and preserve its authored day digits.
|
|
22
|
+
* @param {unknown} [value] - Explicit configuration or adapter override.
|
|
23
|
+
* @returns {string} Validated neutral date; undefined selects the default.
|
|
24
|
+
* @throws {TypeError} An explicit value is not a valid neutral date string.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeCurrentDate(value = undefined) {
|
|
27
|
+
if (value === undefined) return DEFAULT_CURRENT_DATE;
|
|
28
|
+
if (typeof value !== "string" || !/^-?\d+(?:\.\d+)?(?::\d{6})?$/.test(value.trim()))
|
|
29
|
+
throw new TypeError('currentDate must be a quoted neutral date: "<year>[.<day>[:HHMMSS]]"');
|
|
30
|
+
const text = value.trim();
|
|
31
|
+
const parsed = parseNoteDate(text, { allowUnknown: false });
|
|
32
|
+
if (!parsed.date || parsed.findings.some((finding) => finding.severity === "error"))
|
|
33
|
+
throw new TypeError(
|
|
34
|
+
`currentDate: ${parsed.findings[0]?.message ?? "invalid neutral date"}`,
|
|
35
|
+
);
|
|
36
|
+
return text;
|
|
37
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
/** Semantic dictionary rows for HTML definition lists. @module */
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Group each term and its definitions without reparsing their Markdown tokens.
|
|
18
|
+
* Register after markdown-it-deflist; nested blocks and footnotes retain their
|
|
19
|
+
* document context.
|
|
20
|
+
* @param {import("markdown-it")} md - An HTML Markdown parser with definition lists.
|
|
21
|
+
*/
|
|
22
|
+
export function dictionaryListPlugin(md) {
|
|
23
|
+
md.core.ruler.push("dictionary_list_rows", (state) => {
|
|
24
|
+
const output = [],
|
|
25
|
+
lists = [];
|
|
26
|
+
const row = (nesting) => {
|
|
27
|
+
const token = new state.Token(
|
|
28
|
+
nesting === 1 ? "dictionary_row_open" : "dictionary_row_close",
|
|
29
|
+
"div",
|
|
30
|
+
nesting,
|
|
31
|
+
);
|
|
32
|
+
token.block = true;
|
|
33
|
+
output.push(token);
|
|
34
|
+
};
|
|
35
|
+
for (const token of state.tokens) {
|
|
36
|
+
if (token.type === "dl_open") {
|
|
37
|
+
if (!(token.attrGet("class") ?? "").split(/\s+/).includes("dictionary"))
|
|
38
|
+
token.attrJoin("class", "dictionary");
|
|
39
|
+
lists.push({ row: false });
|
|
40
|
+
} else if (token.type === "dt_open" && lists.length) {
|
|
41
|
+
const list = lists.at(-1);
|
|
42
|
+
if (list.row) row(-1);
|
|
43
|
+
row(1);
|
|
44
|
+
list.row = true;
|
|
45
|
+
} else if (token.type === "dd_open" && lists.length) {
|
|
46
|
+
// A loose definition begins with an inline first paragraph.
|
|
47
|
+
token.block = false;
|
|
48
|
+
} else if (token.type === "dt_close" && lists.length) {
|
|
49
|
+
// Inter-tag whitespace would add a word space to the fixed gap.
|
|
50
|
+
token.block = false;
|
|
51
|
+
} else if (token.type === "dl_close" && lists.length) {
|
|
52
|
+
if (lists.pop().row) row(-1);
|
|
53
|
+
}
|
|
54
|
+
output.push(token);
|
|
55
|
+
}
|
|
56
|
+
state.tokens = output;
|
|
57
|
+
});
|
|
58
|
+
}
|
package/engine/first-use.mjs
CHANGED
|
@@ -327,14 +327,16 @@ export function createFirstUseChecker({
|
|
|
327
327
|
"repeated-name",
|
|
328
328
|
`“${entry.full}” is linked or marked more than once`,
|
|
329
329
|
);
|
|
330
|
-
|
|
330
|
+
// A resolved wikilink identifies its note, so its label may be any natural form.
|
|
331
|
+
const introduces = entry.fullUse || entry.key === event.preferred?.key;
|
|
332
|
+
if (!introduces && !fullUsed.has(entry.key))
|
|
331
333
|
add(
|
|
332
334
|
event.start,
|
|
333
335
|
"short-before-full",
|
|
334
336
|
`“${event.label}” appears before the full name “${entry.full}”`,
|
|
335
337
|
);
|
|
336
338
|
used.add(entry.key);
|
|
337
|
-
if (
|
|
339
|
+
if (introduces) fullUsed.add(entry.key);
|
|
338
340
|
}
|
|
339
341
|
for (const span of bold) {
|
|
340
342
|
if (typeof span.label !== "string") continue;
|
|
@@ -1143,6 +1143,7 @@ function checkEmbeddedShortcodes(note, blockName) {
|
|
|
1143
1143
|
* @param {object} note - A note from the link index (`{fm, file, raw, type}`).
|
|
1144
1144
|
* @param {object} opts
|
|
1145
1145
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
1146
|
+
* @param {string} [opts.currentDate="720.1"] - Setting-wide neutral date for age checks.
|
|
1146
1147
|
* @param {object} [opts.index] - The link index, for the reference check, which
|
|
1147
1148
|
* runs through its `shortcodeHit`. Its absence skips that check rather than
|
|
1148
1149
|
* reporting every reference as dead.
|
|
@@ -1175,6 +1176,7 @@ export function lintNote(
|
|
|
1175
1176
|
{
|
|
1176
1177
|
schemas,
|
|
1177
1178
|
index,
|
|
1179
|
+
currentDate,
|
|
1178
1180
|
vocabulary,
|
|
1179
1181
|
packs,
|
|
1180
1182
|
emittedArt,
|
|
@@ -1544,7 +1546,8 @@ export function lintNote(
|
|
|
1544
1546
|
findings.push(...field.check(note, { index, addressContext: referenceContext }));
|
|
1545
1547
|
}
|
|
1546
1548
|
// A check the type declares of the whole note, with the same index.
|
|
1547
|
-
if (typeof entry.check === "function")
|
|
1549
|
+
if (typeof entry.check === "function")
|
|
1550
|
+
findings.push(...entry.check(note, { index, currentDate }));
|
|
1548
1551
|
}
|
|
1549
1552
|
|
|
1550
1553
|
const fields = authoredFields(schema);
|
|
@@ -1717,6 +1720,7 @@ export function lintNote(
|
|
|
1717
1720
|
* @param {object} index - From `buildLinkIndex`.
|
|
1718
1721
|
* @param {object} opts
|
|
1719
1722
|
* @param {Record<string, readonly object[]>} opts.schemas - Type → declaration.
|
|
1723
|
+
* @param {string} [opts.currentDate="720.1"] - Setting-wide neutral date for age checks.
|
|
1720
1724
|
* @param {Record<string, object>} [opts.vocabulary] - Type → the closed regions
|
|
1721
1725
|
* it declares; see {@link lintNote}.
|
|
1722
1726
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
@@ -1732,7 +1736,7 @@ export function lintNote(
|
|
|
1732
1736
|
*/
|
|
1733
1737
|
export function lintFrontmatter(
|
|
1734
1738
|
index,
|
|
1735
|
-
{ schemas, vocabulary, packs, emittedArt, references = true, systems },
|
|
1739
|
+
{ schemas, vocabulary, packs, emittedArt, references = true, systems, currentDate },
|
|
1736
1740
|
) {
|
|
1737
1741
|
const findings = [];
|
|
1738
1742
|
const notes = [...index.notes].sort((a, b) =>
|
|
@@ -1745,6 +1749,7 @@ export function lintFrontmatter(
|
|
|
1745
1749
|
...lintNote(note, {
|
|
1746
1750
|
schemas,
|
|
1747
1751
|
vocabulary,
|
|
1752
|
+
currentDate,
|
|
1748
1753
|
packs,
|
|
1749
1754
|
emittedArt,
|
|
1750
1755
|
index: references ? index : undefined,
|
package/engine/helpers.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import unidecode from "unidecode";
|
|
|
41
41
|
import markdownit from "markdown-it";
|
|
42
42
|
import footnotePlugin from "markdown-it-footnote";
|
|
43
43
|
import deflistPlugin from "markdown-it-deflist";
|
|
44
|
+
import { dictionaryListPlugin } from "./definition-list.mjs";
|
|
44
45
|
import { iconPlugin } from "./content-icons.mjs";
|
|
45
46
|
import { imagePlugin, imagesIn } from "./content-images.mjs";
|
|
46
47
|
import { embedRole, resolveEmbeds } from "./content-embeds.mjs";
|
|
@@ -108,6 +109,7 @@ let localAssetImageInfoCache;
|
|
|
108
109
|
export const md = markdownit({ html: true })
|
|
109
110
|
.use(footnotePlugin)
|
|
110
111
|
.use(deflistPlugin)
|
|
112
|
+
.use(dictionaryListPlugin)
|
|
111
113
|
.use(spanMarkdownPlugin, { spoilers: true })
|
|
112
114
|
.use(dictionaryEntryPlugin)
|
|
113
115
|
// A heading's attribute block, written onto the heading element rather than
|
package/engine/index.mjs
CHANGED
|
@@ -155,6 +155,8 @@ export * as calendarNotes from "./calendar-notes.mjs";
|
|
|
155
155
|
export * as calendarArtifacts from "./calendar-artifacts.mjs";
|
|
156
156
|
|
|
157
157
|
/** A being's `age`, absent beside a dated `born`: the age the date computes to. */
|
|
158
|
+
export * as definitionList from "./definition-list.mjs";
|
|
159
|
+
export * as currentDate from "./current-date.mjs";
|
|
158
160
|
export * as beingAge from "./being-age.mjs";
|
|
159
161
|
|
|
160
162
|
/** The one normalisation this build makes: prose to a URL-safe token. */
|
package/engine/infobox.mjs
CHANGED
|
@@ -1038,11 +1038,7 @@ function noteBox(
|
|
|
1038
1038
|
// A culture note resolves to itself, and a row linking it to itself
|
|
1039
1039
|
// says nothing.
|
|
1040
1040
|
if (field.name === "culture" && cultureNamesItself(fm, raw, { contentPackage })) continue;
|
|
1041
|
-
if (
|
|
1042
|
-
((fm?.type === "being" && ["born", "died"].includes(field.name)) ||
|
|
1043
|
-
(fm?.type === "place" && field.name === "present")) &&
|
|
1044
|
-
dates
|
|
1045
|
-
) {
|
|
1041
|
+
if (fm?.type === "being" && ["born", "died"].includes(field.name) && dates) {
|
|
1046
1042
|
const parsed = parseNoteDate(raw, dates).date;
|
|
1047
1043
|
const era =
|
|
1048
1044
|
parsed?.marker ? dates.markers?.get(parsed.marker)
|
|
@@ -92,7 +92,7 @@ import {
|
|
|
92
92
|
checkWorldFacts,
|
|
93
93
|
} from "./calendar-notes.mjs";
|
|
94
94
|
// Whether a being's authored `age` disagrees with what `born` and the
|
|
95
|
-
//
|
|
95
|
+
// configured current date compute — the closed `data:` container's own
|
|
96
96
|
// question, asked the way `checkPlace` and `checkCalendarNote` are.
|
|
97
97
|
import { checkBeingAge } from "./being-age.mjs";
|
|
98
98
|
import { checkSocialTies } from "./social-ties.mjs";
|
|
@@ -1522,7 +1522,7 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
1522
1522
|
stubbable: true,
|
|
1523
1523
|
subTypes: Object.freeze(["npc", "character", "creature"]),
|
|
1524
1524
|
// Whether an authored `age` disagrees with what `born` and the
|
|
1525
|
-
//
|
|
1525
|
+
// configured current date compute — see `engine/being-age.mjs`.
|
|
1526
1526
|
check: checkBeingAge,
|
|
1527
1527
|
data: Object.freeze([
|
|
1528
1528
|
TOKEN_ICON,
|
package/engine/npc-generate.mjs
CHANGED
|
@@ -44,9 +44,8 @@
|
|
|
44
44
|
* `complexion` is a list of the one word drawn.
|
|
45
45
|
* - `height` — from the profile's range for the gender, in feet and inches.
|
|
46
46
|
* - `weight` — from the height and the frame's body-mass band, in pounds.
|
|
47
|
-
* - `born` — an adult age of {@link ADULT_AGE} years before the
|
|
48
|
-
*
|
|
49
|
-
* gets `born: unknown`.
|
|
47
|
+
* - `born` — an adult age of {@link ADULT_AGE} years before the configured
|
|
48
|
+
* current date, as a neutral `<year>.<day>` date. The default is `720.1`.
|
|
50
49
|
* - `archetypes: [commoner]`, the archetype for a person who fits no more
|
|
51
50
|
* specific one.
|
|
52
51
|
* - `icon`, `species` and `packFolder` — what the caller derives from the
|
|
@@ -55,7 +54,7 @@
|
|
|
55
54
|
* are `[]`; `affiliations` is `{}`; `appearance.extra_features` is `[]`.
|
|
56
55
|
*
|
|
57
56
|
* Plain ESM with no filesystem access; the CLI resolves the culture, its
|
|
58
|
-
* language's name lists and the
|
|
57
|
+
* language's name lists and the configured current date and hands them in.
|
|
59
58
|
*
|
|
60
59
|
* @module
|
|
61
60
|
*/
|
|
@@ -65,6 +64,7 @@ import YAML from "yaml";
|
|
|
65
64
|
import { BEING_LOOK_VOCABULARY } from "./note-vocabulary.mjs";
|
|
66
65
|
import { FRAME_MASS_INDEX, profileLookKeys } from "./appearance-profile.mjs";
|
|
67
66
|
import { parseBeingHeight } from "./being-measurements.mjs";
|
|
67
|
+
import { normalizeCurrentDate } from "./current-date.mjs";
|
|
68
68
|
import { computeAge, parseAgeMagnitude } from "./being-age.mjs";
|
|
69
69
|
import { parseNoteDate } from "./note-dates.mjs";
|
|
70
70
|
import { eraYear } from "./calendars.mjs";
|
|
@@ -435,7 +435,7 @@ function frontmatterYaml(frontmatter) {
|
|
|
435
435
|
* about a name that does not fit.
|
|
436
436
|
* @param {string} [context.seed] - Text joined to the spec's own seed, to draw a
|
|
437
437
|
* different person from the same spec.
|
|
438
|
-
* @param {unknown} [context.
|
|
438
|
+
* @param {unknown} [context.currentDate] - The setting's neutral current date, defaulting to "720.1".
|
|
439
439
|
* @param {object} [context.dateContext] - The reckoning context the present
|
|
440
440
|
* and `born` are read in, with `daysPerYear`.
|
|
441
441
|
* @param {{icon?: unknown, species?: unknown, packFolder?: unknown}} [context.defaults] -
|
|
@@ -449,7 +449,8 @@ function frontmatterYaml(frontmatter) {
|
|
|
449
449
|
export function generateNpc(spec, context) {
|
|
450
450
|
const problems = npcSpecProblems(spec);
|
|
451
451
|
if (problems.length) throw new Error(problems.join("; "));
|
|
452
|
-
const { profile, names,
|
|
452
|
+
const { profile, names, currentDate, dateContext = {}, defaults = {} } = context;
|
|
453
|
+
const present = normalizeCurrentDate(currentDate);
|
|
453
454
|
if (!isPlainMap(names)) throw new Error("`npc new` needs the language's `data.names`");
|
|
454
455
|
const taken = context.taken ?? new Set();
|
|
455
456
|
const seed = canonicalSpec(spec) + (given(context.seed) ? `\u0000${context.seed}` : "");
|
package/engine/pdf-render.mjs
CHANGED
|
@@ -822,9 +822,9 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
822
822
|
definitions.push(renderTokens(tokens.slice(at + 1, definitionEnd), ctx));
|
|
823
823
|
at = definitionEnd + 1;
|
|
824
824
|
}
|
|
825
|
-
entries.push(`
|
|
825
|
+
entries.push(`book-definition([${term}],[${definitions.join("\n\n")}])`);
|
|
826
826
|
}
|
|
827
|
-
out.push(`\n
|
|
827
|
+
out.push(`\n${renderDefinitionList(entries)}\n\n`);
|
|
828
828
|
return end - i + 1;
|
|
829
829
|
}
|
|
830
830
|
case "table_open": {
|
|
@@ -840,6 +840,36 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
840
840
|
}
|
|
841
841
|
}
|
|
842
842
|
|
|
843
|
+
/**
|
|
844
|
+
* Self-contained dictionary rows with the website's 150 CSS-pixel hanging indent.
|
|
845
|
+
*
|
|
846
|
+
* Each row remains a breakable block; only the bold term is an inline box. The
|
|
847
|
+
* negative first-line space lets its label start at the original margin while
|
|
848
|
+
* subsequent paragraphs, lists and blocks retain the definition's inset. A long
|
|
849
|
+
* term enlarges its slot without changing the continuation margin.
|
|
850
|
+
*
|
|
851
|
+
* Scope the helper to this list so exported Markdown fragments also work without
|
|
852
|
+
* a book preamble, including nested definition lists.
|
|
853
|
+
* @param {string[]} entries - Calls containing already-rendered terms and bodies.
|
|
854
|
+
* @returns {string} Typst markup.
|
|
855
|
+
*/
|
|
856
|
+
function renderDefinitionList(entries) {
|
|
857
|
+
return `#context {
|
|
858
|
+
let book-definition(term, body) = {
|
|
859
|
+
let indent = 112.5pt
|
|
860
|
+
let gap = (0.75em).to-absolute()
|
|
861
|
+
let label = text(weight: "bold", term)
|
|
862
|
+
let slot = calc.max(indent - gap, measure(label).width)
|
|
863
|
+
block(width: 100%, breakable: true, inset: (left: indent), above: 0pt, below: 0.5em)[
|
|
864
|
+
#set par(first-line-indent: 0pt, hanging-indent: 0pt)
|
|
865
|
+
#set text(weight: "regular")
|
|
866
|
+
#h(-indent)#box(width: slot, label)#h(gap)#body
|
|
867
|
+
]
|
|
868
|
+
}
|
|
869
|
+
${entries.join("\n ")}
|
|
870
|
+
}`;
|
|
871
|
+
}
|
|
872
|
+
|
|
843
873
|
/**
|
|
844
874
|
* A withheld section, set in the box a `secret` block is set in.
|
|
845
875
|
*
|
|
@@ -1210,7 +1240,10 @@ function renderInline(token, ctx) {
|
|
|
1210
1240
|
out.push(inlineRaw(child.content));
|
|
1211
1241
|
break;
|
|
1212
1242
|
case "span_open": {
|
|
1213
|
-
|
|
1243
|
+
// Inline secrecy has no print presentation; other classes and ids survive.
|
|
1244
|
+
const classes = (child.attrGet?.("class") ?? "")
|
|
1245
|
+
.split(/\s+/)
|
|
1246
|
+
.filter((value) => value && value !== "secret");
|
|
1214
1247
|
const values = classes
|
|
1215
1248
|
.map((value) => '"' + escapeTypstString(value) + '"')
|
|
1216
1249
|
.join(", ");
|
|
@@ -1630,7 +1663,7 @@ export function bookTypstPreamble() {
|
|
|
1630
1663
|
// weight so it remains bold inside an ordinary strong phrase.
|
|
1631
1664
|
"#let book-span(body, classes: ()) = {\n" +
|
|
1632
1665
|
' let styled = if classes.contains("name") { text(weight: "bold", body) } else { body }\n' +
|
|
1633
|
-
|
|
1666
|
+
" box(styled)\n" +
|
|
1634
1667
|
"}",
|
|
1635
1668
|
// Use the file's natural measure for auto. Every requested width is
|
|
1636
1669
|
// bounded by the available measure and by the height of a page.
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
/** Opt-in date configuration for preview hosts; no content or dependency reads. @module */
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import YAML from "yaml";
|
|
18
|
+
import { findConfigFile } from "./pack-config.mjs";
|
|
19
|
+
import { formatDiagnostic, positionOfYamlPath } from "./diagnostics.mjs";
|
|
20
|
+
import { normalizeCurrentDate } from "./current-date.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read only currentDate from the nearest package configuration.
|
|
24
|
+
* YAML does not load the full build configuration. An MJS configuration executes
|
|
25
|
+
* its own module, as any caller opting into a code configuration expects.
|
|
26
|
+
* @param {string} root - Directory at which configuration discovery starts.
|
|
27
|
+
* @returns {Promise<string>} Validated neutral current date.
|
|
28
|
+
*/
|
|
29
|
+
export async function readPreviewCurrentDate(root) {
|
|
30
|
+
const file = findConfigFile(root);
|
|
31
|
+
if (!file) return normalizeCurrentDate();
|
|
32
|
+
let source = "";
|
|
33
|
+
try {
|
|
34
|
+
let input;
|
|
35
|
+
if (file.endsWith(".mjs")) {
|
|
36
|
+
const version = (await fs.stat(file)).mtimeMs;
|
|
37
|
+
const module = await import(`${pathToFileURL(file).href}?previewDate=${version}`);
|
|
38
|
+
input = module.default ?? module;
|
|
39
|
+
} else {
|
|
40
|
+
source = await fs.readFile(file, "utf8");
|
|
41
|
+
const document = YAML.parseDocument(source);
|
|
42
|
+
if (document.errors.length) throw document.errors[0];
|
|
43
|
+
input = document.toJS();
|
|
44
|
+
}
|
|
45
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
46
|
+
throw new TypeError("package configuration must be a mapping");
|
|
47
|
+
}
|
|
48
|
+
return normalizeCurrentDate(input.currentDate);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
formatDiagnostic({
|
|
52
|
+
file,
|
|
53
|
+
...(source ? positionOfYamlPath(source, ["currentDate"]) : {}),
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: error.message,
|
|
56
|
+
}),
|
|
57
|
+
{ cause: error },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|