@heroiclands/package-build 22.6.0 → 22.7.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,14 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ **Draft notes say so.** A note tagged `draft` states that it is unfinished and
8
+ that nothing in it is settled, at the head of its compendium entry, on the
9
+ character sheet a referee opens, and on its page in the book. A finished note
10
+ carries nothing.
11
+
3
12
  ## 22.6.0
4
13
 
5
14
  ### Minor Changes
package/CONTENT.md CHANGED
@@ -1113,6 +1113,11 @@ page, no address and no link target. A note with a body and the `draft` tag is a
1113
1113
  **draft**, and one with a body and no tag is **full**.
1114
1114
  `docs/content-format.md` states the rule; this is what the lint does with it.
1115
1115
 
1116
+ A draft says so on every surface it reaches — its journal entry, the Actor a
1117
+ being compiles into, its page in the book — in one sentence at the head of the
1118
+ entry, above the infobox and the prose. A stub says nothing anywhere, because it
1119
+ publishes nothing to say it on.
1120
+
1116
1121
  **A document that would be empty is not created**, and the index then names no
1117
1122
  artefact — `uuid`, `path` and `address` are `null`. An unwritten note still
1118
1123
  compiles every document derived from `data:`, so an arcane talent is still its
package/docs/api.md CHANGED
@@ -427,6 +427,23 @@ The three states of a note — **stub**, **draft**, **full** — and the one obs
427
427
  | `placeholderBody` | `placeholderBody(body)` | `Array<{phrase: string, line: number}>` | finding a body that reduces to nothing but placeholders, which is an abandoned draft rather than a stub |
428
428
  | `bodyWordCount` | `bodyWordCount(body)` | `number` | counting the prose a reader meets, with a wikilink counting as the one word it renders |
429
429
 
430
+ ### `engine.draftNotice`
431
+
432
+ What a draft note says about itself, in one sentence, on every surface it reaches. The words are stated once and each renderer composes its own markup around them, so a reader meeting the note on a page, in a compendium and in the book meets one statement. The mark is a notice rather than a hazard, and the book draws it rather than setting a glyph, because a face missing one is silent in Typst.
433
+
434
+ | Export | Signature | Returns | Use it when |
435
+ | ------------------------- | ------------------------------------ | -------- | ---------------------------------------------------------------------------------- |
436
+ | `DRAFT_NOTICE` | `const DRAFT_NOTICE` | `string` | reading the sentence itself, which is the floor every surface degrades to |
437
+ | `DRAFT_NOTICE_LABEL` | `const DRAFT_NOTICE_LABEL` | `string` | reading the label that opens the notice |
438
+ | `DRAFT_NOTICE_ICON` | `const DRAFT_NOTICE_ICON` | `string` | naming the Font Awesome mark the HTML surfaces draw |
439
+ | `DRAFT_NOTICE_CLASS` | `const DRAFT_NOTICE_CLASS` | `string` | styling the HTML notice from a package that wants to |
440
+ | `BOOK_DRAFT_NOTICE` | `const BOOK_DRAFT_NOTICE` | `string` | naming the Typst function the book's notice calls |
441
+ | `draftNoticeHtml` | `draftNoticeHtml()` | `string` | drawing the notice for a JournalEntry page or an Actor's prose field |
442
+ | `draftNoticeFor` | `draftNoticeFor(frontmatter)` | `string` | asking whether a note has earned a notice, which is the one place the tag is asked |
443
+ | `withDraftNotice` | `withDraftNotice(frontmatter, html)` | `string` | leading a system's own prose field with the notice where one is due |
444
+ | `draftNoticeTypst` | `draftNoticeTypst()` | `string` | drawing the notice for a leaf of the book |
445
+ | `bookDraftNoticePreamble` | `bookDraftNoticePreamble()` | `string` | emitting the Typst definition the book's notice is drawn by |
446
+
430
447
  ### `engine.stubLint`
431
448
 
432
449
  An empty body must be deliberate. Four rules hold the state honest — a stub states what it is, a stub is not tagged `draft`, a body that renders to nothing is an abandoned draft, and a short unmarked body is asked whether it forgot its marker — and two reports say what the rules cannot decide.
