@heroiclands/package-build 3.4.0 → 5.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/CONTENT.md +100 -22
  3. package/MIGRATING.md +120 -0
  4. package/bin/content-build.mjs +6 -2
  5. package/content-config.mjs +109 -6
  6. package/engine/base-compiler.mjs +33 -36
  7. package/engine/content-links.mjs +15 -13
  8. package/engine/content-package.mjs +3 -2
  9. package/engine/field-reference.mjs +2 -2
  10. package/engine/frontmatter-lint.mjs +26 -0
  11. package/engine/helpers.mjs +10 -12
  12. package/engine/homepage.mjs +150 -0
  13. package/engine/index.mjs +10 -1
  14. package/engine/journals.mjs +2 -3
  15. package/engine/macros.mjs +2 -3
  16. package/engine/manifest-emit.mjs +17 -12
  17. package/engine/note-package.mjs +75 -68
  18. package/engine/note-schemas.mjs +44 -0
  19. package/engine/pack-router.mjs +3 -3
  20. package/engine/retired-fields.mjs +123 -0
  21. package/engine/scenes.mjs +7 -11
  22. package/engine/site-build.mjs +151 -22
  23. package/engine/site-index.mjs +5 -5
  24. package/package.json +1 -1
  25. package/sohl/actors.mjs +2 -3
  26. package/sohl/items.mjs +2 -3
  27. package/sohl/note-schemas.mjs +8 -0
  28. package/types/content-config.d.mts +105 -4
  29. package/types/engine/base-compiler.d.mts +12 -17
  30. package/types/engine/content-package.d.mts +3 -2
  31. package/types/engine/helpers.d.mts +5 -8
  32. package/types/engine/homepage.d.mts +118 -0
  33. package/types/engine/index.d.mts +3 -0
  34. package/types/engine/manifest-emit.d.mts +7 -8
  35. package/types/engine/note-package.d.mts +29 -34
  36. package/types/engine/note-schemas.d.mts +6 -0
  37. package/types/engine/pack-router.d.mts +3 -3
  38. package/types/engine/retired-fields.d.mts +54 -0
  39. package/types/engine/site-build.d.mts +52 -4
  40. package/types/sohl/note-schemas.d.mts +6 -0
@@ -33,7 +33,7 @@
33
33
  * assets:
34
34
  * - { from: assets/icons, to: assets/icons }
35
35
  * publish:
36
- * site: true
36
+ * site: content
37
37
  * manifests: { publish: true, consume: true }
38
38
  * ```
39
39
  *
@@ -153,6 +153,62 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
153
153
  landing: "readme",
154
154
  });
155
155
 
156
+ /**
157
+ * How much of a package reaches the web.
158
+ *
159
+ * Every HeroicLands package publishes something: a top-level, human-authored
160
+ * homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
161
+ * module is, which system it needs and how to install it (#50). So there is no
162
+ * value here meaning *no web presence at all* — homepage-only is the **floor**,
163
+ * and the default.
164
+ *
165
+ * - `homepage` — the authored homepage, and **no other page**. The content tree
166
+ * is not walked for pages, `site.sections` / `site.trees` / `site.landing`
167
+ * emit nothing, and link-manifest entries carry no web `path`.
168
+ * - `content` — the homepage *plus* every page the content tree publishes: the
169
+ * knowledgebase, the extra trees, the section landings.
170
+ *
171
+ * **Homepage-only is a first-class mode, not an accommodation.**
172
+ * `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
173
+ * Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's
174
+ * terms) must each publish a homepage and nothing beneath it — two packages
175
+ * under two different fan-content licences. The boundary is **published
176
+ * content**: journal text, artwork, item descriptions, compiled notes. A
177
+ * human-authored page announcing the module discloses none of it. Because the
178
+ * failure mode is silent — a `site:` block added later ships licensed content
179
+ * with nobody noticing — the mode fences the content surfaces off rather than
180
+ * trusting a configuration to stay empty.
181
+ *
182
+ * This was a boolean until 5.0.0, and `false` read as "no web presence", which
183
+ * no longer describes any package. Both spellings are refused rather than
184
+ * mapped: a value silently reinterpreted reads to its author as though it still
185
+ * means what it said.
186
+ *
187
+ * @typedef {"homepage" | "content"} SiteMode
188
+ */
189
+
190
+ /**
191
+ * The publishing modes {@link PublishSwitches.site} may name, floor first.
192
+ *
193
+ * @satisfies {readonly SiteMode[]}
194
+ */
195
+ export const SITE_MODES = /** @type {const} */ (["homepage", "content"]);
196
+
197
+ /**
198
+ * Whether this package publishes the pages its content tree compiles to.
199
+ *
200
+ * The one question every reader of the mode actually asks — the site build, to
201
+ * decide whether to walk the tree at all, and the link-manifest emitter, to
202
+ * decide whether an entry carries a web `path`. Written once here so the two
203
+ * cannot come to disagree about what a mode means.
204
+ *
205
+ * @param {{publish: {site: SiteMode}}} config - A resolved configuration.
206
+ * @returns {boolean} Whether content pages are published.
207
+ */
208
+ export function publishesContentPages(config) {
209
+ return config.publish.site === "content";
210
+ }
211
+
156
212
  /**
157
213
  * @typedef {"systems" | "modules"} PackageKind
158
214
  */
