@heroiclands/package-build 22.5.0 → 22.5.1

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,16 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ **Unwritten notes**
8
+
9
+ - A note left unwritten builds: an empty body no longer stops a package
10
+ compiling its packs.
11
+ - An unwritten note offers no compendium link, so nothing sends a reader to a
12
+ journal that was never made.
13
+
3
14
  ## 22.5.0
4
15
 
5
16
  ### Minor Changes
package/CONTENT.md CHANGED
@@ -1096,11 +1096,17 @@ was never the key count.)
1096
1096
  ### An empty body is a stub, on purpose
1097
1097
 
1098
1098
  A note whose body is empty is a **stub**: it carries its frontmatter into the
1099
- content index and is queryable there, it keeps its `id` and the Foundry document
1100
- its type compiles, and it publishes no page, no address and no link target. A
1101
- note with a body and the `draft` tag is a **draft**, and one with a body and no
1102
- tag is **full**. `docs/content-format.md` states the rule; this is what the lint
1103
- does with it.
1099
+ content index and is queryable there, it keeps its `id`, and it publishes no
1100
+ page, no address and no link target. A note with a body and the `draft` tag is a
1101
+ **draft**, and one with a body and no tag is **full**.
1102
+ `docs/content-format.md` states the rule; this is what the lint does with it.
1103
+
1104
+ **A document that would be empty is not created**, and the index then names no
1105
+ artefact — `uuid`, `path` and `address` are `null`. An unwritten note still
1106
+ compiles every document derived from `data:`, so an arcane talent is still its
1107
+ Item; what it compiles no journal for is its prose, because there is none. An
1108
+ infobox does not count as content: the panel renders `data:` the index already
1109
+ carries.
1104
1110
 
1105
1111
  Four findings and two reports:
1106
1112
 
package/docs/api.md CHANGED
@@ -416,15 +416,16 @@ Drawing a declared infobox: HTML for a Foundry Journal Page, Typst for the book.
416
416
 
417
417
  The three states of a note — **stub**, **draft**, **full** — and the one observable fact they rest on. A note whose body is empty is a stub: it carries its facts into the index and is queryable there, and it has no page, no address and no link target. Nothing is stored: the ladder is derived once in the SQL `entries` view and no note can assert its own state.
418
418
 
