@heroiclands/package-build 6.0.0 → 7.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 (43) hide show
  1. package/CHANGELOG.md +798 -0
  2. package/CONTENT.md +228 -4
  3. package/bin/content-build.mjs +196 -10
  4. package/bin/package-build.mjs +8 -1
  5. package/config.mjs +25 -3
  6. package/content-config.mjs +283 -29
  7. package/engine/address-diff.mjs +290 -0
  8. package/engine/base-compiler.mjs +25 -0
  9. package/engine/content-links.mjs +132 -27
  10. package/engine/content-lint.mjs +23 -2
  11. package/engine/diagnostics.mjs +61 -1
  12. package/engine/frontmatter-lint.mjs +22 -0
  13. package/engine/generate.mjs +10 -5
  14. package/engine/helpers.mjs +38 -0
  15. package/engine/homepage.mjs +206 -2
  16. package/engine/journals.mjs +8 -1
  17. package/engine/macros.mjs +2 -0
  18. package/engine/pack-config.mjs +143 -13
  19. package/engine/prose-lint.mjs +10 -2
  20. package/engine/scenes.mjs +2 -2
  21. package/engine/site-build.mjs +74 -19
  22. package/engine/web-wikilinks.mjs +13 -4
  23. package/engine/wikilink-syntax.mjs +25 -0
  24. package/engine/wikilinks.mjs +6 -3
  25. package/manifest.mjs +37 -2
  26. package/package.json +5 -3
  27. package/sohl/actors.mjs +23 -10
  28. package/sohl/items.mjs +1 -1
  29. package/types/content-config.d.mts +14 -0
  30. package/types/engine/address-diff.d.mts +108 -0
  31. package/types/engine/base-compiler.d.mts +18 -1
  32. package/types/engine/content-links.d.mts +11 -3
  33. package/types/engine/content-lint.d.mts +4 -1
  34. package/types/engine/diagnostics.d.mts +33 -1
  35. package/types/engine/generate.d.mts +3 -2
  36. package/types/engine/helpers.d.mts +29 -3
  37. package/types/engine/homepage.d.mts +117 -0
  38. package/types/engine/journals.d.mts +7 -1
  39. package/types/engine/pack-config.d.mts +22 -0
  40. package/types/engine/prose-lint.d.mts +10 -2
  41. package/types/engine/site-build.d.mts +28 -3
  42. package/types/engine/wikilink-syntax.d.mts +24 -0
  43. package/types/sohl/actors.d.mts +3 -3
