@heroiclands/package-build 22.3.0 → 22.3.1

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,17 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 22.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - b2bb2b2: **`content-build docs item-fields`.** A destination under the content tree
8
+ (`assets/content/`) now writes a complete note — `type: doc`,
9
+ `subType: reference`, a derived `shortcode`, `name.full` and `pack: none` —
10
+ rather than a typeless page the content walk silently drops. A consumer
11
+ declares further note frontmatter under `docs.itemFields.frontmatter`,
12
+ deep-merged over the generated envelope; `--check` compares the whole file.
13
+ A destination outside the content tree is unaffected.
14
+
3
15
  ## 22.3.0
4
16
 
5
17
  ### Minor Changes
@@ -80,7 +80,7 @@ import {
80
80
  formatUnaddressableFinding,
81
81
  } from "../engine/metadata-index.mjs";
82
82
  import { fetchNavigation, generateHugoConfig, writeHugoConfig } from "../engine/site-config.mjs";
83
- import { renderItemFieldReference } from "../engine/field-reference.mjs";
83
+ import { renderItemFieldReference, renderItemFieldsPage } from "../engine/field-reference.mjs";
84
84
  import { lintContentTree } from "../engine/content-lint.mjs";
85
85
  import { lintContentCharset } from "../engine/content-charset.mjs";
86
86
  import { lintContentHtml } from "../engine/content-html.mjs";
@@ -284,6 +284,14 @@ const argv = yargs(hideBin(process.argv))
284
284
  * second implementation of the comparison. Staleness is a property of the whole
285
285
  * generated file, so there is no line to name.
286
286
  *
287
+ * **A destination under the content tree gets a note envelope**, so the walk
288
+ * that collects every note by its `type:` picks this one up too, rather than
289
+ * silently dropping it — `type: doc`, `subType: reference`, a `shortcode`
290
+ * derived from the destination's basename, `name.full` from the title, and
291
+ * `pack: none`; `docs.itemFields.frontmatter` deep-merges over it. `--check`
292
+ * compares the whole file, envelope included. A destination outside the
293
+ * content tree gets the page body alone, as before.
294
+ *
287
295
  * `--out` and `--title` still override, for a one-off render.
288
296
  *
289
297
  * @returns {object} The yargs command module.
