@heroiclands/package-build 22.2.0 → 22.3.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.
@@ -727,11 +727,12 @@ How this repository frames the documentation pages it generates.
727
727
  registry and are the same wherever rendered; everything here is the
728
728
  consumer's: heading, orientation, where the page is filed.
729
729
 
730
- | Key | Type | Required | Default |
731
- | -------------------------- | -------- | -------- | ---------------------------------------------------------------------- |
732
- | `docs.itemFields.title` | string | no | none — the page's H1 |
733
- | `docs.itemFields.out` | string | no | none — without it, the page goes to stdout |
734
- | `docs.itemFields.preamble` | string[] | no | none — markdown lines between the generated banner and the first table |
730
+ | Key | Type | Required | Default |
731
+ | ----------------------------- | -------- | -------- | ---------------------------------------------------------------------- |
732
+ | `docs.itemFields.title` | string | no | none — the page's H1 |
733
+ | `docs.itemFields.out` | string | no | none — without it, the page goes to stdout |
734
+ | `docs.itemFields.preamble` | string[] | no | none — markdown lines between the generated banner and the first table |
735
+ | `docs.itemFields.frontmatter` | object | no | none — extra note frontmatter, deep-merged over the generated envelope |
735
736
 
736
737
  > ``package-build config: `docs.itemFields` must be a mapping.``
737
738
 
@@ -744,9 +745,14 @@ consumer's: heading, orientation, where the page is filed.
744
745
 
745
746
  > ``package-build config: `docs.itemFields.preamble[<index>]` must be a string.``
746
747
 