@@ -320,16 +320,51 @@ is declared, so a near miss is a finding that names what you probably meant.
320
320
  | **being kind** | `being` | `character`, `creature` |
321
321
  | **state** | any | `draft` |
322
322
 
323
- **`draft` is the one tag either build reads.** A note tagged `draft` exists so a
324
- link into it is not dead, and a link into it renders marked — as
323
+ **`draft` is the one tag either build reads.** It does two things and nothing
324
+ else: a link _into_ a draft note renders marked, and the note itself states that
325
+ it is unfinished wherever a reader meets it.
326
+
327
+ A link into it renders as
325
328
  `<span class="sohl-draft-link" title="Draft — not yet written">…</span>` in a
326
329
  compiled journal and on the website alike, with the appearance supplied by the
327
- consuming system's stylesheet or the site theme. **Nothing else changes**: the
328
- note compiles, validates, publishes and resolves exactly as any other, and it
329
- stays in the packs, in the link manifest and on the site. That is what separates
330
- the tag from the retired `draft:` field, whose whole effect was to move a note
331
- from published to unresolvable without saying so — and which is refused, by name,
332
- if you write it.
330
+ consuming system's stylesheet or the site theme.
331
+
332
+ The note's own statement is the **draft notice**, and it reads the same on every
333
+ surface:
334
+
335
+ > **Draft.** This entry is unfinished. What it states may change, and nothing in
336
+ > it is settled.
337
+
338
+ It leads the entry — above the infobox and above the prose — because a reader
339
+ deciding whether to rely on an entry has to be told before they read it. Where
340
+ it lands:
341
+
342
+ | Surface | What carries it |
343
+ | ------------ | -------------------------------------------------------------------- |
344
+ | JournalEntry | a `blockquote.sohl-draft-notice` at the head of the first page |
345
+ | Actor | the dossier the sheet draws — SoHL's `dossier`, HM3's `biography` |
346
+ | The book | a drawn mark and a left rule between the entry's plate and its panel |
347
+ | The website | the theme's own notice, above the infobox rail and the prose |
348
+
349
+ **An Actor carries it once.** A being's prose reaches two fields, and a document
350
+ saying the same thing twice teaches a reader to skip it — a printed sheet draws
351
+ both in turn — so the dossier carries the notice and the appearance is
352
+ untouched. The two systems pair by the authored section each field reads rather
353
+ than by its name, so `dossier` and `biography` carry it while `appearance` and
354
+ `description` do not.
355
+
356
+ **The mark is a notice, not a hazard** — `fa-circle-exclamation`, which Foundry
357
+ bundles, so a journal page carries it with no module stylesheet loaded. The book
358
+ draws its own, because a consumer declaring no icon family is the ordinary case
359
+ and a glyph a face lacks is silent in Typst. Stripped of mark and rule the
360
+ sentence still states the whole thing, which is the floor every surface is built
361
+ to.
362
+
363
+ **Nothing else changes**: the note compiles, validates, publishes and resolves
364
+ exactly as any other, and it stays in the packs, in the link manifest and on the
365
+ site. That is what separates the tag from the retired `draft:` field, whose whole
366
+ effect was to move a note from published to unresolvable without saying so — and
367
+ which is refused, by name, if you write it.
333
368
 
334
369
  **The group's scope is what makes the check work.** A group names the types it
335
370
  applies to, and a place's kinds are only ever checked on a place. Without that
@@ -1677,11 +1712,11 @@ convention rather than declared in a field — see
1677
1712
  A note has three states, and two of them are nothing but conventions the format
1678
1713
  already carried.
1679
1714
 
1680
- | State | Body | Marker | Page | Document | Link target |
1681
- | --------- | ------- | --------------- | ----------- | --------------------------------- | ----------- |
1682
- | **stub** | empty | none | no | yes, where one would not be empty | no |
1683
- | **draft** | written | `tags: [draft]` | yes, marked | yes | yes |
1684
- | **full** | written | none | yes | yes | yes |
1715
+ | State | Body | Marker | Page | Document | Link target |
1716
+ | --------- | ------- | --------------- | ------------ | --------------------------------- | ----------- |
1717
+ | **stub** | empty | none | no | yes, where one would not be empty | no |
1718
+ | **draft** | written | `tags: [draft]` | yes, noticed | yes, noticed | yes |
1719
+ | **full** | written | none | yes | yes | yes |
1685
1720
 
