@heroiclands/package-build 20.4.0 → 20.6.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 (78) hide show
  1. package/CHANGELOG.md +288 -0
  2. package/CONTENT.md +213 -20
  3. package/README.md +19 -1
  4. package/bin/content-build.mjs +135 -32
  5. package/bin/package-build.mjs +46 -13
  6. package/content-config.mjs +345 -101
  7. package/docs/api.md +1352 -0
  8. package/docs/commands.md +1609 -0
  9. package/docs/configuration.md +1432 -0
  10. package/docs/content-format.md +16 -6
  11. package/docs/diagnostics.md +356 -0
  12. package/docs/getting-started.md +813 -0
  13. package/docs/project-setup.md +469 -0
  14. package/engine/actor-compiler.mjs +30 -27
  15. package/engine/address-diff.mjs +45 -41
  16. package/engine/base-compiler.mjs +6 -0
  17. package/engine/bundles.mjs +9 -0
  18. package/engine/content-address.mjs +9 -9
  19. package/engine/content-index.mjs +44 -23
  20. package/engine/content-links.mjs +44 -11
  21. package/engine/content-lint.mjs +44 -10
  22. package/engine/content-tables.mjs +32 -27
  23. package/engine/folder-notes.mjs +4 -2
  24. package/engine/frontmatter-lint.mjs +35 -38
  25. package/engine/generate.mjs +5 -0
  26. package/engine/helpers.mjs +86 -32
  27. package/engine/index.mjs +12 -2
  28. package/engine/journals.mjs +9 -0
  29. package/engine/note-claims.mjs +18 -10
  30. package/engine/note-schemas.mjs +0 -5
  31. package/engine/note-vocabulary.mjs +32 -31
  32. package/engine/pack-config.mjs +26 -12
  33. package/engine/pack-router.mjs +0 -0
  34. package/engine/pdf-build.mjs +464 -0
  35. package/engine/pdf-fonts.mjs +420 -0
  36. package/engine/pdf-render.mjs +876 -0
  37. package/engine/pdf-toc.mjs +525 -0
  38. package/engine/scenes.mjs +14 -5
  39. package/engine/schema-check.mjs +1 -1
  40. package/engine/site-build.mjs +21 -3
  41. package/engine/web-wikilinks.mjs +6 -3
  42. package/engine/wikilinks.mjs +2 -4
  43. package/hm3/actors.mjs +8 -0
  44. package/hm3/items.mjs +8 -0
  45. package/package.json +1 -1
  46. package/release.mjs +63 -3
  47. package/sohl/actors.mjs +8 -0
  48. package/sohl/items.mjs +8 -0
  49. package/sohl/note-schemas.mjs +5 -5
  50. package/types/content-config.d.mts +66 -15
  51. package/types/engine/actor-compiler.d.mts +34 -30
  52. package/types/engine/address-diff.d.mts +57 -3
  53. package/types/engine/base-compiler.d.mts +10 -2
  54. package/types/engine/bundles.d.mts +9 -0
  55. package/types/engine/content-address.d.mts +9 -9
  56. package/types/engine/content-index.d.mts +57 -13
  57. package/types/engine/content-lint.d.mts +6 -4
  58. package/types/engine/content-tables.d.mts +49 -18
  59. package/types/engine/frontmatter-lint.d.mts +3 -2
  60. package/types/engine/helpers.d.mts +105 -31
  61. package/types/engine/index.d.mts +4 -0
  62. package/types/engine/journals.d.mts +9 -0
  63. package/types/engine/note-claims.d.mts +17 -10
  64. package/types/engine/note-vocabulary.d.mts +23 -196
  65. package/types/engine/pack-config.d.mts +4 -4
  66. package/types/engine/pdf-build.d.mts +42 -0
  67. package/types/engine/pdf-fonts.d.mts +30 -0
  68. package/types/engine/pdf-render.d.mts +156 -0
  69. package/types/engine/pdf-toc.d.mts +114 -0
  70. package/types/engine/scenes.d.mts +10 -1
  71. package/types/engine/schema-check.d.mts +2 -2
  72. package/types/engine/site-build.d.mts +34 -6
  73. package/types/engine/wikilinks.d.mts +2 -3
  74. package/types/hm3/actors.d.mts +8 -0
  75. package/types/hm3/items.d.mts +8 -0
  76. package/types/release.d.mts +15 -4
  77. package/types/sohl/actors.d.mts +10 -2
  78. package/types/sohl/items.d.mts +8 -0
