@heroiclands/package-build 25.5.5 → 25.6.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/assets/styles/content-base.css +1 -1
  3. package/docs/api.md +24 -16
  4. package/docs/authoring/frontmatter.md +1 -1
  5. package/docs/authoring/links-and-markup.md +10 -9
  6. package/docs/content-format.md +40 -1
  7. package/docs/diagnostics.md +2 -2
  8. package/docs/guides/reference-pages.md +4 -1
  9. package/docs/guides/site.md +1 -1
  10. package/docs/reference/format-details.md +174 -129
  11. package/docs/reference/note-types.md +134 -121
  12. package/docs/reference/theme-pages.md +1 -0
  13. package/engine/content-format.yaml +9 -7
  14. package/engine/content-html.mjs +3 -2
  15. package/engine/content-index.mjs +23 -0
  16. package/engine/content-links.mjs +8 -0
  17. package/engine/content-render.mjs +2 -2
  18. package/engine/content-tables.mjs +12 -5
  19. package/engine/culture-reach.mjs +3 -0
  20. package/engine/declared-sections.mjs +100 -0
  21. package/engine/derived-sections.mjs +36 -2
  22. package/engine/dictionary-rows.mjs +2 -1
  23. package/engine/generated-sections.mjs +2 -0
  24. package/engine/holdings.mjs +107 -3
  25. package/engine/index-records.mjs +3 -1
  26. package/engine/index.mjs +1 -1
  27. package/engine/language-data.mjs +0 -1
  28. package/engine/language-rows.mjs +1 -3
  29. package/engine/language-terms.mjs +7 -10
  30. package/engine/map-dot.mjs +22 -0
  31. package/engine/markdown-blocks.mjs +23 -8
  32. package/engine/metadata-index.mjs +1 -0
  33. package/engine/note-addresses.mjs +16 -0
  34. package/engine/note-vocabulary.mjs +19 -8
  35. package/engine/office-holders.mjs +76 -0
  36. package/engine/page-breaks.mjs +36 -8
  37. package/engine/pdf-render.mjs +4 -3
  38. package/engine/place-kinds.mjs +106 -0
  39. package/engine/settlement-tables.mjs +410 -675
  40. package/engine/settlement-terms.mjs +118 -0
  41. package/engine/site-build.mjs +2 -4
  42. package/engine/sql-tables.mjs +101 -7
  43. package/engine/standing-terms.mjs +9 -5
  44. package/engine/standings.mjs +83 -31
  45. package/engine/unique-items.mjs +85 -0
  46. package/package.json +9 -4
  47. package/site-theme/data/banner-kinds.yaml +2 -0
  48. package/sohl/actors.mjs +4 -3
  49. package/types/engine/content-html.d.mts +2 -1
  50. package/types/engine/culture-reach.d.mts +3 -0
  51. package/types/engine/declared-sections.d.mts +30 -0
  52. package/types/engine/derived-sections.d.mts +5 -1
  53. package/types/engine/holdings.d.mts +34 -2
  54. package/types/engine/index-records.d.mts +2 -1
  55. package/types/engine/language-terms.d.mts +5 -1
  56. package/types/engine/markdown-blocks.d.mts +5 -1
  57. package/types/engine/office-holders.d.mts +33 -0
  58. package/types/engine/page-breaks.d.mts +8 -0
  59. package/types/engine/place-kinds.d.mts +18 -0
  60. package/types/engine/settlement-tables.d.mts +25 -158
  61. package/types/engine/settlement-terms.d.mts +53 -0
  62. package/types/engine/sql-tables.d.mts +35 -3
  63. package/types/engine/standing-terms.d.mts +3 -2
  64. package/types/engine/standings.d.mts +14 -2
  65. package/types/engine/unique-items.d.mts +12 -0
  66. 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
+ );
@@ -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 — what a
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 — what a content table reads as
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.
@@ -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[]>}>} Note file to its sections,
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
- for (const file of new Set([...derived.keys(), ...views.keys()]))
1590
- sections.set(file, joinSections([derived.get(file), views.get(file)]));
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[]>}} generated - From
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 (!sources.length && !hasEvents && !derivedOnly.sections.size && !derivedOnly.lexical.size)
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, and
39
- * is stated only where the being holds a post. `societies` names the societies
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
- describe: "A post the body names in its `governance.offices`, where one is held.",
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",
@@ -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**, matched against the keys of the body's
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. The rung follows as its
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
- /** Whether an `office` is a post the named body names. */
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
- // Absent is a membership with no post; a value that is not text is the
480
- // inner-key check's finding.
481
- if (typeof standing.office !== "string") return [];
482
- const at = () => position(note, [...path, "office"]);
483
- if (!standing.office.trim())
484
- return [
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(standing.office)}`,
490
- },
491
- ];
492
- if (!resolved.fm) return [];
493
- const roster = officeRoster(resolved.fm.data?.governance?.offices);
494
- if (roster.has(standing.office)) return [];
495
- if (!roster.size)
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
- return [
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(standing.office)} is not ` +
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.5.5",
3
+ "version": "25.6.0",
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.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.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
  }
@@ -64,8 +64,10 @@ kinds:
64
64
  mysticalability: mystical-abilities
65
65
  place:
66
66
  celestial: default
67
+ district: default
67
68
  feature: default
68
69
  region: region
70
+ route: default
69
71
  settlement: settlement
70
72
  site: default
71
73
  structure: default