@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
@@ -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;
@@ -6,6 +6,9 @@ export * as packConfig from "./pack-config.mjs";
6
6
  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
+ export * as retiredFields from "./retired-fields.mjs";
10
+ export * as homepage from "./homepage.mjs";
11
+ export * as noteSchemas from "./note-schemas.mjs";
9
12
  export * as contentSlug from "./content-slug.mjs";
10
13
  export * as contentAddress from "./content-address.mjs";
11
14
  export * as foreignManifests from "./foreign-manifests.mjs";
@@ -37,14 +37,13 @@ export function entriesForNote(fm: object, name: string, address: string, body:
37
37
  /**
38
38
  * Every note this package publishes, as manifest entries.
39
39
  *
40
- * Drafts are excluded because the site does not publish them, and an entry for
41
- * an unpublished page is exactly the dead link the manifest exists to prevent.
42
- *
43
- * Every note in the tree is this package's note, whether or not it says so:
44
- * `package:` is optional and merely has to agree (#56). A note naming a
45
- * different package **throws** rather than being skipped this build is not
46
- * authoritative for it, and skipping it silently is how a whole tree came to be
47
- * filtered out of a manifest that then claimed the package published nothing.
40
+ * Every note in the tree is this package's, so nothing here selects by package:
41
+ * the key's first segment is `contentPackage` (#56). A note still declaring the
42
+ * retired `package:` or `draft:` field **throws** rather than being skipped —
43
+ * skipping one silently is how a whole tree came to be filtered out of a
44
+ * manifest that then claimed the package published nothing, and it is what let
45
+ * a drafted note's inbound links look like links to a note that never existed
46
+ * (#69).
48
47
  *
49
48
  * A note that has no address is **reported, not guessed** — the finding carries
50
49
  * the file and the reason, so a caller can print it or fail on it. Inventing an
@@ -1,54 +1,49 @@
1
1
  /**
2
- * The package a note belongs to.
3
- *
4
- * Non-validating: the answer for a note that declares nothing, and for one that
5
- * declares the configured package, is the same value. A note declaring some
6
- * *other* package is answered literally here rather than corrected — the
7
- * compile pass reports that, once, through {@link assertNotePackage}.
8
- *
9
- * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
10
- * could not be parsed.
11
- * @param {string} [configured] - The package this build compiles. Defaults to
12
- * the configured `contentPackage`; passed explicitly by callers that already
13
- * carry it in a context object, so a caller's configuration drives every read.
14
- * @returns {string} The package.
15
- */
16
- export function notePackage(fm: object | null | undefined, configured?: string): string;
17
- /**
18
- * A note's frontmatter as a generated table searches it — its package present
19
- * whether or not the note declares one.
2
+ * A note's frontmatter as a generated table searches it — its package present,
3
+ * though no note declares one.
20
4
  *
21
5
  * A `dataview` query resolves `package` out of frontmatter like any other
22
6
  * field, so a collection note that scopes itself with `WHERE … and package =
23
- * "sohl"` matches nothing once the field is deleted, and renders an **empty
24
- * table** in silence. Deriving the value here keeps the two spellings
25
- * equivalent, so a sweep that deletes the field is mechanical rather than a
26
- * trap (#56) — and a query that never mentions `package` is unaffected either
27
- * way.
7
+ * "sohl"` would match nothing now that the field is gone, and would render an
8
+ * **empty table** in silence. Supplying the derived value here is what kept the
9
+ * sweep mechanical rather than a trap (#56) and a query that never mentions
10
+ * `package` is unaffected either way.
28
11
  *
29
- * The declared value is left alone when there is one, so nothing about an
30
- * unswept tree changes.
12
+ * The frontmatter is copied rather than written into: it is the note's own
13
+ * parsed object, shared with every other reader, and the derived package is a
14
+ * property of *this search*, not of the note.
31
15
  *
32
16
  * @param {object|null|undefined} fm - Parsed frontmatter.
33
- * @param {string} [configured] - The package this build compiles.
34
- * @returns {object|null|undefined} The frontmatter itself when it declares a
35
- * package, else a shallow copy carrying the derived one.
17
+ * @param {string} [configured] - The package this build compiles. Defaults to
18
+ * the configured `contentPackage`; passed explicitly by callers that already
19
+ * carry it in a context object, so a caller's configuration drives every read.
20
+ * @returns {object|null|undefined} A shallow copy carrying the derived package,
21
+ * or whatever was passed when it is not frontmatter at all.
36
22
  */
37
23
  export function searchableFrontmatter(fm: object | null | undefined, configured?: string): object | null | undefined;
38
24
  /**
39
- * The package a note belongs to, refusing one that names another package.
25
+ * Refuse a note that declares `package:` at all.
40
26
  *
41
- * @param {object|null|undefined} fm - Parsed frontmatter.
27
+ * Presence is the whole test — a declaration that *agrees* with the
28
+ * configuration is as retired as one that disagrees, and an empty one
29
+ * (`package:`, which parses as `null`) is still the field.
30
+ *
31
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
32
+ * could not be parsed.
42
33
  * @param {object} [options] - Options.
43
34
  * @param {string} [options.file] - The note's path, named in the message. Omit
44
35
  * it where the caller emits through a diagnostic, which puts the locator at
45
36
  * the start of the line already — repeating it prints the path twice.
37
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
38
+ * failing path to locate the offending line and column. The position rides on
39
+ * the thrown error as `position`, for a caller that emits a diagnostic.
46
40
  * @param {string} [options.configured] - The package this build compiles.
47
41
  * Defaults to the configured `contentPackage`.
48
- * @returns {string} The package, which is always `configured`.
49
- * @throws {Error} When the note declares a different package.
42
+ * @returns {void}
43
+ * @throws {Error} When the note declares the field.
50
44
  */
51
- export function assertNotePackage(fm: object | null | undefined, { file, configured }?: {
45
+ export function assertNoDeclaredPackage(fm: object | null | undefined, { file, absPath, configured }?: {
52
46
  file?: string | undefined;
47
+ absPath?: string | undefined;
53
48
  configured?: string | undefined;
54
- }): string;
49
+ }): void;
@@ -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[]>>;
@@ -46,9 +46,9 @@ export class PackRoutingError extends Error {
46
46
  /**
47
47
  * The frontmatter field a note declares its pack in.
48
48
  *
49
- * Deliberately close to the retiring `package:` and deliberately not the same
49
+ * Deliberately close to the retired `package:` and deliberately not the same
50
50
  * word: `package:` said which *distribution* owned a note — now the
51
- * repository's `contentPackage` (#56) — while `pack:` says which *compendium*
52
- * receives its document.
51
+ * repository's `contentPackage`, and no longer authorable (#56) — while `pack:`
52
+ * says which *compendium* receives its document.
53
53
  */
54
54
  export const PACK_FIELD: "pack";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * What a note declaring `draft:` is told, in one place.
3
+ *
4
+ * Written once and shared by the compile-time refusal and the frontmatter lint,
5
+ * because an author meets whichever of the two runs first and they should read
6
+ * the same. It says what the field did and what to write instead, rather than
7
+ * which value to correct: no value makes declaring it right.
8
+ *
9
+ * @param {string} [file] - The note's path, named in the message. Omit it where
10
+ * the caller emits through a diagnostic, whose locator already starts the
11
+ * line — repeating it prints the path twice.
12
+ * @returns {string} The message, unpunctuated at the end as a finding is.
13
+ */
14
+ export function draftRetiredMessage(file?: string): string;
15
+ /**
16
+ * Refuse a note that declares `draft:` at all.
17
+ *
18
+ * Presence is the whole test. `draft: false` is as retired as `draft: true` —
19
+ * it reads as "publish this note", which is what happens either way, and is
20
+ * exactly the belief the message exists to correct.
21
+ *
22
+ * @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
23
+ * could not be parsed.
24
+ * @param {object} [options] - Options.
25
+ * @param {string} [options.file] - The note's path, named in the message. Omit
26
+ * it where the caller emits through a diagnostic, which puts the locator at
27
+ * the start of the line already — repeating it prints the path twice.
28
+ * @param {string} [options.absPath] - The note's file on disk, read only on the
29
+ * failing path to locate the offending line and column. The position rides on
30
+ * the thrown error as `position`, for a caller that emits a diagnostic.
31
+ * @returns {void}
32
+ * @throws {Error} When the note declares the field.
33
+ */
34
+ export function assertNoDraftField(fm: object | null | undefined, { file, absPath }?: {
35
+ file?: string | undefined;
36
+ absPath?: string | undefined;
37
+ }): void;
38
+ /**
39
+ * A frontmatter key's position in a note's file, or nothing.
40
+ *
41
+ * {@link positionInFrontmatter} answers the same question from the file's
42
+ * *text*; this reads the file to ask it. Kept apart from either caller because
43
+ * both refusals need it and a second copy is a second thing to keep correct.
44
+ *
45
+ * @param {string|undefined} absPath - The note's file.
46
+ * @param {string} key - The top-level frontmatter key.
47
+ * @returns {{line?: number, column?: number}|undefined} Spreadable position
48
+ * fields, dropped rather than guessed when the file cannot be read or the key
49
+ * cannot be found — as `formatDiagnostic` requires.
50
+ */
51
+ export function locateFrontmatterKey(absPath: string | undefined, key: string): {
52
+ line?: number;
53
+ column?: number;
54
+ } | undefined;
@@ -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
  /**
@@ -84,10 +132,10 @@ export function tableUniverse(pages: object[]): Map<string, object[]>;
84
132
  * redirect stub at each name. They are dropped, and this build emits no
85
133
  * redirects of its own.
86
134
  *
87
- * A content page carries the package the build **derived**, whether or not the
88
- * note declared one (#65). `package:` became optional in 3.3.0, so a swept tree
89
- * declares none and the note's frontmatter alone would then publish a page
90
- * that does not say which package it belongs to. The emitted page is what a
135
+ * A content page carries the package the build **derived** (#65). No note
136
+ * declares one `package:` is retired (#56) so the note's frontmatter alone
137
+ * would publish a page that does not say which package it belongs to. The
138
+ * emitted page is what a
91
139
  * theme reads: `breadcrumbs.html` builds its middle crumb from
92
140
  * `.Params.package`, so without it that crumb degrades from a linked, labelled
93
141
  * section to a bare type slug. Writing the derived value keeps a page
@@ -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[]>>;