@@ -300,7 +356,8 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
300
356
 
301
357
  /**
302
358
  * @typedef {object} PublishSwitches
303
- * @property {boolean} site Render this package's knowledgebase/site pages.
359
+ * @property {SiteMode} site How much of this package reaches the web.
360
+ * See {@link SITE_MODES}.
304
361
  * @property {ManifestSwitches} manifests
305
362
  */
306
363
 
@@ -387,8 +444,15 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
387
444
 
388
445
  /**
389
446
  * @typedef {object} PublishSwitchesInput
390
- * @property {boolean} [site]
447
+ * @property {SiteMode} [site]
391
448
  * @property {ManifestSwitchesInput} [manifests]
449
+ * @property {AddressSchemeInput} [address]
450
+ */
451
+
452
+ /**
453
+ * @typedef {object} AddressSchemeInput
454
+ * @property {string} [prefix] Where the content tree mounts inside the package.
455
+ * @property {string} [landing] Which note addresses a whole section.
392
456
  */
393
457
 
394
458
  /**
@@ -454,7 +518,9 @@ export const DEFAULT_ADDRESS_SCHEME = Object.freeze({
454
518
  * which has none to invent.
455
519
  * @property {Relationships} [relationships] What this package declares about
456
520
  * others, in Foundry's own shape.
457
- * @property {PublishSwitchesInput} [publish] Publishing switches. Each defaults to off.
521
+ * @property {PublishSwitchesInput} [publish] Publishing switches. The manifest
522
+ * switches default to off; `site`
523
+ * defaults to `homepage`, the floor.
458
524
  */
459
525
 