1686
1721
  "Document" here is the document a note's frontmatter earns. A system's Actor or
1687
1722
  Item is earned by that system's block, in every state —
@@ -0,0 +1,175 @@
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
+ * ## What each surface can rely on
37
+ *
38
+ * - **Foundry** renders a JournalEntry's own HTML inside a sheet, so the floor
39
+ * is what a journal page carries with no module and no system stylesheet
40
+ * loaded. Foundry draws a left rule on a `blockquote` in any application
41
+ * window and bundles Font Awesome, so the rule and the mark are the host's
42
+ * and nothing here ships CSS. The class is a hook for a package that wants
43
+ * one, never a requirement.
44
+ * - **The book** sets Typst, where a glyph missing from the face is silent and
45
+ * a consumer declaring no icon family is the normal case — see {@link
46
+ * module:engine/content-icons}. So the mark is **drawn**, from two
47
+ * primitives, and cannot become a box.
48
+ * - **An Actor's prose** lands in a system's own sheet, in a field the system
49
+ * styles. It gets the same HTML the journal does; what it can rely on is the
50
+ * sentence.
51
+ *
52
+ * @module
53
+ */
54
+
55
+ import { isDraftNote } from "./note-vocabulary.mjs";
56
+
57
+ /** The word that opens the notice, and the only part a reader reads as a label. */
58
+ export const DRAFT_NOTICE_LABEL = "Draft.";
59
+
60
+ /**
61
+ * The sentence, which states the fact and stops.
62
+ *
63
+ * It says the content may change rather than that the writing is rough,
64
+ * because that is the difference between a reader who treats the entry as
65
+ * provisional and one who builds on it. It gives no instruction: a notice that
66
+ * reaches one page in seven wears better as a statement than as a direction,
67
+ * and the fact carries the consequence for anyone who can act on it.
68
+ *
69
+ * @type {string}
70
+ */
71
+ export const DRAFT_NOTICE =
72
+ "This entry is unfinished. What it states may change, and nothing in it is settled.";
73
+
74
+ /** The Font Awesome mark: a notice, not a hazard. */
75
+ export const DRAFT_NOTICE_ICON = "fa-circle-exclamation";
76
+
77
+ /** The class a package may style the HTML notice against. */
78
+ export const DRAFT_NOTICE_CLASS = "sohl-draft-notice";
79
+
80
+ /** The Typst function the book's notice calls. */
81
+ export const BOOK_DRAFT_NOTICE = "book-draft-notice";
82
+
83
+ /**
84
+ * The notice as HTML, for a JournalEntry page and for an Actor's prose field.
85
+ *
86
+ * A `blockquote` because that is the element Foundry already sets apart —
87
+ * `body.game .app blockquote` carries a left rule and an indent — so the
88
+ * treatment survives with no stylesheet of this package's own. The mark is a
89
+ * Font Awesome element, which Foundry bundles; `aria-hidden` keeps it out of a
90
+ * screen reader, where the label reads it.
91
+ *
92
+ * @returns {string} The notice's HTML.
93
+ */
94
+ export function draftNoticeHtml() {
95
+ return (
96
+ `<blockquote class="${DRAFT_NOTICE_CLASS}">` +
97
+ `<p><i class="fa-solid ${DRAFT_NOTICE_ICON}" aria-hidden="true"></i> ` +
98
+ `<strong>${DRAFT_NOTICE_LABEL}</strong> ${DRAFT_NOTICE}</p>` +
99
+ `</blockquote>`
100
+ );
101
+ }
102
+
103
+ /**
104
+ * The notice a note has earned, or nothing.
105
+ *
106
+ * The one place the tag is asked about on a compiled document, so the packs and
107
+ * the book cannot answer it differently. It reads {@link
108
+ * module:engine/note-vocabulary.isDraftNote}, which is the single reader of the
109
+ * tag itself.
110
+ *
111
+ * @param {object|null|undefined} fm - Parsed frontmatter.
112
+ * @returns {string} The notice's HTML, or `""` for a note that is settled.
113
+ */
114
+ export function draftNoticeFor(fm) {
115
+ return isDraftNote(fm) ? draftNoticeHtml() : "";
116
+ }
117
+
118
+ /**
119
+ * Prose led by the notice, where the note is a draft.
120
+ *
121
+ * **Which field a system leads is the system's decision, and it makes it
122
+ * once.** A referee at the table opens an actor sheet rather than the
123
+ * documentation beside it, so the prose a sheet draws says it too — on one
124
+ * field, since a document showing the same statement twice teaches a reader to
125
+ * skip it. Both systems put it on the dossier: SoHL's `dossier` and HM3's
126
+ * `biography` read the same authored section, so the notice lands on the same
127
+ * prose whichever system compiles it.
128
+ *
129
+ * @param {object|null|undefined} fm - Parsed frontmatter.
130
+ * @param {string} html - The rendered prose.
131
+ * @returns {string} The prose, led by the notice where one is due.
132
+ */
133
+ export function withDraftNotice(fm, html) {
134
+ const notice = draftNoticeFor(fm);
135
+ if (!notice) return html;
136
+ return html ? `${notice}\n${html}` : notice;
137
+ }
138
+
139
+ /**
140
+ * The `#let` the book's notice is drawn by.
141
+ *
142
+ * Emitted with the rest of the book's preamble. The mark is a circle with an
143
+ * exclamation set inside it: two primitives and the face already setting the
144
+ * page, so there is no font to resolve and nothing that can come out as a box.
145
+ *
146
+ * @returns {string} The Typst definition.
147
+ */
148
+ export function bookDraftNoticePreamble() {
149
+ return (
150
+ `#let ${BOOK_DRAFT_NOTICE}(label, body) = block(\n` +
151
+ " width: 100%, above: 0.55em, below: 0.75em, breakable: false,\n" +
152
+ " inset: (left: 0.7em, top: 0.35em, bottom: 0.35em),\n" +
153
+ " stroke: (left: 1.6pt + book-faint))[\n" +
154
+ " #set par(justify: false, first-line-indent: 0em)\n" +
155
+ " #set text(size: 8.6pt, fill: book-faint)\n" +
156
+ " #box(baseline: 0.18em, circle(radius: 0.42em, stroke: 0.6pt + book-faint)[\n" +
157
+ ' #align(center + horizon)[#text(size: 6.4pt, weight: "bold")[!]]\n' +
158
+ " ])\n" +
159
+ " #h(0.4em)\n" +
160
+ ' #text(weight: "bold")[#label]\n' +
161
+ " #h(0.25em)\n" +
162
+ " #body\n" +
163
+ "]"
164
+ );
165
+ }
166
+
167
+ /**
168
+ * The notice as Typst, for a leaf of the book.
169
+ *
170
+ * @returns {string} The call, ready to sit between the entry's plate and its
171
+ * infobox panel.
172
+ */
173
+ export function draftNoticeTypst() {
174
+ return `#${BOOK_DRAFT_NOTICE}[${DRAFT_NOTICE_LABEL}][${DRAFT_NOTICE}]`;
175
+ }
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
 