@@ -131,6 +131,20 @@ export const DEFAULT_ADDRESS_SCHEME: Readonly<{
131
131
  * @satisfies {readonly SiteMode[]}
132
132
  */
133
133
  export const SITE_MODES: readonly ["homepage", "content"];
134
+ /**
135
+ * How the loader hands {@link defineConfig} the system version it resolved.
136
+ *
137
+ * A **Symbol**, deliberately. `stats.systemVersion` is refused from an authored
138
+ * configuration (#48), but the value still has to reach here from the loader —
139
+ * which is the half that may do I/O, and which reads a system package's version
140
+ * out of the adjacent `package.json`. A string key would be a second spelling of
141
+ * the refused one, forgeable from YAML and reachable by `rejectUnknownKeys`; a
142
+ * symbol key cannot be written in YAML at all and does not appear in
143
+ * `Object.keys`, so the refusal has no back door.
144
+ *
145
+ * @type {symbol}
146
+ */
147
+ export const DERIVED_SYSTEM_VERSION: symbol;
134
148
  /**
135
149
  * How much of a package reaches the web.
136
150
  *
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The address space a set of compiled Item pack directories publishes.
3
+ *
4
+ * The directories are read as one space for the same reason the actors pass
5
+ * reads them as one: a being names an item by `(type, shortcode)` and never by
6
+ * the pack it happens to ship in. Both sides of a diff are built by this one
7
+ * function, so a released catalogue extracted by `deps fetch` and a freshly
8
+ * compiled pack are indexed identically and a difference between them is a real
9
+ * one rather than an artefact of two readers.
10
+ *
11
+ * A missing directory throws rather than reading as an empty space: an empty
12
+ * baseline would report every address in the package as withdrawn, and an empty
13
+ * current side would report every address as gone — the loudest possible
14
+ * output from the quietest possible mistake.
15
+ *
16
+ * @param {readonly string[]} dirs - Directories of item JSON.
17
+ * @returns {Map<string, {id: string, name: string, type: string, shortcode: string, file: string}>}
18
+ * Every item, keyed `type:shortcode`.
19
+ */
20
+ export function readItemAddresses(dirs: readonly string[]): Map<string, {
21
+ id: string;
22
+ name: string;
23
+ type: string;
24
+ shortcode: string;
25
+ file: string;
26
+ }>;
27
+ /**
28
+ * Every address the baseline published that this build does not.
29
+ *
30
+ * An address that merely *arrived* is not a finding: adding one breaks nobody.
31
+ * The arrivals are read only to answer the one question that matters about a
32
+ * departure — is the document still here under another name?
33
+ *
34
+ * @param {Map<string, object>} baseline - The released address space.
35
+ * @param {Map<string, object>} current - This build's address space.
36
+ * @param {object} opts
37
+ * @param {string} opts.baseline - What the baseline is, for the message —
38
+ * conventionally `<package>@<version>`.
39
+ * @returns {Array<object>} One finding per departed address, in address order
40
+ * so two runs read the same. `kind` is `"renamed"` (with `to`) or
41
+ * `"withdrawn"`.
42
+ */
43
+ export function diffItemAddresses(baseline: Map<string, object>, current: Map<string, object>, { baseline: label }: {
44
+ baseline: string;
45
+ }): Array<object>;
46
+ /**
47
+ * Every content note in a tree, indexed by the document id it authors.
48
+ *
49
+ * The address space is read from compiled output because that is what actually
50
+ * ships; the tree is read only to place a finding somewhere a reader can open
51
+ * and fix it. Each source answers the question it is good at, and the id is the
52
+ * exact key that joins them.
53
+ *
54
+ * @param {string} contentBase - Root of the content tree.
55
+ * @param {object} [opts]
56
+ * @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
57
+ * @returns {Map<string, string>} Document id → the note's absolute path.
58
+ */
59
+ export function noteFilesById(contentBase: string, { skipDirectories }?: {
60
+ skipDirectories?: readonly string[] | undefined;
61
+ }): Map<string, string>;
62
+ /**
63
+ * Where to send the reader for one finding.
64
+ *
65
+ * A rename is fixed in the note that made it, so a finding whose id is still in
66
+ * this tree is reported at that note's `shortcode:` line — the line the author
67
+ * just edited. A withdrawal has no such note by definition, so it degrades to
68
+ * the baseline document, which is the only artefact left that records the
69
+ * address existing. When neither is readable the position is **dropped**, never
70
+ * defaulted to `1:1`.
71
+ *
72
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
73
+ * @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
74
+ * @returns {{file?: string, line?: number, column?: number}} Spreadable
75
+ * position fields for {@link formatDiagnostic}.
76
+ */
77
+ export function locateAddressFinding(finding: object, noteFiles: Map<string, string>): {
78
+ file?: string;
79
+ line?: number;
80
+ column?: number;
81
+ };
82
+ /**
83
+ * What one finding says, without a locator or a severity.
84
+ *
85
+ * The rename message names the identity it matched on, because that is what
86
+ * separates this from a spelling suggestion: the reader can check the id in
87
+ * both artefacts. The withdrawal message names no successor, because none is
88
+ * known — and says so, rather than leaving the reader to wonder whether one was
89
+ * looked for.
90
+ *
91
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
92
+ * @returns {string} The message.
93
+ */
94
+ export function addressFindingMessage(finding: object): string;
95
+ /**
96
+ * One finding, in the standard `file:line:column: severity: message` form.
97
+ *
98
+ * @param {object} finding - One finding from {@link diffItemAddresses}.
99
+ * @param {{file?: string, line?: number, column?: number}} at - From
100
+ * {@link locateAddressFinding}.
101
+ * @param {"warning"|"error"} [severity] - `error` when the caller is gating.
102
+ * @returns {string} The formatted diagnostic, path first on the line.
103
+ */
104
+ export function formatAddressFinding(finding: object, at: {
105
+ file?: string;
106
+ line?: number;
107
+ column?: number;
108
+ }, severity?: "warning" | "error"): string;
@@ -92,7 +92,7 @@ export class BasePackCompiler {
92
92
  * @param {boolean} [options.routingReporter] - Whether this pass reports a
93
93
  * note of its type that routes nowhere.
94
94
  */
95
- constructor({ contentBase, dest, folderResolver, packName, docType, router, routingReporter, }?: {
95
+ constructor({ contentBase, dest, folderResolver, packName, packSystem, docType, router, routingReporter, }?: {
96
96
  contentBase: string;
97
97
  dest: string;
98
98
  folderResolver?: ((path: string | null) => string | null) | undefined;
@@ -153,6 +153,22 @@ export class BasePackCompiler {
153
153
  * @type {number}
154
154
  */
155
155
  unresolvedLinks: number;
156
+ packSystem: any;
157
+ /**
158
+ * The `_stats` block every entry this pass emits is stamped with (#48).
159
+ *
160
+ * Per pack rather than per package, because a module may ship the same
161
+ * content for two systems — `harn-ensemble` has an `actors-hm3` pack and an
162
+ * `actors-sohl` pack — and those documents were built against different
163
+ * system versions. A single global block stamped both identically.
164
+ *
165
+ * Memoised on the instance: one pass, one pack, one system, so the block is
166
+ * constant for the life of the compiler. The previous module-level memo
167
+ * could not be, because it was shared across passes for different packs.
168
+ *
169
+ * @returns {object} The block, built once per compiler.
170
+ */
171
+ get stats(): object;
156
172
  /**
157
173
  * Whether this pass's pack is the one a claimed note belongs in.
158
174
  *
@@ -326,6 +342,7 @@ export class BasePackCompiler {
326
342
  bodyLine: number | undefined;
327
343
  bodyColumn: number | undefined;
328
344
  } | undefined;
345
+ #private;
329
346
  }
330
347
  /**
331
348
  * The tallies one pass accumulates while walking the tree.
@@ -46,9 +46,17 @@ export function buildLinkIndex(contentBase: string, { manifestDir, skipDirectori
46
46
  * and what replaced it, so this is a fact rather than a guess — and it is
47
47
  * exactly the SoHL defect.
48
48
  * - A **hardcoded absolute URL** into this package's own prefix, or into one a
49
- * vendored manifest names. Both have a better form to write, which is why they
50
- * are reported; a bare `/<package>/` is left alone, because a package
51
- * homepage is in no manifest and there is nothing better to write.
49
+ * vendored manifest names. Every one of them has a better form to write, which
50
+ * is why every one is reported including a bare `/<package>/`, which names
51
+ * another package's landing (#87).
52
+ *
53
+ * That last case was exempt until the better form was identified, on the
54
+ * reasoning that a landing is in no link manifest so nothing could resolve it.
55
+ * True, and beside the point: it does not need resolving. A landing's address
56
+ * *is* its package prefix, so `/<package>/` is the absolute URL with the host
57
+ * struck off — host-free, emitted verbatim, and needing no index, which is
58
+ * what lets it hold in homepage-only mode where the tree is never walked. The
59
+ * form was already accepted here; nothing had ever named it as the one to use.
52
60
  * - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
53
61
  * time. `href:` means "already resolved, use verbatim", so the same leading
54
62
  * slash is correct there and is not reported.
@@ -16,12 +16,15 @@ export function isValidShortcode(value: unknown): boolean;
16
16
  * @param {object} [opts]
17
17
  * @param {readonly string[]} [opts.skipDirectories] - Directory names the walk
18
18
  * ignores. Defaults to the configured list.
19
+ * @param {string} [opts.contentPackage] - The package this tree builds, for the
20
+ * homepage rule. Dropped from that finding when unknown rather than guessed.
19
21
  * @returns {{findings: Array<{file: string, line?: number, column?: number,
20
22
  * severity: "error"|"warning", message: string}>, notes: number,
21
23
  * keys: number}} The findings, and what was inspected to produce them.
22
24
  */
23
- export function lintContentTree(contentBase: string, { skipDirectories }?: {
25
+ export function lintContentTree(contentBase: string, { skipDirectories, contentPackage }?: {
24
26
  skipDirectories?: readonly string[] | undefined;
27
+ contentPackage?: string | undefined;
25
28
  }): {
26
29
  findings: Array<{
27
30
  file: string;
@@ -169,12 +169,44 @@ export function positionOfLiteral(text: string, needle: string, occurrence?: num
169
169
  * resolves to nothing — yields `{}`, so a caller spreads the result and the
170
170
  * position is dropped rather than guessed.
171
171
  *
172
+ * **`key: true` addresses the declaration rather than the value.** A finding
173
+ * about a *value* — this pack name is not in `packs[]` — belongs on the value,
174
+ * which is the default. A finding that names a **field** — `\`site.sections.x\`
175
+ * is not a recognized option` — sends the reader to look for that field, so the
176
+ * position should be the field's own, and in a flow mapping
177
+ * (`{ title: X, banner: Y }`) the two are different columns on one line. The
178
+ * key's node is found through the same parse, so this stays one locator rather
179
+ * than a second one free to disagree with it.
180
+ *
172
181
  * @param {string} text - The document's contents.
173
182
  * @param {ReadonlyArray<string|number>} keyPath - Path to the node: map keys as
174
183
  * strings, sequence entries as numbers.
184
+ * @param {object} [opts]
185
+ * @param {boolean} [opts.key=false] - Report where the last segment is
186
+ * *declared* rather than where its value sits. Ignored for a sequence entry,
187
+ * which has no key.
175
188
  * @returns {{line?: number, column?: number}} Spreadable position fields.
176
189
  */
177
- export function positionOfYamlPath(text: string, keyPath: ReadonlyArray<string | number>): {
190
+ export function positionOfYamlPath(text: string, keyPath: ReadonlyArray<string | number>, { key }?: {
191
+ key?: boolean | undefined;
192
+ }): {
178
193
  line?: number;
179
194
  column?: number;
180
195
  };
196
+ /**
197
+ * The YAML key path a **dotted field path** addresses.
198
+ *
199
+ * Configuration checks report the offending key as the path a reader would
200
+ * write it — `packs[1].name`, `site.sections.affliction.title` — because that
201
+ * is what the message has to say. {@link positionOfYamlPath} addresses a node
202
+ * by segments instead, so this is the one translation between them: `.`
203
+ * separates map keys, and a bracketed suffix is a sequence index.
204
+ *
205
+ * A path this cannot parse yields `[]`, which {@link positionOfYamlPath} in
206
+ * turn resolves to no position — dropped rather than guessed, as everything
207
+ * else here is.
208
+ *
209
+ * @param {string} field - The dotted path, as a diagnostic spells it.
210
+ * @returns {Array<string|number>} Its segments, sequence indices as numbers.
211
+ */
212
+ export function yamlKeyPath(field: string): Array<string | number>;
@@ -13,8 +13,9 @@
13
13
  * @param {object} [config] - The resolved build configuration. Defaults to this
14
14
  * repository's.
15
15
  * @returns {string[]} Each Item pack's JSON directory. Empty when the
16
- * repository ships no items at all the actors pass, which is the only
17
- * caller that needs one, refuses that itself.
16
+ * repository ships no items at all, which is a legitimate package: the actors
17
+ * pass accepts an empty list and reports an item it cannot resolve per
18
+ * `(type, shortcode)` instead, naming the being (#49).
18
19
  */
19
20
  export function itemPackJsonDirs(config?: object): string[];
20
21
  /**
@@ -19,7 +19,7 @@ export function parseMarkdownFile(filePath: any): {
19
19
  } | {
20
20
  frontmatter: any;
21
21
  body: string;
22
- description: any;
22
+ description: string;
23
23
  bodyLine: number;
24
24
  bodyColumn: number;
25
25
  };
@@ -56,7 +56,7 @@ export function walkMarkdownTree(rootDir: string, { skipDirectories }?: {
56
56
  absPath: string;
57
57
  frontmatter: any;
58
58
  body: string;
59
- description: any;
59
+ description: string;
60
60
  bodyLine: number;
61
61
  bodyColumn: number;
62
62
  }, void, unknown>;
@@ -190,6 +190,32 @@ export function buildStats(systemVersion?: string, config?: {
190
190
  packageManifest: string;
191
191
  };
192
192
  }): object;
193
+ /**
194
+ * The `_stats` block for one pack, stamped with the system that pack is for
195
+ * (#48).
196
+ *
197
+ * **`systemId` travels with `systemVersion`.** They are one decision, so where
198
+ * one is omitted both are. Stamping a per-pack version against a package-wide
199
+ * id would emit `systemId: sohl, systemVersion: 1.6.3` on HM3 documents — a
200
+ * *plausible lie*, which is worse than the missing value #43 fixed, because
201
+ * nothing about it looks wrong.
202
+ *
203
+ * Resolution, in order:
204
+ *
205
+ * 1. The pack's own `system:`, looked up in the `systems:` block. That is the
206
+ * case a module shipping for two systems needs, and the one no
207
+ * package-wide value could express.
208
+ * 2. Failing that, the package-wide `stats` — a package whose packs are all for
209
+ * one system, which is every package that worked before this existed.
210
+ *
211
+ * A pack naming a system is validated against `systems:` at configuration time,
212
+ * so an unresolvable name never reaches here.
213
+ *
214
+ * @param {string|null|undefined} packSystem - The pack's declared `system:`.
215
+ * @param {object} [config] - The resolved configuration.
216
+ * @returns {object} The `_stats` block for that pack.
217
+ */
218
+ export function statsForPack(packSystem: string | null | undefined, config?: object): object;
193
219
  /**
194
220
  * The `_stats` block every compiler stamps on an entry it emits, built once.
195
221
  *
@@ -342,7 +368,7 @@ export function folderFilename(name: any, id: any): string;
342
368
  * pack, `"JournalEntry"` for the journals pack.
343
369
  */
344
370
  export function writeFolderDocs(folders: any, stats: any, destDir: any, documentType: any): void;
345
- export const md: any;
371
+ export const md: import("markdown-it").MarkdownIt;
346
372
  export { slugify } from "./content-slug.mjs";
347
373
  export { makeId } from "./ids.mjs";
348
374
  export { getFrontmatter, sohlField, resolveCharges, resolveSkillAptitudes, resolveRelation, requireSubType, parseValueDesc } from "./frontmatter.mjs";
@@ -5,6 +5,87 @@
5
5
  * @returns {boolean} Whether it is a homepage note.
6
6
  */
7
7
  export function isHomepage(fm: object | null | undefined): boolean;
8
+ /**
9
+ * The address-bearing fields one note authors, in the order it authored them.
10
+ *
11
+ * Authoring order rather than declaration order, so a caller emitting one
12
+ * diagnostic per finding emits them top to bottom down the file — the order a
13
+ * reader and a compiler-output parser both expect.
14
+ *
15
+ * Presence is the whole test: `shortcode:` authored empty still says "this page
16
+ * has an address of its own", and a value cannot make the claim true.
17
+ *
18
+ * Returned without a locator, because the two things that would supply one —
19
+ * the raw note text and the position helper — belong to the caller. This
20
+ * mirrors {@link module:engine/retired-fields}, whose retired-field messages
21
+ * are likewise positioned by whoever reports them.
22
+ *
23
+ * @param {object|null|undefined} fm - Parsed frontmatter.
24
+ * @returns {Array<{key: string, message: string}>} One entry per field the note
25
+ * authored, empty for any note that is not a homepage.
26
+ */
27
+ export function checkHomepageAddressFields(fm: object | null | undefined): Array<{
28
+ key: string;
29
+ message: string;
30
+ }>;
31
+ /**
32
+ * Require exactly one homepage note in a content tree (#52).
33
+ *
34
+ * "Exactly one" is two rules, and they are **one severity** because they are
35
+ * one defect: a package whose front page is not the page a person chose.
36
+ *
37
+ * - _None_ and the package serves nothing at `/<package>/`. That is the failure
38
+ * #50 exists to prevent, and it is silent — the site build reports `wrote 0
39
+ * homepage(s)` and exits 0.
40
+ * - _Two_ and it serves a page nobody chose. Every homepage is written to the
41
+ * same {@link HOMEPAGE_DESTINATION}, so the second overwrites the first and
42
+ * the package's front page is decided by the order the walk happened to reach
43
+ * the files in — by *filename*, on a type whose whole point is that it is
44
+ * routed by frontmatter. There is no "first wins" convention to fall back on,
45
+ * so nothing here can pick the right one.
46
+ *
47
+ * Neither has a safe default, so neither is a warning. A warning is the right
48
+ * severity for something a build can proceed past correctly, and a build that
49
+ * proceeds past either of these publishes the wrong front page while reporting
50
+ * success — which is the exact outcome a warning would be tolerating.
51
+ *
52
+ * **Two is reported once per note, not once for the tree.** Each note is a
53
+ * place an author has to open and edit, and a single finding saying "there are
54
+ * two" sends them hunting for the second.
55
+ *
56
+ * **None is located at the tree, honestly.** There is no file to name, so the
57
+ * locator is the content root — the directory the note is missing from, which
58
+ * is a real path and the one the author adds it to. No line and no column are
59
+ * invented for it, per the diagnostic rules in
60
+ * {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
61
+ * empty walk against the same locator.
62
+ *
63
+ * The rule reads no `site:` configuration and does not vary by
64
+ * `publish.site`: that setting chooses whether the *content* surfaces are
65
+ * published, and the homepage is the floor underneath both modes.
66
+ *
67
+ * @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
68
+ * order. Paths may be absolute or relative to the working directory.
69
+ * @param {object} options - Options.
70
+ * @param {string} options.contentBase - Root of the content tree, for the
71
+ * locator when there is no file to name.
72
+ * @param {string} [options.contentPackage] - The package this tree builds.
73
+ * Dropped from the message when unknown rather than guessed.
74
+ * @returns {Array<{file: string, line?: number, column?: number,
75
+ * severity: "error", message: string}>} The findings, one per offending note.
76
+ */
77
+ export function checkHomepageCount(found: ReadonlyArray<{
78
+ file: string;
79
+ }>, { contentBase, contentPackage }: {
80
+ contentBase: string;
81
+ contentPackage?: string | undefined;
82
+ }): Array<{
83
+ file: string;
84
+ line?: number;
85
+ column?: number;
86
+ severity: "error";
87
+ message: string;
88
+ }>;
8
89
  /**
9
90
  * The title a homepage publishes under.
10
91
  *
@@ -107,6 +188,42 @@ export const HOMEPAGE_FIELDS: readonly import("./field-spec.mjs").FieldSpec[];
107
188
  * @type {string}
108
189
  */
109
190
  export const HOMEPAGE_DESTINATION: string;
191
+ /**
192
+ * The top-level fields a homepage refuses, and what each one would decide (#53).
193
+ *
194
+ * A note's URL derives from `name.full` and its identity from
195
+ * `(type, shortcode)`. The homepage is the one page for which neither holds: it
196
+ * publishes at `/<package>/`, fixed by the package id. An author fluent in the
197
+ * conventions writes them here expecting exactly what they do everywhere else,
198
+ * and gets none of it.
199
+ *
200
+ * **They were never inert, which is why ignoring them was the wrong answer.** A
201
+ * `shortcode` puts the note in the address index and in the `dataview` link
202
+ * universe, so `[[homepage-<shortcode>]]` resolves *green* — to
203
+ * `homepage/<slug>/`, an address derived from `name.full` and published by
204
+ * nothing, because a homepage is written to {@link HOMEPAGE_DESTINATION} at the
205
+ * package root. A build that reports a live link to a 404 is worse than one
206
+ * that says nothing. It also inflates `content-build lint`'s address tally, so
207
+ * the lint and the link manifest disagree about what the package publishes.
208
+ *
209
+ * **A named class, not an allow-list, and that boundary is the decision.** The
210
+ * documented envelope is `type` plus an optional `title`, and `landing`,
211
+ * `description` and `banner` are legitimate beside them — but a homepage's
212
+ * frontmatter is *emitted into the published page*
213
+ * ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
214
+ * parameter this build has never heard of and has no standing to refuse.
215
+ * Rejecting unknown keys would make every new theme parameter wait on a
216
+ * package-build release. What is refused is the specific class that makes a
217
+ * false claim about *where this page is*.
218
+ *
219
+ * `aliases` is deliberately not in the class: {@link homepageFrontmatter}
220
+ * already drops it from every emitted page, with a reason of its own, so
221
+ * authoring one is the same no-op it is on any other page rather than a wrong
222
+ * belief about this one's address.
223
+ *
224
+ * @type {ReadonlyMap<string, string>}
225
+ */
226
+ export const HOMEPAGE_REFUSED_FIELDS: ReadonlyMap<string, string>;
110
227
  /**
111
228
  * The two frontmatter keys that hold an address, and what each one means.
112
229
  *
@@ -93,15 +93,21 @@ export function buildPages(rawPages: Array<object>, entryId: string, noteName: s
93
93
  * heading; see {@link splitPages}.
94
94
  * @param {string|null} [params.folder] - The folder id, or `null`.
95
95
  * @param {object} [params.flags] - Document flags.
96
+ * @param {object} [params.stats] - The `_stats` block to stamp. Passed by the
97
+ * caller because it is a property of the *pack* being written, not of the
98
+ * entry: a module may ship the same content for two systems, and each pack's
99
+ * documents record the system version they were built against (#48). A
100
+ * caller with no pack in hand gets the package-wide block.
96
101
  * @returns {object} The JournalEntry document, keyed for the pack.
97
102
  */
98
- export function buildJournalEntry({ id, name, markdown, leadName, folder, flags, }: {
103
+ export function buildJournalEntry({ id, name, markdown, leadName, folder, flags, stats, }: {
99
104
  id: string;
100
105
  name: string;
101
106
  markdown: string;
102
107
  leadName?: string | undefined;
103
108
  folder?: string | null | undefined;
104
109
  flags?: object | undefined;
110
+ stats?: object | undefined;
105
111
  }): object;
106
112
  export class Journals extends BasePackCompiler {
107
113
  /**
@@ -14,6 +14,28 @@
14
14
  * {@link CONFIG_FILENAMES}.
15
15
  */
16
16
  export function findConfigFile(from: string): string | undefined;
17
+ /**
18
+ * Attach the position of the key a configuration error names.
19
+ *
20
+ * Eighty-one checks across `content-config.mjs` and `config.mjs` report through
21
+ * one `fail()`, which knows the offending key's dotted path and nothing
22
+ * about where it was written. Locating one of them and not the rest would be
23
+ * worse than locating none — a reader would learn that some configuration
24
+ * errors carry a position and could not predict which — so the path rides on
25
+ * the error and every one of them is located here, at the boundary that knows
26
+ * which file was read (#95).
27
+ *
28
+ * The message keeps its body and gains the `file:line:column: error: ` prefix
29
+ * every other finding in this build already uses, so nothing a reader has today
30
+ * is lost. `located` marks it done, so an error crossing two boundaries is
31
+ * decorated once; the fields are also left on the error, for a caller that
32
+ * wants to re-render it.
33
+ *
34
+ * @param {unknown} err - What was thrown.
35
+ * @param {string} [configPath] - The configuration file that was read.
36
+ * @returns {unknown} The same error, decorated when it named a field.
37
+ */
38
+ export function locateConfigError(err: unknown, configPath?: string): unknown;
17
39
  /**
18
40
  * Turn a parsed YAML configuration into the frozen one the engine reads.
19
41
  *
@@ -36,8 +36,16 @@ export function checkFormatting(root: string, opts?: {
36
36
  *
37
37
  * {@link MARKDOWNLINT_CONFIG} is passed as markdownlint's `optionsDefault`,
38
38
  * which is precisely the "shipped default, consumer overrides" behaviour the
39
- * command promises: a `.markdownlint-cli2.jsonc` found in the tree replaces it,
40
- * and a repository with none gets these rules.
39
+ * command promises: a repository with no configuration of its own gets these
40
+ * rules, and a `.markdownlint-cli2.jsonc` found in the tree overrides them.
41
+ *
42
+ * The override is **key by key, and each key wholesale** — which is not the same
43
+ * as "replaces it", and the difference is the one worth stating. A consumer file
44
+ * declaring only `ignores` keeps this rule set intact, including `default: false`
45
+ * and every per-rule option; but its `ignores` *replaces*
46
+ * {@link MARKDOWN_IGNORES} rather than extending it, so such a file must restate
47
+ * every shared entry it still wants. Omitting `CHANGELOG.md` there silently
48
+ * starts linting a generated file.
41
49
  *
42
50
  * @param {string} root - Repository to lint.
43
51
  * @param {object} [opts]
@@ -50,9 +50,9 @@ export function collectTreePages(tree: object, ctx: object): {
50
50
  * packages ship under is a property of the code path rather than of a
51
51
  * configuration that happens to be empty (#55).
52
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.
53
+ * Returned as a list rather than as the one note there should be, because the
54
+ * count is what {@link checkHomepageCount} judges (#52) — this walk reports
55
+ * what it found, and {@link buildSite} decides whether that is one.
56
56
  *
57
57
  * @param {string} contentBase - Absolute path to the content tree.
58
58
  * @param {object} ctx - `{ skipDirectories }`.
@@ -129,6 +129,31 @@ export function gatesFailed(gates: any): boolean;
129
129
  * @returns {Map<string, object[]>} Package → the notes it may tabulate.
130
130
  */
131
131
  export function tableUniverse(pages: object[]): Map<string, object[]>;
132
+ /**
133
+ * The front matter a section's landing states about itself.
134
+ *
135
+ * The section metadata a configuration resolved, ready to be written or merged
136
+ * onto a page. Two things happen here and nothing else does:
137
+ *
138
+ * - **`title` leads.** It is the one key every landing has carried since the
139
+ * first one, and a landing whose block opened with `banner:` would be a
140
+ * gratuitous diff on every consumer's tree.
141
+ * - **An absent value is left off**, not written as `undefined` — which is not
142
+ * a value YAML can carry, and would abort the serializer.
143
+ *
144
+ * Everything else the section declared is passed through. That is the point of
145
+ * the function: before #91 both writers transcribed `title` and `banner` by
146
+ * name, so the vocabulary lived in three places — the schema that admits a key
147
+ * and the two writers that copy it — and a key added to the schema alone
148
+ * validated cleanly and then reached no page. The *schema* is the bound worth
149
+ * keeping (see `normalizeSectionMeta`, which refuses a key it does not know and
150
+ * names it); a second, silent bound in the writers is not.
151
+ *
152
+ * @param {object} meta - A resolved `site.sections` / `site.readmeSections`
153
+ * entry.
154
+ * @returns {object} Its front matter, `title` first.
155
+ */
156
+ export function sectionFrontmatter(meta: object): object;
132
157
  /**
133
158
  * The frontmatter a page publishes with.
134
159
  *
@@ -23,6 +23,30 @@
23
23
  * @returns {ParsedWikilink} The parts, each trimmed.
24
24
  */
25
25
  export function parseWikilink(rawInner: string): ParsedWikilink;
26
+ /**
27
+ * The label an author actually supplied, or `null` when they supplied none.
28
+ *
29
+ * **An empty label is not a label.** `[[x|]]` is deliberately writable — it
30
+ * means "address this target, and show the target's own name" — so `display:
31
+ * ""` has to read as *absent* everywhere a fallback is chosen, exactly as
32
+ * `display: null` does. The two are still distinguishable through
33
+ * {@link ParsedWikilink.labelled}, which is the thing that genuinely differs
34
+ * and which #1409 depends on.
35
+ *
36
+ * Stated here because the two resolvers had already drawn the line in two
37
+ * places and drawn it differently: the packs tested falsiness and were right,
38
+ * the web tested `??` — which falls through on `null` only — and emitted
39
+ * `[](/url/)`, a link with no clickable text, through every build (#113). That
40
+ * is the same drift this module exists to prevent, in the case its own
41
+ * {@link ParsedWikilink} docstring calls out. One reading, one place.
42
+ *
43
+ * @param {{display: string|null}} parsed - A parsed wikilink, or anything
44
+ * carrying its `display`.
45
+ * @returns {string|null} The label, or `null` when there is none to show.
46
+ */
47
+ export function authoredLabel({ display }: {
48
+ display: string | null;
49
+ }): string | null;
26
50
  /**
27
51
  * Whether a parsed link addresses a section of the page it is written on.
28
52
  *
@@ -1,7 +1,7 @@
1
1
  export class Actors extends BasePackCompiler {
2
2
  constructor({ itemsSourceDirs, foreignSourceDirs, ...options }: {
3
3
  [x: string]: any;
4
- itemsSourceDirs: any;
4
+ itemsSourceDirs?: never[] | undefined;
5
5
  foreignSourceDirs?: never[] | undefined;
6
6
  });
7
7
  /** @type {readonly string[]} */
@@ -60,8 +60,8 @@ export class Actors extends BasePackCompiler {
60
60
  system: {
61
61
  shortcode: any;
62
62
  portrait: string;
63
- appearance: any;
64
- dossier: any;
63
+ appearance: string;
64
+ dossier: string;
65
65
  };
66
66
  items: any[];
67
67
  prototypeToken: {