460
526
  /**
@@ -1272,6 +1338,43 @@ function normalizeItemBuilders(value) {
1272
1338
  };
1273
1339
  }
1274
1340
 
1341
+ /**
1342
+ * The publishing mode, refusing the boolean this setting used to be.
1343
+ *
1344
+ * A boolean is refused rather than mapped onto the nearest mode, because the
1345
+ * reading `false` invited — *this package has no web presence* — is exactly the
1346
+ * belief the change exists to correct, and a value quietly reinterpreted reads
1347
+ * to its author as though it still means what it said. So the message names the
1348
+ * mode to write instead of the value to fix.
1349
+ *
1350
+ * @param {unknown} value - The authored `publish.site`.
1351
+ * @returns {SiteMode} The mode.
1352
+ */
1353
+ function normalizeSiteMode(value) {
1354
+ if (value === undefined) return "homepage";
1355
+ if (typeof value === "boolean") {
1356
+ fail(
1357
+ "publish.site",
1358
+ `is no longer a boolean — write \`site: ${value ? "content" : "homepage"}\`. ` +
1359
+ `Every package publishes an authored homepage at ` +
1360
+ `/<contentPackage>/, so no value means "no web presence": ` +
1361
+ `\`homepage\` publishes that page and nothing else, and ` +
1362
+ `\`content\` publishes it plus every page the content tree ` +
1363
+ `compiles to`,
1364
+ );
1365
+ }
1366
+ if (
1367
+ typeof value !== "string" ||
1368
+ !(/** @type {readonly string[]} */ (SITE_MODES).includes(value))
1369
+ ) {
1370
+ fail(
1371
+ "publish.site",
1372
+ `must be one of ${SITE_MODES.join(", ")} (got ${JSON.stringify(value)})`,
1373
+ );
1374
+ }
1375
+ return /** @type {SiteMode} */ (value);
1376
+ }
1377
+
1275
1378
  /**
1276
1379
  * @param {unknown} value
1277
1380
  * @returns {Readonly<PublishSwitches>}
@@ -1279,7 +1382,7 @@ function normalizeItemBuilders(value) {
1279
1382
  function normalizePublish(value) {
1280
1383
  if (value === undefined) {
1281
1384
  return Object.freeze({
1282
- site: false,
1385
+ site: "homepage",
1283
1386
  manifests: Object.freeze({ publish: false, consume: false }),
1284
1387
  address: Object.freeze({ ...DEFAULT_ADDRESS_SCHEME }),
1285
1388
  });
@@ -1332,7 +1435,7 @@ function normalizePublish(value) {
1332
1435
  }
1333
1436
 
1334
1437
  return Object.freeze({
1335
- site: optionalBoolean(publish.site, "publish.site", false),
1438
+ site: normalizeSiteMode(publish.site),
1336
1439
  address: Object.freeze({ prefix, landing }),
1337
1440
  manifests: Object.freeze({
1338
1441
  publish: optionalBoolean(
@@ -14,9 +14,9 @@
14
14
  /**
15
15
  * `BasePackCompiler` — the one compile loop every pack pass runs.
16
16
  *
17
- * Walking the content tree, rejecting what this build does not own, skipping
18
- * drafts, expanding generated tables, converting wikilinks, writing the JSON
19
- * and counting what failed are the same in every pass. They were written out
17
+ * Walking the content tree, rejecting what this build does not own, expanding
18
+ * generated tables, converting wikilinks, writing the JSON and counting what
19
+ * failed are the same in every pass. They were written out
20
20
  * once per pass — three times when this was filed, five by the time it landed —
21
21
  * so a fix to any of them had to be made everywhere, and the passes drifted
22
22
  * apart in exactly the places nobody was comparing (#1509).
@@ -73,26 +73,25 @@ import {
73
73
  expandNoteTables,
74
74
  } from "./helpers.mjs";
75
75
  import { emitDiagnostic } from "./diagnostics.mjs";
76
- import { contentPackage } from "./content-package.mjs";
77
- import { assertNotePackage } from "./note-package.mjs";
76
+ import { assertNoDeclaredPackage } from "./note-package.mjs";
77
+ import { assertNoDraftField } from "./retired-fields.mjs";
78
78
  import { assertTypeNotRetired, packForType } from "./ids.mjs";
79
79
 
80
80
  /**
81
81
  * The tallies one pass accumulates while walking the tree.
82
82
  *
83
83
  * `declined` and `skippedOther` are deliberately separate numbers. A declined
84
- * note is one this build **refused** — it names a package this repository does
85
- * not compile — and it is an error; a skipped one legitimately belongs to
86
- * another pass, and there are thousands of those. Folding the first into the
87
- * second is what let a whole tree be filtered out in silence (#56).
84
+ * note is one this build **refused** — it declares a retired frontmatter field
85
+ * — and it is an error; a skipped one legitimately belongs to another pass, and
86
+ * there are thousands of those. Folding the first into the second is what let a
87
+ * whole tree be filtered out in silence (#56).
88
88
  *
89
89
  * @typedef {object} PassStats
90
90
  * @property {number} compiled - Notes that became a document.
91
- * @property {number} skippedDraft - Notes marked `draft: true`.
92
91
  * @property {number} skippedNoId - Notes with no `id`, where that is tolerated.
93
92
  * @property {number} skippedOther - Notes this pass does not claim.
94
- * @property {number} declined - Notes refused because they declare another
95
- * package. Counted as errors, never as skips.
93
+ * @property {number} declined - Notes refused because they declare a retired
94
+ * frontmatter field. Counted as errors, never as skips.
96
95
  */
97
96
 