@@ -130,24 +130,6 @@ export function parseMarkdownFile(filePath) {
130
130
  return { frontmatter, body, description, bodyLine, bodyColumn };
131
131
  }
132
132
 
133
- /**
134
- * Recursively yields every `.md` file under `rootDir`, parsed.
135
- * Yields `{ frontmatter, body, description, file, absPath, bodyLine,
136
- * bodyColumn }` for each match — the last two from
137
- * {@link parseMarkdownFile}, so a caller can report a position inside the
138
- * body as a position in the file.
139
- * Silently skips directories that don't exist.
140
- *
141
- * Directory names in `skipDirectories` are ignored wherever they appear. The
142
- * walk itself knows nothing about what they mean: `Templates/` is an Obsidian
143
- * templater convention this repository's vault happens to use, not a property
144
- * of a content tree, so it is configured rather than hard-coded.
145
- *
146
- * @param {string} rootDir - Root of the tree to walk.
147
- * @param {object} [opts]
148
- * @param {readonly string[]} [opts.skipDirectories] - Directory names to ignore.
149
- * Defaults to the configured list.
150
- */
151
133
  /**
152
134
  * Refuse a corpus read whose scope its caller did not state.
153
135
  *
@@ -203,6 +185,31 @@ export function assertSuppliedCorpus(records, who) {
203
185
  }
204
186
  }
205
187
 
188
+ /**
189
+ * Recursively yields every `.md` file under `rootDir`, parsed.
190
+ *
191
+ * Yields `{ frontmatter, body, description, file, absPath, bodyLine,
192
+ * bodyColumn }` for each match — the last from {@link parseMarkdownFile}, so a
193
+ * caller can report a position inside the body as a position in the file. A
194
+ * root that does not exist yields nothing, and a directory that cannot be read
195
+ * is warned about and skipped.
196
+ *
197
+ * Directory names in `skipDirectories` are ignored wherever they appear. The
198
+ * walk itself knows nothing about what they mean: `Templates/` is an Obsidian
199
+ * templater convention this repository's vault happens to use, not a property
200
+ * of a content tree, so it is stated by the caller rather than hard-coded.
201
+ *
202
+ * @param {string} rootDir - Root of the tree to walk.
203
+ * @param {object} opts
204
+ * @param {readonly string[]} opts.skipDirectories - Directory names to ignore.
205
+ * Required: the scope is the caller's to state, so two passes cannot
206
+ * disagree about which files are the corpus.
207
+ * @yields {{frontmatter: object|null, body: string, description: string,
208
+ * file: string, absPath: string, bodyLine?: number, bodyColumn?: number}}
209
+ * One entry per `.md` file found.
210
+ * @throws {Error} When `skipDirectories` is not stated — see
211
+ * {@link assertStatedScope}.
212
+ */
206
213
  export function* walkMarkdownTree(rootDir, { skipDirectories } = {}) {
207
214
  // Stated by the caller, never resolved here. A default here
208
215
  // — `loadPackConfig().skipDirectories` — read whichever configuration
@@ -415,11 +422,6 @@ export function makeFilename(name, id) {
415
422
  return `${unidecode(name)}_${id}`.replace(/[^0-9a-zA-Z]+/g, "_") + ".json";
416
423
  }
417
424
 
418
- /**
419
- * Standardize a name into a slug: lowercase, apostrophes removed,
420
- * non-alphanumerics collapsed to single hyphens.
421
- */
422
-
423
425
  /**
424
426
  * The path prefixes that name a package other than the one being compiled.
425
427
  *
@@ -537,11 +539,19 @@ function addressesAnotherPackage(s) {
537
539
  * `itemArt()`, which runs the path back through this function so a registry
538
540
  * entry and a note's `img:` are spelled the same way (#7).
539
541
  *
542
+ * **A package with no asset root cannot answer at all.** `assetRoot` is derived
543
+ * from the package kind, and a `documentation` package has none: Foundry serves
544
+ * no files for it. Only a compiling pass reaches here, and a documentation
545
+ * package runs none, so a path arriving with no root to put it under is a pass
546
+ * running where it should not — reported as that, rather than emitted as
547
+ * `null/icons/relic.svg` into a document nobody would check.
548
+ *
540
549
  * @param {string | null | undefined} raw - content-relative path from frontmatter.
541
- * @param {{assetRoot: string}} [config] - The resolved build configuration.
550
+ * @param {{assetRoot: string|null}} [config] - The resolved build configuration.
542
551
  * Defaults to this repository's.
543
552
  * @returns {string | null} the Foundry-relative path; `""` for a deliberate
544
553
  * blank, and `null` when the note names no art at all.
554
+ * @throws {Error} When the configuration has no asset root.
545
555
  */
546
556
  export function resolveImg(raw, config = loadPackConfig()) {
547
557
  // Unset — the caller's default applies. An absent key arrives as
@@ -552,6 +562,14 @@ export function resolveImg(raw, config = loadPackConfig()) {
552
562
  if (s === "") return "";
553
563
  // Somebody else's to serve — emit it exactly as authored.
554
564
  if (addressesAnotherPackage(s)) return s;
565
+ if (!config.assetRoot) {
566
+ throw new Error(
567
+ `package-build: \`${s}\` names a file this package serves, and a ` +
568
+ `\`documentation\` package has no asset root to serve it from — ` +
569
+ `Foundry installs no such package. Address the owning package ` +
570
+ `(\`systems/…\`, \`modules/…\`) or a URL.`,
571
+ );
572
+ }
555
573
  // Ours, so root it where Foundry serves this package's files from.
556
574
  return `${config.assetRoot}/${s}`;
557
575
  }
@@ -689,6 +707,13 @@ export function defaultStats() {
689
707
  return cachedDefaultStats;
690
708
  }
691
709
 
710
+ // The one slug rule, re-exported so callers keep a single import path.
711
+ /**
712
+ * Standardize a name into a slug: lowercase, apostrophes removed,
713
+ * non-alphanumerics collapsed to single hyphens.
714
+ */
715
+ export { slugify } from "./content-slug.mjs";
716
+
692
717
  /**
693
718
  * Stable 16-char hex id derived from `${namespace}:${value}`.
694
719
  *
@@ -696,9 +721,6 @@ export function defaultStats() {
696
721
  * resolver this one imports can derive ids too — and re-exported here for the
697
722
  * passes that have always reached it through `helpers`.
698
723
  */
699
- // The one slug rule, re-exported so callers keep a single import path.
700
- export { slugify } from "./content-slug.mjs";
701
-
702
724
  export { makeId } from "./ids.mjs";
703
725
 
704
726
  // The content-type → document-type map, which decides *which* pack list a
@@ -730,6 +752,15 @@ import { collectAnchors } from "./anchors.mjs";
730
752
  * @param {object} [router] - The pack router. Supplied by the calling pass so
731
753
  * the index and the compile agree about where each note landed; defaults to
732
754
  * this repository's own.
755
+ * @param {object} [opts]
756
+ * @param {readonly string[]} [opts.skipDirectories] - Part of the options bag
757
+ * every corpus reader takes; the scope is already settled by `records`.
758
+ * @param {object} [opts.config] - The resolved build configuration; loaded when
759
+ * omitted.
760
+ * @param {readonly object[]} [opts.records] - The corpus, derived once per
761
+ * compile and handed in. Required: see {@link assertSuppliedCorpus}.
762
+ * @param {object[]} [opts.problems] - Part of the same options bag; the notes
763
+ * the index cannot record are collected where the corpus is derived.
733
764
  * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
734
765
  */
735
766
  export function buildContentLinkIndex(
@@ -827,11 +858,21 @@ export function buildContentLinkIndex(
827
858
  * inventing a position.
828
859
  *
829
860
  * @param {string} body - The note's markdown body, tables already expanded.
830
- * @param {object} ctx - `{ type, id, pack, docPack, index, name }` — `name` is
831
- * used in the message, and the two pack names address a `[[#slug]]`
832
- * self-link, whose target is the source note itself and so has no index
833
- * entry. Position is carried by `{ file, bodyLine, bodyColumn, lineMap }`,
834
- * the last from {@link expandNoteTables}.
861
+ * @param {object} ctx
862
+ * @param {string} ctx.type - The source note's content type.
863
+ * @param {string} ctx.id - The source note's document id.
864
+ * @param {string} ctx.pack - The pack the note's own document lands in, which
865
+ * addresses a `[[#slug]]` self-link: its target is the source note itself, so
866
+ * it has no index entry.
867
+ * @param {string} ctx.docPack - The pack the note's documentation journal lands
868
+ * in, addressing a self-link the same way.
869
+ * @param {object} ctx.index - The address index every link resolves through.
870
+ * @param {string} ctx.name - The note, for the message.
871
+ * @param {string} [ctx.file] - The note's file, so a report names it.
872
+ * @param {number} [ctx.bodyLine] - 1-based file line of the body's first line.
873
+ * @param {number} [ctx.bodyColumn] - 1-based file column of the same character.
874
+ * @param {Array<{line: number, generated: boolean}>} [ctx.lineMap] - Which
875
+ * authored line each body line came from, from {@link expandNoteTables}.
835
876
  * @returns {{markdown: string, unresolved: Array<object>}}
836
877
  * @throws {Error} On any link that does not resolve — an unlabelled one, a
837
878
  * target that is not an address, or an address nothing publishes. The error
@@ -908,6 +949,15 @@ export function convertNoteWikilinks(
908
949
  * a table that leaves rows tied still emits identically on every build.
909
950
  *
910
951
  * @param {string} contentBase - Root of the content tree.
952
+ * @param {object} [opts]
953
+ * @param {readonly string[]} [opts.skipDirectories] - Part of the options bag
954
+ * every corpus reader takes; the scope is already settled by `records`.
955
+ * @param {object} [opts.config] - The resolved build configuration; loaded when
956
+ * omitted.
957
+ * @param {readonly object[]} [opts.records] - The corpus, derived once per
958
+ * compile and handed in. Required: see {@link assertSuppliedCorpus}.
959
+ * @param {object[]} [opts.problems] - Part of the same options bag; the notes
960
+ * the walk cannot read are collected where the corpus is derived.
911
961
  * @returns {Array<{fm: object, path: string, tld: string, folder: string,
912
962
  * absPath: string}>}
913
963
  */
@@ -972,6 +1022,10 @@ const packLinkable = (doc) => Boolean(doc.fm?.shortcode) && Boolean(doc.fm?.type
972
1022
  * query's `this` reads. Its entry in `docs` supplies the path as well.
973
1023
  * @param {number} [ctx.bodyLine] - 1-based file line of the body's first line,
974
1024
  * so a failing directive can be reported at its position in the file.
1025
+ * @param {object[]} [ctx.sqlTables] - This note's prepared `sql` results, in
1026
+ * document order, from
1027
+ * {@link module:engine/sql-tables.prepareSqlTables}. An `sql` directive with
1028
+ * no prepared result fails the note: nothing here runs a query.
975
1029
  * @returns {{markdown: string, lineMap: Array<{line: number,
976
1030
  * generated: boolean}>}} The body with every table expanded, and where each
977
1031
  * emitted line came from — which is what lets a diagnostic about the
package/engine/index.mjs CHANGED
@@ -87,8 +87,6 @@ export * as noteVocabulary from "./note-vocabulary.mjs";
87
87
  /** The closed registry of system ids, and the `none` that stands for no system. */
88
88
  export * as systems from "./systems.mjs";
89
89
 
90
- /** The shipped Foundry manifest: locating it, reading it, guarding its id. */
91
-
92
90
  /** The one normalisation this build makes: prose to a URL-safe token. */
93
91
  export * as contentSlug from "./content-slug.mjs";
94
92
 
@@ -158,6 +156,18 @@ export * as wikilinkSyntax from "./wikilink-syntax.mjs";
158
156
  /** The address index a site build resolves its wikilinks against. */
159
157
  export * as siteIndex from "./site-index.mjs";
160
158
 
159
+ /** The document tree a book selects, and the plan it resolves to. */
160
+ export * as pdfToc from "./pdf-toc.mjs";
161
+
162
+ /** Markdown and a plan, rendered as Typst source. Pure. */
163
+ export * as pdfRender from "./pdf-render.mjs";
164
+
165
+ /** Which glyph an icon name resolves to, read from the font that carries it. */
166
+ export * as pdfFonts from "./pdf-fonts.mjs";
167
+
168
+ /** The content tree, built into a book. Reads the tree; runs the compiler. */
169
+ export * as pdfBuild from "./pdf-build.mjs";
170
+
161
171
  /** The shape every pack compiler shares. */
162
172
  export * as baseCompiler from "./base-compiler.mjs";
163
173
 
@@ -303,6 +303,15 @@ export function buildJournalEntry({
303
303
  };
304
304
  }
305
305
 
306
+ /**
307
+ * Journals pack compiler.
308
+ *
309
+ * Walks the content tree and compiles every `type: doc` note, and every note of
310
+ * a doc-carrying type, into one JournalEntry document: the body split into
311
+ * pages on its top-level H1 headings, each rendered to HTML. A doc-carrying
312
+ * note's entry is that document's documentation, filed in the document's own
313
+ * folder.
314
+ */
306
315
  export class Journals extends BasePackCompiler {
307
316
  static id = "journals";
308
317
  static label = "journal";
@@ -111,6 +111,23 @@ import { NOTE_VOCABULARY } from "./note-vocabulary.mjs";
111
111
  */
112
112
  export const NEVER_PACKED_TYPES = Object.freeze(new Set([HOMEPAGE_TYPE]));
113
113
 
114
+ /**
115
+ * The whole note vocabulary of a package that compiles no Foundry documents.
116
+ *
117
+ * Every other type in the vocabulary exists to *become* a document: a `skill` is
118
+ * an Item, a `being` an Actor, a `place` a JournalEntry, a `folder` the Folder
119
+ * they are filed under. In a `documentation` package none of them has a
120
+ * destination, so a note carrying one would be authored, validated, walked and
121
+ * then published as a page of something that was meant to be a compendium
122
+ * entry — the plausible-looking result that reads as success.
123
+ *
124
+ * `doc` is prose whose single document *is* the prose, and `homepage` is the
125
+ * authored front page every package publishes. Both are already pages first.
126
+ *
127
+ * @type {ReadonlySet<string>}
128
+ */
129
+ export const DOCUMENTATION_NOTE_TYPES = Object.freeze(new Set(["doc", HOMEPAGE_TYPE]));
130
+
114
131
  /**
115
132
  * Content types the specification states and this toolchain does not yet
116
133
  * compile.
@@ -229,10 +246,7 @@ function mappingSystems(maps, type) {
229
246
  * pack from appearing to answer for any note.
230
247
  *
231
248
  * @param {string} docType - The Foundry document type a pack holds.
232
- * @param {ClaimSources} [sources] - What to answer from.
233
- * @param {object} [opts] - Options.
234
- * @param {readonly object[]} [opts.records] - The corpus, derived once by the
235
- * compile and handed in — required, for the reason above. Defaults to the
249
+ * @param {ClaimSources} [sources] - What to answer from. Defaults to the
236
250
  * configured registries and the systems this toolchain ships.
237
251
  * @returns {ReadonlySet<string>} The note types such a pass would claim.
238
252
  */
@@ -403,9 +417,6 @@ function noteHasProse(absPath) {
403
417
  * @param {object} [config] - The resolved build configuration. Defaults to this
404
418
  * repository's.
405
419
  * @param {ClaimSources} [sources] - What to answer from.
406
- * @param {object} [opts] - Options.
407
- * @param {readonly object[]} [opts.records] - The corpus, derived once by the
408
- * compile and handed in — required, for the reason above.
409
420
  * @returns {ReadonlySet<string>} The claimed note types.
410
421
  */
411
422
  export function claimedNoteTypes(config = loadPackConfig(), sources) {
@@ -427,9 +438,6 @@ export function claimedNoteTypes(config = loadPackConfig(), sources) {
427
438
  * declare on top.
428
439
  *
429
440
  * @param {ClaimSources} [sources] - What to answer from.
430
- * @param {object} [opts] - Options.
431
- * @param {readonly object[]} [opts.records] - The corpus, derived once by the
432
- * compile and handed in — required, for the reason above.
433
441
  * @returns {ReadonlySet<string>} The vocabulary.
434
442
  */
435
443
  export function noteTypeVocabulary(sources) {
@@ -34,11 +34,6 @@
34
34
 
35
35
  import { HOMEPAGE_FIELDS, HOMEPAGE_TYPE } from "./homepage.mjs";
36
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
37
  /**
43
38
  * A note that compiles to a JournalEntry and nothing else.
44
39
  *
@@ -53,8 +53,8 @@
53
53
  * have swept.
54
54
  *
55
55
  * **A type name and a subType value are held to the address charset**, so
56
- * both are `^[A-Za-z0-9]+$` — the charset `engine/address-charset.mjs` states
57
- * and the shortcode is already held to. For a type that is literal: it is a
56
+ * both match `ADDRESS_SEGMENT_PATTERN` — the charset `engine/address-charset.mjs`
57
+ * states and the shortcode is already held to. For a type that is literal: it is a
58
58
  * segment of every address — the first of the short form an author writes
59
59
  * (`type-shortcode`), the third of the canonical
60
60
  * `package-system-type-shortcode` — the hyphen is the separator between
@@ -223,17 +223,20 @@ const CHARGES = Object.freeze([
223
223
  /* --------------------------------------------------------------------- */
224
224
 
225
225
  /**
226
- * Every note type this toolchain compiles, and the closed vocabulary it
227
- * declares.
226
+ * The declared tag that marks a note as **unfinished**.
228
227
  *
229
- * Taken from the content-format specification, one `### type:` section per
230
- * entry. Where the specification and the shape notes are authored in today
231
- * disagree, the specification wins on the **name** that is what a `data:`
232
- * key will be called — and the disagreement is recorded on the field rather
233
- * than resolved silently.
228
+ * Named once and referenced from the declaration below, because a second
229
+ * spelling is how the two come apart: rename the tag in `DECLARED_TAGS` and a
230
+ * private copy elsewhere keeps matching the old word, silently.
234
231
  *
235
- * @type {Readonly<Record<string, TypeVocabulary>>}
232
+ * It is a **presentation** fact and nothing more. A draft note compiles,
233
+ * validates, publishes and resolves like any other; only a link *into* it
234
+ * renders marked. What it emphatically is not is the retired `draft:` field,
235
+ * whose entire effect was to move a note from published to unresolvable — see
236
+ * {@link draftRetiredMessage}.
236
237
  */
238
+ export const DRAFT_TAG = "draft";
239
+
237
240
  /**
238
241
  * The tags that **classify** a note, grouped by what they classify.
239
242
  *
@@ -260,21 +263,6 @@ const CHARGES = Object.freeze([
260
263
  * fishing village is a `village` that is `fishing`, and the single-valued field
261
264
  * this replaced had to spell it `Fishing Village` as a value of its own.
262
265
  */
263
- /**
264
- * The declared tag that marks a note as **unfinished**.
265
- *
266
- * Named once and referenced from the declaration below, because a second
267
- * spelling is how the two come apart: rename the tag in `DECLARED_TAGS` and a
268
- * private copy elsewhere keeps matching the old word, silently.
269
- *
270
- * It is a **presentation** fact and nothing more. A draft note compiles,
271
- * validates, publishes and resolves like any other; only a link *into* it
272
- * renders marked. What it emphatically is not is the retired `draft:` field,
273
- * whose entire effect was to move a note from published to unresolvable — see
274
- * {@link draftRetiredMessage}.
275
- */
276
- export const DRAFT_TAG = "draft";
277
-
278
266
  export const DECLARED_TAGS = Object.freeze({
279
267
  /** What a place *is*. */
280
268
  placeKind: Object.freeze({
@@ -413,6 +401,18 @@ export function isDraftNote(fm) {
413
401
  return hasTag(fm, DRAFT_TAG);
414
402
  }
415
403
 
404
+ /**
405
+ * Every note type this toolchain compiles, and the closed vocabulary it
406
+ * declares.
407
+ *
408
+ * Taken from the content-format specification, one `### type:` section per
409
+ * entry. Where the specification and the shape notes are authored in today
410
+ * disagree, the specification wins on the **name** — that is what a `data:`
411
+ * key will be called — and the disagreement is recorded on the field rather
412
+ * than resolved silently.
413
+ *
414
+ * @type {Readonly<Record<string, TypeVocabulary>>}
415
+ */
416
416
  export const NOTE_VOCABULARY = Object.freeze({
417
417
  /* ----- actors --------------------------------------------------- */
418
418
 
@@ -793,12 +793,13 @@ export const NOTE_VOCABULARY = Object.freeze({
793
793
  /* ----- core documents ------------------------------------------- */
794
794
 
795
795
  doc: Object.freeze({
796
- // `userguide`, not `user-guide`: a subType is held to the address
797
- // charset, and a segment carries no hyphen. The old spelling was
798
- // accepted transitionally for one release so the consumer trees could
799
- // sweep; they have, so it is refused by the charset check now, with no
800
- // retirement-specific code left over.
801
- subTypes: Object.freeze(["rules", "userguide", "reference"]),
796
+ // Five genres, and a genre is all this field carries: what kind of page
797
+ // it is, never who reads it. An audience term alongside them would give
798
+ // a developer how-to two valid values and no rule for choosing.
799
+ //
800
+ // `userguide` and `howto`, not `user-guide` and `how-to`: a subType is
801
+ // held to the address charset, and a segment carries no hyphen.
802
+ subTypes: Object.freeze(["rules", "userguide", "reference", "howto", "concept"]),
802
803
  data: Object.freeze([]),
803
804
  }),
804
805
 
@@ -104,7 +104,7 @@ import path from "node:path";
104
104
  import { createRequire } from "node:module";
105
105
  import YAML from "yaml";
106
106
 
107
- import { defineConfig, DERIVED_SYSTEM_VERSION } from "../content-config.mjs";
107
+ import { defineConfig, DERIVED_SYSTEM_VERSION, DOCUMENTATION_KIND } from "../content-config.mjs";
108
108
  import {
109
109
  emitDiagnostic,
110
110
  formatDiagnostic,
@@ -513,7 +513,7 @@ export function locateConfigError(err, configPath) {
513
513
  *
514
514
  * @param {unknown} data - The parsed configuration document.
515
515
  * @param {string} configPath - Absolute path of the file it was parsed from.
516
- * @returns {import("../config.mjs").ContentBuildConfig} The frozen configuration.
516
+ * @returns {import("../content-config.mjs").ContentBuildConfig} The frozen configuration.
517
517
  * @throws {Error} When the document is not a mapping, declares `rootDir`, or
518
518
  * names an item-builder registry this package does not ship.
519
519
  */
@@ -541,14 +541,19 @@ export function configFromData(data, configPath) {
541
541
  // Every consumer's copy matched exactly, which is what a transcription
542
542
  // looks like right up until it does not (a transcribed one freezes
543
543
  // four releases while nothing said so).
544
- if (input.foundryPackage !== undefined) {
545
- throw new Error(
546
- `package-build: ${configPath} declares \`foundryPackage\`, which ` +
547
- `a data configuration may not: it is the \`name\` of the ` +
548
- `\`package.json\` beside it. Remove the key.`,
549
- );
544
+ // A documentation package has no Foundry package, so there is no id to
545
+ // derive: the validator refuses the key by name, with a locator, and
546
+ // deriving one here would hand it the very key it is about to refuse.
547
+ if (input.packageKind !== DOCUMENTATION_KIND) {
548
+ if (input.foundryPackage !== undefined) {
549
+ throw new Error(
550
+ `package-build: ${configPath} declares \`foundryPackage\`, which ` +
551
+ `a data configuration may not: it is the \`name\` of the ` +
552
+ `\`package.json\` beside it. Remove the key.`,
553
+ );
554
+ }
555
+ input.foundryPackage = foundryPackageId(rootDir);
550
556
  }
551
- input.foundryPackage = foundryPackageId(rootDir);
552
557
 
553
558
  if (input.itemBuilders !== undefined) {
554
559
  const declared = input.itemBuilders;
@@ -590,7 +595,16 @@ export function configFromData(data, configPath) {
590
595
  }
591
596
 
592
597
  const stats = input.stats;
593
- if (stats !== null && typeof stats === "object" && !Array.isArray(stats)) {
598
+ // Skipped for a documentation package, which stamps no `_stats` and
599
+ // declares no system to take a version from: the derivation would throw a
600
+ // bare error about a missing system relationship, burying the located
601
+ // refusal of the `stats:` key that is the finding an author needs.
602
+ if (
603
+ input.packageKind !== DOCUMENTATION_KIND &&
604
+ stats !== null &&
605
+ typeof stats === "object" &&
606
+ !Array.isArray(stats)
607
+ ) {
594
608
  const declared = /** @type {Record<string, unknown>} */ (stats);
595
609
  // `stats.systemId` and `stats.systemVersion` are both refused by
596
610
  // `defineConfig`, which reports them with a locator — so nothing is
@@ -620,7 +634,7 @@ export function configFromData(data, configPath) {
620
634
  * Load an `.mjs` configuration — one that called `defineConfig` itself.
621
635
  *
622
636
  * @param {string} configPath - Absolute path of the file.
623
- * @returns {import("../config.mjs").ContentBuildConfig} What it exported.
637
+ * @returns {import("../content-config.mjs").ContentBuildConfig} What it exported.
624
638
  * @throws {Error} When its module graph uses top-level `await`, which a
625
639
  * synchronously-read configuration cannot.
626
640
  */
@@ -662,7 +676,7 @@ let loadedFrom;
662
676
  * configuration (#2). The result is memoised, so calling it in a default
663
677
  * parameter — the usual spelling here — costs one property read per call.
664
678
  *
665
- * @returns {import("../config.mjs").ContentBuildConfig} The frozen configuration.
679
+ * @returns {import("../content-config.mjs").ContentBuildConfig} The frozen configuration.
666
680
  * @throws {Error} When no configuration file can be found, or the one named
667
681
  * cannot be loaded. Absence is a defect, not a fallback: without it the
668
682
  * compilers know neither what to compile nor where to put it.
Binary file