@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.
- package/CHANGELOG.md +20 -0
- package/bin/content-commands.mjs +53 -5
- package/ci/publish-npm.mjs +49 -0
- package/docs/api.md +11 -11
- package/docs/authoring/first-note.md +1 -1
- package/docs/content-format.md +2 -0
- package/docs/guides/site.md +4 -4
- package/docs/reference/format-details.md +70 -32
- package/docs/reference/note-types.md +5 -1
- package/docs/reference/theme-pages.md +32 -1
- package/engine/content-format.yaml +5 -0
- package/engine/folder-notes.mjs +2 -3
- package/engine/index.mjs +1 -1
- package/engine/literature-notes.mjs +133 -0
- package/engine/literature-works.mjs +153 -0
- package/engine/metadata-index.mjs +8 -0
- package/engine/note-vocabulary.mjs +15 -9
- package/engine/site-build.mjs +16 -1
- package/engine/stub-lint.mjs +26 -64
- package/githooks/pre-push +1 -1
- package/hugo-theme/layouts/partials/literature-works.html +29 -0
- package/hugo-theme/layouts/partials/page-body.html +1 -0
- package/package.json +3 -1
- package/sohl/default-item-art.mjs +6 -6
- package/types/engine/literature-notes.d.mts +29 -0
- package/types/engine/literature-works.d.mts +68 -0
- package/types/engine/note-vocabulary.d.mts +2 -2
|
@@ -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
|
-
*
|
|
201
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1486
|
-
// is data;
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
|
|
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",
|
package/engine/site-build.mjs
CHANGED
|
@@ -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;
|
package/engine/stub-lint.mjs
CHANGED
|
@@ -12,34 +12,18 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* Every typed note needs a nonempty body.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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,
|
|
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 (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 —
|
|
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
|
|
208
|
-
//
|
|
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. `
|
|
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 -}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "
|
|
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/
|
|
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/
|
|
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/
|
|
63
|
-
mystery: "sohl/assets/icons/
|
|
64
|
-
mysticalability: "sohl/assets/icons/other/
|
|
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/
|
|
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
|
};
|