98
97
  /**
@@ -336,9 +335,6 @@ export class BasePackCompiler {
336
335
  const { markdown: tabulated, lineMap } = expandNoteTables(body, {
337
336
  docs: this.contentDocs,
338
337
  name,
339
- // The repository's package, not the note's: every note in the tree
340
- // is this package's note, whether or not it says so (#56).
341
- pkg: contentPackage(),
342
338
  fm,
343
339
  bodyLine,
344
340
  });
@@ -512,15 +508,13 @@ export class BasePackCompiler {
512
508
  if (stats.skippedNoId) {
513
509
  log.info(`Skipped ${stats.skippedNoId} note(s) missing id`);
514
510
  }
515
- if (stats.skippedDraft) {
516
- log.info(`Skipped ${stats.skippedDraft} draft(s)`);
517
- }
518
511
  if (stats.declined) {
519
512
  // Its own line, at error level: these are not skips, and burying
520
513
  // them in the skipped tally is the defect (#56). Each one has
521
514
  // already been named individually as a diagnostic.
522
515
  log.error(
523
- `Declined ${stats.declined} note(s) declaring another package`,
516
+ `Declined ${stats.declined} note(s) declaring a retired ` +
517
+ `frontmatter field`,
524
518
  );
525
519
  }
526
520
  this.reportDetail(stats);
@@ -535,7 +529,6 @@ export class BasePackCompiler {
535
529
  /** @type {PassStats} */