419
- | Export | Signature | Returns | Use it when |
420
- | --------------------- | ------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
421
- | `NOTE_STATES` | `const NOTE_STATES` | `ReadonlyArray<string>` | naming the three states in ladder order |
422
- | `isEmptyBody` | `isEmptyBody(body)` | `boolean` | asking the exact question: is everything after the closing frontmatter fence whitespace? |
423
- | `isStubbableType` | `isStubbableType(type, vocabulary)` | `boolean` | asking whether an empty body suppresses this type's page; `folder` and `homepage` are structural and exempt |
424
- | `isStubNote` | `isStubNote(frontmatter, body, vocabulary)` | `boolean` | deciding whether one note is a stub, which is what the index and the site build both ask |
425
- | `PLACEHOLDER_PHRASES` | `const PLACEHOLDER_PHRASES` | `ReadonlyArray<string>` | reading the phrases that stand in for prose nobody has written |
426
- | `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 |
427
- | `bodyWordCount` | `bodyWordCount(body)` | `number` | counting the prose a reader meets, with a wikilink counting as the one word it renders |
419
+ | Export | Signature | Returns | Use it when |
420
+ | --------------------- | ------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
421
+ | `NOTE_STATES` | `const NOTE_STATES` | `ReadonlyArray<string>` | naming the three states in ladder order |
422
+ | `isEmptyBody` | `isEmptyBody(body)` | `boolean` | asking the exact question: is everything after the closing frontmatter fence whitespace? |
423
+ | `isStubbableType` | `isStubbableType(type, vocabulary)` | `boolean` | asking whether an empty body suppresses this type's page; `folder` and `homepage` are structural and exempt |
424
+ | `isStubNote` | `isStubNote(frontmatter, body, vocabulary)` | `boolean` | deciding whether one note is a stub, which is what the index and the site build both ask |
425
+ | `journalHasContent` | `journalHasContent(body)` | `boolean` | asking whether the JournalEntry a body compiles into would hold anything, which is what decides whether one is made at all |
426
+ | `PLACEHOLDER_PHRASES` | `const PLACEHOLDER_PHRASES` | `ReadonlyArray<string>` | reading the phrases that stand in for prose nobody has written |
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
+ | `bodyWordCount` | `bodyWordCount(body)` | `number` | counting the prose a reader meets, with a wikilink counting as the one word it renders |
428
429
 
429
430
  ### `engine.stubLint`
430
431
 
@@ -1662,11 +1662,11 @@ convention rather than declared in a field — see
1662
1662
  A note has three states, and two of them are nothing but conventions the format
1663
1663
  already carried.
1664
1664
 
1665
- | State | Body | Marker | Page | Document | Link target |
1666
- | --------- | ------- | --------------- | ----------- | -------------------------------- | ----------- |
1667
- | **stub** | empty | none | no | yes, where its type compiles one | no |
1668
- | **draft** | written | `tags: [draft]` | yes, marked | yes | yes |
1669
- | **full** | written | none | yes | yes | yes |
1665
+ | State | Body | Marker | Page | Document | Link target |
1666
+ | --------- | ------- | --------------- | ----------- | --------------------------------- | ----------- |
1667
+ | **stub** | empty | none | no | yes, where one would not be empty | no |
1668
+ | **draft** | written | `tags: [draft]` | yes, marked | yes | yes |
1669
+ | **full** | written | none | yes | yes | yes |
1670
1670
 
1671
1671
  **Full is the absence of everything.** It is the default and carries no marker,
1672
1672
  which is right: the overwhelming majority of a finished corpus should say
@@ -1706,10 +1706,35 @@ column a table renderer already reads.
1706
1706
  It keeps everything else:
1707
1707
 
1708
1708
  - **`file`**, which is what you edit and what every diagnostic names.
1709
- - **`id` and `foundry`**, because a **document is derived from `data:`, not from
1710
- prose**. An unwritten arcane talent still compiles into its Item, and its
1711
- documentation journal is still a document in its own right. **The empty body
1712
- suppresses the page, never the document.**
1709
+ - **`id`**, which is the identity every diagnostic and every table reads.
1710
+ - **`foundry`**, naming each document that was made.
1711
+
1712
+ #### A document that would be empty is not created
1713
+
1714
+ > **If the resulting document would be empty, it is not created** — and the
1715
+ > index then names no artefact: `uuid`, `path` and `address` are `null`, which
1716
+ > is how a record says one was not made.
1717
+
1718
+ The test is about the **output**, which is what lets one rule cover every case:
1719
+
1720
+ | The note | The document | Made? |
1721
+ | ---------------------------------- | -------------------------------------- | ----- |
1722
+ | `place`, `lore`, `doc`, `scenario` | its JournalEntry, which _is_ the prose | no |
1723
+ | any item type, `macro` | the Item or Macro, from `data:` | yes |
1724
+ | the same note's documentation | a JournalEntry of the same prose | no |
1725
+ | `map` | the Scene, from `data:` | yes |
1726
+
1727
+ An unwritten arcane talent still compiles into its Item, because an Item is its
1728
+ `data:` and that is not empty. What it has no documentation journal for is the
1729
+ same thing an unwritten `place` has no journal for: there is nothing to put in
1730
+ one.
1731
+
1732
+ **An infobox does not count as content.** The panel is a rendering of `data:`
1733
+ that the index already publishes, so an entry holding nothing else would tell a
1734
+ reader exactly what the record told them.
1735
+
1736
+ Nothing anywhere names a document that was not made: a compendium UUID for one
1737
+ would send every consumer that resolves it to a document the pack does not hold.
1713
1738
 
1714
1739
  What is suppressed with the page is the web address and the link target: a
1715
1740
  wikilink into a stub is refused, and a table cell listing one prints its name as
@@ -422,9 +422,10 @@ function assertNoDerivedKeys(frontmatter, relPath, absPath, contentPackage) {
422
422
  * @returns {Record<string, any>} The record, keys sorted at every depth. A
423
423
  * **stub** — a note with an empty body, on a type an empty body suppresses —
424
424
  * carries `address` and `anchors` as `null`: it publishes no page, so it
425
- * holds no address and offers no anchor. It keeps everything else, its `id`
426
- * and its `foundry` block included, because the document it compiles is
427
- * derived from `data:` rather than from prose.
425
+ * holds no address and offers no anchor. It keeps its `id`, and a `foundry`
426
+ * block naming every document that was made — which is every document
427
+ * derived from `data:`, and no journal, because a journal of an empty body
428
+ * would be empty and is not created.
428
429
  * @throws {Error} When the note carries a key this module derives, which would
429
430
  * otherwise be overwritten without a word. `file` and, where the file was
430
431
  * read, `position` ride on the error.
@@ -443,16 +444,18 @@ export function buildIndexRecord({
443
444
  const posix = relPath.split(path.sep).join("/");
444
445
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
445
446
  const address = noteAddress(frontmatter, contentPackage);
446
- // Derived from the address the note *would* hold, because a stub keeps its
447
- // Foundry document: a document is derived from `data:`, not from prose, and
448
- // the empty body suppresses the page rather than the document.
447
+ // Derived from the address the note *would* hold, because a stub still
448
+ // compiles every document derived from `data:` — an unwritten arcane talent
449
+ // is its Item. The one document it has none of is the one that would be
450
+ // empty, and `entriesForNote` withholds that UUID, so this block names
451
+ // nothing that was not made.
449
452
  const entries = foundryEntries({ frontmatter, address, body, manifest });
450
453
  // A stub — an empty body on a type an empty body suppresses — publishes no
451
454
  // page, so it holds no address and offers no anchor to link to. **The
452
455
  // absent address is the signal**, and it is the column the table renderer
453
456
  // already reads through `_ref`. Everything else the note declares is kept:
454
457
  // its file is what the author edits and what every diagnostic names, and
455
- // its `id` and `foundry` block are the document it still compiles.
458
+ // its `id` is the identity every diagnostic and every table reads.
456
459
  const stub = isStubNote(frontmatter, body);
457
460
 
458
461
  return /** @type {Record<string, any>} */ (
@@ -530,6 +533,12 @@ export function buildIndexRecord({
530
533
  * @returns {Record<string, any>} The documentation journal's index record.
531
534
  */
532
535
  function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anchors }) {
536
+ // **The entry names a document only where one was made**, and where it does
537
+ // not, nothing on this record may name one either: no id, no address and no
538
+ // anchors. The record itself stays, because the index records what a note
539
+ // produces and `null` is how it says a document was not produced — the same
540
+ // signal a stub's absent address already is.
541
+ const made = Boolean(entry.uuid);
533
542
  return /** @type {Record<string, any>} */ (
534
543
  sortKeysDeep({
535
544
  package: contentPackage,
@@ -540,13 +549,13 @@ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anc
540
549
  // re-derived: every entry the index gives an identity to publishes
541
550
  // both the id and the UUID, computed once by whatever owns that
542
551
  // entry's derivation.
543
- id: entry.id,
552
+ id: made ? entry.id : null,
544
553
  nameAscii: asciiName(frontmatter?.name?.full),
545
- address: { slug: address.slug, canonical: entry.key },
554
+ address: made ? { slug: address.slug, canonical: entry.key } : null,
546
555
  // The record this is the documentation *for*. `documentation` is
547
556
  // the forward link on that record, so either end reaches the other.
548
557
  documents: address.canonical,
549
- anchors,
558
+ anchors: made ? anchors : null,
550
559
  foundry: foundryBlock(entry), // a journal: no system key
551
560
  file,
552
561
  })
@@ -637,8 +646,8 @@ export function collectContentIndex(
637
646
 
638
647
  // An item note is two documents, so it is two records. Derived from
639
648
  // the address the note *would* hold rather than from the one its
640
- // record carries: a stub withholds its own address and still compiles
641
- // both documents, and the journal is one of them.
649
+ // record carries, because a stub withholds its own address; the second
650
+ // record says whether the journal beside the item was made.
642
651
  const address = noteAddress(fm, contentPackage);
643
652
  const doc = foundryEntries({ frontmatter: fm, address, body, manifest })?.doc;
644
653
  if (doc?.key && address) {
@@ -69,6 +69,7 @@ import { walkMarkdownTree } from "./helpers.mjs";
69
69
  import { resolveNoteId } from "./note-ids.mjs";
70
70
  import { compendiumUuid, currentType, packForType, pageUuid } from "./ids.mjs";
71
71
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
72
+ import { journalHasContent } from "./note-state.mjs";
72
73
  import { isHomepage } from "./homepage.mjs";
73
74
  import { declaresNoPack } from "./pack-router.mjs";
74
75
  import { assertNoDeclaredPackage } from "./note-package.mjs";
@@ -163,7 +164,19 @@ export function entriesForNote(fm, name, address, body, ctx) {
163
164
  // A published address must name the pack the document actually shipped in:
164
165
  // a consumer resolves the UUID verbatim, and a repository may ship several
165
166
  // packs of one type.
166
- const uuidFor = (type, id, routeFm) =>
167
+ const uuidFor = (type, id, routeFm) => {
168
+ // **A document that would be empty is not created, so nothing names
169
+ // one.** A JournalEntry's content is the note's prose, and an empty
170
+ // body compiles no pages, so the pass emits no entry — and a manifest
171
+ // naming one would send every consumer to a document that is not
172
+ // there. Asked of the **document type**, which is what makes one
173
+ // statement cover both journals a note can produce: its own, and the
174
+ // documentation standing beside an item. Every other document is
175
+ // derived from `data:` and is emitted whatever the body holds.
176
+ if (packForType(type)?.docType === "JournalEntry" && !journalHasContent(body)) {
177
+ return undefined;
178
+ }
179
+
167
180
  // A type this cannot name a single compendium document for has no UUID
168
181
  // to publish, whatever id it derives. That used to follow from such a
169
182
  // note authoring no `id:`; every addressable note derives
@@ -185,21 +198,24 @@ export function entriesForNote(fm, name, address, body, ctx) {
185
198
  // statement: it publishes a page and compiles into nothing, so it has
186
199
  // an address and no UUID. The shared declaration is what is read,
187
200
  // because an entry names one document and this pass routes no system.
188
- (
189
- id &&
190
- !NEVER_PACKED_TYPES.has(String(type)) &&
191
- !DERIVED_PACKED_TYPES.has(String(type)) &&
192
- !(routeFm && declaresNoPack(routeFm))
193
- ) ?
194
- compendiumUuid(
195
- foundryPackageId,
196
- type,
197
- id,
198
- routeFm ?
199
- packRouter.resolveOrNull(routeFm, packForType(type).docType)
200
- : packRouter.defaultOf("JournalEntry"),
201
- )
202
- : undefined;
201
+ if (
202
+ !id ||
203
+ NEVER_PACKED_TYPES.has(String(type)) ||
204
+ DERIVED_PACKED_TYPES.has(String(type)) ||
205
+ (routeFm && declaresNoPack(routeFm))
206
+ ) {
207
+ return undefined;
208
+ }
209
+
210
+ return compendiumUuid(
211
+ foundryPackageId,
212
+ type,
213
+ id,
214
+ routeFm ?
215
+ packRouter.resolveOrNull(routeFm, packForType(type).docType)
216
+ : packRouter.defaultOf("JournalEntry"),
217
+ );
218
+ };
203
219
 
204
220
  const carriesDoc =
205
221
  ctx.docEntryTypes ?
@@ -55,6 +55,7 @@ import { infoboxesToHtml, linkToUuid } from "./infobox-render.mjs";
55
55
  import { noteInfoboxes } from "./infobox-registry.mjs";
56
56
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
57
57
  import { JOURNAL_TYPES } from "./ids.mjs";
58
+ import { journalHasContent } from "./note-state.mjs";
58
59
 
59
60
  /**
60
61
  * Splits a markdown body into pages by top-level H1 headings. Fenced
@@ -382,17 +383,24 @@ export class Journals extends BasePackCompiler {
382
383
  }
383
384
 
384
385
  /**
385
- * An item with no prose gets no doc, and the items pass leaves its
386
- * description empty rather than pointing at nothing; a map with no prose
387
- * gets no entry and no pin target. The two passes apply the same rule to
388
- * the same body, so they agree.
386
+ * **A journal that would be empty is not created**, and that is the whole
387
+ * rule — one test, asked of every note this pass claims, whether its
388
+ * journal is its own document or the documentation standing beside an item.
389
+ * A note with no prose gets no entry: the items pass leaves its description
390
+ * empty rather than pointing at nothing, a map gets no pin target, and
391
+ * {@link module:engine/foundry-entries} publishes no UUID, so nothing names
392
+ * a document that was not made.
393
+ *
394
+ * An entry holding only its infobox is empty by this test. The panel is a
395
+ * rendering of `data:` that the content index already carries, so a
396
+ * compendium entry of nothing else tells a reader what the index told them.
389
397
  *
390
398
  * @param {object} fm - The note's frontmatter.
391
399
  * @param {string} body - The note body, frontmatter stripped.
392
400
  * @returns {boolean} True to skip the note.
393
401
  */
394
402
  skipNote(fm, body) {
395
- return hasDocEntry(fm.type) && !String(body).trim();
403
+ return !journalHasContent(body);
396
404
  }
397
405
 
398
406
  /**
@@ -114,6 +114,35 @@ export function isStubNote(frontmatter, body, vocabulary = NOTE_VOCABULARY) {
114
114
  return isEmptyBody(body) && isStubbableType(frontmatter?.type, vocabulary);
115
115
  }
116
116
 
117
+ /**
118
+ * Whether a note's body compiles into a JournalEntry that holds anything.
119
+ *
120
+ * > **A document that would be empty is not created.** Nothing names one
121
+ * > either: the index records the note with every field that names an artefact
122
+ * > set to `null`.
123
+ *
124
+ * The question is about the **output**, not the input, and that is what makes
125
+ * one rule serve every caller: a note's own journal, and the documentation
126
+ * journal an item's prose compiles into beside it, are the same document asked
127
+ * the same question.
128
+ *
129
+ * A JournalEntry's content is its pages, and a page comes from
130
+ * {@link module:engine/journals.splitPages} — a lead page of the prose before
131
+ * the first heading, and one page per heading after it. A body of whitespace
132
+ * yields neither, so an empty body is exactly an empty journal.
133
+ *
134
+ * **An infobox does not count.** The panel is a rendering of `data:`, which the
135
+ * index already publishes; an entry holding nothing else gives a consumer
136
+ * nothing it cannot read from the record.
137
+ *
138
+ * @param {string|null|undefined} body - The note body, frontmatter stripped.
139
+ * @returns {boolean} Whether a JournalEntry compiled from this body holds
140
+ * anything.
141
+ */
142
+ export function journalHasContent(body) {
143
+ return !isEmptyBody(body);
144
+ }
145
+
117
146
  /**
118
147
  * The phrases that stand in for prose nobody has written.
119
148
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.5.0",
3
+ "version": "22.5.1",
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",
@@ -104,9 +104,10 @@ export function asciiAliases(aliases: unknown): Array<string>;
104
104
  * @returns {Record<string, any>} The record, keys sorted at every depth. A
105
105
  * **stub** — a note with an empty body, on a type an empty body suppresses —
106
106
  * carries `address` and `anchors` as `null`: it publishes no page, so it
107
- * holds no address and offers no anchor. It keeps everything else, its `id`
108
- * and its `foundry` block included, because the document it compiles is
109
- * derived from `data:` rather than from prose.
107
+ * holds no address and offers no anchor. It keeps its `id`, and a `foundry`
108
+ * block naming every document that was made — which is every document
109
+ * derived from `data:`, and no journal, because a journal of an empty body
110
+ * would be empty and is not created.
110
111
  * @throws {Error} When the note carries a key this module derives, which would
111
112
  * otherwise be overwritten without a word. `file` and, where the file was
112
113
  * read, `position` ride on the error.
@@ -59,6 +59,32 @@ export function isStubbableType(type: string | null | undefined, vocabulary?: Re
59
59
  export function isStubNote(frontmatter: Record<string, any> | null | undefined, body: string | null | undefined, vocabulary?: Readonly<Record<string, {
60
60
  stubbable?: boolean;
61
61
  }>>): boolean;
62
+ /**
63
+ * Whether a note's body compiles into a JournalEntry that holds anything.
64
+ *
65
+ * > **A document that would be empty is not created.** Nothing names one
66
+ * > either: the index records the note with every field that names an artefact
67
+ * > set to `null`.
68
+ *
69
+ * The question is about the **output**, not the input, and that is what makes
70
+ * one rule serve every caller: a note's own journal, and the documentation
71
+ * journal an item's prose compiles into beside it, are the same document asked
72
+ * the same question.
73
+ *
74
+ * A JournalEntry's content is its pages, and a page comes from
75
+ * {@link module:engine/journals.splitPages} — a lead page of the prose before
76
+ * the first heading, and one page per heading after it. A body of whitespace
77
+ * yields neither, so an empty body is exactly an empty journal.
78
+ *
79
+ * **An infobox does not count.** The panel is a rendering of `data:`, which the
80
+ * index already publishes; an entry holding nothing else gives a consumer
81
+ * nothing it cannot read from the record.
82
+ *
83
+ * @param {string|null|undefined} body - The note body, frontmatter stripped.
84
+ * @returns {boolean} Whether a JournalEntry compiled from this body holds
85
+ * anything.
86
+ */
87
+ export function journalHasContent(body: string | null | undefined): boolean;
62
88
  /**
63
89
  * The placeholder phrases a body reduces to, or an empty list.
64
90
  *