@heroiclands/package-build 22.6.0 → 22.8.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,207 @@
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
+ * **What a draft note says about itself**, in one sentence, on every surface it
16
+ * reaches.
17
+ *
18
+ * A note tagged `draft` compiles, validates, publishes and resolves like any
19
+ * other. What separates it from a finished note is a judgement only its author
20
+ * holds — the content is not settled — and a reader who is not told assumes the
21
+ * opposite. So each surface states it where the reader cannot miss it: at the
22
+ * head of the entry, above the infobox and above the prose.
23
+ *
24
+ * **The words are the same everywhere and are stated once here.** A reader who
25
+ * meets the same note on a page, in a compendium and in the book should meet
26
+ * one statement, not three that agree approximately. {@link DRAFT_NOTICE} is
27
+ * that statement; every renderer below composes its own markup around it and
28
+ * changes none of it.
29
+ *
30
+ * **A label alone will not do.** "Draft" names the writing, so it reads as
31
+ * rough prose; what has to land is that the facts may change. The label and the
32
+ * sentence are one unit, and the sentence carries the meaning by itself — which
33
+ * is the floor each surface is built to, because each of them can lose
34
+ * everything around it.
35
+ *
36
+ * **The label is bold roman and the sentence is italic**, on every surface. The
37
+ * words around it are the world describing itself; this one sentence is the
38
+ * text speaking about its own state, and italic is how a reader is told the
39
+ * voice has changed. It is the one typographic mark the sentence carries:
40
+ * smaller or lighter type would say the reader may skip it, which is the
41
+ * opposite of what it is for, and a bold italic beside the mark would be one
42
+ * emphasis too many.
43
+ *
44
+ * ## What each surface can rely on
45
+ *
46
+ * - **Foundry** renders a JournalEntry's own HTML inside a sheet, so the floor
47
+ * is what a journal page carries with no module and no system stylesheet
48
+ * loaded. Foundry draws a left rule on a `blockquote` in any application
49
+ * window and bundles Font Awesome, so the rule and the mark are the host's
50
+ * and nothing here ships CSS. The class is a hook for a package that wants
51
+ * one, never a requirement.
52
+ * - **The book** sets Typst, where a glyph missing from the face is silent and
53
+ * a consumer declaring no icon family is the normal case — see {@link
54
+ * module:engine/content-icons}. So the mark is **drawn**, from two
55
+ * primitives, and cannot become a box.
56
+ * - **An Actor's prose** lands in a system's own sheet, in a field the system
57
+ * styles. It gets the same HTML the journal does; what it can rely on is the
58
+ * sentence.
59
+ *
60
+ * @module
61
+ */
62
+
63
+ import { isDraftNote } from "./note-vocabulary.mjs";
64
+
65
+ /** The word that opens the notice, and the only part a reader reads as a label. */
66
+ export const DRAFT_NOTICE_LABEL = "Draft.";
67
+
68
+ /**
69
+ * The sentence, which states the fact and stops.
70
+ *
71
+ * It says the content may change rather than that the writing is rough,
72
+ * because that is the difference between a reader who treats the entry as
73
+ * provisional and one who builds on it. It gives no instruction: a notice that
74
+ * reaches one page in seven wears better as a statement than as a direction,
75
+ * and the fact carries the consequence for anyone who can act on it.
76
+ *
77
+ * @type {string}
78
+ */
79
+ export const DRAFT_NOTICE =
80
+ "This entry is unfinished. What it states may change, and nothing in it is settled.";
81
+
82
+ /** The Font Awesome mark: a notice, not a hazard. */
83
+ export const DRAFT_NOTICE_ICON = "fa-circle-exclamation";
84
+
85
+ /** The class a package may style the HTML notice against. */
86
+ export const DRAFT_NOTICE_CLASS = "sohl-draft-notice";
87
+
88
+ /** The Typst function the book's notice calls. */
89
+ export const BOOK_DRAFT_NOTICE = "book-draft-notice";
90
+
91
+ /**
92
+ * The notice as HTML, for a JournalEntry page and for an Actor's prose field.
93
+ *
94
+ * A `blockquote` because that is the element Foundry already sets apart —
95
+ * `body.game .app blockquote` carries a left rule and an indent — so the
96
+ * treatment survives with no stylesheet of this package's own. The mark is a
97
+ * Font Awesome element, which Foundry bundles; `aria-hidden` keeps it out of a
98
+ * screen reader, where the label reads it. The sentence is an `em`, which every
99
+ * renderer of HTML sets in italic with no stylesheet to ask.
100
+ *
101
+ * @returns {string} The notice's HTML.
102
+ */
103
+ export function draftNoticeHtml() {
104
+ return (
105
+ `<blockquote class="${DRAFT_NOTICE_CLASS}">` +
106
+ `<p><i class="fa-solid ${DRAFT_NOTICE_ICON}" aria-hidden="true"></i> ` +
107
+ `<strong>${DRAFT_NOTICE_LABEL}</strong> <em>${DRAFT_NOTICE}</em></p>` +
108
+ `</blockquote>`
109
+ );
110
+ }
111
+
112
+ /**
113
+ * The notice a note has earned, or nothing.
114
+ *
115
+ * The one place the tag is asked about on a compiled document, so the packs and
116
+ * the book cannot answer it differently. It reads {@link
117
+ * module:engine/note-vocabulary.isDraftNote}, which is the single reader of the
118
+ * tag itself.
119
+ *
120
+ * @param {object|null|undefined} fm - Parsed frontmatter.
121
+ * @returns {string} The notice's HTML, or `""` for a note that is settled.
122
+ */
123
+ export function draftNoticeFor(fm) {
124
+ return isDraftNote(fm) ? draftNoticeHtml() : "";
125
+ }
126
+
127
+ /**
128
+ * Prose led by the notice, where the note is a draft.
129
+ *
130
+ * **Which field a system leads is the system's decision, and it makes it
131
+ * once.** A referee at the table opens an actor sheet rather than the
132
+ * documentation beside it, so the prose a sheet draws says it too — on one
133
+ * field, since a document showing the same statement twice teaches a reader to
134
+ * skip it. Both systems put it on the dossier: SoHL's `dossier` and HM3's
135
+ * `biography` read the same authored section, so the notice lands on the same
136
+ * prose whichever system compiles it.
137
+ *
138
+ * @param {object|null|undefined} fm - Parsed frontmatter.
139
+ * @param {string} html - The rendered prose.
140
+ * @returns {string} The prose, led by the notice where one is due.
141
+ */
142
+ export function withDraftNotice(fm, html) {
143
+ const notice = draftNoticeFor(fm);
144
+ if (!notice) return html;
145
+ return html ? `${notice}\n${html}` : notice;
146
+ }
147
+
148
+ /**
149
+ * The `#let` the book's notice is drawn by.
150
+ *
151
+ * Emitted with the rest of the book's preamble. The mark is a disc with an
152
+ * exclamation knocked out of it: two primitives and the face already setting
153
+ * the page, so there is no font to resolve and nothing that can come out as a
154
+ * box. It is filled in the accent the book rules its headings with and the
155
+ * glyph is the colour of the paper, so the mark carries against the running
156
+ * face rather than reading as a character of it.
157
+ *
158
+ * **The mark is a column, not a word.** A two-column grid puts it beside the
159
+ * sentence rather than inside it, so a notice that takes two lines sets its
160
+ * second line against the first and never under the disc; the grid centres the
161
+ * mark's cell on the block, so it holds the middle of one line or two. The rule
162
+ * is the block's own left stroke and spans whatever height the sentence takes.
163
+ *
164
+ * **The sentence sets in the book's body face, size and ink**, because it is
165
+ * the one thing in the entry a reader has to read before trusting the rest. The
166
+ * rule and the mark set the block apart; nothing else has to, and type set
167
+ * smaller or lighter than the prose around it reads as apparatus a reader may
168
+ * skip. The mark scales with that text, so a consumer setting the book larger
169
+ * or smaller carries the disc with it.
170
+ *
171
+ * **One number sets the mark.** The glyph is a multiple of the disc's radius
172
+ * rather than a size of its own, so the exclamation keeps its share of the disc
173
+ * however large the disc is drawn and resizing the mark is one edit. Both are
174
+ * `em`, so the mark still follows the body text.
175
+ *
176
+ * @returns {string} The Typst definition.
177
+ */
178
+ export function bookDraftNoticePreamble() {
179
+ return (
180
+ `#let ${BOOK_DRAFT_NOTICE}(label, body) = block(\n` +
181
+ " width: 100%, above: 0.55em, below: 0.75em, breakable: false,\n" +
182
+ " inset: (left: 0.7em, top: 0.35em, bottom: 0.35em),\n" +
183
+ " stroke: (left: 1.6pt + book-faint))[\n" +
184
+ " #set par(justify: false, first-line-indent: 0em)\n" +
185
+ " #let radius = 0.69em\n" +
186
+ " #grid(columns: (auto, 1fr), column-gutter: 0.5em,\n" +
187
+ " align: (center + horizon, horizon),\n" +
188
+ " circle(radius: radius, fill: book-accent)[\n" +
189
+ " #align(center + horizon)[\n" +
190
+ ' #text(size: 1.6 * radius, weight: "bold", fill: book-paper)[!]\n' +
191
+ " ]\n" +
192
+ " ],\n" +
193
+ ' [#text(weight: "bold")[#label]#h(0.25em)#emph[#body]],\n' +
194
+ " )\n" +
195
+ "]"
196
+ );
197
+ }
198
+
199
+ /**
200
+ * The notice as Typst, for a leaf of the book.
201
+ *
202
+ * @returns {string} The call, ready to sit between the entry's plate and its
203
+ * infobox panel.
204
+ */
205
+ export function draftNoticeTypst() {
206
+ return `#${BOOK_DRAFT_NOTICE}[${DRAFT_NOTICE_LABEL}][${DRAFT_NOTICE}]`;
207
+ }
@@ -455,8 +455,34 @@ function checkDataContainer(note, { type, fields, packs }) {
455
455
  /** First segment of each declared name — `charges.value` is authored as `charges`. */
456
456
  const declared = new Set(fields.map((f) => f.name.split(".")[0]));
457
457
 
458
+ // The retired spelling of a key this type declares → what to write now.
459
+ // Built from the type's own declaration, so a renamed `data:` key is
460
+ // retired exactly where its replacement exists and stays an unrecognised
461
+ // key on every other type.
462
+ const renamed = new Map();
463
+ for (const name of declared) {
464
+ const retired = RETIRED_FIELD_ALIASES[name];
465
+ if (retired) renamed.set(retired, name);
466
+ }
467
+
458
468
  for (const key of Object.keys(entries)) {
459
469
  if (declared.has(key)) continue;
470
+ const current = renamed.get(key);
471
+ if (current) {
472
+ findings.push({
473
+ file: note.file,
474
+ ...positionOfFrontmatterPath(raw, ["data", key], { key: true }),
475
+ // A warning, as every retired spelling is: the note compiles to
476
+ // the correct document, so failing a build over it would red a
477
+ // tree that has done nothing wrong yet. The refusal comes once
478
+ // no tree writes the key, and needs no code — with the alias
479
+ // gone the spelling is an unrecognised key again, which the
480
+ // loop below already refuses.
481
+ severity: "warning",
482
+ message: retiredAliasMessage(key, current),
483
+ });
484
+ continue;
485
+ }
460
486
  const guess = nearest(key, declared);
461
487
  findings.push({
462
488
  file: note.file,
package/engine/index.mjs CHANGED
@@ -87,6 +87,9 @@ export * as noteVocabulary from "./note-vocabulary.mjs";
87
87
  /** The three states of a note — stub, draft, full — and the empty body they rest on. */
88
88
  export * as noteState from "./note-state.mjs";
89
89
 
90
+ /** What a draft note says about itself, in one sentence, on every surface. */
91
+ export * as draftNotice from "./draft-notice.mjs";
92
+
90
93
  /** Whether every empty body is a stub on purpose, and how finished a tree is. */
91
94
  export * as stubLint from "./stub-lint.mjs";
92
95
 
@@ -121,6 +124,12 @@ export * as siteMaps from "./site-maps.mjs";
121
124
  /** The closed registry of system ids, and the `none` that stands for no system. */
122
125
  export * as systems from "./systems.mjs";
123
126
 
127
+ /** The reckonings a package dates by, and the arithmetic that puts them on one line. */
128
+ export * as calendars from "./calendars.mjs";
129
+
130
+ /** A note's dates: one authored string, one grammar, one record shape. */
131
+ export * as noteDates from "./note-dates.mjs";
132
+
124
133
  /** The one normalisation this build makes: prose to a URL-safe token. */
125
134
  export * as contentSlug from "./content-slug.mjs";
126
135
 
@@ -115,6 +115,7 @@
115
115
  import { getFrontmatter } from "./frontmatter.mjs";
116
116
  import { currentType } from "./ids.mjs";
117
117
  import { NOTE_VOCABULARY, dataFields } from "./note-vocabulary.mjs";
118
+ import { readAliasedField } from "./retired-fields.mjs";
118
119
  import { subtypeRow } from "./document-subtypes.mjs";
119
120
 
120
121
  /**
@@ -272,7 +273,6 @@ export const NOTE_FIELD_PRESENTATION = Object.freeze({
272
273
  banner: Object.freeze({ withheld: "an image, which the box never carries" }),
273
274
  overlay: Object.freeze({ withheld: "an image, which the box never carries" }),
274
275
 
275
- birthday: Object.freeze({ label: "Born" }),
276
276
  assocSkill: Object.freeze({ label: "Skill" }),
277
277
  assocAffiliation: Object.freeze({ label: "Affiliation" }),
278
278
  parentSkill: Object.freeze({ label: "Specialises" }),
@@ -637,7 +637,12 @@ function noteBox(
637
637
  for (const field of dataFields(fm?.type, vocabulary) ?? []) {
638
638
  const overlay = overlayFor(presentation, fm?.type, field.name);
639
639
  if (overlay.withheld) continue;
640
- const raw = getFrontmatter(data, field.name, undefined);
640
+ // The retired spelling underneath, so a note part-way through a rename
641
+ // still shows its value rather than losing the row with nothing said.
642
+ // The current name wins, which is the whole of the retirement window's
643
+ // behaviour and is `readAliasedField`'s answer, not a second one.
644
+ const own = getFrontmatter(data, field.name, undefined);
645
+ const raw = hasValue(own) ? own : readAliasedField(fm, field.name, { inData: true });
641
646
  if (!hasValue(raw)) continue;
642
647
 
643
648
  if (overlay.group) {
@@ -64,10 +64,10 @@ export { itemTypes };
64
64
  * Every content type whose **prose compiles into a JournalEntry of its own**,
65
65
  * addressed by the virtual `doc<type>` qualifier.
66
66
  *
67
- * Every item type, plus `macro` — a macro note's body documents the script the
68
- * note also compiles into a Macro, which is the same shape as an item
69
- * and its description: one note, two documents, the prose living in the
70
- * journals pack.
67
+ * Every item type, every actor type, every map type, plus `macro` — a macro
68
+ * note's body documents the script the note also compiles into a Macro, which
69
+ * is the same shape as an item and its description: one note, two documents,
70
+ * the prose living in the journals pack.
71
71
  *
72
72
  * **One set, read by the compiler and the emitter alike.** The journals pass
73
73
  * decides what to compile from it, and the link manifest decides what to
@@ -76,8 +76,9 @@ export { itemTypes };
76
76
  * consumer can address. It is composed exactly once, in `defineConfig`, and
77
77
  * read from there — never recomposed at a call site.
78
78
  *
79
- * `doc` notes and actors are absent: each is a single document, so it has no
80
- * separate documentation to address.
79
+ * `doc` notes are absent, for the reason that applies to them alone: a `doc`
80
+ * note's single document *is* its prose, so there is no second document for a
81
+ * `doc<type>` address to name.
81
82
  *
82
83
  * An accessor rather than a hoisted constant, so that importing this module
83
84
  * needs no configuration (#2).
@@ -93,8 +94,8 @@ export function docEntryTypes() {
93
94
  * its own.
94
95
  *
95
96
  * @param {string} type - The note's `type` frontmatter.
96
- * @returns {boolean} True for an item type, for `macro` and for a map type;
97
- * false for `doc` and for actors.
97
+ * @returns {boolean} True for an item type, an actor type, a map type and
98
+ * `macro`; false for `doc`.
98
99
  */
99
100
  export function hasDocEntry(type) {
100
101
  // Through {@link currentType}, because the set is derived from the item
@@ -18,14 +18,17 @@
18
18
  * The content root (`contentBase`) is walked recursively; any `.md` file
19
19
  * whose frontmatter declares either `type: doc` or a
20
20
  * **doc-carrying type** ({@link sohl.utils.packs.docEntryTypes} — every item
21
- * type, plus `macro`) is compiled into one JournalEntry document. Each note's
21
+ * type, every actor type, every map type, plus `macro`) is compiled into one
22
+ * JournalEntry document. Each note's
22
23
  * body is split on top-level H1 headings; the optional content before the
23
24
  * first H1 becomes a lead page, and each subsequent H1 starts a new page named
24
25
  * after its heading text. All page bodies are rendered to HTML.
25
26
  *
26
27
  * A doc-carrying note compiles into that document's **documentation** — the
27
- * same prose and pages, filed in the same folder as the document itself, which
28
- * keeps only a pointer to it. See `item-docs.mjs` for why, and for the ids the
28
+ * same prose and pages, filed in the same folder as the document itself. An
29
+ * item keeps only a pointer to it; an actor keeps its own inline prose as
30
+ * well, because the indirection an item pays for is bought by embedding one
31
+ * item across hundreds of beings and an actor is singular. See `item-docs.mjs` for why, and for the ids the
29
32
  * two passes agree on. A macro note's `{#script}` page is compiled here like
30
33
  * any other: the macro pass reads the same page independently, and withholds
31
34
  * nothing from the journal.
@@ -56,6 +59,7 @@ import { noteInfoboxes } from "./infobox-registry.mjs";
56
59
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
57
60
  import { JOURNAL_TYPES } from "./ids.mjs";
58
61
  import { journalHasContent } from "./note-state.mjs";
62
+ import { draftNoticeFor } from "./draft-notice.mjs";
59
63
 
60
64
  /**
61
65
  * Splits a markdown body into pages by top-level H1 headings. Fenced
@@ -287,6 +291,11 @@ export function buildPages(rawPages, entryId, noteName) {
287
291
  * that reads. It is **not** a page of its own — a page is what a UUID
288
292
  * addresses, and a summary a reader has to navigate to is a summary they do
289
293
  * not see.
294
+ * @param {string} [params.notice] - A statement about the entry rather than
295
+ * about its subject, already rendered to HTML — see {@link
296
+ * module:engine/draft-notice}. It leads the first page, ahead of the infobox,
297
+ * because a reader deciding whether to rely on the entry has to be told
298
+ * before they read it rather than after.
290
299
  * @returns {object} The JournalEntry document, keyed for the pack.
291
300
  */
292
301
  export function buildJournalEntry({
@@ -298,12 +307,16 @@ export function buildJournalEntry({
298
307
  flags,
299
308
  stats = defaultStats(),
300
309
  infobox = "",
310
+ notice = "",
301
311
  }) {
302
312
  const rawPages = splitPages(markdown, leadName);
303
313
  const pages = buildPages(rawPages, id, name);
304
314
  if (infobox.trim() && pages.length) {
305
315
  pages[0].text.content = `${infobox}\n${pages[0].text.content}`;
306
316
  }
317
+ if (notice.trim() && pages.length) {
318
+ pages[0].text.content = `${notice}\n${pages[0].text.content}`;
319
+ }
307
320
  return {
308
321
  name,
309
322
  pages,
@@ -407,7 +420,7 @@ export class Journals extends BasePackCompiler {
407
420
  * Compile one note into a JournalEntry.
408
421
  *
409
422
  * A `doc` note becomes the entry its frontmatter describes. A
410
- * **doc-carrying note** — every item note, and every macro note — becomes
423
+ * **doc-carrying note** — every item, actor, map and macro note — becomes
411
424
  * that document's documentation instead: the same prose, the same pages,
412
425
  * in the same folder, under an id derived from the note's, so the pointer
413
426
  * the items pass wrote resolves to it (see
@@ -458,6 +471,12 @@ export class Journals extends BasePackCompiler {
458
471
  name,
459
472
  markdown,
460
473
  infobox: infoboxesToHtml(boxes, { link: linkToUuid }),
474
+ // Whether the entry is settled, asked of the tag through the one
475
+ // reader of it. This sits here rather than in `buildJournalEntry`
476
+ // for the reason `skipNote` runs first: a note with no prose
477
+ // compiles into nothing, and a notice composed before that test
478
+ // would be content enough to give it an entry.
479
+ notice: draftNoticeFor(fm),
461
480
  // A doc-carrying note's lead page is the document itself, not an
462
481
  // "Introduction" — see {@link splitPages}.
463
482
  leadName: ownsDoc ? name : undefined,