@heroiclands/package-build 3.3.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.
@@ -38,9 +38,8 @@
38
38
  * Not a standalone script — exports the `Journals` compiler class, imported
39
39
  * and driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
40
40
  *
41
- * The walk itself — filtering by package and type, skipping drafts,
42
- * expanding tables, converting wikilinks, writing the JSON and counting
43
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
41
+ * The walk itself — filtering by type, expanding tables, converting
42
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
44
43
  * states only what makes this pass its own (#1509).
45
44
  */
46
45
 
package/engine/macros.mjs CHANGED
@@ -44,9 +44,8 @@
44
44
  * Not a standalone script — exports the `Macros` compiler class, imported and
45
45
  * driven by `packages/content-build/engine/generate.mjs` (via `npm run build:compiledb`).
46
46
  *
47
- * The walk itself — filtering by package and type, skipping drafts,
48
- * expanding tables, converting wikilinks, writing the JSON and counting
49
- * errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
47
+ * The walk itself — filtering by type, expanding tables, converting
48
+ * wikilinks, writing the JSON and counting errors — belongs to {@link sohl.utils.packs.BasePackCompiler}; this module
50
49
  * states only what makes this pass its own (#1509).
51
50
  */
52
51
 
@@ -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,6 +367,16 @@ 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** (#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
+ * theme reads: `breadcrumbs.html` builds its middle crumb from
375
+ * `.Params.package`, so without it that crumb degrades from a linked, labelled
376
+ * section to a bare type slug. Writing the derived value keeps a page
377
+ * self-describing and makes sweeping the field out of a content tree
378
+ * output-preserving for a site as it already is for the packs.
379
+ *
370
380
  * @param {object} page - The page.
371
381
  * @param {object} options - `{ sections, readmeSections, decorate }`.
372
382
  * @returns {object} The frontmatter to write.
@@ -375,7 +385,15 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
375
385
  const { fm, name, slug, sec, isReadme } = page;
376
386
  let data;
377
387
  if (page.kind === "content") {
378
- data = { ...fm, slug, title: fm.title ?? name, kbfolder: page.folder };
388
+ data = {
389
+ ...fm,
390
+ // Spread after the note's own frontmatter. Guarded because
391
+ // `package: undefined` is not a value YAML can carry.
392
+ ...(page.pkg ? { package: page.pkg } : {}),
393
+ slug,
394
+ title: fm.title ?? name,
395
+ kbfolder: page.folder,
396
+ };
379
397
  if (decorate) decorate(data, page);
380
398
  if (isReadme) {
381
399
  const meta = readmeSections[sec];
@@ -714,7 +732,8 @@ export function buildSite({ config, outRoot } = {}) {
714
732
 
715
733
  const ctx = {
716
734
  packages,
717
- // 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).
718
737
  contentPackage: resolved.contentPackage,
719
738
  skipDirectories: resolved.skipDirectories,
720
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.3.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
 
@@ -598,14 +598,6 @@ export const ITEM_FIELDS = Object.freeze({
598
598
  describe:
599
599
  "Shortcode of the affiliation whose standing confers the ability — a religion, school, or ancestor/totem/spirit.",
600
600
  },
601
- {
602
- name: "assocMysteryCode",
603
- to: "assocMysteryCode",
604
- ref: "mystery",
605
- ...AS_AUTHORED,
606
- default: "",
607
- describe: "Shortcode of the mystery the ability draws on.",
608
- },
609
601
  {
610
602
  name: "masteryLevelBase",
611
603
  to: "masteryLevelBase",
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