@heroiclands/package-build 4.0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 5.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 4da0dbc: Every package publishes an authored homepage at `/<contentPackage>/`, and
8
+ `publish.site` becomes a mode rather than a boolean (#51, #55).
9
+
10
+ **A `type: homepage` note (#51)**
11
+
12
+ A new engine-level content type that compiles into a **page** rather than into a
13
+ compendium document. Its whole frontmatter envelope is `type` and an optional
14
+ `title`, defaulting to `packageBuild.manifest.title` so the package's name is not
15
+ written twice:
16
+
17
+ ```markdown
18
+ ---
19
+ type: homepage
20
+ title: HârnMaster Kethira Basic
21
+ ---
22
+
23
+ What the module is, which system it needs, how to install it.
24
+ ```
25
+
26
+ It compiles into no document, appears in no pack and in no link manifest, and is
27
+ addressed by the **package** — `/<contentPackage>/` — rather than by a slug
28
+ derived from its name, so `name.full`, `shortcode` and `id` decide nothing on it.
29
+ It is written at the root of `site.out`, one level above the content mount.
30
+
31
+ The page is _authored, not assembled_. Deriving it from the manifest, the release
32
+ address and `relationships` was the obvious shortcut and produces a page nobody
33
+ chose the contents of — it cannot express that Kethira requires buying the book
34
+ from Keléstia, or which of twenty sections a reader should start with.
35
+
36
+ It is declared in `engine/note-schemas.mjs` rather than in the `sohl` item
37
+ registry: the `engine/` ÷ `sohl/` line is note-format knowledge against
38
+ game-system knowledge, and a homepage carries no `system` block and mirrors no
39
+ item builder. Reachability is the symptom that makes it obvious —
40
+ `HarnMaster-3-FoundryVTT` declares no `itemBuilders` at all, so a type living in
41
+ the SoHL registry would be unavailable to HM3 and every HM3 module, which is most
42
+ of the packages that need a homepage and nothing else.
43
+
44
+ **`publish.site` is a mode (#55) — breaking**
45
+
46
+ | Was | Write |
47
+ | ------------- | ---------------------------------- |
48
+ | `site: true` | `site: content` |
49
+ | `site: false` | `site: homepage` |
50
+ | absent | absent — the default is `homepage` |
51
+
52
+ `homepage` publishes the authored homepage and **no other page**; `content`
53
+ publishes it plus every page the content tree compiles to. There is no value
54
+ meaning "no web presence", because every package publishes its homepage.
55
+
56
+ Both booleans are **refused rather than mapped** onto the nearest mode, naming
57
+ the mode to write. `false` read as _this package has no web presence_, which
58
+ describes no package now, and a value silently reinterpreted reads to its author
59
+ as though it still means what it said.
60
+
61
+ **Homepage-only is a first-class mode, not an accommodation.**
62
+ `sohl-kethira-basic` (Keléstia Productions' Fan Material Guidelines) and
63
+ `harn-adventures` (HârnFanon under Lythia's terms) each publish a homepage and
64
+ nothing beneath it — two packages under two different fan-content licences. The
65
+ boundary is _published content_: journal text, artwork, item descriptions,
66
+ compiled notes. Because the failure mode is silent — a `site:` block added later
67
+ ships licensed content with nobody noticing — the mode **fences the content
68
+ surfaces off**: in `homepage` mode the tree is never walked for pages, and
69
+ `sections`, `trees`, `landing` and `backfillSections` emit nothing even when they
70
+ are declared. Measured against the real `sohl-kethira-basic` tree — 363 notes,
71
+ and a `site:` block deliberately declaring sections and a landing — the build
72
+ emits exactly one file.
73
+
74
+ `publish.manifests.publish` is a separate decision and stays `false` for both, for
75
+ an unrelated reason: a link manifest is the dependency edge that would stop a
76
+ module being withdrawable, and a homepage is one row in a routing table.
77
+
78
+ **Nothing else moves.** `publish.address`, `publish.manifests` and the whole
79
+ `site:` block are unchanged, and every address `sohl` already publishes is
80
+ byte-identical across the upgrade. Verified against the real tree: 1,669 emitted
81
+ files before, 1,670 after, the one addition being `kb/content/_index.md`; the
82
+ link manifest's 2,989 entries and all 3,126 compiled pack documents are
83
+ byte-identical with and without the homepage note.
84
+
3
85
  ## 4.0.0
4
86
 
5
87
  ### Major Changes
package/CONTENT.md CHANGED
@@ -128,9 +128,11 @@ packageBuild:
128
128
  - { from: assets/icons, to: assets/icons }
129
129
 
130
130
  # Three independent switches — every combination is real — plus the address
131
- # scheme both `manifest` and `site` derive addresses under.
131
+ # scheme both `manifest` and `site` derive addresses under. `site` is a mode,
132
+ # not a boolean: `homepage` (the default) publishes the authored homepage and
133
+ # no other page; `content` publishes it plus every page the tree compiles to.
132
134
  publish:
133
- site: true
135
+ site: content
134
136
  manifests: { publish: true, consume: true }
135
137
  address:
136
138
  prefix: kb/
@@ -147,8 +149,8 @@ site:
147
149
 
148
150
  The loader validates the document, resolves every path against the directory
149
151
  the file sits in, fills the optional halves with their defaults
150
- (`skipDirectories: []`, `packageBuild: {}`, the conventional `paths`, every
151
- publishing switch off),
152
+ (`skipDirectories: []`, `packageBuild: {}`, the conventional `paths`, both
153
+ manifest switches off and `publish.site` at its `homepage` floor),
152
154
  derives `assetRoot`, `packDirectories`, `itemTypes` and `docEntryTypes`, and
153
155
  freezes the result. A malformed configuration throws a `TypeError` naming the
154
156
  offending field, so it fails at load rather than as an empty pack much later.
@@ -594,10 +596,11 @@ It reads its whole input from configuration and takes nothing else:
594
596
  | `publish.address` | The address scheme those paths are derived under — see below. |
595
597
 
596
598
  **Both addresses are optional, independently.** A note that compiles into no
597
- document has no `uuid`, and a package that ships compendiums and publishes no
598
- site (`publish.site: false`) has no `path` on any entry. Neither is an error, and
599
- neither is guessed: inventing the missing one asserts a target that does not
600
- exist, which is the silent dead link the manifest exists to prevent.
599
+ document has no `uuid`, and a package that ships compendiums and publishes only
600
+ a homepage (`publish.site: homepage`) has no `path` on any entry its notes are
601
+ not pages. Neither is an error, and neither is guessed: inventing the missing one
602
+ asserts a target that does not exist, which is the silent dead link the manifest
603
+ exists to prevent.
601
604
 
602
605
  **`publish.manifests.publish` is a declaration, not a preference.** The file is
603
606
  vendored by other repositories and read as authoritative, so emitting one is a
@@ -615,7 +618,7 @@ time and 404s for the reader.
615
618
 
616
619
  ```yaml
617
620
  publish:
618
- site: true
621
+ site: content
619
622
  manifests: { publish: true, consume: true }
620
623
  address:
621
624
  prefix: kb/ # default: "" — the package root
@@ -658,6 +661,64 @@ address derivation, the address index, table expansion, wikilink resolution,
658
661
  code-fence protection, the foreign-manifest merge, the page emission and the
659
662
  section-landing backfill.
660
663
 
664
+ ### The homepage, and how much else is published
665
+
666
+ Every package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
667
+ and what a reader finds there is a note in the content tree — one markdown file,
668
+ written by a person:
669
+
670
+ ```markdown
671
+ ---
672
+ type: homepage
673
+ title: HârnMaster Kethira Basic # optional; defaults to packageBuild.manifest.title
674
+ ---
675
+
676
+ What the module is, which system it needs, how to install it.
677
+ ```
678
+
679
+ That is the whole envelope. A homepage **compiles into no compendium
680
+ document**, appears in no pack and in no link manifest, and is addressed by the
681
+ _package_ rather than by its own name — so `name.full`, `shortcode` and `id`
682
+ decide nothing on it. It is dispatched on `type` like every other note, not on a
683
+ filename: `README.md` is already a section landing under `landing: readme`, and
684
+ in `sohl-thalorna` it is a developer explainer about the source tree.
685
+
686
+ `type: homepage` is declared by the **engine**, not by the `sohl` item registry,
687
+ so a package that configures no `itemBuilders` at all — `HarnMaster-3-FoundryVTT`
688
+ and every HM3 module — can author one. The `engine/` ÷ `sohl/` line is
689
+ note-format knowledge against game-system knowledge, and a homepage carries no
690
+ `system` block.
691
+
692
+ `publish.site` then says how much _else_ is published:
693
+
694
+ | Mode | What is published |
695
+ | ---------- | ------------------------------------------------------------------------------- |
696
+ | `homepage` | The authored homepage, and no other page. **The default, and the floor.** |
697
+ | `content` | The homepage plus every page the content tree compiles to, and its extra trees. |
698
+
699
+ There is no value meaning "no web presence": every package publishes its
700
+ homepage. It was a boolean until 5.0.0, and both spellings are now refused
701
+ naming the mode to write instead — see [MIGRATING.md](MIGRATING.md).
702
+
703
+ **Homepage-only is a first-class mode, not an accommodation.**
704
+ `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
705
+ Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's terms)
706
+ must each publish a homepage and nothing beneath it. The boundary is _published
707
+ content_ — journal text, artwork, item descriptions, compiled notes — and a page
708
+ announcing the module discloses none of it. Because the failure mode is silent,
709
+ the mode **fences the content surfaces off** rather than trusting a
710
+ configuration to stay empty: in `homepage` mode the tree is never walked for
711
+ pages, and `sections`, `trees`, `landing` and `backfillSections` emit nothing
712
+ even when they are declared.
713
+
714
+ That is separate from `publish.manifests.publish`, which stays off for both for
715
+ an unrelated reason: a link manifest is the dependency edge that would stop the
716
+ module being withdrawable, and a homepage is one row in a routing table.
717
+
718
+ The homepage is written at the root of `site.out` — the package's own address —
719
+ one level above the content mount, which is where `publish.address.prefix` puts
720
+ everything else.
721
+
661
722
  **What it does not do is decide addresses.** Those come from `publish.address`,
662
723
  the same setting the link manifest reads, so a page and its manifest entry cannot
663
724
  disagree about where the page is. Everything under `site:` is _framing_ —
@@ -686,7 +747,7 @@ site:
686
747
 
687
748
  | Key | What it decides |
688
749
  | ------------------ | ------------------------------------------------------------------------------------------------ |
689
- | `out` | The Hugo content root. **Required**, and wiped on every run — see the safety note below. |
750
+ | `out` | The Hugo content root. **Required** in both modes, and wiped on every run — see below. |
690
751
  | `base` | Where the package is served. Defaults to `/<contentPackage>/`. |
691
752
  | `packages` | Which content packages this site renders. Defaults to its own. |
692
753
  | `sections` | Landing title and hero per section, so a landing matches the card that links to it. |
package/MIGRATING.md CHANGED
@@ -1,3 +1,76 @@
1
+ # Migrating to `@heroiclands/package-build` 5.0.0
2
+
3
+ **One configuration change: `publish.site` is a mode, not a boolean.** And one
4
+ new authoring capability that needs no migration: a `type: homepage` note.
5
+
6
+ ## 1. Respell `publish.site`
7
+
8
+ ```yaml
9
+ publish:
10
+ site: content # was `site: true`
11
+ ```
12
+
13
+ ```yaml
14
+ publish:
15
+ site: homepage # was `site: false`, or absent
16
+ ```
17
+
18
+ `homepage` is the default, so a repository that never set the key needs no edit.
19
+ A repository that set it to either boolean gets a `TypeError` at load naming the
20
+ mode to write:
21
+
22
+ ```text
23
+ package-build config: `publish.site` is no longer a boolean — write `site: content`. Every package publishes an authored homepage at /<contentPackage>/, so no value means "no web presence": `homepage` publishes that page and nothing else, and `content` publishes it plus every page the content tree compiles to.
24
+ ```
25
+
26
+ Both spellings are **refused rather than mapped** onto the nearest mode. `false`
27
+ read as _this package has no web presence_, which now describes no package at
28
+ all, and a value silently reinterpreted reads to its author as though it still
29
+ means what it said.
30
+
31
+ Nothing else about publishing moved: `publish.address`, `publish.manifests` and
32
+ the whole `site:` block are unchanged, and every address `sohl` and `thalorna`
33
+ already publish is byte-identical across the upgrade.
34
+
35
+ ## 2. Author a homepage (optional here, required by #52)
36
+
37
+ Every package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
38
+ and the page there is a note in the content tree:
39
+
40
+ ```markdown
41
+ ---
42
+ type: homepage
43
+ title: HârnMaster Kethira Basic # optional; defaults to packageBuild.manifest.title
44
+ ---
45
+
46
+ What the module is, which system it needs, how to install it.
47
+ ```
48
+
49
+ It compiles into no compendium document, appears in no pack and in no link
50
+ manifest, and is addressed by the package rather than by its own name. It is
51
+ written to the root of `site.out`, one level above the content mount.
52
+
53
+ A repository with no homepage note publishes none, and the site build says so in
54
+ its count. Requiring exactly one is a separate change (#52).
55
+
56
+ ## 3. What homepage-only means
57
+
58
+ `homepage` mode does not merely leave the content configuration unused — it
59
+ **fences the content surfaces off**. The tree is never walked for pages, and
60
+ `site.sections`, `site.trees`, `site.landing` and `site.backfillSections` emit
61
+ nothing even when they are declared.
62
+
63
+ That is deliberate, and it is a licensing requirement rather than a preference.
64
+ `sohl-kethira-basic` (Keléstia Productions' Fan Material Guidelines) and
65
+ `harn-adventures` (HârnFanon under Lythia's terms) publish a homepage and no
66
+ other page; the failure mode is silent — a `site:` block added later ships
67
+ licensed content with nobody noticing — so the property is asserted by the code
68
+ path rather than left to configuration.
69
+
70
+ `publish.manifests.publish` is a separate decision and stays `false` for both: a
71
+ link manifest is the dependency edge that would stop a module being withdrawable,
72
+ and a homepage is not.
73
+
1
74
  # Migrating to `@heroiclands/package-build` 4.0.0
2
75
 
3
76
  **One authoring change: delete `package:` from every content note.** A note's
@@ -76,6 +76,9 @@ import { lintFrontmatter } from "../engine/frontmatter-lint.mjs";
76
76
  // set — an adventure module ships skills, beings and magic swords — so no
77
77
  // consumer gets a subset (#19, #20).
78
78
  import { NOTE_SCHEMAS } from "../sohl/note-schemas.mjs";
79
+ // The engine's own types, merged under the registry's so the vocabulary stands
80
+ // in a package that configures no `itemBuilders` at all (#51).
81
+ import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
79
82
  import { checkFormatting, lintMarkdown } from "../engine/prose-lint.mjs";
80
83
  import { emitLinkManifest } from "../engine/manifest-emit.mjs";
81
84
  import {
@@ -346,7 +349,7 @@ function lintCommand() {
346
349
  skipDirectories: config.skipDirectories,
347
350
  });
348
351
  const frontmatter = lintFrontmatter(index, {
349
- schemas: NOTE_SCHEMAS,
352
+ schemas: { ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS },
350
353
  references: argv.references,
351
354
  });
352
355
 
@@ -823,7 +826,8 @@ function siteCommand() {
823
826
 
824
827
  const s = result.stats;
825
828
  log.info(
826
- `wrote ${s.content ?? 0} content page(s) + ` +
829
+ `wrote ${s.homepages ?? 0} homepage(s) + ` +
830
+ `${s.content ?? 0} content page(s) + ` +
827
831
  `${s.tree ?? 0} tree page(s) + ${s.landings} ` +
828
832
  `landing(s) to ${path.relative(process.cwd(), s.out)}`,
829
833
  );
@@ -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(
@@ -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
+ }
package/engine/index.mjs CHANGED
@@ -60,6 +60,12 @@ export * as notePackage from "./note-package.mjs";
60
60
  /** Frontmatter fields a note may no longer declare, and the refusal of them. */
61
61
  export * as retiredFields from "./retired-fields.mjs";
62
62
 
63
+ /** The package homepage: the note type that compiles to a page, not a document. */
64
+ export * as homepage from "./homepage.mjs";
65
+
66
+ /** The note types the engine itself declares, whatever a consumer registers. */
67
+ export * as noteSchemas from "./note-schemas.mjs";
68
+
63
69
  /** The shipped Foundry manifest: locating it, reading it, guarding its id. */
64
70
 
65
71
  /** The URL a content note is published at — the one web-address rule. */
@@ -59,6 +59,7 @@ import { assertNoDraftField } from "./retired-fields.mjs";
59
59
  import { journalPageId, splitPages } from "./journals.mjs";
60
60
  import { routerFor } from "./pack-router.mjs";
61
61
  import { loadPackConfig } from "./pack-config.mjs";
62
+ import { publishesContentPages } from "../content-config.mjs";
62
63
 
63
64
  /**
64
65
  * The reserved anchor name for a journal's **first** page.
@@ -271,7 +272,7 @@ export function manifestContext(config = loadPackConfig()) {
271
272
  foundryPackageId: config.foundryPackage,
272
273
  packRouter: routerFor(config),
273
274
  scheme: config.publish.address,
274
- web: config.publish.site,
275
+ web: publishesContentPages(config),
275
276
  // The walk's own configuration, threaded through rather than left to
276
277
  // its default, so a caller that passes a config drives every read.
277
278
  skipDirectories: config.skipDirectories,
@@ -0,0 +1,44 @@
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 note types the **engine** declares — the ones whose vocabulary is a fact
16
+ * about the note format rather than about any game system (#51).
17
+ *
18
+ * `sohl/note-schemas.mjs` is the other half, and the line between them is the
19
+ * `engine/` ÷ `sohl/` line everywhere else in this package: note-format
20
+ * knowledge here, game-system knowledge there. It is not a permission boundary
21
+ * between consumers — every content project authors the full vocabulary — but it
22
+ * is a reachability one in exactly one direction. A package that declares no
23
+ * `itemBuilders` (`HarnMaster-3-FoundryVTT`, and every HM3 module) uses only the
24
+ * packaging half of the toolchain, so a type declared in the SoHL registry would
25
+ * be unavailable to it. These are the types every package has, whatever it
26
+ * ships.
27
+ *
28
+ * One entry today. A consumer merges it under its own registry —
29
+ * `{ ...ENGINE_NOTE_SCHEMAS, ...NOTE_SCHEMAS }` — so a game system may extend
30
+ * these but the engine's declaration stands wherever no registry is configured.
31
+ *
32
+ * @module
33
+ */
34
+
35
+ import { HOMEPAGE_FIELDS, HOMEPAGE_TYPE } from "./homepage.mjs";
36
+
37
+ /**
38
+ * Every engine-level content type, and what a note of that type may write.
39
+ *
40
+ * @type {Readonly<Record<string, readonly import("./field-spec.mjs").FieldSpec[]>>}
41
+ */
42
+ export const ENGINE_NOTE_SCHEMAS = Object.freeze({
43
+ [HOMEPAGE_TYPE]: HOMEPAGE_FIELDS,
44
+ });
@@ -63,6 +63,13 @@ import {
63
63
  import { deriveBeingInfo, isBeing } from "../sohl/being-info.mjs";
64
64
  import { loadPackConfig } from "./pack-config.mjs";
65
65
  import { searchableFrontmatter } from "./note-package.mjs";
66
+ import {
67
+ HOMEPAGE_DESTINATION,
68
+ homepageFrontmatter,
69
+ homepageTitle,
70
+ isHomepage,
71
+ } from "./homepage.mjs";
72
+ import { publishesContentPages } from "../content-config.mjs";
66
73
 
67
74
  const require = createRequire(import.meta.url);
68
75
 
@@ -137,6 +144,10 @@ export function collectContentPages(contentBase, ctx) {
137
144
  // (#56).
138
145
  const pkg = ctx.contentPackage;
139
146
  if (!ctx.packages.has(pkg) || !fm.type) continue;
147
+ // A homepage is addressed by the *package*, not by its own name, so it
148
+ // never takes a section and a slug (#51). {@link collectHomepages}
149
+ // gathers it instead.
150
+ if (isHomepage(fm)) continue;
140
151
 
141
152
  for (const hit of frontmatterWikilinks(fm)) {
142
153
  fmLinkFindings.push({ file, ...hit });
@@ -249,6 +260,62 @@ export function collectTreePages(tree, ctx) {
249
260
  return { pages, fmLinkFindings };
250
261
  }
251
262
 
263
+ /**
264
+ * The package's homepage notes — the authored page at `/<contentPackage>/`.
265
+ *
266
+ * A separate walk from {@link collectContentPages} rather than a branch inside
267
+ * it, because in homepage-only mode it is the **whole** of the site build: the
268
+ * content tree is never read for pages at all, so the licensing constraint two
269
+ * packages ship under is a property of the code path rather than of a
270
+ * configuration that happens to be empty (#55).
271
+ *
272
+ * Returned as a list rather than as the one note there should be. Requiring
273
+ * exactly one is #52's, and it is a separate decision — this reports what it
274
+ * found so a count is visible either way.
275
+ *
276
+ * @param {string} contentBase - Absolute path to the content tree.
277
+ * @param {object} ctx - `{ skipDirectories }`.
278
+ * @returns {{pages: object[]}} The homepage notes, in walk order.
279
+ */
280
+ export function collectHomepages(contentBase, ctx) {
281
+ const pages = [];
282
+ for (const file of walkSiteTree(contentBase, ctx.skipDirectories)) {
283
+ const note = readNote(file);
284
+ if (!note || !isHomepage(note.fm)) continue;
285
+ pages.push({ kind: "homepage", file, fm: note.fm, body: note.body });
286
+ }
287
+ return { pages };
288
+ }
289
+
290
+ /**
291
+ * Writes each homepage at the package's own root.
292
+ *
293
+ * Its own writer, deliberately small. A homepage is authored markdown published
294
+ * verbatim — no table expansion, no section landing, and (until #54) no link
295
+ * resolution — so routing it through {@link renderPages} would buy it a pipeline
296
+ * it has no input for, and would make homepage-only mode depend on the index,
297
+ * the foreign manifests and the table universe that mode exists to not build.
298
+ *
299
+ * @param {string} outRoot - The package's site root — the configured `site.out`,
300
+ * one level above the content mount.
301
+ * @param {readonly object[]} pages - From {@link collectHomepages}.
302
+ * @param {object} config - The resolved configuration, for the package name and
303
+ * the default title.
304
+ * @returns {number} How many pages were written.
305
+ */
306
+ export function writeHomepages(outRoot, pages, config) {
307
+ for (const page of pages) {
308
+ const data = homepageFrontmatter(page.fm, {
309
+ contentPackage: config.contentPackage,
310
+ title: homepageTitle(page.fm, config),
311
+ });
312
+ const dest = path.join(outRoot, HOMEPAGE_DESTINATION);
313
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
314
+ fs.writeFileSync(dest, matter.stringify(page.body, data));
315
+ }
316
+ return pages.length;
317
+ }
318
+
252
319
  /**
253
320
  * The integrity gates a site build runs before it writes anything.
254
321
  *
@@ -318,6 +385,30 @@ export function siteGates(pages, findings, { manifestDir }) {
318
385
  return out;
319
386
  }
320
387
 
388
+ /**
389
+ * The gate result of a build that ran none of them.
390
+ *
391
+ * Homepage-only publishes one authored page and resolves nothing, so every gate
392
+ * here is about a surface that mode does not have. The shape is returned all the
393
+ * same, because a caller reads the same fields whichever mode ran and a `null`
394
+ * would make each of them a special case.
395
+ *
396
+ * @returns {object} An all-clear gate result.
397
+ */
398
+ export function emptyGates() {
399
+ return {
400
+ frontmatterLinks: [],
401
+ slugErrors: [],
402
+ collisions: [],
403
+ staleManifests: [],
404
+ unaddressable: [],
405
+ conflicts: [],
406
+ index: null,
407
+ foreign: null,
408
+ manifests: null,
409
+ };
410
+ }
411
+
321
412
  /** Whether any gate produced a finding. */
322
413
  export function gatesFailed(gates) {
323
414
  return Boolean(
@@ -706,6 +797,10 @@ export function buildSite({ config, outRoot } = {}) {
706
797
  const resolved = config ?? loadPackConfig();
707
798
  const site = resolved.site;
708
799
  const scheme = resolved.publish.address;
800
+ // Homepage-only or homepage-plus-content (#55). The floor is the homepage,
801
+ // so this decides whether the *content* surfaces are published, never
802
+ // whether anything is.
803
+ const publishesContent = publishesContentPages(resolved);
709
804
 
710
805
  // Where the package is served, and where its content mounts inside it. The
711
806
  // two are separate facts: `base` is the package's own address on the site
@@ -722,9 +817,16 @@ export function buildSite({ config, outRoot } = {}) {
722
817
  // directory it was launched from (#1508).
723
818
  const outBase = resolveOutputRoot(resolved.rootDir, site.out);
724
819
  const out =
725
- outRoot ?
726
- path.resolve(outRoot)
727
- : path.join(outBase, scheme.prefix.replace(/\/$/, ""));
820
+ outRoot ? path.resolve(outRoot)
821
+ : publishesContent ?
822
+ path.join(outBase, scheme.prefix.replace(/\/$/, ""))
823
+ // Homepage-only has no content mount, so the package's root *is*
824
+ // the output root and `--out` redirects the whole of it.
825
+ : outBase;
826
+ // The homepage publishes at `/<contentPackage>/`, which is the package's
827
+ // own root — one level above the content mount, and the same directory in
828
+ // homepage-only mode.
829
+ const homeRoot = publishesContent ? outBase : out;
728
830
 
729
831
  const packages = new Set(
730
832
  site.packages.length ? site.packages : [resolved.contentPackage],
@@ -744,6 +846,25 @@ export function buildSite({ config, outRoot } = {}) {
744
846
  // note was deleted or renamed would otherwise linger and keep publishing.
745
847
  fs.rmSync(outBase, { recursive: true, force: true });
746
848
 
849
+ const homepages = collectHomepages(resolved.paths.content, ctx).pages;
850
+
851
+ // Homepage-only stops here, and stopping is the point: nothing below reads
852
+ // the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
853
+ // cannot publish one whatever else their `site:` block declares (#55).
854
+ if (!publishesContent) {
855
+ return {
856
+ gates: emptyGates(),
857
+ manifests: null,
858
+ tableErrors: [],
859
+ wikiErrors: [],
860
+ stats: {
861
+ homepages: writeHomepages(homeRoot, homepages, resolved),
862
+ landings: 0,
863
+ out: homeRoot,
864
+ },
865
+ };
866
+ }
867
+
747
868
  const content = collectContentPages(resolved.paths.content, ctx);
748
869
  const pages = [...content.pages];
749
870
  const fmLinkFindings = [...content.fmLinkFindings];
@@ -804,12 +925,21 @@ export function buildSite({ config, outRoot } = {}) {
804
925
  sectionTitle: site.backfillSections ? pluralTitle : null,
805
926
  });
806
927
 
928
+ // Last, and outside the mount: the package's front page is not part of the
929
+ // content tree it introduces.
930
+ const homepagesWritten = writeHomepages(homeRoot, homepages, resolved);
931
+
807
932
  return {
808
933
  gates,
809
934
  manifests: gates.manifests,
810
935
  tableErrors: rendered.tableErrors,
811
936
  wikiErrors: rendered.wikiErrors,
812
- stats: { ...rendered.byKind, landings, out },
937
+ stats: {
938
+ ...rendered.byKind,
939
+ homepages: homepagesWritten,
940
+ landings,
941
+ out,
942
+ },
813
943
  };
814
944
  }
815
945
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "4.0.0",
3
+ "version": "5.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",
@@ -38,6 +38,7 @@
38
38
  */
39
39
 
40
40
  import { AS_AUTHORED, NUMBER, STRING } from "../engine/field-spec.mjs";
41
+ import { ENGINE_NOTE_SCHEMAS } from "../engine/note-schemas.mjs";
41
42
  import { ITEM_FIELDS } from "./item-fields.mjs";
42
43
 
43
44
  /** A map-valued property, whose entries the compiler walks by key. */
@@ -302,9 +303,16 @@ const PRESENTATION_FIELDS = Object.freeze({
302
303
  * Every content type this package compiles, and what a note of that type may
303
304
  * write.
304
305
  *
306
+ * The engine's own types are merged in first, so a SoHL tree is checked against
307
+ * one vocabulary rather than two. They are declared there rather than here
308
+ * because they are note-format knowledge — a `homepage` carries no `system`
309
+ * block and would mean the same thing for a game system that is not SoHL — and
310
+ * because a package declaring no `itemBuilders` never reaches this file (#51).
311
+ *
305
312
  * @type {Readonly<Record<string, readonly import("../engine/field-spec.mjs").FieldSpec[]>>}
306
313
  */
307
314
  export const NOTE_SCHEMAS = Object.freeze({
315
+ ...ENGINE_NOTE_SCHEMAS,
308
316
  ...Object.fromEntries(
309
317
  Object.entries(ITEM_FIELDS).map(([type, fields]) => [
310
318
  type,
@@ -1,3 +1,19 @@
1
+ /**
2
+ * Whether this package publishes the pages its content tree compiles to.
3
+ *
4
+ * The one question every reader of the mode actually asks — the site build, to
5
+ * decide whether to walk the tree at all, and the link-manifest emitter, to
6
+ * decide whether an entry carries a web `path`. Written once here so the two
7
+ * cannot come to disagree about what a mode means.
8
+ *
9
+ * @param {{publish: {site: SiteMode}}} config - A resolved configuration.
10
+ * @returns {boolean} Whether content pages are published.
11
+ */
12
+ export function publishesContentPages(config: {
13
+ publish: {
14
+ site: SiteMode;
15
+ };
16
+ }): boolean;
1
17
  /**
2
18
  * Validate and normalize a content configuration.
3
19
  *
@@ -76,6 +92,77 @@ export const DEFAULT_ADDRESS_SCHEME: Readonly<{
76
92
  prefix: "";
77
93
  landing: "readme";
78
94
  }>;
95
+ /**
96
+ * How much of a package reaches the web.
97
+ *
98
+ * Every HeroicLands package publishes something: a top-level, human-authored
99
+ * homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
100
+ * module is, which system it needs and how to install it (#50). So there is no
101
+ * value here meaning *no web presence at all* — homepage-only is the **floor**,
102
+ * and the default.
103
+ *
104
+ * - `homepage` — the authored homepage, and **no other page**. The content tree
105
+ * is not walked for pages, `site.sections` / `site.trees` / `site.landing`
106
+ * emit nothing, and link-manifest entries carry no web `path`.
107
+ * - `content` — the homepage *plus* every page the content tree publishes: the
108
+ * knowledgebase, the extra trees, the section landings.
109
+ *
110
+ * **Homepage-only is a first-class mode, not an accommodation.**
111
+ * `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
112
+ * Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's
113
+ * terms) must each publish a homepage and nothing beneath it — two packages
114
+ * under two different fan-content licences. The boundary is **published
115
+ * content**: journal text, artwork, item descriptions, compiled notes. A
116
+ * human-authored page announcing the module discloses none of it. Because the
117
+ * failure mode is silent — a `site:` block added later ships licensed content
118
+ * with nobody noticing — the mode fences the content surfaces off rather than
119
+ * trusting a configuration to stay empty.
120
+ *
121
+ * This was a boolean until 5.0.0, and `false` read as "no web presence", which
122
+ * no longer describes any package. Both spellings are refused rather than
123
+ * mapped: a value silently reinterpreted reads to its author as though it still
124
+ * means what it said.
125
+ *
126
+ * @typedef {"homepage" | "content"} SiteMode
127
+ */
128
+ /**
129
+ * The publishing modes {@link PublishSwitches.site} may name, floor first.
130
+ *
131
+ * @satisfies {readonly SiteMode[]}
132
+ */
133
+ export const SITE_MODES: readonly ["homepage", "content"];
134
+ /**
135
+ * How much of a package reaches the web.
136
+ *
137
+ * Every HeroicLands package publishes something: a top-level, human-authored
138
+ * homepage at `https://www.heroiclands.org/<contentPackage>/` saying what the
139
+ * module is, which system it needs and how to install it (#50). So there is no
140
+ * value here meaning *no web presence at all* — homepage-only is the **floor**,
141
+ * and the default.
142
+ *
143
+ * - `homepage` — the authored homepage, and **no other page**. The content tree
144
+ * is not walked for pages, `site.sections` / `site.trees` / `site.landing`
145
+ * emit nothing, and link-manifest entries carry no web `path`.
146
+ * - `content` — the homepage *plus* every page the content tree publishes: the
147
+ * knowledgebase, the extra trees, the section landings.
148
+ *
149
+ * **Homepage-only is a first-class mode, not an accommodation.**
150
+ * `sohl-kethira-basic` (unofficial Hârn fan material under Keléstia Productions'
151
+ * Fan Material Guidelines) and `harn-adventures` (HârnFanon under Lythia's
152
+ * terms) must each publish a homepage and nothing beneath it — two packages
153
+ * under two different fan-content licences. The boundary is **published
154
+ * content**: journal text, artwork, item descriptions, compiled notes. A
155
+ * human-authored page announcing the module discloses none of it. Because the
156
+ * failure mode is silent — a `site:` block added later ships licensed content
157
+ * with nobody noticing — the mode fences the content surfaces off rather than
158
+ * trusting a configuration to stay empty.
159
+ *
160
+ * This was a boolean until 5.0.0, and `false` read as "no web presence", which
161
+ * no longer describes any package. Both spellings are refused rather than
162
+ * mapped: a value silently reinterpreted reads to its author as though it still
163
+ * means what it said.
164
+ */
165
+ export type SiteMode = "homepage" | "content";
79
166
  export type PackageKind = "systems" | "modules";
80
167
  export type PackDocumentType = "Actor" | "Adventure" | "Item" | "JournalEntry" | "Macro" | "Scene";
81
168
  /**
@@ -272,9 +359,10 @@ export type ManifestSwitches = {
272
359
  };
273
360
  export type PublishSwitches = {
274
361
  /**
275
- * Render this package's knowledgebase/site pages.
362
+ * How much of this package reaches the web.
363
+ * See {@link SITE_MODES}.
276
364
  */
277
- site: boolean;
365
+ site: SiteMode;
278
366
  manifests: ManifestSwitches;
279
367
  };
280
368
  export type ManifestSwitchesInput = {
@@ -394,8 +482,19 @@ export type DocsSpec = {
394
482
  itemFields?: DocPageSpec | undefined;
395
483
  };
396
484
  export type PublishSwitchesInput = {
397
- site?: boolean | undefined;
485
+ site?: SiteMode | undefined;
398
486
  manifests?: ManifestSwitchesInput | undefined;
487
+ address?: AddressSchemeInput | undefined;
488
+ };
489
+ export type AddressSchemeInput = {
490
+ /**
491
+ * Where the content tree mounts inside the package.
492
+ */
493
+ prefix?: string | undefined;
494
+ /**
495
+ * Which note addresses a whole section.
496
+ */
497
+ landing?: string | undefined;
399
498
  };
400
499
  /**
401
500
  * One entry of a consumer's `itemBuilders` registry.
@@ -502,7 +601,9 @@ export type ContentBuildConfigInput = {
502
601
  */
503
602
  relationships?: Relationships | undefined;
504
603
  /**
505
- * Publishing switches. Each defaults to off.
604
+ * Publishing switches. The manifest
605
+ * switches default to off; `site`
606
+ * defaults to `homepage`, the floor.
506
607
  */
507
608
  publish?: PublishSwitchesInput | undefined;
508
609
  };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Whether a note's frontmatter declares the homepage type.
3
+ *
4
+ * @param {object|null|undefined} fm - Parsed frontmatter.
5
+ * @returns {boolean} Whether it is a homepage note.
6
+ */
7
+ export function isHomepage(fm: object | null | undefined): boolean;
8
+ /**
9
+ * The title a homepage publishes under.
10
+ *
11
+ * The one defaulted value on the page, and it defaults to the package's own
12
+ * `packageBuild.manifest.title` — the name Foundry already shows for the
13
+ * package — so a homepage that adds nothing to it need not restate it. An
14
+ * authored `title` wins, because a front page is allowed to greet a reader
15
+ * differently from a package browser.
16
+ *
17
+ * Falls back to `contentPackage` last, so a package that has no manifest of its
18
+ * own still yields a titled page rather than a blank heading.
19
+ *
20
+ * @param {object|null|undefined} fm - The note's frontmatter.
21
+ * @param {object} config - The resolved configuration.
22
+ * @returns {string} The title.
23
+ */
24
+ export function homepageTitle(fm: object | null | undefined, config: object): string;
25
+ /**
26
+ * The frontmatter a homepage publishes with.
27
+ *
28
+ * The note's own, plus the two derived values every emitted page carries: the
29
+ * resolved `title`, and the package the build derived — no note declares one
30
+ * (`package:` is retired, #56) and the theme's breadcrumb partial reads
31
+ * `.Params.package`.
32
+ *
33
+ * An authored `aliases` is dropped for the same reason it is on every other
34
+ * page: Obsidian reads it as names a reader might call the note, Hugo reads it
35
+ * as URL redirects, and passing it through would publish a redirect stub at
36
+ * each one.
37
+ *
38
+ * @param {object} fm - The note's frontmatter.
39
+ * @param {object} options - Options.
40
+ * @param {string} options.contentPackage - The package this build publishes.
41
+ * @param {string} options.title - The resolved title.
42
+ * @returns {object} The frontmatter to write.
43
+ */
44
+ export function homepageFrontmatter(fm: object, { contentPackage, title }: {
45
+ contentPackage: string;
46
+ title: string;
47
+ }): object;
48
+ /**
49
+ * The package homepage — a note that compiles to a **page** rather than to a
50
+ * compendium document (#51).
51
+ *
52
+ * Every HeroicLands package is reachable at `https://www.heroiclands.org/<contentPackage>/`,
53
+ * and what a reader finds there is one markdown file in the content tree,
54
+ * written by a person: what the module is, which system it needs, how to install
55
+ * it, where its source lives. Nothing about it is derived.
56
+ *
57
+ * **Authored, not assembled.** An earlier sketch generated the page in tiers —
58
+ * identity and licence from the manifest, install URL from the release address,
59
+ * "requires" links from `relationships`, a card per configured section. It would
60
+ * have worked and needed almost no authoring, and it produces a page nobody
61
+ * chose the contents of. The things that matter most on these pages cannot be
62
+ * derived: that Kethira requires buying the book from Keléstia, what Thalorna's
63
+ * setting *is*, which of twenty sections a reader should start with. So the only
64
+ * thing defaulted here is the title, from `packageBuild.manifest.title`, so that
65
+ * the package's name is not written twice.
66
+ *
67
+ * **Dispatched by `type`, not by filename.** A fixed `homepage.md` the walker
68
+ * special-cased would be the anomaly: notes are routed by frontmatter, not by
69
+ * location, and `NOTE_SCHEMAS` already routes `doc`, `macro`, `being` and the
70
+ * map types. `homepage` is one more entry whose compile step emits a page. It is
71
+ * deliberately not `README.md`: `landing: readme` already means "a `README.md`
72
+ * is its section's landing page", and `sohl-thalorna/assets/content/README.md`
73
+ * is a developer explainer about the source tree — adopting that name would make
74
+ * Thalorna's public front page its build documentation.
75
+ *
76
+ * **Engine, not `sohl/`.** The `engine/` ÷ `sohl/` line separates *note-format*
77
+ * knowledge from *game-system* knowledge, and a homepage is note format: it
78
+ * carries no `system` block, mirrors no item builder, and would mean the same
79
+ * thing for a game system that is not SoHL. Reachability is the symptom that
80
+ * makes it obvious — `HarnMaster-3-FoundryVTT` declares no `itemBuilders`, so a
81
+ * type living in the SoHL registry would be unavailable to HM3 and to every HM3
82
+ * module, which is most of the packages that need a homepage and nothing else.
83
+ *
84
+ * **Its address is the package's, not the note's.** A homepage publishes at
85
+ * `/<contentPackage>/` because that is where the package is, so `name.full`,
86
+ * `shortcode` and `id` decide nothing on it (#53 refuses them outright; this
87
+ * module simply never reads them). It compiles into no document, so it carries
88
+ * no compendium UUID and appears in no pack and in no link-manifest entry.
89
+ *
90
+ * @module
91
+ */
92
+ /**
93
+ * The note type that compiles to the package homepage.
94
+ *
95
+ * @type {string}
96
+ */
97
+ export const HOMEPAGE_TYPE: string;
98
+ /**
99
+ * What a homepage note may write under `sohl:` — nothing.
100
+ *
101
+ * Empty on purpose, and declared rather than omitted: a type with no vocabulary
102
+ * and a type that is unknown are different findings, and only the second is an
103
+ * authoring error. The whole envelope is the two top-level keys `type` and an
104
+ * optional `title`; there is no game-system data on a page that compiles to no
105
+ * document.
106
+ *
107
+ * @type {readonly import("./field-spec.mjs").FieldSpec[]}
108
+ */
109
+ export const HOMEPAGE_FIELDS: readonly import("./field-spec.mjs").FieldSpec[];
110
+ /**
111
+ * Where a homepage is written, relative to the package's site root.
112
+ *
113
+ * Hugo's section landing, because the page *is* the package's landing: the
114
+ * package root is a section and this is its index.
115
+ *
116
+ * @type {string}
117
+ */
118
+ export const HOMEPAGE_DESTINATION: string;
@@ -7,6 +7,8 @@ export * as packRouter from "./pack-router.mjs";
7
7
  export * as contentPackage from "./content-package.mjs";
8
8
  export * as notePackage from "./note-package.mjs";
9
9
  export * as retiredFields from "./retired-fields.mjs";
10
+ export * as homepage from "./homepage.mjs";
11
+ export * as noteSchemas from "./note-schemas.mjs";
10
12
  export * as contentSlug from "./content-slug.mjs";
11
13
  export * as contentAddress from "./content-address.mjs";
12
14
  export * as foreignManifests from "./foreign-manifests.mjs";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Every engine-level content type, and what a note of that type may write.
3
+ *
4
+ * @type {Readonly<Record<string, readonly import("./field-spec.mjs").FieldSpec[]>>}
5
+ */
6
+ export const ENGINE_NOTE_SCHEMAS: Readonly<Record<string, readonly import("./field-spec.mjs").FieldSpec[]>>;
@@ -41,6 +41,43 @@ export function collectTreePages(tree: object, ctx: object): {
41
41
  pages: object[];
42
42
  fmLinkFindings: object[];
43
43
  };
44
+ /**
45
+ * The package's homepage notes — the authored page at `/<contentPackage>/`.
46
+ *
47
+ * A separate walk from {@link collectContentPages} rather than a branch inside
48
+ * it, because in homepage-only mode it is the **whole** of the site build: the
49
+ * content tree is never read for pages at all, so the licensing constraint two
50
+ * packages ship under is a property of the code path rather than of a
51
+ * configuration that happens to be empty (#55).
52
+ *
53
+ * Returned as a list rather than as the one note there should be. Requiring
54
+ * exactly one is #52's, and it is a separate decision — this reports what it
55
+ * found so a count is visible either way.
56
+ *
57
+ * @param {string} contentBase - Absolute path to the content tree.
58
+ * @param {object} ctx - `{ skipDirectories }`.
59
+ * @returns {{pages: object[]}} The homepage notes, in walk order.
60
+ */
61
+ export function collectHomepages(contentBase: string, ctx: object): {
62
+ pages: object[];
63
+ };
64
+ /**
65
+ * Writes each homepage at the package's own root.
66
+ *
67
+ * Its own writer, deliberately small. A homepage is authored markdown published
68
+ * verbatim — no table expansion, no section landing, and (until #54) no link
69
+ * resolution — so routing it through {@link renderPages} would buy it a pipeline
70
+ * it has no input for, and would make homepage-only mode depend on the index,
71
+ * the foreign manifests and the table universe that mode exists to not build.
72
+ *
73
+ * @param {string} outRoot - The package's site root — the configured `site.out`,
74
+ * one level above the content mount.
75
+ * @param {readonly object[]} pages - From {@link collectHomepages}.
76
+ * @param {object} config - The resolved configuration, for the package name and
77
+ * the default title.
78
+ * @returns {number} How many pages were written.
79
+ */
80
+ export function writeHomepages(outRoot: string, pages: readonly object[], config: object): number;
44
81
  /**
45
82
  * The integrity gates a site build runs before it writes anything.
46
83
  *
@@ -63,6 +100,17 @@ export function collectTreePages(tree: object, ctx: object): {
63
100
  * @returns {object} The gate results and, when they pass, the built index.
64
101
  */
65
102
  export function siteGates(pages: object[], findings: object, { manifestDir }: object): object;
103
+ /**
104
+ * The gate result of a build that ran none of them.
105
+ *
106
+ * Homepage-only publishes one authored page and resolves nothing, so every gate
107
+ * here is about a surface that mode does not have. The shape is returned all the
108
+ * same, because a caller reads the same fields whichever mode ran and a `null`
109
+ * would make each of them a special case.
110
+ *
111
+ * @returns {object} An all-clear gate result.
112
+ */
113
+ export function emptyGates(): object;
66
114
  /** Whether any gate produced a finding. */
67
115
  export function gatesFailed(gates: any): boolean;
68
116
  /**
@@ -2,6 +2,12 @@
2
2
  * Every content type this package compiles, and what a note of that type may
3
3
  * write.
4
4
  *
5
+ * The engine's own types are merged in first, so a SoHL tree is checked against
6
+ * one vocabulary rather than two. They are declared there rather than here
7
+ * because they are note-format knowledge — a `homepage` carries no `system`
8
+ * block and would mean the same thing for a game system that is not SoHL — and
9
+ * because a package declaring no `itemBuilders` never reaches this file (#51).
10
+ *
5
11
  * @type {Readonly<Record<string, readonly import("../engine/field-spec.mjs").FieldSpec[]>>}
6
12
  */
7
13
  export const NOTE_SCHEMAS: Readonly<Record<string, readonly import("../engine/field-spec.mjs").FieldSpec[]>>;