@@ -332,13 +340,27 @@ function docsCommand() {
332
340
  const spec = config.docs?.itemFields ?? {};
333
341
  const destination =
334
342
  argv.out ?? (spec.out ? path.resolve(config.rootDir, spec.out) : null);
343
+ const pageTitle = title ?? spec.title ?? "Item Note Frontmatter";
335
344
 
336
- const page = `${renderItemFieldReference({
337
- ...((title ?? spec.title) ? { title: title ?? spec.title } : {}),
345
+ const body = `${renderItemFieldReference({
346
+ title: pageTitle,
338
347
  ...(spec.preamble ? { preamble: spec.preamble } : {}),
339
348
  generatedBy: "`content-build docs item-fields`",
340
349
  config,
341
350
  })}\n`;
351
+ // A page filed under the content tree is walked for its
352
+ // `type:` like any other note, so it needs the envelope; one
353
+ // filed anywhere else — a repository's own `docs/` — is not,
354
+ // and gets exactly the body it always did.
355
+ const page =
356
+ destination ?
357
+ renderItemFieldsPage(body, {
358
+ title: pageTitle,
359
+ destination,
360
+ contentRoot: config.paths.content,
361
+ frontmatter: spec.frontmatter,
362
+ })
363
+ : body;
342
364
 
343
365
  if (check) {
344
366
  if (!destination) {
@@ -528,6 +528,11 @@ export function publishesContentPages(config) {
528
528
  * Without it the page goes to stdout.
529
529
  * @property {string[]} [preamble] Lines between the generated banner and the
530
530
  * first table. Markdown, emitted verbatim.
531
+ * @property {Record<string, unknown>} [frontmatter] Further note frontmatter,
532
+ * deep-merged over the generated envelope
533
+ * (`type: doc`, `subType: reference`,
534
+ * `shortcode`, `name.full`, `pack: none`)
535
+ * when `out` is under the content tree.
531
536
  */
532
537
 
533
538
  /**
@@ -777,7 +782,7 @@ const PDF_KEYS = ["title", "subtitle", "document", "out", "front", "fonts", "ico
777
782
  const PDF_FONT_KEYS = ["serif", "sans", "mono", "path"];
778
783
  const EMPTY_PDF_FONTS = Object.freeze({ serif: "", sans: "", mono: "", path: "" });
779
784
  const SECTION_META_KEYS = ["title", "banner", "description", "listType", "listSubType"];
780
- const DOC_PAGE_KEYS = ["title", "out", "preamble"];
785
+ const DOC_PAGE_KEYS = ["title", "out", "preamble", "frontmatter"];
781
786
  const RELATIONSHIP_KINDS = ["systems", "requires", "recommends", "conflicts"];
782
787
  const RELATIONSHIP_KEYS = [
783
788
  "id",
@@ -1435,6 +1440,12 @@ function normalizeDocPage(value, where) {
1435
1440
  }),
1436
1441
  );
1437
1442
  }
1443
+ if (input.frontmatter !== undefined) {
1444
+ if (!isPlainObject(input.frontmatter)) {
1445
+ fail(`${where}.frontmatter`, "must be a mapping");
1446
+ }
1447
+ out.frontmatter = deepFreeze({ .../** @type {object} */ (input.frontmatter) });
1448
+ }
1438
1449
  return Object.freeze(out);
1439
1450
  }
1440
1451
 
package/docs/commands.md CHANGED
@@ -1064,6 +1064,29 @@ named. Reads the configured `itemBuilders` registries; writes (or checks)
1064
1064
  the destination file, or prints to stdout when none is configured and
1065
1065
  `--out` is not given.
1066
1066
 
1067
+ **A destination under `paths.content` gets a complete note, not a typeless
1068
+ page.** The content-tree walk collects every published page by its `type:`,
1069
+ so a generated page filed there needs the envelope every other note carries
1070
+ or the walk drops it silently — no build failure, no page, every wikilink
1071
+ into it dead. So when `--out` (or `docs.itemFields.out`) resolves inside the
1072
+ content tree, the written file carries:
1073
+
1074
+ - `type: doc`, `subType: reference` — out-of-world lookup material, like
1075
+ every other generated reference page;
1076
+ - `shortcode`, derived from the destination's basename — lowercase
1077
+ alphanumerics only, so `item-frontmatter.md` derives `itemfrontmatter` —
1078
+ unless `docs.itemFields.frontmatter.shortcode` gives one;
1079
+ - `name.full`, from the page's title;
1080
+ - `pack: none` — the page publishes to the website and compiles into no
1081
+ compendium document.
1082
+
1083
+ `docs.itemFields.frontmatter` is deep-merged over that envelope, so a
1084
+ consumer may add keys (`description`, `tags`) or override any of the derived
1085
+ ones. `--check` then compares the **whole** file, envelope included — a page
1086
+ committed with a hand-written or stale envelope reads as out of date exactly
1087
+ as a stale body does. A destination outside the content tree gets the page
1088
+ body alone, with no frontmatter, exactly as before.
1089
+
1067
1090
  **OPTIONS**
1068
1091
 
1069
1092
  | Positional | Type | Default | Description |
@@ -727,11 +727,12 @@ How this repository frames the documentation pages it generates.
727
727
  registry and are the same wherever rendered; everything here is the
728
728
  consumer's: heading, orientation, where the page is filed.
729
729
 
730
- | Key | Type | Required | Default |
731
- | -------------------------- | -------- | -------- | ---------------------------------------------------------------------- |
732
- | `docs.itemFields.title` | string | no | none — the page's H1 |
733
- | `docs.itemFields.out` | string | no | none — without it, the page goes to stdout |
734
- | `docs.itemFields.preamble` | string[] | no | none — markdown lines between the generated banner and the first table |
730
+ | Key | Type | Required | Default |
731
+ | ----------------------------- | -------- | -------- | ---------------------------------------------------------------------- |
732
+ | `docs.itemFields.title` | string | no | none — the page's H1 |
733
+ | `docs.itemFields.out` | string | no | none — without it, the page goes to stdout |
734
+ | `docs.itemFields.preamble` | string[] | no | none — markdown lines between the generated banner and the first table |
735
+ | `docs.itemFields.frontmatter` | object | no | none — extra note frontmatter, deep-merged over the generated envelope |
735
736
 
736
737
  > ``package-build config: `docs.itemFields` must be a mapping.``
737
738
 
@@ -744,9 +745,14 @@ consumer's: heading, orientation, where the page is filed.
744
745
 
745
746
  > ``package-build config: `docs.itemFields.preamble[<index>]` must be a string.``
746
747
 
748
+ `frontmatter` is deep-merged over the note envelope written when `out` is
749
+ under the content tree — see [`content-build docs item-fields`](commands.md#content-build-docs-item-fields):
750
+
751
+ > ``package-build config: `docs.itemFields.frontmatter` must be a mapping.``
752
+
747
753
  Any other key under `docs.itemFields` is refused:
748
754
 
749
- > ``package-build config: `docs.itemFields.<key>` is not a recognized option (expected one of: title, out, preamble).``
755
+ > ``package-build config: `docs.itemFields.<key>` is not a recognized option (expected one of: title, out, preamble, frontmatter).``
750
756
 
751
757
  ### `site`
752
758
 
@@ -34,6 +34,9 @@
34
34
  * @module
35
35
  */
36
36
 
37
+ import path from "node:path";
38
+ import matter from "gray-matter";
39
+
37
40
  import { authoredFields, runtimeOnlyFields } from "./field-spec.mjs";
38
41
  import { loadPackConfig } from "./pack-config.mjs";
39
42
 
@@ -309,3 +312,135 @@ export function renderItemFieldReference({
309
312
  // and then reported stale by `--check` forever after.
310
313
  return lines.join("\n").replace(/\n+$/, "");
311
314
  }
315
+
316
+ /**
317
+ * @param {unknown} value - Anything.
318
+ * @returns {boolean} Whether it is a mapping a field may be read out of.
319
+ */
320
+ function isPlainObject(value) {
321
+ return typeof value === "object" && value !== null && !Array.isArray(value);
322
+ }
323
+
324
+ /**
325
+ * Recursively merge `overlay` onto `base`. Plain objects merge key-by-key;
326
+ * everything else (arrays, primitives, `null`) replaces. Inputs are not
327
+ * mutated.
328
+ *
329
+ * @param {any} base - The generated envelope.
330
+ * @param {any} overlay - The consumer's declared `frontmatter`.
331
+ * @returns {any} The merged value.
332
+ */
333
+ function deepMerge(base, overlay) {
334
+ if (overlay === undefined) return base;
335
+ if (!isPlainObject(base) || !isPlainObject(overlay)) return overlay;
336
+ const out = { ...base };
337
+ for (const [key, value] of Object.entries(overlay)) {
338
+ out[key] = key in base ? deepMerge(base[key], value) : value;
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * Whether a destination file sits under a content tree.
345
+ *
346
+ * The one question that decides whether `docs item-fields` writes a note
347
+ * envelope: `assets/content/` walks every file under it for its `type:`, so a
348
+ * generated page filed there needs one to publish at all, while a page filed
349
+ * anywhere else — a repository's own `docs/` — is read by nobody but Hugo's
350
+ * `--check` guard and the reader following a link, neither of which wants
351
+ * frontmatter.
352
+ *
353
+ * @param {string} destination - Absolute path of the file being written.
354
+ * @param {string} contentRoot - Absolute path of the content tree root
355
+ * (`config.paths.content`).
356
+ * @returns {boolean} Whether `destination` resolves inside `contentRoot`.
357
+ */
358
+ export function isUnderContentTree(destination, contentRoot) {
359
+ const relative = path.relative(contentRoot, destination);
360
+ return (
361
+ relative !== "" &&
362
+ relative !== ".." &&
363
+ !relative.startsWith(`..${path.sep}`) &&
364
+ !path.isAbsolute(relative)
365
+ );
366
+ }
367
+
368
+ /**
369
+ * A note's `shortcode`, derived from the basename of its destination file.
370
+ *
371
+ * Lowercase alphanumerics only — the address charset every other shortcode in
372
+ * the tree is held to — so `item-frontmatter.md` derives `itemfrontmatter`
373
+ * rather than carrying a hyphen no address segment permits.
374
+ *
375
+ * @param {string} destination - Where the note is written.
376
+ * @returns {string} The derived shortcode.
377
+ */
378
+ export function shortcodeFromBasename(destination) {
379
+ return path
380
+ .basename(destination, path.extname(destination))
381
+ .toLowerCase()
382
+ .replace(/[^a-z0-9]/g, "");
383
+ }
384
+
385
+ /**
386
+ * The note envelope `docs item-fields` writes when its page lives in the
387
+ * content tree.
388
+ *
389
+ * The universal keys every note in the format carries: `type: doc`,
390
+ * `subType: reference` — this page is out-of-world lookup material, the same
391
+ * genre as every other generated reference — a `shortcode`, `name.full` from
392
+ * the page's title, and `pack: none`, since the page publishes to the website
393
+ * and compiles into no compendium document. A consumer's own
394
+ * `docs.itemFields.frontmatter` is deep-merged over it, so it may add keys or
395
+ * override any of the derived ones, `shortcode` included.
396
+ *
397
+ * @param {object} options
398
+ * @param {string} options.title - The page's H1, and `name.full`'s default.
399
+ * @param {string} options.shortcode - The derived shortcode, from
400
+ * {@link shortcodeFromBasename}.
401
+ * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
402
+ * declared `docs.itemFields.frontmatter`.
403
+ * @returns {Record<string, unknown>} The envelope, ready for `matter.stringify`.
404
+ */
405
+ export function itemFieldsEnvelope({ title, shortcode, frontmatter }) {
406
+ return deepMerge(
407
+ {
408
+ type: "doc",
409
+ subType: "reference",
410
+ shortcode,
411
+ name: { full: title },
412
+ pack: "none",
413
+ },
414
+ frontmatter,
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Wrap the rendered item-fields page in the note envelope, when its
420
+ * destination is under the content tree.
421
+ *
422
+ * `--check` compares the **whole** file this returns, envelope included — a
423
+ * page committed with its old envelope by hand, or with none at all, is
424
+ * exactly the staleness the guard exists to catch.
425
+ *
426
+ * @param {string} body - The page {@link renderItemFieldReference} rendered.
427
+ * @param {object} options
428
+ * @param {string} options.title - The page's H1, threaded through to
429
+ * `name.full`.
430
+ * @param {string} options.destination - Absolute path the page is written to.
431
+ * @param {string} options.contentRoot - Absolute path of the content tree
432
+ * root (`config.paths.content`).
433
+ * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
434
+ * declared `docs.itemFields.frontmatter`.
435
+ * @returns {string} `body`, unchanged when `destination` is outside the
436
+ * content tree; otherwise `body` with the note envelope stringified above it.
437
+ */
438
+ export function renderItemFieldsPage(body, { title, destination, contentRoot, frontmatter }) {
439
+ if (!isUnderContentTree(destination, contentRoot)) return body;
440
+ const envelope = itemFieldsEnvelope({
441
+ title,
442
+ shortcode: shortcodeFromBasename(destination),
443
+ frontmatter,
444
+ });
445
+ return matter.stringify(body, envelope);
446
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "22.3.0",
3
+ "version": "22.3.1",
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",
@@ -541,6 +541,14 @@ export type DocPageSpec = {
541
541
  * first table. Markdown, emitted verbatim.
542
542
  */
543
543
  preamble?: string[] | undefined;
544
+ /**
545
+ * Further note frontmatter,
546
+ * deep-merged over the generated envelope
547
+ * (`type: doc`, `subType: reference`,
548
+ * `shortcode`, `name.full`, `pack: none`)
549
+ * when `out` is under the content tree.
550
+ */
551
+ frontmatter?: Record<string, unknown> | undefined;
544
552
  };
545
553
  /**
546
554
  * The documentation pages this repository generates.
@@ -18,3 +18,81 @@ export function renderItemFieldReference({ title, preamble, generatedBy, config,
18
18
  generatedBy?: string | undefined;
19
19
  config?: object | undefined;
20
20
  }): string;
21
+ /**
22
+ * Whether a destination file sits under a content tree.
23
+ *
24
+ * The one question that decides whether `docs item-fields` writes a note
25
+ * envelope: `assets/content/` walks every file under it for its `type:`, so a
26
+ * generated page filed there needs one to publish at all, while a page filed
27
+ * anywhere else — a repository's own `docs/` — is read by nobody but Hugo's
28
+ * `--check` guard and the reader following a link, neither of which wants
29
+ * frontmatter.
30
+ *
31
+ * @param {string} destination - Absolute path of the file being written.
32
+ * @param {string} contentRoot - Absolute path of the content tree root
33
+ * (`config.paths.content`).
34
+ * @returns {boolean} Whether `destination` resolves inside `contentRoot`.
35
+ */
36
+ export function isUnderContentTree(destination: string, contentRoot: string): boolean;
37
+ /**
38
+ * A note's `shortcode`, derived from the basename of its destination file.
39
+ *
40
+ * Lowercase alphanumerics only — the address charset every other shortcode in
41
+ * the tree is held to — so `item-frontmatter.md` derives `itemfrontmatter`
42
+ * rather than carrying a hyphen no address segment permits.
43
+ *
44
+ * @param {string} destination - Where the note is written.
45
+ * @returns {string} The derived shortcode.
46
+ */
47
+ export function shortcodeFromBasename(destination: string): string;
48
+ /**
49
+ * The note envelope `docs item-fields` writes when its page lives in the
50
+ * content tree.
51
+ *
52
+ * The universal keys every note in the format carries: `type: doc`,
53
+ * `subType: reference` — this page is out-of-world lookup material, the same
54
+ * genre as every other generated reference — a `shortcode`, `name.full` from
55
+ * the page's title, and `pack: none`, since the page publishes to the website
56
+ * and compiles into no compendium document. A consumer's own
57
+ * `docs.itemFields.frontmatter` is deep-merged over it, so it may add keys or
58
+ * override any of the derived ones, `shortcode` included.
59
+ *
60
+ * @param {object} options
61
+ * @param {string} options.title - The page's H1, and `name.full`'s default.
62
+ * @param {string} options.shortcode - The derived shortcode, from
63
+ * {@link shortcodeFromBasename}.
64
+ * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
65
+ * declared `docs.itemFields.frontmatter`.
66
+ * @returns {Record<string, unknown>} The envelope, ready for `matter.stringify`.
67
+ */
68
+ export function itemFieldsEnvelope({ title, shortcode, frontmatter }: {
69
+ title: string;
70
+ shortcode: string;
71
+ frontmatter?: Record<string, unknown> | undefined;
72
+ }): Record<string, unknown>;
73
+ /**
74
+ * Wrap the rendered item-fields page in the note envelope, when its
75
+ * destination is under the content tree.
76
+ *
77
+ * `--check` compares the **whole** file this returns, envelope included — a
78
+ * page committed with its old envelope by hand, or with none at all, is
79
+ * exactly the staleness the guard exists to catch.
80
+ *
81
+ * @param {string} body - The page {@link renderItemFieldReference} rendered.
82
+ * @param {object} options
83
+ * @param {string} options.title - The page's H1, threaded through to
84
+ * `name.full`.
85
+ * @param {string} options.destination - Absolute path the page is written to.
86
+ * @param {string} options.contentRoot - Absolute path of the content tree
87
+ * root (`config.paths.content`).
88
+ * @param {Record<string, unknown>} [options.frontmatter] - The consumer's
89
+ * declared `docs.itemFields.frontmatter`.
90
+ * @returns {string} `body`, unchanged when `destination` is outside the
91
+ * content tree; otherwise `body` with the note envelope stringified above it.
92
+ */
93
+ export function renderItemFieldsPage(body: string, { title, destination, contentRoot, frontmatter }: {
94
+ title: string;
95
+ destination: string;
96
+ contentRoot: string;
97
+ frontmatter?: Record<string, unknown> | undefined;
98
+ }): string;