748
+ `frontmatter` is deep-merged over the note envelope written when `out` is
749
+ under the content tree — see [`content-build docs item-fields`](commands.md#content-build-docs-item-fields):
750
+
751
+ > ``package-build config: `docs.itemFields.frontmatter` must be a mapping.``
752
+
747
753
  Any other key under `docs.itemFields` is refused:
748
754
 
749
- > ``package-build config: `docs.itemFields.<key>` is not a recognized option (expected one of: title, out, preamble).``
755
+ > ``package-build config: `docs.itemFields.<key>` is not a recognized option (expected one of: title, out, preamble, frontmatter).``
750
756
 
751
757
  ### `site`
752
758
 
@@ -759,9 +765,7 @@ Any other key under `docs.itemFields` is refused:
759
765
  | `site.description` | string | `""`, but required for `content-build site` |
760
766
  | `site.packages` | string[] | `[]` |
761
767
  | `site.sections` | object | `{}` |
762
- | `site.readmeSections` | object | `{}` |
763
768
  | `site.landing` | object | `null` |
764
- | `site.trees` | array | `[]` |
765
769
  | `site.pass` | string | `""` |
766
770
  | `site.passOptions` | object | `{}` |
767
771
  | `site.backfillSections` | boolean | `false` |
@@ -771,10 +775,19 @@ Any other key under `docs.itemFields` is refused:
771
775
 
772
776
  How much of a package reaches the web at all is **not** here — it is
773
777
  [`publish.site`](#publish). `site` is framing: what a section is called,
774
- which extra trees are published beside the content, which named pass bundle
775
- supplies the repository's own body rewrites, and the residue of the
776
- [generated Hugo configuration](#the-generated-hugo-configuration) that is
777
- genuinely this repository's own.
778
+ which named pass bundle supplies the repository's own body rewrites, and the
779
+ residue of the [generated Hugo configuration](#the-generated-hugo-configuration)
780
+ that is genuinely this repository's own.
781
+
782
+ What the site publishes is the content tree, and nothing beside it. A page of
783
+ documentation is a note — `type: doc`, addressed by its shortcode, and
784
+ `pack: none` where it compiles into no Foundry document — so there is no
785
+ second mechanism for mounting a directory of markdown, and a configuration
786
+ that names one is refused with a message saying where the page goes instead:
787
+
788
+ > ``package-build config: `site.trees` is retired — a page is a note in the content tree. Give each page `type: doc`, a `shortcode` and `pack: none`, file it under `assets/content/`, and declare the section that lists it under `site.sections`.``
789
+
790
+ > ``package-build config: `site.readmeSections` is retired — a page is a note in the content tree. Give each page `type: doc`, a `shortcode` and `pack: none`, file it under `assets/content/`, and declare the section that lists it under `site.sections`.``
778
791
 
779
792
  Where the Hugo tree is written is not a choice. `content-build site` writes
780
793
  the whole Hugo source tree under `build/hugo/` — the generated `hugo.toml`,
@@ -786,7 +799,7 @@ published. A `site.out` is refused by name:
786
799
 
787
800
  > ``package-build config: `site` must be a mapping.``
788
801
 
789
- > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, assets, description, packages, sections, readmeSections, landing, trees, pass, passOptions, backfillSections, list, notfound, hugo).``
802
+ > ``package-build config: `site.<key>` is not a recognized option (expected one of: base, assets, description, packages, sections, landing, pass, passOptions, backfillSections, list, notfound, hugo).``
790
803
 
791
804
  `site.assets` is the host every package's imagery is served from, and it is
792
805
  the one address in this file that is not this repository's own. A note names
@@ -832,25 +845,8 @@ names a registry):
832
845
 
833
846
  > ``package-build config: `site.packages[<index>]` must be a non-empty string.``
834
847
 
835
- `site.trees` are the extra trees published beside the content, each naming
836
- where it comes from and which section it publishes into:
837
-
838
- | Key (under `site.trees[]`) | Type | Required |
839
- | -------------------------- | ------ | -------- |
840
- | `site.trees[].from` | string | yes |
841
- | `site.trees[].section` | string | yes |
842
-
843
- > ``package-build config: `site.trees` must be a list.``
844
-
845
- > ``package-build config: `site.trees[<index>]` must be a mapping.``
846
-
847
- > ``package-build config: `site.trees[<index>].from` must be a non-empty string.``
848
-
849
- > ``package-build config: `site.trees[<index>].<key>` is not a recognized option (expected one of: from, section).``
850
-
851
- `site.sections` (and `site.readmeSections`, the same shape) is a closed
852
- vocabulary — a section's _only_ place to speak, since it exists solely as
853
- the generated `_index.md` this build writes for it:
848
+ `site.sections` is a closed vocabulary a section's _only_ place to speak,
849
+ since it exists solely as the generated `_index.md` this build writes for it:
854
850
 
855
851
  | Key (under `site.sections.<name>`) | Type | Required | Default |
856
852
  | ---------------------------------- | -------------------------- | -------- | ------- |
@@ -1336,9 +1332,9 @@ its content tree's addresses mount inside the package.
1336
1332
  Every HeroicLands package publishes at least an authored homepage at
1337
1333
  `https://www.heroiclands.org/<contentPackage>/` — there is no value meaning
1338
1334
  _no web presence at all_. `homepage` is the floor: the authored homepage
1339
- and nothing else, no content-tree walk, no `site.sections` / `site.trees` /
1340
- `site.landing` output. `content` is the homepage plus every page the
1341
- content tree publishes. `publishesContentPages(config)`, exported from
1335
+ and nothing else, no content-tree walk, no `site.sections` / `site.landing`
1336
+ output. `content` is the homepage plus every page the content tree
1337
+ publishes. `publishesContentPages(config)`, exported from
1342
1338
  `content-config.mjs` alongside [`compilesFoundryDocuments`](#packagekind),
1343
1339
  answers the one question every reader of the mode actually asks — the site
1344
1340
  build, to decide whether to walk the tree at all, and the content index, to
@@ -801,13 +801,43 @@ hm3:
801
801
  Where no pack of that type is the default, the build refuses rather than
802
802
  guessing, and names the candidates.
803
803
 
804
- Three declarations are refused, each with the reason:
804
+ **`pack: none` compiles the note into no document.** The note is walked,
805
+ published as a page, present in the content index with an address and no
806
+ Foundry UUID, and addressable by wikilink like any other; every pack compiler
807
+ passes over it without a finding, and the check that reports a note nothing
808
+ compiles says nothing about it. Write it on a page that belongs on the website
809
+ and in no compendium — a page of developer documentation, a page about the
810
+ package itself:
805
811
 
806
- | written | why it is refused |
807
- | ----------------------------------- | --------------------------------------------------------------------------------- |
808
- | a **companion** pack | A companion is written by another pack's pass, so no note may be routed into one. |
809
- | a pack **nothing answers to** | The message lists the configured packs of that document type. |
810
- | a pack of **another document type** | A note's `pack:` names a pack of its own document type. |
812
+ ```yaml
813
+ type: doc
814
+ subType: concept
815
+ shortcode: architecture
816
+ pack: none
817
+ ```
818
+
819
+ A link to such a note resolves everywhere the note is addressable. On the web
820
+ it is an ordinary link to the page. In a compiled journal there is no document
821
+ to open, so the reader gets the link's text as prose and no `@UUID` — the
822
+ mirror of a link into a package that publishes documents and no pages.
823
+
824
+ It is accepted only on a type whose **sole document is the JournalEntry its
825
+ prose becomes** — `doc`, `place`, `lore` and `scenario`. On a type that
826
+ compiles an Item, an Actor, a Macro, a Scene or an Adventure it is refused by
827
+ name, because there it would drop the document the type exists to produce;
828
+ the message names that document. It is read per system like any other
829
+ `pack:`, so `<system>.pack: none` withholds one system's document while the
830
+ shared declaration names a pack for the rest, and a configured pack may not
831
+ be called `none`.
832
+
833
+ Four declarations are refused, each with the reason:
834
+
835
+ | written | why it is refused |
836
+ | ------------------------------------------- | -------------------------------------------------------------------------------------------- |
837
+ | a **companion** pack | A companion is written by another pack's pass, so no note may be routed into one. |
838
+ | a pack **nothing answers to** | The message lists the configured packs of that document type. |
839
+ | a pack of **another document type** | A note's `pack:` names a pack of its own document type. |
840
+ | **`none`** on a type with a second document | The Item, Actor, Macro, Scene or Adventure the type compiles into would be dropped, by name. |
811
841
 
812
842
  #### Template priority: which template wins
813
843
 
@@ -34,6 +34,9 @@
34
34
  * @module
35
35
  */
36
36
 
37
+ import path from "node:path";
38
+ import matter from "gray-matter";
39
+
37
40
  import { authoredFields, runtimeOnlyFields } from "./field-spec.mjs";
38
41
  import { loadPackConfig } from "./pack-config.mjs";
39
42
 
@@ -309,3 +312,135 @@ export function renderItemFieldReference({
309
312
  // and then reported stale by `--check` forever after.
310
313
  return lines.join("\n").replace(/\n+$/, "");
311
314
  }
315
+
316
+ /**
317
+ * @param {unknown} value - Anything.
318
+ * @returns {boolean} Whether it is a mapping a field may be read out of.
319
+ */
320
+ function isPlainObject(value) {
321
+ return typeof value === "object" && value !== null && !Array.isArray(value);
322
+ }
323
+
324
+ /**
325
+ * Recursively merge `overlay` onto `base`. Plain objects merge key-by-key;
326
+ * everything else (arrays, primitives, `null`) replaces. Inputs are not
327
+ * mutated.
328
+ *
329
+ * @param {any} base - The generated envelope.
330
+ * @param {any} overlay - The consumer's declared `frontmatter`.
331
+ * @returns {any} The merged value.
332
+ */
333
+ function deepMerge(base, overlay) {
334
+ if (overlay === undefined) return base;
335
+ if (!isPlainObject(base) || !isPlainObject(overlay)) return overlay;
336
+ const out = { ...base };
337
+ for (const [key, value] of Object.entries(overlay)) {
338
+ out[key] = key in base ? deepMerge(base[key], value) : value;
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * Whether a destination file sits under a content tree.
345
+ *
346
+ * The one question that decides whether `docs item-fields` writes a note
347
+ * envelope: `assets/content/` walks every file under it for its `type:`, so a
348
+ * generated page filed there needs one to publish at all, while a page filed
349
+ * anywhere else — a repository's own `docs/` — is read by nobody but Hugo's
350
+ * `--check` guard and the reader following a link, neither of which wants
351
+ * frontmatter.
352
+ *
353
+ * @param {string} destination - Absolute path of the file being written.
354
+ * @param {string} contentRoot - Absolute path of the content tree root
355
+ * (`config.paths.content`).
356
+ * @returns {boolean} Whether `destination` resolves inside `contentRoot`.
357
+ */
358
+ export function isUnderContentTree(destination, contentRoot) {
359
+ const relative = path.relative(contentRoot, destination);
360
+ return (
361
+ relative !== "" &&
362
+ relative !== ".." &&
363
+ !relative.startsWith(`..${path.sep}`) &&
364
+ !path.isAbsolute(relative)
365
+ );
366
+ }
367
+
368
+ /**
369
+ * A note's `shortcode`, derived from the basename of its destination file.
370
+ *
371
+ * Lowercase alphanumerics only — the address charset every other shortcode in
372
+ * the tree is held to — so `item-frontmatter.md` derives `itemfrontmatter`
373
+ * rather than carrying a hyphen no address segment permits.
374
+ *
375
+ * @param {string} destination - Where the note is written.
376
+ * @returns {string} The derived shortcode.
377
+ */
378
+ export function shortcodeFromBasename(destination) {
379
+ return path
380
+ .basename(destination, path.extname(destination))
381
+ .toLowerCase()
382
+ .replace(/[^a-z0-9]/g, "");
383
+ }
384
+
385
+ /**
386
+ * The note envelope `docs item-fields` writes when its page lives in the
387
+ * content tree.
388
+ *
389
+ * The universal keys every note in the format carries: `type: doc`,
390
+ * `subType: reference` — this page is out-of-world lookup material, the same
391
+ * genre as every other generated reference — a `shortcode`, `name.full` from
392
+ * the page's title, and `pack: none`, since the page publishes to the website
393
+ * and compiles into no compendium document. A consumer's own
394
+ * `docs.itemFields.frontmatter` is deep-merged over it, so it may add keys or
395
+ * override any of the derived ones, `shortcode` included.
396
+ *
397
+ * @param {object} options
398
+ * @param {string} options.title - The page's H1, and `name.full`'s default.
399
+ * @param {string} options.shortcode - The derived shortcode, from
400
+ * {@link shortcodeFromBasename}.
401
+ * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
402
+ * declared `docs.itemFields.frontmatter`.
403
+ * @returns {Record<string, unknown>} The envelope, ready for `matter.stringify`.
404
+ */
405
+ export function itemFieldsEnvelope({ title, shortcode, frontmatter }) {
406
+ return deepMerge(
407
+ {
408
+ type: "doc",
409
+ subType: "reference",
410
+ shortcode,
411
+ name: { full: title },
412
+ pack: "none",
413
+ },
414
+ frontmatter,
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Wrap the rendered item-fields page in the note envelope, when its
420
+ * destination is under the content tree.
421
+ *
422
+ * `--check` compares the **whole** file this returns, envelope included — a
423
+ * page committed with its old envelope by hand, or with none at all, is
424
+ * exactly the staleness the guard exists to catch.
425
+ *
426
+ * @param {string} body - The page {@link renderItemFieldReference} rendered.
427
+ * @param {object} options
428
+ * @param {string} options.title - The page's H1, threaded through to
429
+ * `name.full`.
430
+ * @param {string} options.destination - Absolute path the page is written to.
431
+ * @param {string} options.contentRoot - Absolute path of the content tree
432
+ * root (`config.paths.content`).
433
+ * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
434
+ * declared `docs.itemFields.frontmatter`.
435
+ * @returns {string} `body`, unchanged when `destination` is outside the
436
+ * content tree; otherwise `body` with the note envelope stringified above it.
437
+ */
438
+ export function renderItemFieldsPage(body, { title, destination, contentRoot, frontmatter }) {
439
+ if (!isUnderContentTree(destination, contentRoot)) return body;
440
+ const envelope = itemFieldsEnvelope({
441
+ title,
442
+ shortcode: shortcodeFromBasename(destination),
443
+ frontmatter,
444
+ });
445
+ return matter.stringify(body, envelope);
446
+ }
@@ -70,6 +70,7 @@ 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
72
  import { isHomepage } from "./homepage.mjs";
73
+ import { declaresNoPack } from "./pack-router.mjs";
73
74
  import { assertNoDeclaredPackage } from "./note-package.mjs";
74
75
  import { assertNoDeclaredFolder } from "./folder-notes.mjs";
75
76
  import {
@@ -179,7 +180,17 @@ export function entriesForNote(fm, name, address, body, ctx) {
179
180
  // and its id is hashed under the `folder` namespace against its own
180
181
  // address rather than under `document`. Emitting one would publish an
181
182
  // `Item` UUID for a `Folder`, at an id no document carries.
182
- id && !NEVER_PACKED_TYPES.has(String(type)) && !DERIVED_PACKED_TYPES.has(String(type)) ?
183
+ //
184
+ // And a note declaring **`pack: none`** is in no pack by its own
185
+ // statement: it publishes a page and compiles into nothing, so it has
186
+ // an address and no UUID. The shared declaration is what is read,
187
+ // 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
+ ) ?
183
194
  compendiumUuid(
184
195
  foundryPackageId,
185
196
  type,
@@ -1265,9 +1265,8 @@ export function lintNote(
1265
1265
  message: draftRetiredMessage(),
1266
1266
  });
1267
1267
  }
1268
- // Anchored at column 1 for the same reason `aliases` is: `section` names a
1269
- // configuration key too (`site.trees[].section`), and a nested one under
1270
- // some other block is not this field.
1268
+ // Anchored at column 1 for the same reason `aliases` is: a nested
1269
+ // `section` under some other block is not this field.
1271
1270
  if (Object.hasOwn(fm, "section")) {
1272
1271
  findings.push({
1273
1272
  file: note.file,
@@ -38,7 +38,7 @@ import { foundryAddressProblem, pathnameProblem, resolvePathname } from "./pathn
38
38
  import log from "loglevel";
39
39
 
40
40
  import { loadPackConfig } from "./pack-config.mjs";
41
- import { packRouter } from "./pack-router.mjs";
41
+ import { declaresNoPack, packRouter } from "./pack-router.mjs";
42
42
  import { contentPackage, foundryPackageId } from "./content-package.mjs";
43
43
  import { searchableFrontmatter } from "./note-package.mjs";
44
44
  import { PACKAGE_BASE } from "./content-address.mjs";
@@ -741,6 +741,9 @@ export function buildContentLinkIndex(
741
741
  // its prose compiles into lands — two documents, two packs.
742
742
  pack: router.resolveOrNull(fm, packForType(fm.type).docType),
743
743
  docPack: router.resolveOrNull(fm, "JournalEntry"),
744
+ // Whether the note declares `pack: none` and so has no document
745
+ // at all: a link to it names a page, never a compendium entry.
746
+ none: declaresNoPack(fm),
744
747
  shortcode: fm.shortcode ?? null,
745
748
  // What the note *is*, carried so a caller resolving a reference
746
749
  // can group by the family its target declares rather than only by
package/engine/ids.mjs CHANGED
@@ -86,6 +86,18 @@ export const MAP_SUBTYPES = Object.freeze(["battlemap", "localmap", "regionalmap
86
86
  */
87
87
  export const JOURNAL_TYPES = Object.freeze(new Set(["doc", "place", "lore", "scenario"]));
88
88
 
89
+ /**
90
+ * The `pack:` value that routes a note's document into no compendium.
91
+ *
92
+ * One reading, on the key that already decides which compendium receives a
93
+ * note: the note publishes to the site and compiles into nothing. Declared
94
+ * here, beside the pack table, so the configuration can refuse a pack of this
95
+ * name and the router can read the value from one spelling.
96
+ *
97
+ * @type {string}
98
+ */
99
+ export const NO_PACK = "none";
100
+
89
101
  /**
90
102
  * Content type → the pack its documents compile into, and the document type
91
103
  * that pack holds.
@@ -91,6 +91,7 @@ import { JOURNAL_TYPES, MAP_TYPES, PACK_BY_TYPE, RETIRED_TYPES, currentType } fr
91
91
  import { itemTypes } from "./item-registry.mjs";
92
92
  import { docEntryTypes } from "./item-docs.mjs";
93
93
  import { loadPackConfig } from "./pack-config.mjs";
94
+ import { declaresNoPack } from "./pack-router.mjs";
94
95
  import { locateFrontmatterKey } from "./retired-fields.mjs";
95
96
  import { noteTypesFor, subtypeRow } from "./document-subtypes.mjs";
96
97
  import { KNOWN_DOCUMENT_SUBTYPE_MAPS } from "./subtype-registry.mjs";
@@ -641,6 +642,12 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
641
642
  // {@link claimedNoteTypes} states: its JSON is checked in, it has no pass,
642
643
  // and no note is routed into it.
643
644
  const configured = new Set((config.packs ?? []).filter((p) => !p.prebuilt).map((p) => p.type));
645
+ // Every system a pack declares, plus the shared position: a note that
646
+ // answers `pack: none` for all of them compiles into nothing anywhere.
647
+ const routed = [
648
+ undefined,
649
+ ...new Set((config.packs ?? []).map((p) => p.system).filter(Boolean)),
650
+ ];
644
651
 
645
652
  // The corpus this compile derived once, required rather than
646
653
  // derived here: this module is imported *by* the content index, so it
@@ -681,6 +688,20 @@ export function unclaimedNoteFindings(config = loadPackConfig(), sources, { reco
681
688
  // Every document it produces has somewhere to go.
682
689
  if (produces.length && !missing.length) continue;
683
690
 
691
+ // `pack: none`, on a type whose only document is the JournalEntry its
692
+ // prose becomes: the note compiles into nothing **by declaration**, so
693
+ // a missing JournalEntry pack drops nothing it meant to keep. A type
694
+ // that also produces an Item or an Actor is not excused here — the
695
+ // router refuses that declaration by name when a pass asks it, and
696
+ // where no pass does, the finding below still says nothing claims the
697
+ // note.
698
+ if (
699
+ produces.every((docType) => docType === "JournalEntry") &&
700
+ routed.every((system) => declaresNoPack(fm, system))
701
+ ) {
702
+ continue;
703
+ }
704
+
684
705
  // Some do and some do not: the note compiles, and one of its documents
685
706
  // is dropped in silence. A type nothing produces at all falls past this
686
707
  // to the unclaimed messages below, where `produces` being empty is itself
@@ -65,11 +65,22 @@
65
65
  * a shared value that cannot describe a system-specific pack, so it does not
66
66
  * answer and that system falls through to its own default. Both were silent
67
67
  * losses of one system's whole document set.
68
+ * - **`pack: none` routes the document nowhere, on purpose.** The note is
69
+ * walked, indexed, published and linkable, and no compendium receives it:
70
+ * {@link createPackRouter}'s `resolve` answers `undefined`, which no pass's
71
+ * name equals, so every pass passes over the note as one it does not own.
72
+ * It is accepted only on a type whose sole document is the JournalEntry its
73
+ * prose becomes — `doc` and the other journal-only types. On a type that
74
+ * compiles an Item, an Actor, a Macro, a Scene or an Adventure it is refused
75
+ * by name, because there the declaration would drop the document the type
76
+ * exists to produce. Read per system like any other `pack:`, so a block may
77
+ * say `none` for one system and the shared declaration may name a pack for
78
+ * the rest.
68
79
  *
69
80
  * @module
70
81
  */
71
82
 
72
- import { packForType } from "./ids.mjs";
83
+ import { NO_PACK, packForType } from "./ids.mjs";
73
84
  import { loadPackConfig } from "./pack-config.mjs";
74
85
  import { blockProperty, systemBlock } from "./system-block.mjs";
75
86
 
@@ -95,6 +106,35 @@ export class PackRoutingError extends Error {
95
106
  */
96
107
  export const PACK_FIELD = "pack";
97
108
 
109
+ /**
110
+ * The `pack:` value that routes a note's document into no compendium — see
111
+ * {@link module:engine/ids.NO_PACK}. Re-exported here because this is the
112
+ * module that reads it; a configured pack may not take the name, so the value
113
+ * can never be mistaken for one.
114
+ */
115
+ export { NO_PACK };
116
+
117
+ /**
118
+ * Whether a note declares `pack: none` for one system's document.
119
+ *
120
+ * The same reading {@link createPackRouter}'s `resolve` applies: the system's
121
+ * block wins where it declares a pack, and the shared top-level value stands
122
+ * otherwise. Asked with no system, it reads the shared value alone, which is
123
+ * what every single-system build and the content index read.
124
+ *
125
+ * Pure, so the readers that never route — the Foundry-address pass, the link
126
+ * index, the unclaimed-type check — can ask it without a router.
127
+ *
128
+ * @param {object} fm - The note's frontmatter.
129
+ * @param {string} [system] - The system whose document is asked about.
130
+ * @returns {boolean} True when the answer for that document is `none`.
131
+ */
132
+ export function declaresNoPack(fm, system) {
133
+ const declared =
134
+ system === undefined ? fm?.[PACK_FIELD] : blockProperty(fm, system, PACK_FIELD);
135
+ return declared === NO_PACK;
136
+ }
137
+
98
138
  /**
99
139
  * Build the router for one configured pack list.
100
140
  *
@@ -103,7 +143,7 @@ export const PACK_FIELD = "pack";
103
143
  *
104
144
  * @param {readonly object[]} packs - The resolved `packs` list from
105
145
  * `defineConfig`.
106
- * @returns {{resolve: (fm: object, docType: string, system?: string) => string,
146
+ * @returns {{resolve: (fm: object, docType: string, system?: string) => string|undefined,
107
147
  * resolveOrNull: (fm: object, docType: string, system?: string) => string|undefined,
108
148
  * packsOfType: (docType: string) => string[],
109
149
  * defaultOf: (docType: string) => string|undefined}} The router.
@@ -179,6 +219,14 @@ export function createPackRouter(packs) {
179
219
  /** @param {object} fm */
180
220
  const noteLabel = (fm) => fm?.name?.full ?? fm?.shortcode ?? fm?.id ?? "a note";
181
221
 
222
+ /**
223
+ * `"a"` or `"an"`, so a document class reads as English in a message.
224
+ *
225
+ * @param {string} word - The word the article precedes.
226
+ * @returns {string} The article.
227
+ */
228
+ const article = (word) => (/^[AEIOUaeiou]/.test(word) ? "an" : "a");
229
+
182
230
  /**
183
231
  * The pack a note declaring none is routed to.
184
232
  *
@@ -212,8 +260,11 @@ export function createPackRouter(packs) {
212
260
  * @param {string} [system] - The system whose document is being routed. Its
213
261
  * block's `pack:` wins over the shared one; without it only the shared
214
262
  * declaration is read, which is every single-system build.
215
- * @returns {string} The pack name.
216
- * @throws {PackRoutingError} When the note routes nowhere.
263
+ * @returns {string|undefined} The pack name — or `undefined` where the
264
+ * note declares `pack: none`, which no pass's name equals, so every pass
265
+ * passes over the note as one it does not own.
266
+ * @throws {PackRoutingError} When the note routes nowhere, or declares
267
+ * `pack: none` on a type that compiles a document beside its prose.
217
268
  */
218
269
  function resolve(fm, docType, system) {
219
270
  const inBlock = system === undefined ? undefined : systemBlock(fm, system)?.[PACK_FIELD];
@@ -225,6 +276,29 @@ export function createPackRouter(packs) {
225
276
  // JournalEntry — is not what the author was addressing.
226
277
  const ownDocType = packForType(fm?.type).docType;
227
278
 
279
+ // `none`: the note compiles into no document. Only a type whose own
280
+ // document *is* the JournalEntry may say so — for every other type
281
+ // the declaration would drop the Item, Actor, Macro, Scene or
282
+ // Adventure the type exists to produce, and its prose with it. The
283
+ // document class comes from the same table every pass routes by, so
284
+ // the refusal names what the compile would have written. Asked only
285
+ // by the pass that writes the note's own document, as every `pack:`
286
+ // is, so the refusal is reported once rather than once per pass.
287
+ if (declared === NO_PACK && docType === ownDocType) {
288
+ if (ownDocType !== "JournalEntry") {
289
+ const spelled = authoredInBlock ? `${system}.pack: ${NO_PACK}` : `pack: ${NO_PACK}`;
290
+ throw new PackRoutingError(
291
+ `${noteLabel(fm)} declares \`${spelled}\`, but a ${fm?.type} note ` +
292
+ `compiles into ${article(ownDocType)} ${ownDocType}, and ` +
293
+ `\`pack: ${NO_PACK}\` would drop it. Only a type whose sole ` +
294
+ `document is the JournalEntry its prose becomes may ` +
295
+ `declare it — name the pack the ${ownDocType} goes to, or ` +
296
+ `leave \`pack:\` unset for the default.`,
297
+ );
298
+ }
299
+ return undefined;
300
+ }
301
+
228
302
  // **A declaration naming another system's pack is not this system's
229
303
  // answer**, and which of two things that means depends on where
230
304
  // it was written.
@@ -328,7 +402,10 @@ export function createPackRouter(packs) {
328
402
  * @param {object} fm - The note's frontmatter.
329
403
  * @param {string} docType - The document type being addressed.
330
404
  * @param {string} [system] - The system whose document is addressed.
331
- * @returns {string|undefined} The pack name, or `undefined`.
405
+ * @returns {string|undefined} The pack name, or `undefined` — for a
406
+ * note that routes nowhere and for one declaring `pack: none`
407
+ * alike. A caller that must tell the two apart asks
408
+ * {@link declaresNoPack}.
332
409
  */
333
410
  resolveOrNull(fm, docType, system) {
334
411
  try {
@@ -35,7 +35,7 @@
35
35
  * ## `publish.site` is the switch, and it is the only switch
36
36
  *
37
37
  * `homepage` mode fences the content surfaces off: the tree is not walked and
38
- * `sections`, `trees` and `landing` emit nothing however they are declared. A
38
+ * `sections` and `landing` emit nothing however they are declared. A
39
39
  * PDF of the content tree is a content surface by any reading — arguably the
40
40
  * most portable one there is — so it is fenced on exactly the same terms, by
41
41
  * asking the same {@link module:content-config.publishesContentPages} the
@@ -481,7 +481,7 @@ export async function buildPdf({ config, out, version = "", compile = true } = {
481
481
  * @returns {string} Typst markup.
482
482
  */
483
483
  const renderPage = (page, headingOffset, anchorPrefix) => {
484
- const src = page.relPath ?? page.rel ?? page.base;
484
+ const src = page.relPath ?? page.base;
485
485
  const wikiErrors = [];
486
486
  const { markdown, errors } = expandContentTables(page.body, {
487
487
  docs: universe.get(page.pkg) ?? [],
@@ -326,9 +326,8 @@ export function assertNoSectionField(fm, { file, absPath } = {}) {
326
326
  if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "section")) return;
327
327
 
328
328
  const err = new Error(`${sectionRetiredMessage(file)}.`);
329
- // Anchored at column 1: `site.trees[].section` is a *configuration* key of
330
- // the same name, and a nested `section:` inside some other block is not
331
- // this field — a finding about the top-level one must not open on it.
329
+ // Anchored at column 1: a nested `section:` inside some other block is
330
+ // not this field a finding about the top-level one must not open on it.
332
331
  const position = locateFrontmatterKey(absPath, "section", undefined, { topLevel: true });
333
332
  if (position) err.position = position;
334
333
  throw err;