@heroiclands/package-build 3.4.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,7 +54,8 @@ import { canonicalKey, writeManifests } from "./kb-manifest.mjs";
54
54
  import { walkMarkdownTree } from "./helpers.mjs";
55
55
  import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
56
56
  import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
57
- import { assertNotePackage } from "./note-package.mjs";
57
+ import { assertNoDeclaredPackage } from "./note-package.mjs";
58
+ import { assertNoDraftField } from "./retired-fields.mjs";
58
59
  import { journalPageId, splitPages } from "./journals.mjs";
59
60
  import { routerFor } from "./pack-router.mjs";
60
61
  import { loadPackConfig } from "./pack-config.mjs";
@@ -193,14 +194,13 @@ export function entriesForNote(fm, name, address, body, ctx) {
193
194
  /**
194
195
  * Every note this package publishes, as manifest entries.
195
196
  *
196
- * Drafts are excluded because the site does not publish them, and an entry for
197
- * an unpublished page is exactly the dead link the manifest exists to prevent.
198
- *
199
- * Every note in the tree is this package's note, whether or not it says so:
200
- * `package:` is optional and merely has to agree (#56). A note naming a
201
- * different package **throws** rather than being skipped this build is not
202
- * authoritative for it, and skipping it silently is how a whole tree came to be
203
- * filtered out of a manifest that then claimed the package published nothing.
197
+ * Every note in the tree is this package's, so nothing here selects by package:
198
+ * the key's first segment is `contentPackage` (#56). A note still declaring the
199
+ * retired `package:` or `draft:` field **throws** rather than being skipped —
200
+ * skipping one silently is how a whole tree came to be filtered out of a
201
+ * manifest that then claimed the package published nothing, and it is what let
202
+ * a drafted note's inbound links look like links to a note that never existed
203
+ * (#69).
204
204
  *
205
205
  * A note that has no address is **reported, not guessed** — the finding carries
206
206
  * the file and the reason, so a caller can print it or fail on it. Inventing an
@@ -226,8 +226,12 @@ export function collectManifestEntries(contentBase, ctx) {
226
226
  )) {
227
227
  if (!fm) continue;
228
228
  const rel = path.relative(contentBase, absPath);
229
- assertNotePackage(fm, { file: rel, configured: ctx.contentPackage });
230
- if (fm.draft === true) continue;
229
+ assertNoDeclaredPackage(fm, {
230
+ file: rel,
231
+ absPath,
232
+ configured: ctx.contentPackage,
233
+ });
234
+ assertNoDraftField(fm, { file: rel, absPath });
231
235
  if (!fm.type || !fm.shortcode) continue;
232
236
 
233
237
  const base = path.basename(absPath);
@@ -14,10 +14,10 @@
14
14
  /**
15
15
  * Which content package a note belongs to.
16
16
  *
17
- * **It is the repository's configured `contentPackage`.** A content tree holds
18
- * exactly one package's notes — every package is single-sourced in the
19
- * repository that ships it — so the package is a property of the *repository*,
20
- * not of the note.
17
+ * **It is the repository's configured `contentPackage`, and nothing else.** A
18
+ * content tree holds exactly one package's notes — every package is
19
+ * single-sourced in the repository that ships it — so the package is a property
20
+ * of the *repository*, not of the note, and no note declares it.
21
21
  *
22
22
  * It used to be a property of the note, and a **selector**: the compile loop
23
23
  * read `package:` out of frontmatter and skipped anything that did not match
@@ -27,100 +27,107 @@
27
27
  * **zero notes and exited 0** — which is exactly the state the un-migrated
28
28
  * `hm-loc-*` / `hm-adv-*` repositories are in today.
29
29
  *
30
- * So the field is being retired, in three steps, of which this is the first:
30
+ * The field was retired in three steps, and all three have landed:
31
31
  *
32
- * 1. **Optional here.** An absent `package:` is normal and the note compiles; a
33
- * present one is accepted while it agrees, and is a loud, named error when
34
- * it does not. Non-breaking, so a consumer adopts it before changing a note.
35
- * 2. **Swept** out of every content tree in the org, on this version.
36
- * 3. **Rejected outright**, as a major, once the sweeps have merged.
32
+ * 1. **Optional** (3.3.0). An absent `package:` was normal and a present one
33
+ * was accepted while it agreed. Non-breaking, so a consumer adopted it
34
+ * before changing a note.
35
+ * 2. **Swept** out of every content tree on the org — 6,235 notes across
36
+ * `sohl`, `thalorna`, `kethira` and `harnensemble`.
37
+ * 3. **Rejected outright**, as this major. A note declaring the field fails the
38
+ * build naming the file, whatever the value says.
37
39
  *
38
- * The distinction between the two functions here is which question is being
39
- * asked. {@link assertNotePackage} is for a site that used to *select* it
40
- * answers "may this build compile this note", and a disagreement is a finding.
41
- * {@link notePackage} is for a site that *derives an address* it answers
42
- * "which package's namespace does this address sit in", where a disagreement
43
- * has already been reported by the compile pass and repeating it would print
44
- * the same fault twice.
40
+ * Step 3 is deliberately a rejection rather than continued tolerance: a field
41
+ * accepted while it agrees is a field that grows back, one note at a time, and
42
+ * every one of them is a line restating a constant the configuration already
43
+ * carries. There is no value that makes writing it correct, so the diagnostic
44
+ * says what to write instead rather than which value to change.
45
+ *
46
+ * **One `package` survives, and it is synthesised.** A `dataview` table scopes
47
+ * itself with `WHERE … and package = "<pkg>"`, and that clause resolves against
48
+ * frontmatter like any other field. {@link searchableFrontmatter} supplies the
49
+ * derived value to the search so the 45 authored clauses across `sohl` and
50
+ * `thalorna` keep matching. It is a *search* value, never an authored one, and
51
+ * it is never written back to a note.
45
52
  *
46
53
  * @module
47
54
  */
48
55
 
49
56
  import { contentPackage } from "./content-package.mjs";
57
+ import { locateFrontmatterKey } from "./retired-fields.mjs";
50
58
 
51
59
  /**
52
- * The package a note belongs to.
53
- *
54
- * Non-validating: the answer for a note that declares nothing, and for one that
55
- * declares the configured package, is the same value. A note declaring some
56
- * *other* package is answered literally here rather than corrected — the
57
- * compile pass reports that, once, through {@link assertNotePackage}.
58
- *
59
- * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
60
- * could not be parsed.
61
- * @param {string} [configured] - The package this build compiles. Defaults to
62
- * the configured `contentPackage`; passed explicitly by callers that already
63
- * carry it in a context object, so a caller's configuration drives every read.
64
- * @returns {string} The package.
65
- */
66
- export function notePackage(fm, configured) {
67
- const declared = fm?.package;
68
- // A blank is a declaration of nothing, not a package named "".
69
- if (declared != null && declared !== "") return declared;
70
- // Resolved only when it is needed, so a caller holding a note that declares
71
- // one never touches the configuration (#2).
72
- return configured ?? contentPackage();
73
- }
74
-
75
- /**
76
- * A note's frontmatter as a generated table searches it — its package present
77
- * whether or not the note declares one.
60
+ * A note's frontmatter as a generated table searches it — its package present,
61
+ * though no note declares one.
78
62
  *
79
63
  * A `dataview` query resolves `package` out of frontmatter like any other
80
64
  * field, so a collection note that scopes itself with `WHERE … and package =
81
- * "sohl"` matches nothing once the field is deleted, and renders an **empty
82
- * table** in silence. Deriving the value here keeps the two spellings
83
- * equivalent, so a sweep that deletes the field is mechanical rather than a
84
- * trap (#56) — and a query that never mentions `package` is unaffected either
85
- * way.
65
+ * "sohl"` would match nothing now that the field is gone, and would render an
66
+ * **empty table** in silence. Supplying the derived value here is what kept the
67
+ * sweep mechanical rather than a trap (#56) and a query that never mentions
68
+ * `package` is unaffected either way.
86
69
  *
87
- * The declared value is left alone when there is one, so nothing about an
88
- * unswept tree changes.
70
+ * The frontmatter is copied rather than written into: it is the note's own
71
+ * parsed object, shared with every other reader, and the derived package is a
72
+ * property of *this search*, not of the note.
89
73
  *
90
74
  * @param {object|null|undefined} fm - Parsed frontmatter.
91
- * @param {string} [configured] - The package this build compiles.
92
- * @returns {object|null|undefined} The frontmatter itself when it declares a
93
- * package, else a shallow copy carrying the derived one.
75
+ * @param {string} [configured] - The package this build compiles. Defaults to
76
+ * the configured `contentPackage`; passed explicitly by callers that already
77
+ * carry it in a context object, so a caller's configuration drives every read.
78
+ * @returns {object|null|undefined} A shallow copy carrying the derived package,
79
+ * or whatever was passed when it is not frontmatter at all.
94
80
  */
95
81
  export function searchableFrontmatter(fm, configured) {
96
82
  if (!fm || typeof fm !== "object") return fm;
97
- if (fm.package != null && fm.package !== "") return fm;
98
- return { ...fm, package: notePackage(fm, configured) };
83
+ return { ...fm, package: configured ?? contentPackage() };
99
84
  }
100
85
 
101
86
  /**
102
- * The package a note belongs to, refusing one that names another package.
87
+ * Refuse a note that declares `package:` at all.
103
88
  *
104
- * @param {object|null|undefined} fm - Parsed frontmatter.
89
+ * Presence is the whole test — a declaration that *agrees* with the
90
+ * configuration is as retired as one that disagrees, and an empty one
91
+ * (`package:`, which parses as `null`) is still the field.
92
+ *
93
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
94
+ * could not be parsed.
105
95
  * @param {object} [options] - Options.
106
96
  * @param {string} [options.file] - The note's path, named in the message. Omit
107
97
  * it where the caller emits through a diagnostic, which puts the locator at
108
98
  * the start of the line already — repeating it prints the path twice.
99
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
100
+ * failing path to locate the offending line and column. The position rides on
101
+ * the thrown error as `position`, for a caller that emits a diagnostic.
109
102
  * @param {string} [options.configured] - The package this build compiles.
110
103
  * Defaults to the configured `contentPackage`.
111
- * @returns {string} The package, which is always `configured`.
112
- * @throws {Error} When the note declares a different package.
104
+ * @returns {void}
105
+ * @throws {Error} When the note declares the field.
113
106
  */
114
- export function assertNotePackage(fm, { file, configured } = {}) {
107
+ export function assertNoDeclaredPackage(
108
+ fm,
109
+ { file, absPath, configured } = {},
110
+ ) {
111
+ if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "package")) return;
112
+
115
113
  const target = configured ?? contentPackage();
116
- const pkg = notePackage(fm, target);
117
- if (pkg === target) return target;
118
- throw new Error(
119
- `note declares \`package: ${pkg}\`, but this repository compiles ` +
120
- `"${target}"` +
114
+ const declared = fm.package;
115
+ const wrote =
116
+ declared === null || declared === undefined || declared === "" ?
117
+ "`package:`"
118
+ : `\`package: ${declared}\``;
119
+
120
+ const err = new Error(
121
+ `${wrote} is a retired frontmatter field — delete it` +
121
122
  (file ? ` — ${file}` : "") +
122
- `. A note's package is the configured \`contentPackage\`, so the ` +
123
- `field is redundant and is being retired: delete it, or correct ` +
124
- `\`contentPackage\` in package-build.config.yaml.`,
123
+ `. A note's package is this repository's configured ` +
124
+ `\`contentPackage\` ("${target}", in package-build.config.yaml), ` +
125
+ `and every note in the tree belongs to it.`,
125
126
  );
127
+ // Where the field is, so the caller's diagnostic opens on the line that has
128
+ // to be deleted. Read here rather than carried through every walk: this is
129
+ // the failing path, and the build stops on it.
130
+ const position = locateFrontmatterKey(absPath, "package");
131
+ if (position) err.position = position;
132
+ throw err;
126
133
  }
@@ -69,10 +69,10 @@ export class PackRoutingError extends Error {
69
69
  /**
70
70
  * The frontmatter field a note declares its pack in.
71
71
  *
72
- * Deliberately close to the retiring `package:` and deliberately not the same
72
+ * Deliberately close to the retired `package:` and deliberately not the same
73
73
  * word: `package:` said which *distribution* owned a note — now the
74
- * repository's `contentPackage` (#56) — while `pack:` says which *compendium*
75
- * receives its document.
74
+ * repository's `contentPackage`, and no longer authorable (#56) — while `pack:`
75
+ * says which *compendium* receives its document.
76
76
  */
77
77
  export const PACK_FIELD = "pack";
78
78
 
@@ -0,0 +1,123 @@
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
+ * Frontmatter fields a note may no longer declare.
16
+ *
17
+ * A retired field has exactly two possible fates, and only one of them is
18
+ * honest. Left honoured, it keeps doing whatever it did — which is why it was
19
+ * retired. Left *ignored*, it reads to its author as though it still works: the
20
+ * note says one thing and the build does another, and nothing says so. So a
21
+ * retired field is **refused**, naming the file and the line, and the message
22
+ * says what to write instead rather than which value to correct.
23
+ *
24
+ * `package:` is retired the same way and is refused from `note-package.mjs`,
25
+ * where the concept it belonged to still lives. `draft:` has no such home —
26
+ * there is no surviving concept it was part of — so it is refused here.
27
+ *
28
+ * **What `draft:` did (#69).** It excluded a note from the compiled packs, from
29
+ * the link manifest and from a consuming site build. Nothing reported the
30
+ * consequence: `content-links.mjs`, `site-index.mjs` and `content-lint.mjs`
31
+ * never read the field, so a link into a drafted note was indistinguishable
32
+ * from a link to a note that does not exist, and the checkers could not say
33
+ * which. The field's entire effect was to move a note from *published* to
34
+ * *unresolvable*, silently — and it also suppressed real build failures, since
35
+ * a note the compilers never reached could not fail on the defects it carried.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import fs from "node:fs";
41
+
42
+ import { positionInFrontmatter } from "./diagnostics.mjs";
43
+
44
+ /**
45
+ * What a note declaring `draft:` is told, in one place.
46
+ *
47
+ * Written once and shared by the compile-time refusal and the frontmatter lint,
48
+ * because an author meets whichever of the two runs first and they should read
49
+ * the same. It says what the field did and what to write instead, rather than
50
+ * which value to correct: no value makes declaring it right.
51
+ *
52
+ * @param {string} [file] - The note's path, named in the message. Omit it where
53
+ * the caller emits through a diagnostic, whose locator already starts the
54
+ * line — repeating it prints the path twice.
55
+ * @returns {string} The message, unpunctuated at the end as a finding is.
56
+ */
57
+ export function draftRetiredMessage(file) {
58
+ return (
59
+ "`draft:` is a retired frontmatter field — delete it" +
60
+ (file ? ` — ${file}` : "") +
61
+ ". It excluded the note from the compiled packs, the link manifest " +
62
+ "and the site, and no checker reported the exclusion, so every " +
63
+ "wikilink into it read as a link to a note that does not exist. To " +
64
+ "mark a note as unfinished, tag it `#draft` instead: the build " +
65
+ "ignores tags, and a `FROM #draft` query still finds it"
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Refuse a note that declares `draft:` at all.
71
+ *
72
+ * Presence is the whole test. `draft: false` is as retired as `draft: true` —
73
+ * it reads as "publish this note", which is what happens either way, and is
74
+ * exactly the belief the message exists to correct.
75
+ *
76
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
77
+ * could not be parsed.
78
+ * @param {object} [options] - Options.
79
+ * @param {string} [options.file] - The note's path, named in the message. Omit
80
+ * it where the caller emits through a diagnostic, which puts the locator at
81
+ * the start of the line already — repeating it prints the path twice.
82
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
83
+ * failing path to locate the offending line and column. The position rides on
84
+ * the thrown error as `position`, for a caller that emits a diagnostic.
85
+ * @returns {void}
86
+ * @throws {Error} When the note declares the field.
87
+ */
88
+ export function assertNoDraftField(fm, { file, absPath } = {}) {
89
+ if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "draft")) return;
90
+
91
+ const err = new Error(`${draftRetiredMessage(file)}.`);
92
+ // Where the field is, so the caller's diagnostic opens on the line that has
93
+ // to be deleted. Read here rather than carried through every walk: this is
94
+ // the failing path, and the build stops on it.
95
+ const position = locateFrontmatterKey(absPath, "draft");
96
+ if (position) err.position = position;
97
+ throw err;
98
+ }
99
+
100
+ /**
101
+ * A frontmatter key's position in a note's file, or nothing.
102
+ *
103
+ * {@link positionInFrontmatter} answers the same question from the file's
104
+ * *text*; this reads the file to ask it. Kept apart from either caller because
105
+ * both refusals need it and a second copy is a second thing to keep correct.
106
+ *
107
+ * @param {string|undefined} absPath - The note's file.
108
+ * @param {string} key - The top-level frontmatter key.
109
+ * @returns {{line?: number, column?: number}|undefined} Spreadable position
110
+ * fields, dropped rather than guessed when the file cannot be read or the key
111
+ * cannot be found — as `formatDiagnostic` requires.
112
+ */
113
+ export function locateFrontmatterKey(absPath, key) {
114
+ if (!absPath) return undefined;
115
+ let raw;
116
+ try {
117
+ raw = fs.readFileSync(absPath, "utf8");
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ const at = positionInFrontmatter(raw, key);
122
+ return at.line === undefined ? undefined : at;
123
+ }
package/engine/scenes.mjs CHANGED
@@ -42,9 +42,8 @@
42
42
  * Not a standalone script — exports the `Scenes` compiler class, imported and
43
43
  * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
44
44
  *
45
- * The walk itself — filtering by package and type, skipping drafts,
46
- * expanding tables, converting wikilinks, writing the JSON and counting
47
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
45
+ * The walk itself — filtering by type, expanding tables, converting
46
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
48
47
  * states only what makes this pass its own (#1509).
49
48
  */
50
49
 
@@ -192,10 +191,11 @@ export class Scenes extends BasePackCompiler {
192
191
  for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
193
192
  this.contentBase,
194
193
  )) {
195
- // No package test: every note in the tree is this package's, and
196
- // this pass's own walk the shared compile loop is where a note
197
- // declaring another one is reported, once (#56). Repeating the
198
- // check here would either double the diagnostic or throw past it.
194
+ // No retired-field test: this pass's own walk the shared compile
195
+ // loopis where a note still declaring `package:` (#56) or
196
+ // `draft:` (#69) is reported, once. Repeating either check here
197
+ // would double the diagnostic or throw past it. A refused note is
198
+ // indexed and then never compiled, so it reaches no document.
199
199
  if (!fm || !fm.id) continue;
200
200
  if (
201
201
  fm.shortcode &&
@@ -216,10 +216,6 @@ export class Scenes extends BasePackCompiler {
216
216
  });
217
217
  }
218
218
  if (!isMapType(fm.type)) continue;
219
- if (fm.draft === true) {
220
- log.debug(`Skipping draft map: ${absPath}`);
221
- continue;
222
- }
223
219
  maps.push({ fm, body, absPath });
224
220
  }
225
221
  return { maps, effectsByAddress };
@@ -62,7 +62,7 @@ import {
62
62
  } from "./foreign-manifests.mjs";
63
63
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
64
64
  import { loadPackConfig } from "./pack-config.mjs";
65
- import { notePackage, searchableFrontmatter } from "./note-package.mjs";
65
+ import { searchableFrontmatter } from "./note-package.mjs";
66
66
 
67
67
  const require = createRequire(import.meta.url);
68
68
 
@@ -132,11 +132,10 @@ export function collectContentPages(contentBase, ctx) {
132
132
  const note = readNote(file);
133
133
  if (!note) continue;
134
134
  const { fm, body } = note;
135
- // Derived rather than read: `package:` is optional, and a note that
136
- // declares nothing belongs to the package this repository compiles
137
- // (#56). Once the field is retired outright this collapses to
138
- // `ctx.contentPackage` and the set membership becomes a formality.
139
- const pkg = notePackage(fm, ctx.contentPackage);
135
+ // The configuration's, never a note's: `package:` is retired, so every
136
+ // note in the tree belongs to the package this repository compiles
137
+ // (#56).
138
+ const pkg = ctx.contentPackage;
140
139
  if (!ctx.packages.has(pkg) || !fm.type) continue;
141
140
 
142
141
  for (const hit of frontmatterWikilinks(fm)) {
@@ -162,9 +161,9 @@ export function collectContentPages(contentBase, ctx) {
162
161
  pages.push({
163
162
  kind: "content",
164
163
  fm,
165
- // The page's package, resolved once here so every consumer — the
164
+ // The page's package, recorded once here so every consumer — the
166
165
  // index's canonical keys, the table universe, the local-package set
167
- // — reads one derived value instead of frontmatter (#56).
166
+ // — reads one configured value and never frontmatter (#56).
168
167
  pkg,
169
168
  body,
170
169
  name,
@@ -347,7 +346,8 @@ export function tableUniverse(pages) {
347
346
  const pkg = p.pkg;
348
347
  if (!byPackage.has(pkg)) byPackage.set(pkg, []);
349
348
  byPackage.get(pkg).push({
350
- // Package present however the note spells it see
349
+ // Package present for a `WHERE package = "…"` clause,
350
+ // synthesised rather than authored — see
351
351
  // {@link searchableFrontmatter} (#56).
352
352
  fm: searchableFrontmatter(p.fm, pkg),
353
353
  path: p.relPath,
@@ -367,10 +367,10 @@ export function tableUniverse(pages) {
367
367
  * redirect stub at each name. They are dropped, and this build emits no
368
368
  * redirects of its own.
369
369
  *
370
- * A content page carries the package the build **derived**, whether or not the
371
- * note declared one (#65). `package:` became optional in 3.3.0, so a swept tree
372
- * declares none and the note's frontmatter alone would then publish a page
373
- * that does not say which package it belongs to. The emitted page is what a
370
+ * A content page carries the package the build **derived** (#65). No note
371
+ * declares one `package:` is retired (#56) so the note's frontmatter alone
372
+ * would publish a page that does not say which package it belongs to. The
373
+ * emitted page is what a
374
374
  * theme reads: `breadcrumbs.html` builds its middle crumb from
375
375
  * `.Params.package`, so without it that crumb degrades from a linked, labelled
376
376
  * section to a bare type slug. Writing the derived value keeps a page
@@ -387,10 +387,8 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
387
387
  if (page.kind === "content") {
388
388
  data = {
389
389
  ...fm,
390
- // Spread after the note's own frontmatter, so a note that declares
391
- // the field keeps its authored position and value and an unswept
392
- // tree emits byte-identically. Guarded because `package: undefined`
393
- // is not a value YAML can carry.
390
+ // Spread after the note's own frontmatter. Guarded because
391
+ // `package: undefined` is not a value YAML can carry.
394
392
  ...(page.pkg ? { package: page.pkg } : {}),
395
393
  slug,
396
394
  title: fm.title ?? name,
@@ -734,7 +732,8 @@ export function buildSite({ config, outRoot } = {}) {
734
732
 
735
733
  const ctx = {
736
734
  packages,
737
- // What a note that declares no `package:` belongs to (#56).
735
+ // The package every note in the tree belongs to. `package:` is
736
+ // retired, so this is the only source of it (#56).
738
737
  contentPackage: resolved.contentPackage,
739
738
  skipDirectories: resolved.skipDirectories,
740
739
  mount,
@@ -53,7 +53,7 @@ import path from "node:path";
53
53
 
54
54
  import { canonicalKey, readCanonicalKey } from "./kb-manifest.mjs";
55
55
  import { hasDocEntry } from "./item-docs.mjs";
56
- import { notePackage } from "./note-package.mjs";
56
+ import { contentPackage } from "./content-package.mjs";
57
57
 
58
58
  /**
59
59
  * One page the site will publish, as the index needs to see it.
@@ -239,11 +239,11 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
239
239
  // stays because a bare `[[skill-lang]]` defaults to the citing
240
240
  // note's own package and must keep resolving unchanged; the
241
241
  // canonical form is what cross-package links use (#1499).
242
- // The page's package is derived — the site collection resolves it
243
- // and records it as `pkg` rather than read out of frontmatter,
244
- // where `package:` is optional and on its way out (#56).
242
+ // The page's package is the configured one — the site collection
243
+ // resolves it and records it as `pkg`. Never read out of
244
+ // frontmatter: `package:` is retired (#56).
245
245
  index.set(
246
- canonicalKey(e.pkg ?? notePackage(e.fm), type, shortcode),
246
+ canonicalKey(e.pkg ?? contentPackage(), type, shortcode),
247
247
  value,
248
248
  );
249
249
  // In Foundry an item and its documentation are two documents, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "3.4.0",
3
+ "version": "4.0.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
package/sohl/actors.mjs CHANGED
@@ -34,9 +34,8 @@
34
34
  * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`). Must run
35
35
  * after the items pass, since it reads the items pack's generated JSON tree.
36
36
  *
37
- * The walk itself — filtering by package and type, skipping drafts,
38
- * expanding tables, converting wikilinks, writing the JSON and counting
39
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
37
+ * The walk itself — filtering by type, expanding tables, converting
38
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
40
39
  * states only what makes this pass its own (#1509).
41
40
  */
42
41
 
package/sohl/items.mjs CHANGED
@@ -29,9 +29,8 @@
29
29
  * Not a standalone script — exports the `Items` compiler class, imported and
30
30
  * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
31
31
  *
32
- * The walk itself — filtering by package and type, skipping drafts,
33
- * expanding tables, converting wikilinks, writing the JSON and counting
34
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
32
+ * The walk itself — filtering by type, expanding tables, converting
33
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
35
34
  * states only what makes this pass its own (#1509).
36
35
  */
37
36
 
@@ -2,18 +2,17 @@
2
2
  * The tallies one pass accumulates while walking the tree.
3
3
  *
4
4
  * `declined` and `skippedOther` are deliberately separate numbers. A declined
5
- * note is one this build **refused** — it names a package this repository does
6
- * not compile — and it is an error; a skipped one legitimately belongs to
7
- * another pass, and there are thousands of those. Folding the first into the
8
- * second is what let a whole tree be filtered out in silence (#56).
5
+ * note is one this build **refused** — it declares a retired frontmatter field
6
+ * — and it is an error; a skipped one legitimately belongs to another pass, and
7
+ * there are thousands of those. Folding the first into the second is what let a
8
+ * whole tree be filtered out in silence (#56).
9
9
  *
10
10
  * @typedef {object} PassStats
11
11
  * @property {number} compiled - Notes that became a document.
12
- * @property {number} skippedDraft - Notes marked `draft: true`.
13
12
  * @property {number} skippedNoId - Notes with no `id`, where that is tolerated.
14
13
  * @property {number} skippedOther - Notes this pass does not claim.
15
- * @property {number} declined - Notes refused because they declare another
16
- * package. Counted as errors, never as skips.
14
+ * @property {number} declined - Notes refused because they declare a retired
15
+ * frontmatter field. Counted as errors, never as skips.
17
16
  */
18
17
  /**
19
18
  * The shared walk → filter → expand → convert → build → write → count loop.
@@ -310,20 +309,16 @@ export class BasePackCompiler {
310
309
  * The tallies one pass accumulates while walking the tree.
311
310
  *
312
311
  * `declined` and `skippedOther` are deliberately separate numbers. A declined
313
- * note is one this build **refused** — it names a package this repository does
314
- * not compile — and it is an error; a skipped one legitimately belongs to
315
- * another pass, and there are thousands of those. Folding the first into the
316
- * second is what let a whole tree be filtered out in silence (#56).
312
+ * note is one this build **refused** — it declares a retired frontmatter field
313
+ * — and it is an error; a skipped one legitimately belongs to another pass, and
314
+ * there are thousands of those. Folding the first into the second is what let a
315
+ * whole tree be filtered out in silence (#56).
317
316
  */
318
317
  export type PassStats = {
319
318
  /**
320
319
  * - Notes that became a document.
321
320
  */
322
321
  compiled: number;
323
- /**
324
- * - Notes marked `draft: true`.
325
- */
326
- skippedDraft: number;
327
322
  /**
328
323
  * - Notes with no `id`, where that is tolerated.
329
324
  */
@@ -333,8 +328,8 @@ export type PassStats = {
333
328
  */
334
329
  skippedOther: number;
335
330
  /**
336
- * - Notes refused because they declare another
337
- * package. Counted as errors, never as skips.
331
+ * - Notes refused because they declare a retired
332
+ * frontmatter field. Counted as errors, never as skips.
338
333
  */
339
334
  declined: number;
340
335
  };
@@ -12,8 +12,9 @@
12
12
  * `package:` frontmatter and the compilers kept the ones that matched. Every
13
13
  * content tree is single-package — each is single-sourced in the repository that
14
14
  * ships it — so the field restated this constant once per note while a value
15
- * that matched nothing filtered the whole tree out in silence. The field is
16
- * being retired; the value stays, here, where it is declared once.
15
+ * that matched nothing filtered the whole tree out in silence. That field is
16
+ * retired and declaring it now fails the build; this value stays, here, where
17
+ * it is declared once.
17
18
  *
18
19
  * Stable across compilation targets. If this content were ever compiled for a
19
20
  * second game system, it would still be published as `sohl` — only the Foundry