536
530
  const stats = {
537
531
  compiled: 0,
538
- skippedDraft: 0,
539
532
  skippedNoId: 0,
540
533
  skippedOther: 0,
541
534
  declined: 0,
@@ -560,18 +553,27 @@ export class BasePackCompiler {
560
553
  stats.skippedOther++;
561
554
  continue;
562
555
  }
563
- // The package a note belongs to is this repository's configured
564
- // one; `package:` is optional and merely has to agree (#56). A
565
- // disagreement is reported and counted as an error — never skipped,
566
- // which is how a tree naming a package nothing answers to used to
567
- // compile zero notes and exit 0. The file comes from the diagnostic
568
- // locator, so the message must not repeat it.
556
+ // The retired frontmatter fields, refused before `selects` so a
557
+ // note is answered whichever pass would have claimed it — and
558
+ // whatever the declared value says.
559
+ //
560
+ // - `package:` (#56): a note's package is the repository's
561
+ // configured one, so declaring it restates a constant.
562
+ // - `draft:` (#69): it excluded the note from the packs, the
563
+ // manifest and the site, and no checker reported the links that
564
+ // left dangling.
565
+ //
566
+ // Both are reported and counted — never skipped, which is how a
567
+ // tree naming a package nothing answers to used to compile zero
568
+ // notes and exit 0. The file comes from the diagnostic locator, so
569
+ // neither message may repeat it.
569
570
  try {
570
- assertNotePackage(fm);
571
+ assertNoDeclaredPackage(fm, { absPath });
572
+ assertNoDraftField(fm, { absPath });
571
573
  } catch (err) {
572
574
  stats.declined++;
573
575
  this.errorCount++;
574
- this.noteError(err.message);
576
+ this.noteError(err.message, err.position);
575
577
  continue;
576
578
  }
577
579
  // Checked before `selects`, and therefore for every note this
@@ -584,11 +586,6 @@ export class BasePackCompiler {
584
586
  stats.skippedOther++;
585
587
  continue;
586
588
  }
587
- if (fm.draft === true) {
588
- stats.skippedDraft++;
589
- log.debug(`Skipping draft: ${absPath}`);
590
- continue;
591
- }
592
589
  if (!fm.id) {
593
590
  if (this.constructor.requiresId) {
594
591
  throw new Error(`${Label} missing id: ${absPath}`);
@@ -597,9 +594,9 @@ export class BasePackCompiler {
597
594
  this.noteWarn(`${label} note has no id, skipping`);
598
595
  continue;
599
596
  }
600
- // Which pack of this type takes it. Applied after the draft and
601
- // id checks a draft is not compiled anywhere, so its declaration
602
- // is nobody's business — and before `skipNote`, so a note this pack
597
+ // Which pack of this type takes it. Applied after the id check —
598
+ // a note with no id is nobody's document, so its routing is
599
+ // nobody's business — and before `skipNote`, so a note this pack
603
600
  // does not own never reaches this pass's own rejection rules.
604
601
  try {
605
602
  if (!this.routesHere(fm)) {
@@ -52,7 +52,8 @@ import { matchAllOutsideCode } from "./code-fences.mjs";
52
52
  import { expandContentTables } from "./content-tables.mjs";
53
53
  import { walkMarkdownTree } from "./helpers.mjs";
54
54
  import { hasDocEntry } from "./item-docs.mjs";
55
- import { notePackage, searchableFrontmatter } from "./note-package.mjs";
55
+ import { contentPackage } from "./content-package.mjs";
56
+ import { searchableFrontmatter } from "./note-package.mjs";
56
57
  import {
57
58
  canonicalKey,
58
59
  loadForeignManifests,
@@ -130,12 +131,13 @@ export function buildLinkIndex(
130
131
  const byAlias = new Map();
131
132
  const aliasCollide = new Set();
132
133
 
134
+ // The one package every note in this tree belongs to. Taken from the
135
+ // configuration, never from a note: `package:` is retired, so there is no
136
+ // second source an address could disagree with (#56).
137
+ const pkg = contentPackage();
138
+
133
139
  for (const note of notes) {
134
140
  const { fm, type } = note;
135
- // Derived, never read out of frontmatter: `package:` is optional, and a
136
- // note that declares nothing addresses exactly as one that declares the
137
- // configured package (#56).
138
- const pkg = notePackage(fm);
139
141
  if (typeof fm.shortcode === "string" && fm.shortcode) {
140
142
  byKey.set(`${type}/${fm.shortcode}`.toLowerCase(), note);
141
143
  // The canonical, fully qualified address alongside the short one,
@@ -170,7 +172,7 @@ export function buildLinkIndex(
170
172
  // A foreign package may use a type this tree has never seen, so its types
171
173
  // join `types` — otherwise `readQualifier` reads the link as prose and it
172
174
  // is never checked at all.
173
- const localPackages = new Set(notes.map((n) => notePackage(n.fm)));
175
+ const localPackages = new Set([pkg]);
174
176
  const foreign =
175
177
  manifestDir ?
176
178
  loadForeignManifests(manifestDir, localPackages)
@@ -178,15 +180,15 @@ export function buildLinkIndex(
178
180
  for (const v of foreign.index.values()) if (v.type) types.add(v.type);
179
181
 
180
182
  const packages = new Set([
181
- ...[...byKey.values()].map((n) => notePackage(n.fm)),
183
+ ...(byKey.size ? [pkg] : []),
182
184
  ...foreign.packages,
183
185
  ]);
184
186
 
185
187
  /** The searchable universe a `dataview` table draws its rows from. */
186
188
  const tableDocs = notes.map((n) => ({
187
- // Package present however the note spells it see
188
- // {@link searchableFrontmatter} (#56).
189
- fm: searchableFrontmatter(n.fm),
189
+ // Package present for a `WHERE package = "…"` clause, synthesised
190
+ // rather than authored — see {@link searchableFrontmatter} (#56).
191
+ fm: searchableFrontmatter(n.fm, pkg),
190
192
  path: n.rel,
191
193
  tld: n.rel.split("/")[0],
192
194
  folder: path.dirname(n.rel).split("/").pop(),
@@ -205,9 +207,9 @@ export function buildLinkIndex(
205
207
  let body = note.body;
206
208
  if (/^[ \t]*(?:`{3,}|~{3,})[ \t]*dataview\b/im.test(body)) {
207
209
  body = expandContentTables(body, {
208
- docs: tableDocs.filter(
209
- (d) => notePackage(d.fm) === notePackage(note.fm),
210
- ),
210
+ // Unfiltered: every note in the tree is this package's, so
211
+ // there is no other package's note to exclude (#56).
212
+ docs: tableDocs,
211
213
  linkable: (d) => Boolean(d.fm.shortcode),
212
214
  source: note.file,
213
215
  }).markdown;
@@ -38,8 +38,9 @@ import { loadPackConfig } from "./pack-config.mjs";
38
38
  * `package:` frontmatter and the compilers kept the ones that matched. Every
39
39
  * content tree is single-package — each is single-sourced in the repository that
40
40
  * ships it — so the field restated this constant once per note while a value
41
- * that matched nothing filtered the whole tree out in silence. The field is
42
- * being retired; the value stays, here, where it is declared once.
41
+ * that matched nothing filtered the whole tree out in silence. That field is
42
+ * retired and declaring it now fails the build; this value stays, here, where
43
+ * it is declared once.
43
44
  *
44
45
  * Stable across compilation targets. If this content were ever compiled for a
45
46
  * second game system, it would still be published as `sohl` — only the Foundry
@@ -147,8 +147,8 @@ function workedExample(type, fields) {
147
147
  `type: ${type}`,
148
148
  "shortcode: xmpl",
149
149
  // No `package:`. A note's package is the repository's configured
150
- // `contentPackage`, so the field is redundant and is being retired
151
- // (#56) — and this example is the smallest note that compiles.
150
+ // `contentPackage`, and declaring the field is a build error (#56)
151
+ // this example is the smallest note that compiles.
152
152
  "id: <16-character id>",
153
153
  "sohl:",
154
154
  " archetype: null",
@@ -51,6 +51,7 @@
51
51
  import { authoredFields } from "./field-spec.mjs";
52
52
  import { positionInFrontmatter } from "./diagnostics.mjs";
53
53
  import { RETIRED_TYPES } from "./ids.mjs";
54
+ import { draftRetiredMessage } from "./retired-fields.mjs";
54
55
 
55
56
  /**
56
57
  * `sohl:` keys every type accepts, whatever its schema says.
@@ -211,6 +212,31 @@ export function lintNote(note, { schemas, index }) {
211
212
  const at = (key, literal) =>
212
213
  positionInFrontmatter(raw(), key, literal ?? undefined);
213
214
 
215
+ // The retired top-level fields, checked before the type: a note may carry
216
+ // one whatever its type is, and each finding stands on its own. Reported
217
+ // here as well as refused at compile because this is where an author meets
218
+ // every finding in the tree at once, rather than one note at a time (#56).
219
+ if (Object.hasOwn(fm, "package")) {
220
+ findings.push({
221
+ file: note.file,
222
+ ...at("package"),
223
+ severity: "error",
224
+ message:
225
+ "`package:` is a retired frontmatter field — delete it. A " +
226
+ "note's package is this repository's configured " +
227
+ "`contentPackage`, in package-build.config.yaml, and every " +
228
+ "note in the tree belongs to it",
229
+ });
230
+ }
231
+ if (Object.hasOwn(fm, "draft")) {
232
+ findings.push({
233
+ file: note.file,
234
+ ...at("draft"),
235
+ severity: "error",
236
+ message: draftRetiredMessage(),
237
+ });
238
+ }
239
+
214
240
  const replacement = RETIRED_TYPES[type];
215
241
  if (replacement) {
216
242
  findings.push({
@@ -36,7 +36,7 @@ import log from "loglevel";
36
36
  import { loadPackConfig } from "./pack-config.mjs";
37
37
  import { packRouter } from "./pack-router.mjs";
38
38
  import { contentPackage, foundryPackageId } from "./content-package.mjs";
39
- import { notePackage, searchableFrontmatter } from "./note-package.mjs";
39
+ import { searchableFrontmatter } from "./note-package.mjs";
40
40
  import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
41
41
  import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
42
42
  import { expandContentTables } from "./content-tables.mjs";
@@ -602,8 +602,9 @@ export function collectContentDocs(contentBase) {
602
602
  if (!fm) continue;
603
603
  const segments = path.relative(contentBase, absPath).split(path.sep);
604
604
  docs.push({
605
- // With its package present whether the note declares one or not, so
606
- // a `WHERE package = "…"` query reads the same either way (#56).
605
+ // With its package supplied for a `WHERE package = "…"` query —
606
+ // synthesised from the configuration, since no note declares it
607
+ // (#56).
607
608
  fm: searchableFrontmatter(fm),
608
609
  // POSIX-separated and relative to the content root — what a
609
610
  // `path:` search term globs, on every platform.
@@ -636,17 +637,15 @@ const packLinkable = (doc) =>
636
637
  * Expand the fenced `dataview` tables in one note's markdown, before wikilinks
637
638
  * are resolved — so a generated cell may itself be a wikilink.
638
639
  *
639
- * A table searches only notes of the source note's own package, so a SoHL page
640
- * never tabulates setting-package content (and vice versa). Each candidate's
641
- * package is **derived** rather than read out of its frontmatter: `package:` is
642
- * optional, and comparing a declared value with an absent one would drop every
643
- * unswept — or every swept — note from the table (#56).
640
+ * A table searches the whole tree, which is one package's notes and nothing
641
+ * else so there is no longer a package to scope on. It used to filter, back
642
+ * when a tree could hold several packages' notes and `package:` said which was
643
+ * which; that field is retired and the filter with it (#56).
644
644
  *
645
645
  * @param {string} body - The note's markdown body.
646
646
  * @param {object} ctx
647
647
  * @param {Array<object>} ctx.docs - From {@link collectContentDocs}.
648
648
  * @param {string} ctx.name - The note, for the error message.
649
- * @param {string} [ctx.pkg] - The source note's package.
650
649
  * @param {object} [ctx.fm] - The source note's frontmatter, which is what a
651
650
  * query's `this` reads. Its entry in `docs` supplies the path as well.
652
651
  * @param {number} [ctx.bodyLine] - 1-based file line of the body's first line,
@@ -659,8 +658,7 @@ const packLinkable = (doc) =>
659
658
  * compile rather than shipping a table-shaped hole. The error carries
660
659
  * `position`, the directive's own line.
661
660
  */
662
- export function expandNoteTables(body, { docs, name, pkg, fm, bodyLine }) {
663
- const scoped = pkg ? docs.filter((d) => notePackage(d.fm) === pkg) : docs;
661
+ export function expandNoteTables(body, { docs, name, fm, bodyLine }) {
664
662
  const self =
665
663
  fm ?
666
664
  (docs.find((d) => d.fm?.id && d.fm.id === fm.id) ?? {
@@ -668,7 +666,7 @@ export function expandNoteTables(body, { docs, name, pkg, fm, bodyLine }) {
668
666
  })
669
667
  : undefined;
670
668
  const { markdown, errors, lineMap } = expandContentTables(body ?? "", {
671
- docs: scoped,
669
+ docs,
672
670
  linkable: packLinkable,
673
671
  source: name,
674
672
  self,
@@ -0,0 +1,150 @@
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
+ * The package homepage — a note that compiles to a **page** rather than to a
16
+ * compendium document (#51).
17
+ *
18
+ * Every HeroicLands package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
19
+ * and what a reader finds there is one markdown file in the content tree,
20
+ * written by a person: what the module is, which system it needs, how to install
21
+ * it, where its source lives. Nothing about it is derived.
22
+ *
23
+ * **Authored, not assembled.** An earlier sketch generated the page in tiers —
24
+ * identity and licence from the manifest, install URL from the release address,
25
+ * "requires" links from `relationships`, a card per configured section. It would
26
+ * have worked and needed almost no authoring, and it produces a page nobody
27
+ * chose the contents of. The things that matter most on these pages cannot be
28
+ * derived: that Kethira requires buying the book from Keléstia, what Thalorna's
29
+ * setting *is*, which of twenty sections a reader should start with. So the only
30
+ * thing defaulted here is the title, from `packageBuild.manifest.title`, so that
31
+ * the package's name is not written twice.
32
+ *
33
+ * **Dispatched by `type`, not by filename.** A fixed `homepage.md` the walker
34
+ * special-cased would be the anomaly: notes are routed by frontmatter, not by
35
+ * location, and `NOTE_SCHEMAS` already routes `doc`, `macro`, `being` and the
36
+ * map types. `homepage` is one more entry whose compile step emits a page. It is
37
+ * deliberately not `README.md`: `landing: readme` already means "a `README.md`
38
+ * is its section's landing page", and `sohl-thalorna/assets/content/README.md`
39
+ * is a developer explainer about the source tree — adopting that name would make
40
+ * Thalorna's public front page its build documentation.
41
+ *
42
+ * **Engine, not `sohl/`.** The `engine/` ÷ `sohl/` line separates *note-format*
43
+ * knowledge from *game-system* knowledge, and a homepage is note format: it
44
+ * carries no `system` block, mirrors no item builder, and would mean the same
45
+ * thing for a game system that is not SoHL. Reachability is the symptom that
46
+ * makes it obvious — `HarnMaster-3-FoundryVTT` declares no `itemBuilders`, so a
47
+ * type living in the SoHL registry would be unavailable to HM3 and to every HM3
48
+ * module, which is most of the packages that need a homepage and nothing else.
49
+ *
50
+ * **Its address is the package's, not the note's.** A homepage publishes at
51
+ * `/<contentPackage>/` because that is where the package is, so `name.full`,
52
+ * `shortcode` and `id` decide nothing on it (#53 refuses them outright; this
53
+ * module simply never reads them). It compiles into no document, so it carries
54
+ * no compendium UUID and appears in no pack and in no link-manifest entry.
55
+ *
56
+ * @module
57
+ */
58
+
59
+ /**
60
+ * The note type that compiles to the package homepage.
61
+ *
62
+ * @type {string}
63
+ */
64
+ export const HOMEPAGE_TYPE = "homepage";
65
+
66
+ /**
67
+ * What a homepage note may write under `sohl:` — nothing.
68
+ *
69
+ * Empty on purpose, and declared rather than omitted: a type with no vocabulary
70
+ * and a type that is unknown are different findings, and only the second is an
71
+ * authoring error. The whole envelope is the two top-level keys `type` and an
72
+ * optional `title`; there is no game-system data on a page that compiles to no
73
+ * document.
74
+ *
75
+ * @type {readonly import("./field-spec.mjs").FieldSpec[]}
76
+ */
77
+ export const HOMEPAGE_FIELDS = Object.freeze([]);
78
+
79
+ /**
80
+ * Where a homepage is written, relative to the package's site root.
81
+ *
82
+ * Hugo's section landing, because the page *is* the package's landing: the
83
+ * package root is a section and this is its index.
84
+ *
85
+ * @type {string}
86
+ */
87
+ export const HOMEPAGE_DESTINATION = "_index.md";
88
+
89
+ /**
90
+ * Whether a note's frontmatter declares the homepage type.
91
+ *
92
+ * @param {object|null|undefined} fm - Parsed frontmatter.
93
+ * @returns {boolean} Whether it is a homepage note.
94
+ */
95
+ export function isHomepage(fm) {
96
+ return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
97
+ }
98
+
99
+ /**
100
+ * The title a homepage publishes under.
101
+ *
102
+ * The one defaulted value on the page, and it defaults to the package's own
103
+ * `packageBuild.manifest.title` — the name Foundry already shows for the
104
+ * package — so a homepage that adds nothing to it need not restate it. An
105
+ * authored `title` wins, because a front page is allowed to greet a reader
106
+ * differently from a package browser.
107
+ *
108
+ * Falls back to `contentPackage` last, so a package that has no manifest of its
109
+ * own still yields a titled page rather than a blank heading.
110
+ *
111
+ * @param {object|null|undefined} fm - The note's frontmatter.
112
+ * @param {object} config - The resolved configuration.
113
+ * @returns {string} The title.
114
+ */
115
+ export function homepageTitle(fm, config) {
116
+ const authored = fm?.title;
117
+ if (typeof authored === "string" && authored.trim()) return authored;
118
+ const manifest = /** @type {Record<string, unknown>|undefined} */ (
119
+ config?.packageBuild?.manifest
120
+ );
121
+ const title = manifest?.title;
122
+ return typeof title === "string" && title.trim() ?
123
+ title
124
+ : config.contentPackage;
125
+ }
126
+
127
+ /**
128
+ * The frontmatter a homepage publishes with.
129
+ *
130
+ * The note's own, plus the two derived values every emitted page carries: the
131
+ * resolved `title`, and the package the build derived — no note declares one
132
+ * (`package:` is retired, #56) and the theme's breadcrumb partial reads
133
+ * `.Params.package`.
134
+ *
135
+ * An authored `aliases` is dropped for the same reason it is on every other
136
+ * page: Obsidian reads it as names a reader might call the note, Hugo reads it
137
+ * as URL redirects, and passing it through would publish a redirect stub at
138
+ * each one.
139
+ *
140
+ * @param {object} fm - The note's frontmatter.
141
+ * @param {object} options - Options.
142
+ * @param {string} options.contentPackage - The package this build publishes.
143
+ * @param {string} options.title - The resolved title.
144
+ * @returns {object} The frontmatter to write.
145
+ */
146
+ export function homepageFrontmatter(fm, { contentPackage, title }) {
147
+ const data = { ...fm, package: contentPackage, title };
148
+ delete data.aliases;
149
+ return data;
150
+ }