@heroiclands/package-build 22.27.0 → 23.0.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.
@@ -0,0 +1,133 @@
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 work a people tells, sings or writes: the `literature` lore subType and the
16
+ * four optional facts such a note may state about itself.
17
+ *
18
+ * `DataFieldSpec` declares the keys a type accepts, not the subType that may
19
+ * write them, so {@link checkLiteratureNote} scopes these keys to `literature`
20
+ * the way the calendar check scopes its own to `calendar`.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import { isAddressTuple, parseAddress, renderAddress } from "./address.mjs";
26
+ import { checkCultureChoice } from "./culture-choice.mjs";
27
+ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
28
+
29
+ /** The `lore` subType whose notes are works of literature. */
30
+ export const LITERATURE_SUBTYPE = "literature";
31
+
32
+ /**
33
+ * Require `data.language` to address a `skill` note whose subType is
34
+ * `language`.
35
+ *
36
+ * An Address that does not resolve is the reference check's to report, so
37
+ * this speaks only about a target that exists and is the wrong kind.
38
+ *
39
+ * @param {object} note
40
+ * @param {{index?: object}} [options]
41
+ * @returns {object[]} The findings.
42
+ */
43
+ export function checkLanguageChoice(note, { index } = {}) {
44
+ const value = note.fm?.data?.language;
45
+ if (value === undefined || value === null || !index?.addressHit) return [];
46
+ const tuple = parseAddress(value, {
47
+ package: index.contentPackage,
48
+ system: "note",
49
+ type: "skill",
50
+ types: index.types,
51
+ packages: index.packages,
52
+ });
53
+ if (tuple.reason) return [];
54
+ const target = index.addressHit(renderAddress(tuple));
55
+ if (!target) return [];
56
+ const fm = target.fm ?? target;
57
+ if (tuple.type === "skill" && fm.subType === "language") return [];
58
+ return [
59
+ {
60
+ file: note.file,
61
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", "language"]),
62
+ severity: "error",
63
+ message: `data.language ${isAddressTuple(value) ? renderAddress(value) : value} names a note that is not a skill whose subType is language`,
64
+ },
65
+ ];
66
+ }
67
+
68
+ /**
69
+ * The fields a `literature` note may state. Every one is optional.
70
+ *
71
+ * @type {ReadonlyArray<object>}
72
+ */
73
+ export const LITERATURE_FIELDS = Object.freeze([
74
+ {
75
+ name: "culture",
76
+ shape: "an Address",
77
+ kind: "address",
78
+ ref: "lore",
79
+ accepts: ["lore"],
80
+ check: checkCultureChoice,
81
+ describe: "The people whose work this is, as a culture lore note.",
82
+ },
83
+ {
84
+ name: "form",
85
+ shape: "string",
86
+ kind: "string",
87
+ describe:
88
+ "The kind of work in its people's own terms — an epic, a saga, a " +
89
+ "praise-song, an elegy. Free text.",
90
+ },
91
+ {
92
+ name: "subjects",
93
+ shape: "list of Addresses",
94
+ kind: "list",
95
+ entryKind: "address",
96
+ describe: "The beings, places, gods, events and other notes the work concerns.",
97
+ },
98
+ {
99
+ name: "language",
100
+ shape: "an Address",
101
+ kind: "address",
102
+ ref: "skill",
103
+ accepts: ["skill"],
104
+ check: checkLanguageChoice,
105
+ describe: "The tongue the work is composed in, as a language skill note.",
106
+ },
107
+ ]);
108
+
109
+ const LITERATURE_KEYS = Object.freeze(LITERATURE_FIELDS.map((field) => field.name));
110
+
111
+ /**
112
+ * Refuse a literature field on a lore note of any other subType.
113
+ *
114
+ * @param {object} note
115
+ * @returns {object[]} One error per literature key written off `literature`.
116
+ */
117
+ export function checkLiteratureNote(note) {
118
+ const type = String(note?.type ?? note?.fm?.type ?? "").toLowerCase();
119
+ const subType = String(note?.fm?.subType ?? "").toLowerCase();
120
+ if (type !== "lore" || subType === LITERATURE_SUBTYPE) return [];
121
+ const data = note?.fm?.data;
122
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
123
+ return LITERATURE_KEYS.filter((key) => data[key] !== undefined && data[key] !== null).map(
124
+ (key) => ({
125
+ file: note.file,
126
+ ...positionOfFrontmatterPath(note.raw ?? "", ["data", key], { key: true }),
127
+ severity: "error",
128
+ message:
129
+ `\`data.${key}\` describes a work of literature, and this note's ` +
130
+ `\`subType\` is not \`literature\``,
131
+ }),
132
+ );
133
+ }
@@ -0,0 +1,153 @@
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
+ * The works of literature that concern each page, derived from what the works
16
+ * say. A `literature` lore note names its subjects in `data.subjects`; the site
17
+ * build inverts that into a `works` list on every subject's page, so a reader on
18
+ * a hero's, a place's or a god's page reaches the epics and legends about it.
19
+ * The subject note writes nothing.
20
+ *
21
+ * A work in a fetched index lists on this package's subject pages when that
22
+ * index carries its `subjects`. A subject in another package has no page here,
23
+ * so a local work naming one lists nowhere.
24
+ *
25
+ * @module
26
+ */
27
+
28
+ import { parseAddress, renderAddress } from "./address.mjs";
29
+ import { readCanonicalKey } from "./content-address.mjs";
30
+ import { LITERATURE_SUBTYPE } from "./literature-notes.mjs";
31
+
32
+ /** The page front-matter key the site build writes. */
33
+ export const WORKS_KEY = "works";
34
+
35
+ /** @typedef {{title: string, url?: string, form?: string}} WorkEntry */
36
+ /**
37
+ * @typedef {object} WorksNode
38
+ * @property {string} key Own Address, normalized to the `note` system.
39
+ * @property {string} title
40
+ * @property {string} [url] Absent for a note that publishes no page.
41
+ * @property {string} [package] Owning content package.
42
+ * @property {string} [form] A work's `data.form`.
43
+ * @property {unknown[]} subjects A work's `data.subjects`; empty for any other note.
44
+ */
45
+
46
+ /** The note-system key of one Address, or "" where it names nothing. */
47
+ function addressKey(value, pkg, types) {
48
+ if (value == null) return "";
49
+ const tuple = parseAddress(value, { package: pkg ?? "local", system: "note", types });
50
+ if (tuple.reason) return "";
51
+ return renderAddress({ ...tuple, system: "note" }).toLowerCase();
52
+ }
53
+
54
+ /** Whether a note is a work of literature. */
55
+ function isWork(type, subType) {
56
+ return type === "lore" && String(subType ?? "").toLowerCase() === LITERATURE_SUBTYPE;
57
+ }
58
+
59
+ /**
60
+ * One local page's node.
61
+ *
62
+ * @param {object} fm - The note's frontmatter.
63
+ * @param {{title: string, url?: string, package?: string}} page
64
+ * @returns {WorksNode|null} Null for a note with no type or shortcode.
65
+ */
66
+ export function worksNode(fm, { title, url, package: pkg }) {
67
+ const type = String(fm?.type ?? "");
68
+ const shortcode = String(fm?.shortcode ?? "").toLowerCase();
69
+ if (!type || !shortcode) return null;
70
+ const data = fm.data && typeof fm.data === "object" && !Array.isArray(fm.data) ? fm.data : {};
71
+ const work = isWork(type, fm.subType);
72
+ return {
73
+ key: renderAddress({ package: pkg ?? "local", system: "note", type, shortcode }),
74
+ title,
75
+ ...(url ? { url } : {}),
76
+ ...(pkg ? { package: pkg } : {}),
77
+ ...(work && typeof data.form === "string" && data.form ? { form: data.form } : {}),
78
+ subjects:
79
+ !work ? []
80
+ : Array.isArray(data.subjects) ? data.subjects
81
+ : data.subjects ? [data.subjects]
82
+ : [],
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The works a fetched index carries, as nodes. Only a work can list itself on
88
+ * a page, and only a page of this package can carry the list, so a foreign
89
+ * entry that is not a work is never needed here.
90
+ *
91
+ * @param {Map<string, object>|undefined} foreignIndex
92
+ * @returns {WorksNode[]}
93
+ */
94
+ export function foreignWorksNodes(foreignIndex) {
95
+ const out = [];
96
+ for (const [canonical, entry] of foreignIndex ?? []) {
97
+ const tuple = readCanonicalKey(canonical);
98
+ if (!tuple || !isWork(entry?.type, entry?.subType)) continue;
99
+ if (!Array.isArray(entry.subjects) || !entry.subjects.length) continue;
100
+ out.push({
101
+ key: renderAddress({ ...tuple, system: "note" }),
102
+ title: String(entry.name ?? tuple.shortcode),
103
+ ...(entry.url ? { url: entry.url } : {}),
104
+ package: tuple.package,
105
+ ...(typeof entry.form === "string" && entry.form ? { form: entry.form } : {}),
106
+ subjects: entry.subjects,
107
+ });
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * Invert every work's subjects into the list each subject's page carries.
114
+ *
115
+ * A subject is resolved in its work's own package. A work naming one subject
116
+ * twice lists once; a work naming itself is not listed on its own page.
117
+ * Entries sort by title.
118
+ *
119
+ * @param {Iterable<WorksNode|null>} nodes - Every local page, then any foreign work.
120
+ * @param {object} options
121
+ * @param {ReadonlySet<string>} options.types - Note types an Address may name.
122
+ * @returns {Map<string, {works: WorkEntry[]}>} Keyed by the subject page's URL.
123
+ */
124
+ export function worksPages(nodes, { types }) {
125
+ const all = [...nodes].filter(Boolean);
126
+ /** @type {Map<string, WorksNode>} */
127
+ const pages = new Map();
128
+ for (const node of all) {
129
+ if (!node.url) continue;
130
+ const key = node.key.toLowerCase();
131
+ if (!pages.has(key)) pages.set(key, node);
132
+ }
133
+ /** @type {Map<string, {works: WorkEntry[]}>} */
134
+ const lists = new Map();
135
+ for (const work of all) {
136
+ const seen = new Set();
137
+ for (const subject of work.subjects) {
138
+ const target = pages.get(addressKey(subject, work.package, types));
139
+ if (!target || target.key === work.key || seen.has(target.url)) continue;
140
+ seen.add(target.url);
141
+ let block = lists.get(target.url);
142
+ if (!block) lists.set(target.url, (block = { works: [] }));
143
+ block.works.push({
144
+ title: work.title,
145
+ ...(work.url ? { url: work.url } : {}),
146
+ ...(work.form ? { form: work.form } : {}),
147
+ });
148
+ }
149
+ }
150
+ for (const block of lists.values())
151
+ block.works.sort((a, b) => a.title.localeCompare(b.title, "en"));
152
+ return lists;
153
+ }
@@ -437,6 +437,14 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
437
437
  ...(Object.hasOwn(record.data ?? {}, "government") ?
438
438
  { government: record.data.government }
439
439
  : {}),
440
+ // What a work of literature concerns and what kind of work it
441
+ // is, so a consumer's subject pages list a dependency's works.
442
+ ...(parts.type === "lore" && record.subType === "literature" ?
443
+ {
444
+ subjects: record.data?.subjects ?? undefined,
445
+ form: record.data?.form ?? undefined,
446
+ }
447
+ : {}),
440
448
  package: pkg,
441
449
  });
442
450
  const entry = index.get(key);
@@ -109,6 +109,7 @@ import { checkAffiliationRankFloor, checkRankLadder } from "./rank-ladder.mjs";
109
109
  import { checkCalendarChoice } from "./calendar-choice.mjs";
110
110
  import { checkLoreEvents } from "./lore-events.mjs";
111
111
  import { checkCultureChoice } from "./culture-choice.mjs";
112
+ import { LITERATURE_FIELDS, checkLiteratureNote } from "./literature-notes.mjs";
112
113
  import { SOCIAL_TIES, SOCIAL_TIE_TARGET_TYPES } from "./social-tie-terms.mjs";
113
114
  import { parseNoteDate } from "./note-dates.mjs";
114
115
  import { reckoningContext } from "./reckoning-markers.mjs";
@@ -197,8 +198,8 @@ import { positionOfFrontmatterPath } from "./diagnostics.mjs";
197
198
  *
198
199
  * Two types answer **false**, and both are structural: a `folder` note's page
199
200
  * is a generated section index and a `homepage`'s is the site's front door, so
200
- * each is complete with no body at all. An empty body on one of them keeps its
201
- * address and resolves as `full`.
201
+ * an empty body on one of them keeps its address and resolves as `full`.
202
+ * This is classification only: author validation requires a body for every type.
202
203
  *
203
204
  * @typedef {object} TypeVocabulary
204
205
  * @property {readonly DataFieldSpec[]} data - The `data:` keys, closed.
@@ -1413,8 +1414,7 @@ export const NOTE_VOCABULARY = Object.freeze({
1413
1414
  // document, so its address carries the `none` system segment and
1414
1415
  // everything it says is a `data` property.
1415
1416
  //
1416
- // It carries **no prose**: a folder
1417
- // is structure, not content, so it wants no documentation journal and takes
1417
+ // Its required body produces no documentation journal and takes
1418
1418
  // no part in `docEntryTypes`.
1419
1419
  folder: Object.freeze({
1420
1420
  stubbable: false,
@@ -1480,15 +1480,21 @@ export const NOTE_VOCABULARY = Object.freeze({
1480
1480
  "custom", // How a people practices a rite, observance, or usage.
1481
1481
  "bestiary", // A kind of creature that is not a people.
1482
1482
  "gathering", // A recurring public assembly, such as a fair or tournament.
1483
+ "literature", // A work a people tells, sings, or writes, such as an epic or legend.
1483
1484
  ]),
1484
1485
  // A lore note is prose, and what it *is* about is its subType — with
1485
- // one exception. A calendar is a division of the year, and a division
1486
- // is data; the check scopes the family to the subType that means it,
1487
- // because `DataFieldSpec` declares the keys a type accepts and not the
1488
- // subType that may write them.
1489
- check: checkCalendarNote,
1486
+ // two exceptions. A calendar is a division of the year, and a division
1487
+ // is data; a work of literature states whose it is, in what tongue and
1488
+ // about what. The checks scope each family to the subType that means
1489
+ // it, because `DataFieldSpec` declares the keys a type accepts and not
1490
+ // the subType that may write them.
1491
+ check: (note, context) => [
1492
+ ...checkCalendarNote(note, context),
1493
+ ...checkLiteratureNote(note),
1494
+ ],
1490
1495
  data: Object.freeze([
1491
1496
  ...CALENDAR_FIELDS,
1497
+ ...LITERATURE_FIELDS,
1492
1498
  {
1493
1499
  name: "events",
1494
1500
  kind: "list",
@@ -95,7 +95,7 @@ import { resolvedDateFields } from "./note-dates.mjs";
95
95
  import { isNoteRecord, noteFile } from "./index-records.mjs";
96
96
  // The one statement of what an empty body means, shared with the index.
97
97
  import { isStubNote } from "./note-state.mjs";
98
- import { isGmNote } from "./note-vocabulary.mjs";
98
+ import { NOTE_VOCABULARY, isGmNote } from "./note-vocabulary.mjs";
99
99
  import { ART_SLOTS, artPathname, assetAddressIndex, pathnameRoles } from "./art-fields.mjs";
100
100
  import { embedRole } from "./content-embeds.mjs";
101
101
  import {
@@ -109,6 +109,7 @@ import { publishesContentPages } from "../content-config.mjs";
109
109
  import { HUGO_CONTENT } from "./site-config.mjs";
110
110
  import { homepageLinkTargets, relatedPages } from "./related-pages.mjs";
111
111
  import { HOLDINGS_KEYS, foreignHoldingsNodes, holdingsNode, holdingsPages } from "./holdings.mjs";
112
+ import { WORKS_KEY, foreignWorksNodes, worksNode, worksPages } from "./literature-works.mjs";
112
113
  import { drawSiteMaps } from "./site-maps.mjs";
113
114
 
114
115
  const require = createRequire(import.meta.url);
@@ -607,6 +608,7 @@ export function pageFrontmatter(page, { decorate, webSrc, artSrc }) {
607
608
  delete data.aliases;
608
609
  delete data.related;
609
610
  for (const key of HOLDINGS_KEYS) delete data[key];
611
+ delete data[WORKS_KEY];
610
612
  delete data.map;
611
613
  if (webSrc && artSrc) resolveArtFields(data, webSrc, artSrc);
612
614
  return data;
@@ -1089,12 +1091,25 @@ export function renderPages(pages, options) {
1089
1091
  },
1090
1092
  );
1091
1093
 
1094
+ // Each work of literature lists on the pages of the subjects it names,
1095
+ // including works a fetched index carries.
1096
+ const works = worksPages(
1097
+ [
1098
+ ...pages.map((page) =>
1099
+ worksNode(page.fm, { title: pageTitle(page), url: page.url, package: page.pkg }),
1100
+ ),
1101
+ ...foreignWorksNodes(foreign?.index),
1102
+ ],
1103
+ { types: new Set(Object.keys(NOTE_VOCABULARY)) },
1104
+ );
1105
+
1092
1106
  let withMap = 0;
1093
1107
  const outputs = capture === true ? new Map() : null;
1094
1108
  for (const { page, body, data } of rendered) {
1095
1109
  const block = related.get(page.url);
1096
1110
  if (block) data.related = block;
1097
1111
  Object.assign(data, holdings.get(page.url));
1112
+ Object.assign(data, works.get(page.url));
1098
1113
  const map = maps.get(page.url);
1099
1114
  if (map) {
1100
1115
  data.map = map.name;
@@ -12,34 +12,18 @@
12
12
  */
13
13
 
14
14
  /**
15
- * An empty body must be deliberate.
15
+ * Every typed note needs a nonempty body.
16
16
  *
17
- * An empty body is what makes a note a **stub**, and the whole scheme turns on
18
- * that being on purpose rather than something somebody forgot. So four rules
19
- * hold the state honest, and two more report what the rules cannot decide:
17
+ * A missing or whitespace-only body is an error, whether or not the note has
18
+ * a `draft` tag or a frontmatter description. Placeholder-only bodies remain
19
+ * errors. A nonempty body below twenty-five words receives a warning when it
20
+ * is not tagged `draft`; the author decides whether that short note is done.
21
+ * A `folder` note is held only to the first rule: any nonempty body passes,
22
+ * with or without `draft`.
20
23
  *
21
- * 1. **A stub states what it is.** With nothing in the body, the `description`
22
- * is the only sentence a reader gets and it is what every table and index
23
- * prints. An **error**.
24
- * 2. **A stub is not tagged `draft`.** A thing not started is not a thing in
25
- * progress. An **error**.
26
- * 3. **A body that renders to nothing is an abandoned draft, not a stub.** This
27
- * is what keeps the empty-body rule's severity honest: the softer cases are
28
- * *reported* rather than silently treated as one state or the other. An
29
- * **error**.
30
- * 4. **A short unmarked body may be a draft that forgot its marker.** A
31
- * **warning**, never an error — a note reading `See [[affiliation-meivor|Mëivōr]]`
32
- * says everything it has to say, and a corpus holds many of them.
33
- * 5. **A long-standing draft is a report, not a finding.** It is unfinished,
34
- * which the tag already says, so the oldest are listed and nobody is asked
35
- * to dismiss a warning they cannot clear.
36
- * 6. **The counts are reported**, per package and per type, so the ratio of
37
- * written to unwritten is visible on every run rather than discovered in a
38
- * year.
39
- *
40
- * **What this does not decide is what earns a body.** Whether a given village
41
- * deserves prose is a question about a setting, and it belongs in the
42
- * repository that ships it. The toolchain's job is to make promotion free.
24
+ * Counts and the oldest drafts remain reports. Classification and indexing
25
+ * still derive full, draft and stub states from the existing note records;
26
+ * validation reports missing content without changing those records.
43
27
  *
44
28
  * @module
45
29
  */
@@ -50,7 +34,7 @@ import path from "node:path";
50
34
  import { authoredFrontmatter, isNoteRecord, isStub, noteFile } from "./index-records.mjs";
51
35
  import { positionInFrontmatter, positionOfLiteral } from "./diagnostics.mjs";
52
36
  import { parseMarkdownFile } from "./helpers.mjs";
53
- import { bodyWordCount, isStubNote, placeholderBody } from "./note-state.mjs";
37
+ import { bodyWordCount, placeholderBody } from "./note-state.mjs";
54
38
  import { isDraftNote } from "./note-vocabulary.mjs";
55
39
 
56
40
  /**
@@ -67,16 +51,6 @@ export const SHORT_BODY_WORDS = 25;
67
51
  /** How many of the oldest drafts the report names. */
68
52
  const OLDEST_DRAFTS = 10;
69
53
 
70
- /**
71
- * Whether a note states a description.
72
- *
73
- * @param {Record<string, any>} fm - The note's authored frontmatter.
74
- * @returns {boolean} Whether `description` carries a sentence.
75
- */
76
- function hasDescription(fm) {
77
- return String(fm?.description ?? "").trim() !== "";
78
- }
79
-
80
54
  /**
81
55
  * Check one note's state, and say where the fault is.
82
56
  *
@@ -87,32 +61,21 @@ function checkNote({ fm, body, file, raw }) {
87
61
  const findings = [];
88
62
  const at = (key, value) => positionInFrontmatter(raw, key, value, { topLevel: true });
89
63
 
90
- if (isStubNote(fm, body)) {
91
- if (!hasDescription(fm)) {
92
- findings.push({
93
- file,
94
- ...at("type", fm.type),
95
- severity: "error",
96
- message:
97
- "this note has no body, so it is a stub — and a stub carries " +
98
- "a `description`, which is the only sentence a reader gets " +
99
- "and what every table listing it prints",
100
- });
101
- }
102
- if (isDraftNote(fm)) {
103
- findings.push({
104
- file,
105
- ...at("type", fm.type),
106
- severity: "error",
107
- message:
108
- "this note has no body and is tagged `draft`, and a thing " +
109
- "not started is not a thing in progress — remove the tag, " +
110
- "or write a body",
111
- });
112
- }
64
+ if (String(body ?? "").trim() === "") {
65
+ findings.push({
66
+ file,
67
+ ...at("type", fm.type),
68
+ severity: "error",
69
+ message:
70
+ "this note has no body — write a nonempty body, even when it is tagged `draft`",
71
+ });
113
72
  return findings;
114
73
  }
115
74
 
75
+ // A folder's body describes a pack folder rather than prose a reader is
76
+ // asked to finish, so any nonempty body is complete.
77
+ if (fm.type === "folder") return findings;
78
+
116
79
  const placeholders = placeholderBody(body);
117
80
  if (placeholders.length) {
118
81
  const [{ phrase }] = placeholders;
@@ -122,8 +85,7 @@ function checkNote({ fm, body, file, raw }) {
122
85
  severity: "error",
123
86
  message:
124
87
  `this note's body says only "${phrase}", so it publishes a page ` +
125
- `that tells a reader nothing — empty the body to make this a ` +
126
- `stub, or write it`,
88
+ `that tells a reader nothing — replace the placeholder with content`,
127
89
  });
128
90
  return findings;
129
91
  }
@@ -204,8 +166,8 @@ export function lintNoteStates(contentBase, { records, contentPackage } = {}) {
204
166
  if (state === "draft") drafts.push({ file, modified: fs.statSync(absPath).mtime });
205
167
 
206
168
  // Held to the rules only where there is a note to hold: a file that
207
- // declares no type declares nothing for them to read, and asking it for
208
- // a `description` would report vault scaffolding as content.
169
+ // declares no type declares nothing for them to read; requiring its
170
+ // body would report vault scaffolding as content.
209
171
  if (record.type) findings.push(...checkNote({ fm, body, file, raw }));
210
172
  }
211
173
 
package/githooks/pre-push CHANGED
@@ -4,7 +4,7 @@
4
4
  # container, over a clean export of HEAD — so a red GitHub check is something
5
5
  # you chose rather than something you discover.
6
6
  #
7
- # The steps are not listed here. `utils/ci-steps.mjs` reads them out of
7
+ # The steps are not listed here. `ci/ci-steps.mjs` reads them out of
8
8
  # `.github/workflows/build.yml`, because a hook holding its own copy of the
9
9
  # command list is a second statement of one thing, and the copy is the one that
10
10
  # goes stale — silently, since running four of five steps still exits 0.
@@ -0,0 +1,29 @@
1
+ {{- /*
2
+ The "In song and story" card: the works of literature that name this page's
3
+ subject, rendered after the body and the geography and government card.
4
+
5
+ Front matter `@heroiclands/package-build` derives from every `literature`
6
+ lore note's `data.subjects`, and a note never authors itself:
7
+
8
+ works: [{ title, url, form? }] works naming this page, sorted by title
9
+
10
+ `form` is the work's `data.form` — an epic, a saga, a praise-song — printed
11
+ beside its name. Presence of the key is the only test: a page no work names
12
+ carries no `works` and renders no card. An entry with no `url` is a work
13
+ that publishes no page, rendered as plain text by `holdings/entry.html`.
14
+ */ -}}
15
+ {{- with .Params.works -}}
16
+ <section class="holdings literature-works" data-pagefind-ignore>
17
+ <div class="holdings-table">
18
+ <h3 class="holdings-table-label">In song and story</h3>
19
+ <ul class="holdings-table-grid">
20
+ {{- range . }}
21
+ <li>
22
+ {{- partial "holdings/entry.html" (dict "entry" . "class" "holdings-entry-name") -}}
23
+ {{- with .form }}<span class="holdings-entry-kind"> ({{ . }})</span>{{ end -}}
24
+ </li>
25
+ {{- end }}
26
+ </ul>
27
+ </div>
28
+ </section>
29
+ {{- end -}}
@@ -71,6 +71,7 @@
71
71
  </div>
72
72
 
73
73
  {{ partial "holdings.html" . }}
74
+ {{ partial "literature-works.html" . }}
74
75
  {{ partial "place-map.html" . }}
75
76
  {{ partial "related.html" . }}
76
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.27.0",
3
+ "version": "23.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -182,6 +182,8 @@
182
182
  "scripts": {
183
183
  "test": "vitest run",
184
184
  "test:watch": "vitest",
185
+ "build": "node bin/package-build.mjs ci",
186
+ "deploy:npm": "node ci/publish-npm.mjs",
185
187
  "prepack": "run-s build:types build:asset-index",
186
188
  "build:types": "tsc -p tsconfig.dts.json",
187
189
  "build:asset-index": "node ci/emit-asset-index.mjs",
@@ -53,17 +53,17 @@
53
53
  * build translates once and no second copy of those three rows exists.
54
54
  */
55
55
  export const DEFAULT_ITEM_ART = {
56
- affiliation: "sohl/assets/icons/noun/shield.svg",
56
+ affiliation: "sohl/assets/icons/noun/shieldnn.svg",
57
57
  affliction: "sohl/assets/icons/other/sick.svg",
58
58
  armorgear: "sohl/assets/icons/game-icons/lorc/breastplate.svg",
59
- attribute: "sohl/assets/icons/other/charm.svg",
59
+ attribute: "sohl/assets/icons/game-icons/lorc/charm.svg",
60
60
  concoctiongear: "sohl/assets/icons/game-icons/badges/flask.svg",
61
61
  containergear: "sohl/assets/icons/other/sack.svg",
62
- miscgear: "sohl/assets/icons/other/question-mark.svg",
63
- mystery: "sohl/assets/icons/other/sparkles.svg",
64
- mysticalability: "sohl/assets/icons/other/hand-sparkles.svg",
62
+ miscgear: "sohl/assets/icons/other/questionmark.svg",
63
+ mystery: "sohl/assets/icons/game-icons/delapouite/sparkles.svg",
64
+ mysticalability: "sohl/assets/icons/other/handsparkles.svg",
65
65
  projectilegear: "sohl/assets/icons/noun/arrow.svg",
66
- skill: "sohl/assets/icons/other/head-gear.svg",
66
+ skill: "sohl/assets/icons/other/headgear.svg",
67
67
  trauma: "sohl/assets/icons/other/injury.svg",
68
68
  weapongear: "sohl/assets/icons/other/sword.svg",
69
69
  };