@heroiclands/package-build 25.5.5 → 25.6.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 +25 -0
- package/assets/styles/content-base.css +1 -1
- package/docs/api.md +24 -16
- package/docs/authoring/frontmatter.md +1 -1
- package/docs/authoring/links-and-markup.md +10 -9
- package/docs/content-format.md +40 -1
- package/docs/diagnostics.md +2 -2
- package/docs/encoding-core.md +43 -5
- package/docs/guides/reference-pages.md +4 -1
- package/docs/guides/site.md +1 -1
- package/docs/reference/format-details.md +239 -194
- package/docs/reference/note-types.md +187 -174
- package/docs/reference/theme-pages.md +1 -0
- package/engine/content-format.yaml +9 -7
- package/engine/content-html.mjs +3 -2
- package/engine/content-index.mjs +23 -0
- package/engine/content-links.mjs +8 -0
- package/engine/content-render.mjs +2 -2
- package/engine/content-tables.mjs +12 -5
- package/engine/culture-reach.mjs +3 -0
- package/engine/declared-sections.mjs +100 -0
- package/engine/derived-sections.mjs +36 -2
- package/engine/dictionary-rows.mjs +2 -1
- package/engine/encoding-template.mjs +28 -0
- package/engine/encoding-terms.mjs +20 -12
- package/engine/encoding.mjs +147 -17
- package/engine/generated-sections.mjs +2 -0
- package/engine/holdings.mjs +107 -3
- package/engine/index-records.mjs +3 -1
- package/engine/index.mjs +1 -1
- package/engine/language-data.mjs +0 -1
- package/engine/language-rows.mjs +1 -3
- package/engine/language-terms.mjs +7 -10
- package/engine/map-dot.mjs +22 -0
- package/engine/markdown-blocks.mjs +23 -8
- package/engine/metadata-index.mjs +1 -0
- package/engine/note-addresses.mjs +16 -0
- package/engine/note-vocabulary.mjs +19 -8
- package/engine/office-holders.mjs +76 -0
- package/engine/page-breaks.mjs +36 -8
- package/engine/pdf-render.mjs +4 -3
- package/engine/place-kinds.mjs +106 -0
- package/engine/settlement-tables.mjs +410 -675
- package/engine/settlement-terms.mjs +118 -0
- package/engine/site-build.mjs +2 -4
- package/engine/sql-tables.mjs +101 -7
- package/engine/standing-terms.mjs +9 -5
- package/engine/standings.mjs +83 -31
- package/engine/unique-items.mjs +85 -0
- package/package.json +9 -4
- package/site-theme/data/banner-kinds.yaml +2 -0
- package/sohl/actors.mjs +4 -3
- package/types/engine/content-html.d.mts +2 -1
- package/types/engine/content-scripts.d.mts +6 -0
- package/types/engine/culture-reach.d.mts +3 -0
- package/types/engine/declared-sections.d.mts +30 -0
- package/types/engine/derived-sections.d.mts +5 -1
- package/types/engine/encoding-lexicon.d.mts +6 -0
- package/types/engine/encoding-template.d.mts +6 -0
- package/types/engine/encoding.d.mts +12 -0
- package/types/engine/holdings.d.mts +34 -2
- package/types/engine/index-records.d.mts +2 -1
- package/types/engine/language-terms.d.mts +5 -1
- package/types/engine/markdown-blocks.d.mts +5 -1
- package/types/engine/office-holders.d.mts +33 -0
- package/types/engine/page-breaks.d.mts +8 -0
- package/types/engine/place-kinds.d.mts +18 -0
- package/types/engine/settlement-tables.d.mts +25 -158
- package/types/engine/settlement-terms.d.mts +53 -0
- package/types/engine/sql-tables.d.mts +35 -3
- package/types/engine/standing-terms.d.mts +3 -2
- package/types/engine/standings.d.mts +14 -2
- package/types/engine/unique-items.d.mts +12 -0
- package/vendor/markdownlint.mjs +855 -569
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
/** Dependency-free settlement row declarations. @module */
|
|
15
|
+
/** The generated heading. */
|
|
16
|
+
export const SETTLEMENTS_HEADING = "Settlements";
|
|
17
|
+
|
|
18
|
+
/** Rendered columns, in their fixed order; Held by and For appear when a row states them. */
|
|
19
|
+
export const SETTLEMENT_COLUMNS = Object.freeze([
|
|
20
|
+
Object.freeze({ name: "Name", required: true }),
|
|
21
|
+
Object.freeze({ name: "Market", required: true }),
|
|
22
|
+
Object.freeze({ name: "People", required: true }),
|
|
23
|
+
Object.freeze({ name: "Held by", required: false }),
|
|
24
|
+
Object.freeze({ name: "For", required: false }),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** The closed row fields; existing subjects use native Addresses. */
|
|
28
|
+
export const SETTLEMENT_ROW_FIELDS = Object.freeze(
|
|
29
|
+
[
|
|
30
|
+
{
|
|
31
|
+
name: "place",
|
|
32
|
+
kind: "address",
|
|
33
|
+
shape: "a place Address",
|
|
34
|
+
describe: "The existing settlement note; exclusive with name.",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "name",
|
|
38
|
+
kind: "string",
|
|
39
|
+
shape: "plain text",
|
|
40
|
+
describe: "A settlement without a note; exclusive with place.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "market",
|
|
44
|
+
kind: "number",
|
|
45
|
+
required: true,
|
|
46
|
+
shape: "a market class, 1 to 6",
|
|
47
|
+
describe: "What trade the settlement supports.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "population",
|
|
51
|
+
kind: "number",
|
|
52
|
+
required: true,
|
|
53
|
+
shape: "a positive whole number",
|
|
54
|
+
describe: "How many people live here.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "heldBy",
|
|
58
|
+
kind: "address",
|
|
59
|
+
shape: "an affiliation or being Address",
|
|
60
|
+
describe:
|
|
61
|
+
"What holds the settlement's land, where its culture holds land; what the holder is says how it is held.",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "for",
|
|
65
|
+
kind: "string",
|
|
66
|
+
shape: "plain text",
|
|
67
|
+
describe: "Optional reason the settlement exists.",
|
|
68
|
+
},
|
|
69
|
+
].map(Object.freeze),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** The place vocabulary's single settlement declaration. */
|
|
73
|
+
export const SETTLEMENTS_FIELD = Object.freeze({
|
|
74
|
+
name: "settlements",
|
|
75
|
+
kind: "map",
|
|
76
|
+
shape: "{ rows, tail }",
|
|
77
|
+
fields: Object.freeze([
|
|
78
|
+
{
|
|
79
|
+
name: "rows",
|
|
80
|
+
kind: "list",
|
|
81
|
+
required: true,
|
|
82
|
+
shape: "a list of settlement rows",
|
|
83
|
+
entries: { kind: "map", shape: "a settlement row", fields: SETTLEMENT_ROW_FIELDS },
|
|
84
|
+
describe: "One row per settlement.",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "tail",
|
|
88
|
+
kind: "string",
|
|
89
|
+
required: true,
|
|
90
|
+
shape: "a nonempty sentence",
|
|
91
|
+
describe: "What the table leaves out.",
|
|
92
|
+
},
|
|
93
|
+
]),
|
|
94
|
+
describe:
|
|
95
|
+
"A region's settlement rows and the sentence stating what they leave out; the table is generated from this declaration.",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** Every semantic finding, named once for the reference and its guards. */
|
|
99
|
+
export const SETTLEMENT_TABLE_RULES = Object.freeze(
|
|
100
|
+
[
|
|
101
|
+
[
|
|
102
|
+
"body settlements table",
|
|
103
|
+
"A body table under Settlements is refused; declare data.settlements.",
|
|
104
|
+
],
|
|
105
|
+
["settlements shape", "A region declares rows and tail, with one name or place per row."],
|
|
106
|
+
["unresolved settlement reference", "A place or heldBy Address does not resolve."],
|
|
107
|
+
["market class", "A row's market is not an integer class from 1 to 6."],
|
|
108
|
+
["people figure", "A row's population is not a positive whole number."],
|
|
109
|
+
["not a holder", "A heldBy Address names neither an affiliation nor a being."],
|
|
110
|
+
["not a settlement", "A place Address names a subject that is not a place."],
|
|
111
|
+
["figure disagrees", "A linked population differs from its note, or the note states none."],
|
|
112
|
+
["market disagrees", "A linked market differs from its note, or the note states none."],
|
|
113
|
+
["unlinked settlement", "A plain name has an existing settlement note in this region."],
|
|
114
|
+
["missing row", "A covered settlement note has no row."],
|
|
115
|
+
["duplicate settlement", "A settlement has another row here or in another region."],
|
|
116
|
+
["no tail", "The declaration has no sentence stating what it leaves out."],
|
|
117
|
+
].map(([name, describe]) => Object.freeze({ name, describe })),
|
|
118
|
+
);
|
package/engine/site-build.mjs
CHANGED
|
@@ -275,11 +275,9 @@ export function collectContentPages(contentBase, ctx) {
|
|
|
275
275
|
// not use it: a note is addressed as `type/shortcode` wherever it
|
|
276
276
|
// is filed.
|
|
277
277
|
tld: rel.split(path.sep)[0],
|
|
278
|
-
// Location below the content root, POSIX-separated
|
|
279
|
-
// generated table reads as `file.path` and scopes on with `FROM`.
|
|
278
|
+
// Location below the content root, POSIX-separated.
|
|
280
279
|
relPath: rel.split(path.sep).join("/"),
|
|
281
|
-
// The immediate source subfolder —
|
|
282
|
-
// `file.folder`, and the page's `kbfolder`.
|
|
280
|
+
// The immediate source subfolder — the page's `kbfolder`.
|
|
283
281
|
folder: path.basename(path.dirname(file)),
|
|
284
282
|
// Every page is addressed by `(type, shortcode)` at the package
|
|
285
283
|
// root, which takes no content mount.
|
package/engine/sql-tables.mjs
CHANGED
|
@@ -217,6 +217,59 @@ export function findSqlBlocks(markdown) {
|
|
|
217
217
|
return blocks;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Overwrite string literals and comments with spaces, keeping every offset and
|
|
222
|
+
* newline, so a search of what remains reads only identifiers and operators.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} sql - A query.
|
|
225
|
+
* @returns {string} The query, the same length.
|
|
226
|
+
*/
|
|
227
|
+
function maskLiterals(sql) {
|
|
228
|
+
const blank = (text) => text.replace(/[^\n]/g, " ");
|
|
229
|
+
return sql.replace(/'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g, blank);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Where a query reads the `file` block of the index record: `file.path`,
|
|
234
|
+
* `file.folder` or `file.name`, bare or qualified by a relation or its alias.
|
|
235
|
+
* A note is selected by property, tag or address, so a query that reaches for
|
|
236
|
+
* the file it was written in is refused. A field of a note's own data that is
|
|
237
|
+
* named `file` is not the block and is left alone.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} sql - One fence's query.
|
|
240
|
+
* @returns {{line: number, column: number, text: string}|undefined} The first
|
|
241
|
+
* reference, its 0-based line within the query and 1-based column, or
|
|
242
|
+
* nothing where the query makes none.
|
|
243
|
+
*/
|
|
244
|
+
export function findFileReference(sql) {
|
|
245
|
+
const masked = maskLiterals(String(sql ?? ""));
|
|
246
|
+
const qualifiers = new Set();
|
|
247
|
+
for (const [, relation, alias] of masked.matchAll(
|
|
248
|
+
/\b(?:from|join)\s+([\w."]+)(?:\s+(?:as\s+)?([A-Za-z_]\w*))?/gi,
|
|
249
|
+
)) {
|
|
250
|
+
qualifiers.add(relation.split(".").at(-1).replaceAll('"', ""));
|
|
251
|
+
if (
|
|
252
|
+
alias &&
|
|
253
|
+
!/^(where|group|order|limit|join|inner|left|right|full|cross|on|union|having|window|qualify|using|natural)$/i.test(
|
|
254
|
+
alias,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
qualifiers.add(alias);
|
|
258
|
+
}
|
|
259
|
+
const reference = /(?<![\w$"])(?:(\w+|"\w+")\s*\.\s*)?"?file"?\s*\.\s*"?(path|folder|name)\b/gi;
|
|
260
|
+
for (const match of masked.matchAll(reference)) {
|
|
261
|
+
const qualifier = match[1]?.replaceAll('"', "");
|
|
262
|
+
if (qualifier && !qualifiers.has(qualifier)) continue;
|
|
263
|
+
const before = masked.slice(0, match.index);
|
|
264
|
+
return {
|
|
265
|
+
line: before.split("\n").length - 1,
|
|
266
|
+
column: match.index - (before.lastIndexOf("\n") + 1) + 1,
|
|
267
|
+
text: match[0].replace(/\s+/g, ""),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
220
273
|
/** Omit the Foundry export from SQL; the canonical index stays intact.
|
|
221
274
|
* Keep encoding source keys as case-sensitive values in the SQL projection.
|
|
222
275
|
* The canonical index retains its authored mapping object; DuckDB structs
|
|
@@ -1371,6 +1424,16 @@ export async function prepareSqlTables(db, sources, { linkable, records, pageLan
|
|
|
1371
1424
|
for (const [ordinal, block] of blocks.entries()) {
|
|
1372
1425
|
try {
|
|
1373
1426
|
if (block.problems.length) throw new Error(block.problems.join("; "));
|
|
1427
|
+
const reference = findFileReference(block.query);
|
|
1428
|
+
if (reference) {
|
|
1429
|
+
forNote.push({
|
|
1430
|
+
reason: `\`${reference.text}\` is not available to a query; a query selects notes by property, tag or address`,
|
|
1431
|
+
line: block.line + 1 + reference.line,
|
|
1432
|
+
column: reference.column,
|
|
1433
|
+
allowEmpty: block.allowEmpty,
|
|
1434
|
+
});
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1374
1437
|
const result = await runSqlQuery(reader, block.query);
|
|
1375
1438
|
const options = {
|
|
1376
1439
|
linkable,
|
|
@@ -1558,11 +1621,13 @@ export async function prepareInlineSqlExpressions(db, sources) {
|
|
|
1558
1621
|
* @param {Map<string, Map<string, string>>} [opts.derived] - The derived
|
|
1559
1622
|
* lists, from an earlier call over the same records, which are not derived
|
|
1560
1623
|
* again.
|
|
1624
|
+
* @param {Map<string,{raw?:string}>} [opts.parsed] - Captured source locators.
|
|
1561
1625
|
* @param {object[]} [opts.all=records] - Complete records for lexical lineage
|
|
1562
1626
|
* traversal through providers outside the surface's audience.
|
|
1563
1627
|
* @returns {Promise<{sections: Map<string, string>, views: Set<string>,
|
|
1564
1628
|
* derived: Map<string, Map<string, string>>,
|
|
1565
|
-
* lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]
|
|
1629
|
+
* lexical: Map<string, import("./lexical-sections.mjs").LexicalSection[]>,
|
|
1630
|
+
* declared: Map<string, Array<{slug:string,heading:string,markdown:string}>>}>} Note file to its sections,
|
|
1566
1631
|
* joined in order; the files whose sections hold an event view's fence; and
|
|
1567
1632
|
* the derived lists alone.
|
|
1568
1633
|
*/
|
|
@@ -1570,7 +1635,7 @@ export async function generatedSectionsFor(
|
|
|
1570
1635
|
db,
|
|
1571
1636
|
records,
|
|
1572
1637
|
contentBase,
|
|
1573
|
-
{ only, foreignIndex, config, derived: given, all = records } = {},
|
|
1638
|
+
{ only, foreignIndex, config, derived: given, all = records, parsed } = {},
|
|
1574
1639
|
) {
|
|
1575
1640
|
const notes = records.filter((record) => isNoteRecord(record));
|
|
1576
1641
|
const fileOf = (record) => noteFile(contentBase, record);
|
|
@@ -1583,11 +1648,27 @@ export async function generatedSectionsFor(
|
|
|
1583
1648
|
foreignIndex,
|
|
1584
1649
|
config,
|
|
1585
1650
|
only,
|
|
1651
|
+
all,
|
|
1652
|
+
rawOf: (file) => parsed?.get(file)?.raw,
|
|
1586
1653
|
});
|
|
1587
1654
|
const views = db ? await eventViewSections(db, notes, fileOf, only) : new Map();
|
|
1588
1655
|
const sections = new Map();
|
|
1589
|
-
|
|
1590
|
-
|
|
1656
|
+
const declared = new Map();
|
|
1657
|
+
for (const file of new Set([...derived.keys(), ...views.keys()])) {
|
|
1658
|
+
const lists = new Map(derived.get(file));
|
|
1659
|
+
const settlements = lists.get("settlements");
|
|
1660
|
+
if (settlements)
|
|
1661
|
+
declared.set(file, [
|
|
1662
|
+
{
|
|
1663
|
+
slug: "settlements",
|
|
1664
|
+
heading: "Settlements",
|
|
1665
|
+
markdown: settlements.replace(/^# Settlements \{#settlements\}\s*\n/, ""),
|
|
1666
|
+
},
|
|
1667
|
+
]);
|
|
1668
|
+
lists.delete("settlements");
|
|
1669
|
+
const text = joinSections([lists, views.get(file)]);
|
|
1670
|
+
if (text) sections.set(file, text);
|
|
1671
|
+
}
|
|
1591
1672
|
// Automatic sections follow their source's audience even in a Foundry
|
|
1592
1673
|
// corpus containing both public and private packs. Explicit SQL keeps
|
|
1593
1674
|
// the database audience the caller requested.
|
|
@@ -1607,7 +1688,7 @@ export async function generatedSectionsFor(
|
|
|
1607
1688
|
if (owners.has(key) && (only === undefined || fileOf(note) === only))
|
|
1608
1689
|
lexical.set(fileOf(note), owners.get(key));
|
|
1609
1690
|
}
|
|
1610
|
-
return { sections, views: new Set(views.keys()), derived, lexical };
|
|
1691
|
+
return { sections, views: new Set(views.keys()), derived, lexical, declared };
|
|
1611
1692
|
}
|
|
1612
1693
|
|
|
1613
1694
|
/**
|
|
@@ -1654,11 +1735,16 @@ export function joinGeneratedSections(generated, sources, parsed) {
|
|
|
1654
1735
|
* empty set carrying them.
|
|
1655
1736
|
*
|
|
1656
1737
|
* @param {Map<string, object[]>} prepared - From {@link prepareSqlTables}.
|
|
1657
|
-
* @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]
|
|
1738
|
+
* @param {{sections: Map<string, string>, lexical?: Map<string, import("./lexical-sections.mjs").LexicalSection[]>, declared?: Map<string, Array<{slug:string,heading:string,markdown:string}>>}} generated - From
|
|
1658
1739
|
* {@link generatedSectionsFor}.
|
|
1659
1740
|
* @returns {void}
|
|
1660
1741
|
*/
|
|
1661
1742
|
export function attachGeneratedSections(prepared, generated) {
|
|
1743
|
+
for (const [absPath, sections] of generated.declared ?? []) {
|
|
1744
|
+
let results = prepared.get(absPath);
|
|
1745
|
+
if (!results) prepared.set(absPath, (results = []));
|
|
1746
|
+
results.declared = sections;
|
|
1747
|
+
}
|
|
1662
1748
|
for (const [absPath, text] of generated.sections) {
|
|
1663
1749
|
let results = prepared.get(absPath);
|
|
1664
1750
|
if (!results) prepared.set(absPath, (results = []));
|
|
@@ -1881,11 +1967,18 @@ export async function prepareTreeSqlTables(
|
|
|
1881
1967
|
}
|
|
1882
1968
|
}
|
|
1883
1969
|
const derivedOnly = await generatedSectionsFor(undefined, indexRecords, contentBase, {
|
|
1970
|
+
parsed,
|
|
1884
1971
|
foreignIndex: foreign,
|
|
1885
1972
|
config,
|
|
1886
1973
|
all: allRecords,
|
|
1887
1974
|
});
|
|
1888
|
-
if (
|
|
1975
|
+
if (
|
|
1976
|
+
!sources.length &&
|
|
1977
|
+
!hasEvents &&
|
|
1978
|
+
!derivedOnly.sections.size &&
|
|
1979
|
+
!derivedOnly.lexical.size &&
|
|
1980
|
+
!derivedOnly.declared.size
|
|
1981
|
+
)
|
|
1889
1982
|
return undefined;
|
|
1890
1983
|
|
|
1891
1984
|
// A page list is a filter over the records, so it needs no database. A tree
|
|
@@ -1940,6 +2033,7 @@ export async function prepareTreeSqlTables(
|
|
|
1940
2033
|
}));
|
|
1941
2034
|
try {
|
|
1942
2035
|
const generated = await generatedSectionsFor(db, indexRecords, contentBase, {
|
|
2036
|
+
parsed,
|
|
1943
2037
|
derived: derivedOnly.derived,
|
|
1944
2038
|
all: allRecords,
|
|
1945
2039
|
});
|
|
@@ -35,8 +35,9 @@ export const STANDING_BODY_TYPES = Object.freeze(["affiliation"]);
|
|
|
35
35
|
* The keys one standing carries, as inner-key declarations.
|
|
36
36
|
*
|
|
37
37
|
* `rank` is the level on the body's own `governance.ranks` ladder, and every
|
|
38
|
-
* membership states one. `office` is a key of its `governance.offices` map,
|
|
39
|
-
*
|
|
38
|
+
* membership states one. `office` is a key of its `governance.offices` map, or
|
|
39
|
+
* a list of them where the being holds several posts in that body, and is
|
|
40
|
+
* stated only where the being holds a post. `societies` names the societies
|
|
40
41
|
* the membership is held in, as a list because a membership can span several.
|
|
41
42
|
*
|
|
42
43
|
* @type {readonly import("./data-keys.mjs").InnerKeySpec[]}
|
|
@@ -53,9 +54,12 @@ export const STANDING_FIELDS = Object.freeze([
|
|
|
53
54
|
}),
|
|
54
55
|
Object.freeze({
|
|
55
56
|
name: "office",
|
|
56
|
-
kind: "string",
|
|
57
|
-
shape: "a post from that body's `governance.offices
|
|
58
|
-
|
|
57
|
+
kind: "string-or-list",
|
|
58
|
+
shape: "a post from that body's `governance.offices`, or a list of them",
|
|
59
|
+
entries: Object.freeze({ kind: "string", shape: "a post" }),
|
|
60
|
+
describe:
|
|
61
|
+
"A post the body names in its `governance.offices`, where one is held, or a list of " +
|
|
62
|
+
"the posts one being holds in that body.",
|
|
59
63
|
}),
|
|
60
64
|
Object.freeze({
|
|
61
65
|
name: "societies",
|
package/engine/standings.mjs
CHANGED
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
* **`rank` is a number, not a link.** The body declares `{level, title,
|
|
32
32
|
* description}` for every rung of its `governance.ranks`, so a number indexes
|
|
33
33
|
* into that ladder and the body stays the single source for what its level 5 is
|
|
34
|
-
* called. **`office` is a string
|
|
34
|
+
* called. **`office` is a string, or a list of them** for one being holding
|
|
35
|
+
* several posts in one body, each matched against the keys of the body's
|
|
35
36
|
* `governance.offices` map: there are far more offices in a tree than bodies,
|
|
36
37
|
* most of them held by one body alone, and an Address would make writing the
|
|
37
38
|
* fact wait on authoring a page about the post.
|
|
@@ -55,7 +56,7 @@ import { acceptsType, isAddressTuple, parseAddress, renderAddress } from "./addr
|
|
|
55
56
|
import { AddressEntries } from "./address-values.mjs";
|
|
56
57
|
import { slugify } from "./content-slug.mjs";
|
|
57
58
|
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
58
|
-
import { fitsKind } from "./data-keys.mjs";
|
|
59
|
+
import { fitsKind, pathLabel } from "./data-keys.mjs";
|
|
59
60
|
import { STANDING_BODY_TYPES, STANDING_FIELDS, STANDING_KEYS } from "./standing-terms.mjs";
|
|
60
61
|
|
|
61
62
|
export { STANDING_BODY_TYPES, STANDING_FIELDS, STANDING_KEYS };
|
|
@@ -221,12 +222,45 @@ export function standingsDigest(fm) {
|
|
|
221
222
|
};
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The posts one standing names, as written: one post, or each post of a list.
|
|
227
|
+
*
|
|
228
|
+
* Every entry is kept, blank or not text included, so the check can locate
|
|
229
|
+
* each one; a reader of the posts themselves takes {@link standingOffices}.
|
|
230
|
+
*
|
|
231
|
+
* @param {unknown} office - A standing's authored `office`.
|
|
232
|
+
* @returns {Array<{value: unknown, at: Array<number>}>} Each post with its
|
|
233
|
+
* position below `office` — `[]` for the single form, `[i]` for a list entry.
|
|
234
|
+
*/
|
|
235
|
+
function writtenOffices(office) {
|
|
236
|
+
if (office === undefined || office === null) return [];
|
|
237
|
+
if (Array.isArray(office)) return office.map((value, i) => ({ value, at: [i] }));
|
|
238
|
+
return [{ value: office, at: [] }];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The posts a standing holds, trimmed, in the order written.
|
|
243
|
+
*
|
|
244
|
+
* `office` is one post or a list of them, and every reader — the line on the
|
|
245
|
+
* being's page, the SoHL item, the derived holders of an office — wants the
|
|
246
|
+
* same list from both.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} [standing] - `{rank, office}`, either or both absent.
|
|
249
|
+
* @returns {string[]} The posts, blank and non-text entries left out.
|
|
250
|
+
*/
|
|
251
|
+
export function standingOffices(standing) {
|
|
252
|
+
return writtenOffices(standing?.office)
|
|
253
|
+
.map(({ value }) => (typeof value === "string" ? value.trim() : ""))
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
}
|
|
256
|
+
|
|
224
257
|
/**
|
|
225
258
|
* What a standing says before the body that confers it — `War Chief, Hárár
|
|
226
259
|
* (5), of ` — or `""` where no standing is held.
|
|
227
260
|
*
|
|
228
261
|
* The office leads, because it is the more particular of the two: a reader
|
|
229
|
-
* meeting "War Chief" has been told the rung as well.
|
|
262
|
+
* meeting "War Chief" has been told the rung as well. Several posts in one
|
|
263
|
+
* body lead in the order written, `Treasurer, Chancellor, …`. The rung follows as its
|
|
230
264
|
* title with its level in parentheses, read from the body's own ladder; where
|
|
231
265
|
* the ladder names no rung at that level, or none is available, it reads as
|
|
232
266
|
* `Rank 5` and the number is not repeated. The body closes the phrase, which is
|
|
@@ -241,9 +275,7 @@ export function standingsDigest(fm) {
|
|
|
241
275
|
* @returns {string} The words before the body's name, ending `of `.
|
|
242
276
|
*/
|
|
243
277
|
export function standingLead(standing, digest) {
|
|
244
|
-
const parts = [];
|
|
245
|
-
if (typeof standing?.office === "string" && standing.office.trim())
|
|
246
|
-
parts.push(standing.office.trim());
|
|
278
|
+
const parts = [...standingOffices(standing)];
|
|
247
279
|
const rank = standing?.rank;
|
|
248
280
|
const level = typeof rank === "number" || typeof rank === "string" ? Number(rank) : NaN;
|
|
249
281
|
if (rank !== "" && Number.isFinite(level)) {
|
|
@@ -264,7 +296,7 @@ export function standingLead(standing, digest) {
|
|
|
264
296
|
* 3. the entry states a standing at all — what a stated standing holds is the
|
|
265
297
|
* inner-key check's, read from {@link STANDING_FIELDS};
|
|
266
298
|
* 4. a `rank` is a level the named body's `governance.ranks` declares;
|
|
267
|
-
* 5. an `office` is a key of that body's `governance.offices`.
|
|
299
|
+
* 5. each post an `office` names is a key of that body's `governance.offices`.
|
|
268
300
|
*
|
|
269
301
|
* Both of the last two are read from the named body's own frontmatter as the
|
|
270
302
|
* lint runs, so a rung or a post that does not exist fails on every being pointing
|
|
@@ -474,39 +506,59 @@ function rankFindings(note, standing, body, path, resolved) {
|
|
|
474
506
|
];
|
|
475
507
|
}
|
|
476
508
|
|
|
477
|
-
/**
|
|
509
|
+
/**
|
|
510
|
+
* Whether each post an `office` names is one the named body names.
|
|
511
|
+
*
|
|
512
|
+
* `office` is one post or a list of them, and each is checked on its own and
|
|
513
|
+
* located at its own value, so a list naming one unknown post among several is
|
|
514
|
+
* one finding at that post.
|
|
515
|
+
*/
|
|
478
516
|
function officeFindings(note, standing, body, path, resolved) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
517
|
+
const roster = resolved.fm ? officeRoster(resolved.fm.data?.governance?.offices) : undefined;
|
|
518
|
+
const findings = [];
|
|
519
|
+
for (const { value, at: below } of writtenOffices(standing.office)) {
|
|
520
|
+
if (typeof value !== "string") {
|
|
521
|
+
// A list entry that is not text is the inner-key check's finding,
|
|
522
|
+
// read from the declaration's `entries`. The single form is one
|
|
523
|
+
// post too, and the inner-key check reads only a list's entries.
|
|
524
|
+
if (!below.length)
|
|
525
|
+
findings.push({
|
|
526
|
+
...position(note, [...path, "office"]),
|
|
527
|
+
message:
|
|
528
|
+
`\`${pathLabel(["data", "affiliations", ...path.slice(2), "office"])}\` ` +
|
|
529
|
+
`should be ${STANDING_FIELDS.find((f) => f.name === "office").shape}, ` +
|
|
530
|
+
`but reads ${JSON.stringify(value)}`,
|
|
531
|
+
});
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const at = () => position(note, [...path, "office", ...below]);
|
|
535
|
+
if (!value.trim()) {
|
|
536
|
+
findings.push({
|
|
486
537
|
...at(),
|
|
487
538
|
message:
|
|
488
539
|
`\`data.affiliations\` entry for ${body}: \`office\` must name a post from its ` +
|
|
489
|
-
`\`governance.offices\`, but reads ${JSON.stringify(
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
return [
|
|
497
|
-
{
|
|
540
|
+
`\`governance.offices\`, but reads ${JSON.stringify(value)}`,
|
|
541
|
+
});
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (!roster || roster.has(value)) continue;
|
|
545
|
+
if (!roster.size) {
|
|
546
|
+
findings.push({
|
|
498
547
|
...at(),
|
|
499
548
|
message:
|
|
500
549
|
`\`data.affiliations\` entry for ${body} names an office, and that body ` +
|
|
501
550
|
`${resolved.address} declares no \`data.governance.offices\` to hold one`,
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
551
|
+
});
|
|
552
|
+
// One finding says the body holds no posts; each further post
|
|
553
|
+
// would repeat it.
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
findings.push({
|
|
506
557
|
...at(),
|
|
507
558
|
message:
|
|
508
|
-
`\`data.affiliations\` entry for ${body}: office ${JSON.stringify(
|
|
559
|
+
`\`data.affiliations\` entry for ${body}: office ${JSON.stringify(value)} is not ` +
|
|
509
560
|
`an office ${resolved.address} names; it holds ${[...roster.keys()].join(", ")}`,
|
|
510
|
-
}
|
|
511
|
-
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
return findings;
|
|
512
564
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* **A one-of-a-kind item is tied to the places and beings involved.**
|
|
16
|
+
*
|
|
17
|
+
* `data.unique: true` on a gear note marks an item there is only one of — a
|
|
18
|
+
* named hammer, a crown, a sword in a tomb. Where it lies and who claims it are
|
|
19
|
+
* prose, because possession changes with play and ownership is a claim that
|
|
20
|
+
* can be disputed; so the note's own links are what tie it to the world, and a
|
|
21
|
+
* unique item whose note links no place and no being is a finding.
|
|
22
|
+
*
|
|
23
|
+
* A link counts wherever the note writes it: a wikilink in its body, or a
|
|
24
|
+
* `made` or `lost` event Address naming a place's or a being's event.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { isAddressTuple, parseAddress } from "./address.mjs";
|
|
30
|
+
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
31
|
+
import { positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
32
|
+
import { parseWikilink, WIKILINK } from "./wikilink-syntax.mjs";
|
|
33
|
+
|
|
34
|
+
/** The note types a unique item's links must reach at least one of. */
|
|
35
|
+
export const UNIQUE_ITEM_LINK_TYPES = Object.freeze(["place", "being"]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The note type one written target names, or `""` where it is no Address.
|
|
39
|
+
*
|
|
40
|
+
* @param {unknown} target - A wikilink target or an authored Address.
|
|
41
|
+
* @param {object} [index] - The lint's address index.
|
|
42
|
+
* @returns {string} The type.
|
|
43
|
+
*/
|
|
44
|
+
function typeOf(target, index) {
|
|
45
|
+
if (isAddressTuple(target)) return target.type;
|
|
46
|
+
// An event Address arrives decoded as a link to its note and anchor.
|
|
47
|
+
if (isAddressTuple(target?.target)) return target.target.type;
|
|
48
|
+
const tuple = parseAddress(String(target ?? "").split("#")[0], {
|
|
49
|
+
package: index?.contentPackage,
|
|
50
|
+
system: "note",
|
|
51
|
+
types: index?.types,
|
|
52
|
+
packages: index?.packages,
|
|
53
|
+
});
|
|
54
|
+
return tuple.reason ? "" : String(tuple.type ?? "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Report a unique item whose note links no place or being.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} note - The note: `file`, `raw`, `body` and parsed `fm`.
|
|
61
|
+
* @param {{index?: object}} [options] - The lint's options.
|
|
62
|
+
* @returns {object[]} The finding, located at `unique`, or none.
|
|
63
|
+
*/
|
|
64
|
+
export function checkUniqueItem(note, { index } = {}) {
|
|
65
|
+
const data = note.fm?.data;
|
|
66
|
+
if (data?.unique !== true && data?.unique !== "true") return [];
|
|
67
|
+
const targets = [
|
|
68
|
+
data.made,
|
|
69
|
+
data.lost,
|
|
70
|
+
...matchAllOutsideCode(note.body ?? "", new RegExp(WIKILINK.source, "g")).map(
|
|
71
|
+
([, inner]) => parseWikilink(inner).target,
|
|
72
|
+
),
|
|
73
|
+
].filter(Boolean);
|
|
74
|
+
if (targets.some((target) => UNIQUE_ITEM_LINK_TYPES.includes(typeOf(target, index)))) return [];
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
file: note.file,
|
|
78
|
+
...positionOfFrontmatterPath(note.raw ?? "", ["data", "unique"], { key: true }),
|
|
79
|
+
severity: "error",
|
|
80
|
+
message:
|
|
81
|
+
"a unique item links no place or being; link where it lies or who " +
|
|
82
|
+
"holds or claims it in the note's prose",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.6.1",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package \u2014 content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
"eslint-plugin-yml": "^3.8.1",
|
|
158
158
|
"fflate": "^0.8.3",
|
|
159
159
|
"flesch": "^2.0.1",
|
|
160
|
-
"glob": "^14.0.
|
|
160
|
+
"glob": "^14.0.3",
|
|
161
161
|
"handlebars": "^4.7.10",
|
|
162
162
|
"highlight.js": "^11.12.0",
|
|
163
163
|
"ignore": "^7.0.12",
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
"markdown-it": "^15.0.0",
|
|
169
169
|
"markdown-it-deflist": "^4.0.0",
|
|
170
170
|
"markdown-it-footnote": "^4.0.0",
|
|
171
|
-
"minimatch": "^11.0.
|
|
171
|
+
"minimatch": "^11.0.3",
|
|
172
172
|
"pagefind": "^1.5.2",
|
|
173
173
|
"prettier": "^3.9.6",
|
|
174
174
|
"remark-frontmatter": "^5.0.0",
|
|
@@ -243,5 +243,10 @@
|
|
|
243
243
|
"sohl",
|
|
244
244
|
"song-of-heroic-lands"
|
|
245
245
|
],
|
|
246
|
-
"homepage": "https://github.com/HeroicLands/package-build#documentation"
|
|
246
|
+
"homepage": "https://github.com/HeroicLands/package-build#documentation",
|
|
247
|
+
"overrides": {
|
|
248
|
+
"minimatch": "$minimatch",
|
|
249
|
+
"katex": "0.18.2",
|
|
250
|
+
"re2js": "2.8.7"
|
|
251
|
+
}
|
|
247
252
|
}
|