@heroiclands/package-build 22.28.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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 23.0.0
4
+
5
+ ### Major Changes
6
+
7
+ Every page a work of literature names in its subjects lists that work under **In song and story**, with its form beside the title, including works from a package this one depends on.
8
+
9
+ ### Patch Changes
10
+
11
+ Affiliations, skills, attributes, mysteries, mystical abilities and miscellaneous gear without their own art show SoHL's default icons again instead of a broken image.
12
+
13
+ Folder notes need only a nonempty body: a short line or placeholder no longer warns or fails, with or without `draft`. An empty folder body is still an error.
14
+
15
+ Lore notes can be typed `literature` for epics, sagas, poems, legends and other works a people tells, sings or writes. A literature note may name its people, its form, its subjects and its language, and each appears in the note's infobox, with the people, subjects and language as links.
16
+
3
17
  ## 22.28.0
4
18
 
5
19
  ### Minor Changes
package/docs/api.md CHANGED
@@ -471,7 +471,7 @@ What a draft note says about itself, in one sentence, on every surface it reache
471
471
 
472
472
  ### `engine.stubLint`
473
473
 
474
- Every typed note must have a nonempty body. Empty and whitespace-only bodies are errors regardless of description or `draft` tag, including structural types such as `folder` and `homepage`. Placeholder-only bodies also produce errors. Nonempty bodies with fewer than 25 prose words produce warnings unless tagged `draft`. Untyped vault scaffolding is excluded. The lint, content-index, package compile, site and PDF commands enforce body errors before publishing. The state classification helpers still describe incomplete records for inspection; their classifications do not exempt notes from validation.
474
+ Every typed note must have a nonempty body. Empty and whitespace-only bodies are errors regardless of description or `draft` tag, including structural types such as `folder` and `homepage`. Placeholder-only bodies also produce errors. Nonempty bodies with fewer than 25 prose words produce warnings unless tagged `draft`. A `folder` note is held only to the nonempty rule: any body, placeholder or short, passes with or without `draft`. Untyped vault scaffolding is excluded. The lint, content-index, package compile, site and PDF commands enforce body errors before publishing. The state classification helpers still describe incomplete records for inspection; their classifications do not exempt notes from validation.
475
475
 
476
476
  | Export | Signature | Returns | Use it when |
477
477
  | ------------------ | ---------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------- |
@@ -45,4 +45,4 @@ The full Address is `<package>-note-lore-harbor`; `<package>` comes from `conten
45
45
 
46
46
  Add `description` for a short page summary, `tags: [draft]` to mark unfinished prose, or `data` for fields the note's type permits. A system block such as `sohl:` or `hm3:` adds mechanics and can produce a Foundry Actor or Item. It does not replace the shared prose or make another JournalEntry. [Frontmatter](frontmatter.md) explains the allowed keys, and the [note-type reference](../reference/note-types.md) lists every type-specific field.
47
47
 
48
- Every typed note must have a nonempty body after its frontmatter. An empty or whitespace-only body is an error, even with `tags: [draft]` or a description. This includes folder and homepage notes. A nonempty body with fewer than 25 prose words produces a warning unless the note has a `draft` tag. See [note states](../reference/format-details.md#three-states-of-a-note).
48
+ Every typed note must have a nonempty body after its frontmatter. An empty or whitespace-only body is an error, even with `tags: [draft]` or a description. This includes folder and homepage notes. A nonempty body with fewer than 25 prose words produces a warning unless the note has a `draft` tag. A folder note needs only a nonempty body: any content passes, with or without the tag. See [note states](../reference/format-details.md#three-states-of-a-note).
@@ -9,7 +9,7 @@ subType: userguide
9
9
 
10
10
  A content package begins with Markdown notes under `assets/content/`. Each note has YAML frontmatter describing its identity and data, followed by prose. Folder names beneath `assets/content/` are for authors; `type`, `subType`, and Addresses determine what the build creates.
11
11
 
12
- Every typed note requires a nonempty body, including drafts, folders and homepages. An empty or whitespace-only body is an error. A nonempty body with fewer than 25 prose words warns unless tagged `draft`; the tag never exempts an empty body.
12
+ Every typed note requires a nonempty body, including drafts, folders and homepages. An empty or whitespace-only body is an error. A nonempty body with fewer than 25 prose words warns unless tagged `draft`; the tag never exempts an empty body. A `folder` note needs only a nonempty body: any content passes, with or without `draft`.
13
13
 
14
14
  Start with [your first note](authoring/first-note.md). It explains the smallest valid note and shows the JournalEntry, web page, book entry, and content-index record made from it. Then use the chapters below in the order your note needs them.
15
15
 
@@ -144,12 +144,12 @@ The build always writes `title`, `slug`, `url`, `kbfolder`, `package` and
144
144
  note. `infoboxes` is assembled from the note's own fields, so no note authors
145
145
  it and nothing in the note format accepts it.
146
146
 
147
- The build writes `resolvedDates`, `related`, `contains`, `governed_by`, `governed_places`
148
- and `map` only when it has something to write. A page with no links either way
147
+ The build writes `resolvedDates`, `related`, `contains`, `governed_by`, `governed_places`,
148
+ `works` and `map` only when it has something to write. A page with no links either way
149
149
  carries no `related`, and a place with no drawing carries no `map`.
150
150
 
151
- An authored `aliases`, `related`, `contains`, `governed_by`, `governed_places` or `map` is
152
- dropped. Each states a fact about the whole tree rather than about one note, so
151
+ An authored `aliases`, `related`, `contains`, `governed_by`, `governed_places`, `works` or
152
+ `map` is dropped. Each states a fact about the whole tree rather than about one note, so
153
153
  the build replaces whatever a note wrote.
154
154
 
155
155
  Artwork addresses are rewritten in place under `data`. A page's `data.icon`,
@@ -837,7 +837,7 @@ and the whole of it is the suffix.
837
837
 
838
838
  ```markdown