@@ -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,
@@ -112,6 +112,8 @@ import {
112
112
  renderBook,
113
113
  resolveDanglingLabels,
114
114
  } from "./pdf-render.mjs";
115
+ import { draftNoticeTypst } from "./draft-notice.mjs";
116
+ import { isDraftNote } from "./note-vocabulary.mjs";
115
117
  import { infoboxTypstPreamble, infoboxesToTypst, linkToTypst } from "./infobox-render.mjs";
116
118
  import { noteInfoboxes } from "./infobox-registry.mjs";
117
119
  import { resolveIconGlyphs } from "./pdf-fonts.mjs";
@@ -540,7 +542,12 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
540
542
  const panel = infoboxesToTypst(boxes, {
541
543
  link: (value) => linkToTypst(value, plan.links, labelFor),
542
544
  });
543
- return panel ? `${panel}\n\n${prose}` : prose;
545
+ // Whether the entry is settled comes before anything it says: the
546
+ // notice leads the leaf, ahead of the panel, which is the position it
547
+ // holds on every other surface.
548
+ const notice = isDraftNote(page.fm) ? draftNoticeTypst() : "";
549
+ const parts = [notice, panel, prose].filter((part) => part);
550
+ return parts.join("\n\n");
544
551
  };
545
552
 
546
553
  const bodies = new Map();
