@heroiclands/package-build 3.2.0 → 3.4.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,6 +54,7 @@ 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
58
  import { journalPageId, splitPages } from "./journals.mjs";
58
59
  import { routerFor } from "./pack-router.mjs";
59
60
  import { loadPackConfig } from "./pack-config.mjs";
@@ -194,9 +195,12 @@ export function entriesForNote(fm, name, address, body, ctx) {
194
195
  *
195
196
  * Drafts are excluded because the site does not publish them, and an entry for
196
197
  * an unpublished page is exactly the dead link the manifest exists to prevent.
197
- * A note belonging to another content package is skipped for the same reason in
198
- * reverse: this build is not authoritative for it, and its own build says where
199
- * it lives.
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.
200
204
  *
201
205
  * A note that has no address is **reported, not guessed** — the finding carries
202
206
  * the file and the reason, so a caller can print it or fail on it. Inventing an
@@ -220,11 +224,12 @@ export function collectManifestEntries(contentBase, ctx) {
220
224
  contentBase,
221
225
  { skipDirectories: ctx.skipDirectories },
222
226
  )) {
223
- if (!fm || fm.package !== ctx.contentPackage) continue;
227
+ if (!fm) continue;
228
+ const rel = path.relative(contentBase, absPath);
229
+ assertNotePackage(fm, { file: rel, configured: ctx.contentPackage });
224
230
  if (fm.draft === true) continue;
225
231
  if (!fm.type || !fm.shortcode) continue;
226
232
 
227
- const rel = path.relative(contentBase, absPath);
228
233
  const base = path.basename(absPath);
229
234
  const name = fm.name?.full ?? path.basename(absPath, ".md");
230
235
 
@@ -0,0 +1,126 @@
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
+ * Which content package a note belongs to.
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.
21
+ *
22
+ * It used to be a property of the note, and a **selector**: the compile loop
23
+ * read `package:` out of frontmatter and skipped anything that did not match
24
+ * the configured value. That is the defect this module exists to remove
25
+ * (#56). The skip was silent and it was bucketed as "belongs to another pass",
26
+ * so a tree whose notes named a package no configuration answered to compiled
27
+ * **zero notes and exited 0** — which is exactly the state the un-migrated
28
+ * `hm-loc-*` / `hm-adv-*` repositories are in today.
29
+ *
30
+ * So the field is being retired, in three steps, of which this is the first:
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.
37
+ *
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.
45
+ *
46
+ * @module
47
+ */
48
+
49
+ import { contentPackage } from "./content-package.mjs";
50
+
51
+ /**
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.
78
+ *
79
+ * A `dataview` query resolves `package` out of frontmatter like any other
80
+ * 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.
86
+ *
87
+ * The declared value is left alone when there is one, so nothing about an
88
+ * unswept tree changes.
89
+ *
90
+ * @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.
94
+ */
95
+ export function searchableFrontmatter(fm, configured) {
96
+ if (!fm || typeof fm !== "object") return fm;
97
+ if (fm.package != null && fm.package !== "") return fm;
98
+ return { ...fm, package: notePackage(fm, configured) };
99
+ }
100
+
101
+ /**
102
+ * The package a note belongs to, refusing one that names another package.
103
+ *
104
+ * @param {object|null|undefined} fm - Parsed frontmatter.
105
+ * @param {object} [options] - Options.
106
+ * @param {string} [options.file] - The note's path, named in the message. Omit
107
+ * it where the caller emits through a diagnostic, which puts the locator at
108
+ * the start of the line already — repeating it prints the path twice.
109
+ * @param {string} [options.configured] - The package this build compiles.
110
+ * Defaults to the configured `contentPackage`.
111
+ * @returns {string} The package, which is always `configured`.
112
+ * @throws {Error} When the note declares a different package.
113
+ */
114
+ export function assertNotePackage(fm, { file, configured } = {}) {
115
+ 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}"` +
121
+ (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.`,
125
+ );
126
+ }
@@ -69,8 +69,9 @@ export class PackRoutingError extends Error {
69
69
  /**
70
70
  * The frontmatter field a note declares its pack in.
71
71
  *
72
- * Deliberately close to `package:` and deliberately not the same word: a note's
73
- * `package:` says which *distribution* owns it, `pack:` which *compendium*
72
+ * Deliberately close to the retiring `package:` and deliberately not the same
73
+ * word: `package:` said which *distribution* owned a note now the
74
+ * repository's `contentPackage` (#56) — while `pack:` says which *compendium*
74
75
  * receives its document.
75
76
  */
76
77
  export const PACK_FIELD = "pack";
package/engine/scenes.mjs CHANGED
@@ -63,7 +63,7 @@ import { BasePackCompiler } from "./base-compiler.mjs";
63
63
  import { buildJournalEntry, splitPages, journalPageId } from "./journals.mjs";
64
64
  import { compendiumUuid, makeId, packForType } from "./ids.mjs";
65
65
  import { packRouter } from "./pack-router.mjs";
66
- import { contentPackage, foundryPackageId } from "./content-package.mjs";
66
+ import { foundryPackageId } from "./content-package.mjs";
67
67
  import { itemDocEntryId } from "./item-docs.mjs";
68
68
  import {
69
69
  behaviorDocId,
@@ -192,7 +192,11 @@ export class Scenes extends BasePackCompiler {
192
192
  for (const { frontmatter: fm, body, absPath } of walkMarkdownTree(
193
193
  this.contentBase,
194
194
  )) {
195
- if (!fm || fm.package !== contentPackage() || !fm.id) continue;
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.
199
+ if (!fm || !fm.id) continue;
196
200
  if (
197
201
  fm.shortcode &&
198
202
  Array.isArray(fm.effects) &&
@@ -62,6 +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
66
 
66
67
  const require = createRequire(import.meta.url);
67
68
 
@@ -117,7 +118,9 @@ function readNote(file) {
117
118
  * The content tree's pages, and what could not be addressed.
118
119
  *
119
120
  * @param {string} contentBase - Absolute path to the content tree.
120
- * @param {object} ctx - `{ packages, skipDirectories, mount, scheme }`.
121
+ * @param {object} ctx - `{ packages, contentPackage, skipDirectories, mount,
122
+ * scheme }`. `contentPackage` is the package a note that declares none
123
+ * belongs to.
121
124
  * @returns {{pages: object[], slugFindings: object[], fmLinkFindings: object[]}}
122
125
  */
123
126
  export function collectContentPages(contentBase, ctx) {
@@ -129,7 +132,12 @@ export function collectContentPages(contentBase, ctx) {
129
132
  const note = readNote(file);
130
133
  if (!note) continue;
131
134
  const { fm, body } = note;
132
- if (!ctx.packages.has(fm.package) || !fm.type) continue;
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);
140
+ if (!ctx.packages.has(pkg) || !fm.type) continue;
133
141
 
134
142
  for (const hit of frontmatterWikilinks(fm)) {
135
143
  fmLinkFindings.push({ file, ...hit });
@@ -154,6 +162,10 @@ export function collectContentPages(contentBase, ctx) {
154
162
  pages.push({
155
163
  kind: "content",
156
164
  fm,
165
+ // The page's package, resolved once here so every consumer — the
166
+ // index's canonical keys, the table universe, the local-package set
167
+ // — reads one derived value instead of frontmatter (#56).
168
+ pkg,
157
169
  body,
158
170
  name,
159
171
  slug,
@@ -287,7 +299,7 @@ export function siteGates(pages, findings, { manifestDir }) {
287
299
  // and that is only known once the tree is walked — reading it from a
288
300
  // configured list instead silently discarded the manifest of any package
289
301
  // the list named but the tree did not contain.
290
- const localPackages = new Set(content.map((p) => p.fm.package));
302
+ const localPackages = new Set(content.map((p) => p.pkg));
291
303
  const foreign = loadForeignManifests(manifestDir, localPackages);
292
304
  out.foreign = foreign;
293
305
  if (foreign.stale.length) {
@@ -332,10 +344,12 @@ export function tableUniverse(pages) {
332
344
  const byPackage = new Map();
333
345
  for (const p of pages) {
334
346
  if (p.kind !== "content") continue;
335
- const pkg = p.fm.package;
347
+ const pkg = p.pkg;
336
348
  if (!byPackage.has(pkg)) byPackage.set(pkg, []);
337
349
  byPackage.get(pkg).push({
338
- fm: p.fm,
350
+ // Package present however the note spells it — see
351
+ // {@link searchableFrontmatter} (#56).
352
+ fm: searchableFrontmatter(p.fm, pkg),
339
353
  path: p.relPath,
340
354
  tld: p.tld,
341
355
  folder: p.folder,
@@ -353,6 +367,16 @@ export function tableUniverse(pages) {
353
367
  * redirect stub at each name. They are dropped, and this build emits no
354
368
  * redirects of its own.
355
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
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
+ *
356
380
  * @param {object} page - The page.
357
381
  * @param {object} options - `{ sections, readmeSections, decorate }`.
358
382
  * @returns {object} The frontmatter to write.
@@ -361,7 +385,17 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
361
385
  const { fm, name, slug, sec, isReadme } = page;
362
386
  let data;
363
387
  if (page.kind === "content") {
364
- data = { ...fm, slug, title: fm.title ?? name, kbfolder: page.folder };
388
+ data = {
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.
394
+ ...(page.pkg ? { package: page.pkg } : {}),
395
+ slug,
396
+ title: fm.title ?? name,
397
+ kbfolder: page.folder,
398
+ };
365
399
  if (decorate) decorate(data, page);
366
400
  if (isReadme) {
367
401
  const meta = readmeSections[sec];
@@ -457,10 +491,13 @@ export function renderPages(pages, options) {
457
491
  let body = page.body;
458
492
  if (page.kind === "content") {
459
493
  const { markdown, errors } = expandContentTables(body, {
460
- docs: universe.get(page.fm.package) ?? [],
494
+ docs: universe.get(page.pkg) ?? [],
461
495
  linkable,
462
496
  source: src,
463
- self: { fm: page.fm, path: page.relPath },
497
+ self: {
498
+ fm: searchableFrontmatter(page.fm, page.pkg),
499
+ path: page.relPath,
500
+ },
464
501
  });
465
502
  tableErrors.push(...errors);
466
503
  body = markdown;
@@ -697,6 +734,8 @@ export function buildSite({ config, outRoot } = {}) {
697
734
 
698
735
  const ctx = {
699
736
  packages,
737
+ // What a note that declares no `package:` belongs to (#56).
738
+ contentPackage: resolved.contentPackage,
700
739
  skipDirectories: resolved.skipDirectories,
701
740
  mount,
702
741
  scheme,
@@ -53,6 +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
57
 
57
58
  /**
58
59
  * One page the site will publish, as the index needs to see it.
@@ -238,7 +239,13 @@ export function buildSiteIndex(entries, { foreignIndex = new Map() } = {}) {
238
239
  // stays because a bare `[[skill-lang]]` defaults to the citing
239
240
  // note's own package and must keep resolving unchanged; the
240
241
  // canonical form is what cross-package links use (#1499).
241
- index.set(canonicalKey(e.fm.package, type, shortcode), value);
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).
245
+ index.set(
246
+ canonicalKey(e.pkg ?? notePackage(e.fm), type, shortcode),
247
+ value,
248
+ );
242
249
  // In Foundry an item and its documentation are two documents, so
243
250
  // `skill/wpnc` and `docskill/wpnc` are two UUIDs (#1362). Here the
244
251
  // item note renders as one page which *is* its documentation, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "3.2.0",
3
+ "version": "3.4.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
@@ -55,6 +55,7 @@ import {
55
55
  md,
56
56
  } from "../engine/helpers.mjs";
57
57
  import { emitDiagnostic } from "../engine/diagnostics.mjs";
58
+ import { openingMasteryLevel } from "./skill-base.mjs";
58
59
  import { BasePackCompiler } from "../engine/base-compiler.mjs";
59
60
  import { contentPackage } from "../engine/content-package.mjs";
60
61
 
@@ -498,9 +499,63 @@ export class Actors extends BasePackCompiler {
498
499
  });
499
500
  }
500
501
 
502
+ this.openUnopenedSkills(items, ctx);
501
503
  return items;
502
504
  }
503
505
 
506
+ /**
507
+ * Bake each unopened skill's opening mastery level into the document (#46).
508
+ *
509
+ * A skill whose `masteryLevelBase` is still null once the note's frontmatter
510
+ * has been merged onto the catalogue entry is *not yet opened*, and the
511
+ * client fills it in on import — `Skill Base × initSkillMult`, in
512
+ * `SkillLogic.initialize`. Computing it here instead leaves the compiled
513
+ * pack self-describing: what a being's skills open at is visible in the
514
+ * document, reviewable in a diff, and testable without standing up Foundry.
515
+ *
516
+ * This runs last because the Skill Base formula reads the actor's
517
+ * attributes, so every attribute item has to exist first. It only ever
518
+ * fills nulls — a skill that states a `masteryLevelBase`, whether from the
519
+ * catalogue or the note, keeps it untouched.
520
+ *
521
+ * **The scores used are the ones just written.** `SkillLogic` resolves
522
+ * `attr.<code>` to an attribute's *effective* score, after active effects;
523
+ * all this pass has is the `scoreBase` it set from `sohl.attributes`. For a
524
+ * compiled being carrying no attribute-altering effects the two agree,
525
+ * which is every being in content today. One that did carry such an effect
526
+ * would bake a Skill Base its client then disagrees with — that is the
527
+ * limit of doing this at build time, and the point to revisit if it bites.
528
+ *
529
+ * @param {object[]} items - The actor's embedded items, attributes included.
530
+ * @param {string} ctx - Diagnostic context (the actor's label).
531
+ */
532
+ openUnopenedSkills(items, ctx) {
533
+ const skills = items.filter(
534
+ (item) =>
535
+ item.type === "skill" &&
536
+ item.system &&
537
+ item.system.masteryLevelBase == null,
538
+ );
539
+ if (!skills.length) return;
540
+
541
+ const attrs = {};
542
+ for (const item of items) {
543
+ if (item.type !== "attribute") continue;
544
+ const code = item.system?.shortcode?.toLowerCase();
545
+ if (code) attrs[code] = Number(item.system.scoreBase) || 0;
546
+ }
547
+
548
+ for (const skill of skills) {
549
+ const { value, error } = openingMasteryLevel(skill.system, attrs);
550
+ if (error) {
551
+ this.noteError(`${ctx}: skill "${skill.name}": ${error}`);
552
+ this.errorCount++;
553
+ continue;
554
+ }
555
+ if (value !== null) skill.system.masteryLevelBase = value;
556
+ }
557
+ }
558
+
504
559
  buildBeing(itemsMap, fm, body) {
505
560
  const name = resolveName(fm);
506
561
  const id = fm.id;
@@ -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",