839
839
  ![A map of the Vale](images/maps/valeofthorns.webp) <!-- this package's -->
840
- ![A shield](sohl/assets/icons/noun/shield.svg) <!-- the sohl package's -->
840
+ ![A shield](sohl/assets/icons/noun/shieldnn.svg) <!-- the sohl package's -->
841
841
  ```
842
842
 
843
843
  The four forms, for a `harnensemble` note (the `harnensemble` content package
@@ -850,14 +850,14 @@ ships as the Foundry module `harn-ensemble`) writing `images/map.webp`:
850
850
  | **Web** | `https://cdn.heroiclands.org/harnensemble/images/map.webp` |
851
851
  | **Book** | `assets/images/map.webp`, staged beside the Typst source |
852
852
 
853
- And for the same note writing `sohl/assets/icons/noun/shield.svg`, a file the
853
+ And for the same note writing `sohl/assets/icons/noun/shieldnn.svg`, a file the
854
854
  system ships and this repository does not hold:
855
855
 
856
856
  | Surface | Address |
857
857
  | ----------- | ------------------------------------------------------------- |
858
- | **Foundry** | `systems/sohl/assets/icons/noun/shield.svg` |
859
- | **Local** | `assets/icons/noun/shield.svg`, in the `sohl` repository |
860
- | **Web** | `https://cdn.heroiclands.org/sohl/icons/noun/shield.svg` |
858
+ | **Foundry** | `systems/sohl/assets/icons/noun/shieldnn.svg` |
859
+ | **Local** | `assets/icons/noun/shieldnn.svg`, in the `sohl` repository |
860
+ | **Web** | `https://cdn.heroiclands.org/sohl/icons/noun/shieldnn.svg` |
861
861
  | **Book** | not carried, unless a declared replacement carries it instead |
862
862
 
863
863
  **The content package name and Foundry id are separate values.**
@@ -2010,7 +2010,9 @@ Every typed note requires a nonempty body after its closing frontmatter fence.
2010
2010
  An empty or whitespace-only body is an error, regardless of description or
2011
2011
  `tags: [draft]`. This includes `folder` and `homepage` notes. Untyped vault
2012
2012
  scaffolding is excluded. A nonempty body with fewer than 25 prose words warns
2013
- unless tagged `draft`; the tag never exempts an empty body.
2013
+ unless tagged `draft`; the tag never exempts an empty body. A `folder` note is
2014
+ held only to the nonempty rule: any body, a placeholder or a single line
2015
+ included, passes with or without `draft`.
2014
2016
 
2015
2017
  The `lint`, `content-index`, `package compile`, `site` and `pdf` commands enforce
2016
2018
  body errors. Write a short beginning and tag it `draft` when work is unfinished.