@@ -63,6 +63,7 @@
63
63
 
64
64
  import MarkdownIt from "markdown-it";
65
65
 
66
+ import { bookDraftNoticePreamble } from "./draft-notice.mjs";
66
67
  import { iconPlugin, ICON_PATTERN } from "./content-icons.mjs";
67
68
  import { IMAGE_CLASSES, IMAGE_FLOATS, imagePlugin } from "./content-images.mjs";
68
69
  import { slugify } from "./content-slug.mjs";
@@ -882,6 +883,11 @@ export function bookTypstPreamble() {
882
883
  " ]\n" +
883
884
  " ]\n" +
884
885
  "}",
886
+ // A statement about the entry rather than about its subject, set in
887
+ // the running face at the head of the leaf. The mark is drawn from two
888
+ // primitives rather than set from a face: a consumer declares its icon
889
+ // families or declares none, and a glyph the face lacks is silent here.
890
+ bookDraftNoticePreamble(),
885
891
  "#let book-epigraph(body) = {\n" +
886
892
  " v(0.42cm)\n" +
887
893
  " align(center)[\n" +
package/hm3/actors.mjs CHANGED
@@ -53,6 +53,7 @@ import { resolveName } from "../engine/helpers.mjs";
53
53
  import { buildFromFields, retiredTopLevelKey, STRING } from "../engine/field-spec.mjs";
54
54
  import { SystemActorCompiler } from "../engine/actor-compiler.mjs";
55
55
  import { renderSection } from "../engine/anchored-sections.mjs";
56
+ import { withDraftNotice } from "../engine/draft-notice.mjs";
56
57
  import { documentSubtype } from "../engine/document-subtypes.mjs";
57
58
  import { HM3_DOCUMENT_SUBTYPES } from "./document-subtypes.mjs";
58
59
  import { templateFlags } from "./template-priority.mjs";
@@ -348,7 +349,10 @@ export class Hm3Actors extends SystemActorCompiler {
348
349
  // `system.portrait` is not: a being's portrait is the lead image of
349
350
  // its `{#appearance}` section, which is the markup below.
350
351
  description: renderSection(body || "", "appearance"),
351
- biography: renderSection(body || "", "dossier"),
352
+ // `biography` is this system's dossier: the two are paired by the
353
+ // authored section each reads, not by their names, so the notice
354
+ // lands on the same prose here as it does under SoHL.
355
+ biography: withDraftNotice(fm, renderSection(body || "", "dossier")),
352
356
  ...buildFromFields(ACTOR_FIELDS, reports)(fm),
353
357
  // Declared on `character` alone, so written there alone — see the
354
358
  // module note.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.6.0",
3
+ "version": "22.7.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",
package/sohl/actors.mjs CHANGED
@@ -49,6 +49,7 @@
49
49
  import { sohlField, resolveName, systemTemplatePriority, folderField } from "../engine/helpers.mjs";
50
50
  import { openingMasteryLevel } from "./skill-base.mjs";
51
51
  import { SystemActorCompiler, renderSection } from "../engine/actor-compiler.mjs";
52
+ import { withDraftNotice } from "../engine/draft-notice.mjs";
52
53
  // Which Foundry Actor subtype a note's `type` compiles into. Looked up in the
53
54
  // system's declared map, never inferred from the type itself.
54
55
  import { documentSubtype } from "../engine/document-subtypes.mjs";
@@ -347,7 +348,11 @@ export class Actors extends SystemActorCompiler {
347
348
  // key that used to declare one is not a key. With no authored
348
349
  // source left, the field keeps the schema's own initial.
349
350
  appearance: renderSection(body || "", "appearance"),
350
- dossier: renderSection(body || "", "dossier"),
351
+ // One document says it once. `dossier` is the field that carries
352
+ // an unsettled entry's notice, and `appearance` carries none —
353
+ // the print sheet draws both blocks in turn, so a notice on each
354
+ // would be read twice on one page.
355
+ dossier: withDraftNotice(fm, renderSection(body || "", "dossier")),
351
356
  };
352
357
 
353
358
  // Fill `system.body` (+ the base-actor movement fields) from the being's
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The notice as HTML, for a JournalEntry page and for an Actor's prose field.
3
+ *
4
+ * A `blockquote` because that is the element Foundry already sets apart —
5
+ * `body.game .app blockquote` carries a left rule and an indent — so the
6
+ * treatment survives with no stylesheet of this package's own. The mark is a
7
+ * Font Awesome element, which Foundry bundles; `aria-hidden` keeps it out of a
8
+ * screen reader, where the label reads it.
9
+ *
10
+ * @returns {string} The notice's HTML.
11
+ */
12
+ export function draftNoticeHtml(): string;
13
+ /**
14
+ * The notice a note has earned, or nothing.
15
+ *
16
+ * The one place the tag is asked about on a compiled document, so the packs and
17
+ * the book cannot answer it differently. It reads {@link
18
+ * module:engine/note-vocabulary.isDraftNote}, which is the single reader of the
19
+ * tag itself.
20
+ *
21
+ * @param {object|null|undefined} fm - Parsed frontmatter.
22
+ * @returns {string} The notice's HTML, or `""` for a note that is settled.
23
+ */
24
+ export function draftNoticeFor(fm: object | null | undefined): string;
25
+ /**
26
+ * Prose led by the notice, where the note is a draft.
27
+ *
28
+ * **Which field a system leads is the system's decision, and it makes it
29
+ * once.** A referee at the table opens an actor sheet rather than the
30
+ * documentation beside it, so the prose a sheet draws says it too — on one
31
+ * field, since a document showing the same statement twice teaches a reader to
32
+ * skip it. Both systems put it on the dossier: SoHL's `dossier` and HM3's
33
+ * `biography` read the same authored section, so the notice lands on the same
34
+ * prose whichever system compiles it.
35
+ *
36
+ * @param {object|null|undefined} fm - Parsed frontmatter.
37
+ * @param {string} html - The rendered prose.
38
+ * @returns {string} The prose, led by the notice where one is due.
39
+ */
40
+ export function withDraftNotice(fm: object | null | undefined, html: string): string;
41
+ /**
42
+ * The `#let` the book's notice is drawn by.
43
+ *
44
+ * Emitted with the rest of the book's preamble. The mark is a circle with an
45
+ * exclamation set inside it: two primitives and the face already setting the
46
+ * page, so there is no font to resolve and nothing that can come out as a box.
47
+ *
48
+ * @returns {string} The Typst definition.
49
+ */
50
+ export function bookDraftNoticePreamble(): string;
51
+ /**
52
+ * The notice as Typst, for a leaf of the book.
53
+ *
54
+ * @returns {string} The call, ready to sit between the entry's plate and its
55
+ * infobox panel.
56
+ */
57
+ export function draftNoticeTypst(): string;
58
+ /** The word that opens the notice, and the only part a reader reads as a label. */
59
+ export const DRAFT_NOTICE_LABEL: "Draft.";
60
+ /**
61
+ * The sentence, which states the fact and stops.
62
+ *
63
+ * It says the content may change rather than that the writing is rough,
64
+ * because that is the difference between a reader who treats the entry as
65
+ * provisional and one who builds on it. It gives no instruction: a notice that
66
+ * reaches one page in seven wears better as a statement than as a direction,
67
+ * and the fact carries the consequence for anyone who can act on it.
68
+ *
69
+ * @type {string}
70
+ */
71
+ export const DRAFT_NOTICE: string;
72
+ /** The Font Awesome mark: a notice, not a hazard. */
73
+ export const DRAFT_NOTICE_ICON: "fa-circle-exclamation";
74
+ /** The class a package may style the HTML notice against. */
75
+ export const DRAFT_NOTICE_CLASS: "sohl-draft-notice";
76
+ /** The Typst function the book's notice calls. */
77
+ export const BOOK_DRAFT_NOTICE: "book-draft-notice";
@@ -16,6 +16,7 @@ export * as homepage from "./homepage.mjs";
16
16
  export * as noteSchemas from "./note-schemas.mjs";
17
17
  export * as noteVocabulary from "./note-vocabulary.mjs";
18
18
  export * as noteState from "./note-state.mjs";
19
+ export * as draftNotice from "./draft-notice.mjs";
19
20
  export * as stubLint from "./stub-lint.mjs";
20
21
  export * as placeRelations from "./place-relations.mjs";
21
22
  export * as holdings from "./holdings.mjs";
@@ -2,10 +2,10 @@
2
2
  * Every content type whose **prose compiles into a JournalEntry of its own**,
3
3
  * addressed by the virtual `doc<type>` qualifier.
4
4
  *
5
- * Every item type, plus `macro` — a macro note's body documents the script the
6
- * note also compiles into a Macro, which is the same shape as an item
7
- * and its description: one note, two documents, the prose living in the
8
- * journals pack.
5
+ * Every item type, every actor type, every map type, plus `macro` — a macro
6
+ * note's body documents the script the note also compiles into a Macro, which
7
+ * is the same shape as an item and its description: one note, two documents,
8
+ * the prose living in the journals pack.
9
9
  *
10
10
  * **One set, read by the compiler and the emitter alike.** The journals pass
11
11
  * decides what to compile from it, and the link manifest decides what to
@@ -14,8 +14,9 @@
14
14
  * consumer can address. It is composed exactly once, in `defineConfig`, and
15
15
  * read from there — never recomposed at a call site.
16
16
  *
17
- * `doc` notes and actors are absent: each is a single document, so it has no
18
- * separate documentation to address.
17
+ * `doc` notes are absent, for the reason that applies to them alone: a `doc`
18
+ * note's single document *is* its prose, so there is no second document for a
19
+ * `doc<type>` address to name.
19
20
  *
20
21
  * An accessor rather than a hoisted constant, so that importing this module
21
22
  * needs no configuration (#2).
@@ -28,8 +29,8 @@ export function docEntryTypes(): ReadonlySet<string>;
28
29
  * its own.
29
30
  *
30
31
  * @param {string} type - The note's `type` frontmatter.
31
- * @returns {boolean} True for an item type, for `macro` and for a map type;
32
- * false for `doc` and for actors.
32
+ * @returns {boolean} True for an item type, an actor type, a map type and
33
+ * `macro`; false for `doc`.
33
34
  */
34
35
  export function hasDocEntry(type: string): boolean;
35
36
  /**
@@ -143,9 +143,14 @@ export function buildPages(rawPages: Array<object>, entryId: string, noteName: s
143
143
  * that reads. It is **not** a page of its own — a page is what a UUID
144
144
  * addresses, and a summary a reader has to navigate to is a summary they do
145
145
  * not see.
146
+ * @param {string} [params.notice] - A statement about the entry rather than
147
+ * about its subject, already rendered to HTML — see {@link
148
+ * module:engine/draft-notice}. It leads the first page, ahead of the infobox,
149
+ * because a reader deciding whether to rely on the entry has to be told
150
+ * before they read it rather than after.
146
151
  * @returns {object} The JournalEntry document, keyed for the pack.
147
152
  */
148
- export function buildJournalEntry({ id, name, markdown, leadName, folder, flags, stats, infobox, }: {
153
+ export function buildJournalEntry({ id, name, markdown, leadName, folder, flags, stats, infobox, notice, }: {
149
154
  id: string;
150
155
  name: string;
151
156
  markdown: string;
@@ -154,6 +159,7 @@ export function buildJournalEntry({ id, name, markdown, leadName, folder, flags,
154
159
  flags?: object | undefined;
155
160
  stats?: object | undefined;
156
161
  infobox?: string | undefined;
162
+ notice?: string | undefined;
157
163
  }): object;
158
164
  /**
159
165
  * Journals pack compiler.