@@ -2055,7 +2057,7 @@ same.
2055
2057
  | A lone heading | no | The same |
2056
2058
  | `_To be written._` | no | The same |
2057
2059
 
2058
- An HTML comment is deliberately not empty, but an invisible marker does not supply prose. Replace placeholder-only content with written text.
2060
+ An HTML comment is deliberately not empty, but an invisible marker does not supply prose. Replace placeholder-only content with written text. A `folder` note is the exception: any of these bodies satisfies it, and the lint reports nothing.
2059
2061
 
2060
2062
  #### What a stub emits
2061
2063
 
@@ -2794,6 +2796,15 @@ An Item can mark its readable description with `# ... {#description}`. That head
2794
2796
 
2795
2797
  Generates a living (or undead, or spirit) being.
2796
2798
 
2799
+ **subType**:
2800
+
2801
+ - npc: A character meant to be encountered. It may describe a reusable template and need not be
2802
+ a singular individual.
2803
+ - character: A detailed, playable, singular individual.
2804
+ - creature: Any being that is neither a character nor an NPC.
2805
+
2806
+ See [a being's subtype describes the subject](#a-beings-subtype-describes-the-subject).
2807
+
2797
2808
  | `data` property | Values | Description |
2798
2809
  | --------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
2799
2810
  | `tokenIcon` | `Address` | What a token on the canvas wears — an `icon` address; defaults to `icon` |
@@ -3926,22 +3937,39 @@ the setting itself, rather than instructions or other apparatus for the GM.
3926
3937
  which covers the _reckoning_ — a festival's date is calendar and the festival is not, and a
3927
3938
  tournament is not a matter of time-reckoning at all — and from `culture`, which is a grouping
3928
3939
  of people rather than an occasion they attend.
3929
-
3930
- | `data` property | Values | Description |
3931
- | ----------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
3932
- | `epoch` | canonical `<year>.<day>` | The canonical day that equals calendar year 1, day 1 |
3933
- | `months` | `{ name, abbreviation?, days }[]` | Ordered months; their days sum to the world's year |
3934
- | `weekdays` | `{ name, abbreviation? }[]` | Ordered week days, indexed from zero; weeks run continuously |
3935
- | `seasons` | `{ name, abbreviation?, start }[]` | Seasons starting on one-based days of year |
3936
- | `namedDays` | `{ name, abbreviation?, day }[]` | Names assigned to one-based days of year |
3937
- | `eras` | `{ shortcode, name, abbreviation?, marker?, proclaimedBy?, start, label? }[]` | Year counts; `start` is `null` or an in-calendar year |
3938
- | `formats` | map of names to Calendaria patterns | Named date formats; `std` is the preferred default |
3939
- | `events` | `{ when, until?, recurs? }[]` | This note's dated occurrences, each with its own relationships and chronicle fields |
3940
- | `events[].when` | a date, or `"0.<day>"` | The occurrence's anchor and first instance — required |
3941
- | `events[].until` | a date | Where the occurrence ran to, or where a recurring series stopped |
3942
- | `events[].recurs` | `{ every }` or `{ on }` | How further occurrences are found, absent for a one-time occurrence |
3943
- | `events[].recurs.every` | whole number of years, 1 or more | A period counted on the canonical axis from `when` |
3944
- | `events[].recurs.on` | list of dates, strictly increasing, each later than `when` | Recorded occurrences beyond the first, in place of a period |
3940
+ - literature: A work a people composes and keeps — an epic, saga, poem, legend, hymn or
3941
+ chronicle, told, sung or written. Held apart from `history`, which states what happened: a
3942
+ legend is literature whether or not its events took place, and a chronicle is literature as a
3943
+ work even where its events are also history.
3944
+
3945
+ Each note a work names in `data.subjects` lists that work on its own site page, under **In
3946
+ song and story**, with the work's `form` beside its title; the subject note writes nothing.
3947
+ A work in a fetched index lists on this package's pages when that index carries its
3948
+ `subjects`.
3949
+
3950
+ Calendar fields are written only on a `calendar` note, and `culture`, `form`, `subjects` and
3951
+ `language` only on a `literature` note; written on any other lore subType, each is an error at
3952
+ its own key.
3953
+
3954
+ | `data` property | Values | Description |
3955
+ | ----------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
3956
+ | `epoch` | canonical `<year>.<day>` | The canonical day that equals calendar year 1, day 1 |
3957
+ | `months` | `{ name, abbreviation?, days }[]` | Ordered months; their days sum to the world's year |
3958
+ | `weekdays` | `{ name, abbreviation? }[]` | Ordered week days, indexed from zero; weeks run continuously |
3959
+ | `seasons` | `{ name, abbreviation?, start }[]` | Seasons starting on one-based days of year |
3960
+ | `namedDays` | `{ name, abbreviation?, day }[]` | Names assigned to one-based days of year |
3961
+ | `eras` | `{ shortcode, name, abbreviation?, marker?, proclaimedBy?, start, label? }[]` | Year counts; `start` is `null` or an in-calendar year |
3962
+ | `formats` | map of names to Calendaria patterns | Named date formats; `std` is the preferred default |
3963
+ | `culture` | `Address` | `literature` only: the people whose work it is, naming a lore note with `subType: culture` |
3964
+ | `form` | `string` | `literature` only: the kind of work in its people's own terms — `epic`, `saga`, `praise-song`, `elegy`. Free text |
3965
+ | `subjects` | `Address[]` | `literature` only: the beings, places, gods, events and other notes the work concerns |
3966
+ | `language` | `Address` | `literature` only: the tongue it is composed in, naming a skill note with `subType: language` |
3967
+ | `events` | `{ when, until?, recurs? }[]` | This note's dated occurrences, each with its own relationships and chronicle fields |
3968
+ | `events[].when` | a date, or `"0.<day>"` | The occurrence's anchor and first instance — required |
3969
+ | `events[].until` | a date | Where the occurrence ran to, or where a recurring series stopped |
3970
+ | `events[].recurs` | `{ every }` or `{ on }` | How further occurrences are found, absent for a one-time occurrence |
3971
+ | `events[].recurs.every` | whole number of years, 1 or more | A period counted on the canonical axis from `when` |
3972
+ | `events[].recurs.on` | list of dates, strictly increasing, each later than `when` | Recorded occurrences beyond the first, in place of a period |
3945
3973
 
3946
3974
  `data.events` is available on every `lore` subType. Each entry's `kind`, `depth`, `summary`, `sources`, `standing`, `accounts`, `who`, `where`, `unresolved`, `follows` and `names` hold its chronicle detail; their shapes belong to whatever design governs chronicle records, and this format only asks that they sit inside an entry. The shared format checks `when`, `until` and `recurs`; a content package can check the rest. `events` itself does not appear as an infobox row, but a recurring entry's computed next occurrence does.
3947
3975
 
@@ -417,7 +417,7 @@ These fields are accepted by every note type. A field's value can still be irrel
417
417
 
418
418
  ### lore
419
419
 
420
- **Subtypes:** `cosmology`, `deity`, `theology`, `arcana`, `spirit`, `economy`, `law`, `calendar`, `history`, `material`, `folk`, `culture`, `custom`, `bestiary`, `gathering`.
420
+ **Subtypes:** `cosmology`, `deity`, `theology`, `arcana`, `spirit`, `economy`, `law`, `calendar`, `history`, `material`, `folk`, `culture`, `custom`, `bestiary`, `gathering`, `literature`.
421
421
 
422
422
  Lore records in-world knowledge. A `culture` describes a people; a `custom` describes how they practice a rite, observance, or usage. A `material` describes a physical constituent and its qualities, which may vary by region. See the [lore subtype definitions](format-details.md#type-lore) for the complete vocabulary.
423
423
 
@@ -430,6 +430,10 @@ Lore records in-world knowledge. A `culture` describes a people; a `custom` desc
430
430
  | `data.namedDays` | list of `{ name, abbreviation?, day }` | Names assigned to particular days of the year. |
431
431
  | `data.eras` | list of `{ shortcode, name, marker?, abbreviation?, proclaimedBy?, start, label? }` | The year-counts kept in this calendar. A marker names one era and uses these months. |
432
432
  | `data.formats` | map of named Calendaria format strings | Named patterns for reading and writing this calendar's dates. |
433
+ | `data.culture` | an Address | The people whose work this is, as a culture lore note. |
434
+ | `data.form` | string | The kind of work in its people's own terms — an epic, a saga, a praise-song, an elegy. Free text. |
435
+ | `data.subjects` | list of Addresses | The beings, places, gods, events and other notes the work concerns. |
436
+ | `data.language` | an Address | The tongue the work is composed in, as a language skill note. |
433
437
  | `data.events` | list of `{ when, until?, recurs? }` | This note's dated occurrences — a founding once, an anniversary that recurs, or a list of recorded happenings. |
434
438
 
435
439
  ### map
@@ -14,7 +14,7 @@ map, search, and the markup an image or a figure renders as — and names the
14
14
  classes a consumer styles against.
15
15
 
16
16
  **None of the sections below is an authoring instruction.** `infoboxes`,
17
- `related`, `contains`, `governed_by`, `governed_places`, `map`, `package`, `slug`, `url`
17
+ `related`, `contains`, `governed_by`, `governed_places`, `works`, `map`, `package`, `slug`, `url`
18
18
  and `title` cannot be written by a note at all: a note's top-level region is
19
19
  closed and does not admit them, so each arrives already resolved by the time
20
20
  the theme sees it. Read this page when a panel is missing or a class needs
@@ -215,6 +215,37 @@ uses, so a region with forty settlements stays scannable.
215
215
  A page whose front matter declares none of `contains`, `governed_by` or
216
216
  `governed_places` renders no card at all.
217
217
 
218
+ ## The "In song and story" card
219
+
220
+ `partials/literature-works.html` renders a second card directly below the
221
+ government card, listing the works of literature that name the page's subject.
222
+ The build derives `works` from every `literature` lore note's `data.subjects`,
223
+ including works a fetched index carries, so no note writes it:
224
+
225
+ ```yaml
226
+ ---
227
+ works: [{ title: …, url: …, form: … }] # works naming this page, sorted by title
228
+ ---
229
+ ```
230
+
231
+ `form` is the work's `data.form`, printed in parentheses beside its title, and is
232
+ absent when the work states none. A work naming one subject twice lists once, and
233
+ a work naming itself is not listed on its own page. An entry's `url` is absent
234
+ for a work that publishes no page, which renders as plain text through
235
+ `partials/holdings/entry.html`. A page no work names carries no `works` and
236
+ renders no card.
237
+
238
+ The card reuses the government card's classes, so a consumer styling one styles
239
+ both; `.literature-works` on the `<section>` tells them apart.
240
+
241
+ | class | drawn as |
242
+ | ----------------------- | --------------------------------- |
243
+ | `.literature-works` | the card, beside `.holdings` |
244
+ | `.holdings-table-label` | "In song and story" |
245
+ | `.holdings-table-grid` | the grid of works |
246
+ | `.holdings-entry-name` | a work's title, linked or plain |
247
+ | `.holdings-entry-kind` | the work's form, beside its title |
248
+
218
249
  ## The place map
219
250
 
220
251
  A place page with `map: from-<shortcode>.svg` displays a **From here** panel
@@ -241,6 +241,10 @@ types:
241
241
  - namedDays
242
242
  - eras
243
243
  - formats
244
+ - culture
245
+ - form
246
+ - subjects
247
+ - language
244
248
  - events
245
249
  subTypes:
246
250
  - cosmology
@@ -258,6 +262,7 @@ types:
258
262
  - custom
259
263
  - bestiary
260
264
  - gathering
265
+ - literature
261
266
  map:
262
267
  data:
263
268
  - scene
@@ -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";
@@ -1479,15 +1480,21 @@ export const NOTE_VOCABULARY = Object.freeze({
1479
1480
  "custom", // How a people practices a rite, observance, or usage.
1480
1481
  "bestiary", // A kind of creature that is not a people.
1481
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.
1482
1484
  ]),
1483
1485
  // A lore note is prose, and what it *is* about is its subType — with
1484
- // one exception. A calendar is a division of the year, and a division
1485
- // is data; the check scopes the family to the subType that means it,
1486
- // because `DataFieldSpec` declares the keys a type accepts and not the
1487
- // subType that may write them.
1488
- 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
+ ],
1489
1495
  data: Object.freeze([
1490
1496
  ...CALENDAR_FIELDS,
1497
+ ...LITERATURE_FIELDS,
1491
1498
  {
1492
1499
  name: "events",
1493
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;
@@ -18,6 +18,8 @@
18
18
  * a `draft` tag or a frontmatter description. Placeholder-only bodies remain
19
19
  * errors. A nonempty body below twenty-five words receives a warning when it
20
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`.
21
23
  *
22
24
  * Counts and the oldest drafts remain reports. Classification and indexing
23
25
  * still derive full, draft and stub states from the existing note records;
@@ -70,6 +72,10 @@ function checkNote({ fm, body, file, raw }) {
70
72
  return findings;
71
73
  }
72
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
+
73
79
  const placeholders = placeholderBody(body);
74
80
  if (placeholders.length) {
75
81
  const [{ phrase }] = placeholders;
@@ -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.28.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",
@@ -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
  };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Require `data.language` to address a `skill` note whose subType is
3
+ * `language`.
4
+ *
5
+ * An Address that does not resolve is the reference check's to report, so
6
+ * this speaks only about a target that exists and is the wrong kind.
7
+ *
8
+ * @param {object} note
9
+ * @param {{index?: object}} [options]
10
+ * @returns {object[]} The findings.
11
+ */
12
+ export function checkLanguageChoice(note: object, { index }?: {
13
+ index?: object;
14
+ }): object[];
15
+ /**
16
+ * Refuse a literature field on a lore note of any other subType.
17
+ *
18
+ * @param {object} note
19
+ * @returns {object[]} One error per literature key written off `literature`.
20
+ */
21
+ export function checkLiteratureNote(note: object): object[];
22
+ /** The `lore` subType whose notes are works of literature. */
23
+ export const LITERATURE_SUBTYPE: "literature";
24
+ /**
25
+ * The fields a `literature` note may state. Every one is optional.
26
+ *
27
+ * @type {ReadonlyArray<object>}
28
+ */
29
+ export const LITERATURE_FIELDS: ReadonlyArray<object>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * One local page's node.
3
+ *
4
+ * @param {object} fm - The note's frontmatter.
5
+ * @param {{title: string, url?: string, package?: string}} page
6
+ * @returns {WorksNode|null} Null for a note with no type or shortcode.
7
+ */
8
+ export function worksNode(fm: object, { title, url, package: pkg }: {
9
+ title: string;
10
+ url?: string;
11
+ package?: string;
12
+ }): WorksNode | null;
13
+ /**
14
+ * The works a fetched index carries, as nodes. Only a work can list itself on
15
+ * a page, and only a page of this package can carry the list, so a foreign
16
+ * entry that is not a work is never needed here.
17
+ *
18
+ * @param {Map<string, object>|undefined} foreignIndex
19
+ * @returns {WorksNode[]}
20
+ */
21
+ export function foreignWorksNodes(foreignIndex: Map<string, object> | undefined): WorksNode[];
22
+ /**
23
+ * Invert every work's subjects into the list each subject's page carries.
24
+ *
25
+ * A subject is resolved in its work's own package. A work naming one subject
26
+ * twice lists once; a work naming itself is not listed on its own page.
27
+ * Entries sort by title.
28
+ *
29
+ * @param {Iterable<WorksNode|null>} nodes - Every local page, then any foreign work.
30
+ * @param {object} options
31
+ * @param {ReadonlySet<string>} options.types - Note types an Address may name.
32
+ * @returns {Map<string, {works: WorkEntry[]}>} Keyed by the subject page's URL.
33
+ */
34
+ export function worksPages(nodes: Iterable<WorksNode | null>, { types }: {
35
+ types: ReadonlySet<string>;
36
+ }): Map<string, {
37
+ works: WorkEntry[];
38
+ }>;
39
+ /** The page front-matter key the site build writes. */
40
+ export const WORKS_KEY: "works";
41
+ export type WorkEntry = {
42
+ title: string;
43
+ url?: string;
44
+ form?: string;
45
+ };
46
+ export type WorksNode = {
47
+ /**
48
+ * Own Address, normalized to the `note` system.
49
+ */
50
+ key: string;
51
+ title: string;
52
+ /**
53
+ * Absent for a note that publishes no page.
54
+ */
55
+ url?: string | undefined;
56
+ /**
57
+ * Owning content package.
58
+ */
59
+ package?: string | undefined;
60
+ /**
61
+ * A work's `data.form`.
62
+ */
63
+ form?: string | undefined;
64
+ /**
65
+ * A work's `data.subjects`; empty for any other note.
66
+ */
67
+ subjects: unknown[];
68
+ };