@heroiclands/package-build 22.3.0 → 22.4.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.
@@ -621,21 +621,18 @@ export function buildLinkIndex(
621
621
  const SITE_HOST = /^(?:[a-z0-9-]+\.)*heroiclands\.org$/i;
622
622
 
623
623
  /**
624
- * Every package landing this build can name, as `package` → base.
624
+ * Every package front page this build can name, as `package` → base.
625
625
  *
626
- * **A landing needs no manifest, and that is what makes it work.** The link
627
- * manifest indexes content notes, and a homepage is deliberately not one — it
628
- * compiles to no document and is entered in no manifest. The reading that
629
- * follows from this, and that left a hardcoded URL as the only authored form,
630
- * is that a landing therefore cannot be addressed. It does not follow: a
631
- * landing's address is not a *note's* address but the **package's**, and
632
- * {@link PACKAGE_BASE} already records where each package is served. That is a
633
- * frozen constant compiled into every build, so consulting it walks no
634
- * tree, reads no manifest and builds no index — which is precisely why the
635
- * mechanism survives `homepage` mode, where the licensing fence means none of
636
- * those exist.
626
+ * **A front page needs no manifest, and that is what makes it work.** A
627
+ * homepage compiles to no document and is entered in no manifest, but its
628
+ * address is not a *note's* address but the **package's** it is the mount's
629
+ * `_index.md`, published at `/<package>/` and {@link PACKAGE_BASE} already
630
+ * records where each package is served. That is a frozen constant compiled
631
+ * into every build, so consulting it walks no tree, reads no manifest and
632
+ * builds no index which is precisely why the mechanism survives `homepage`
633
+ * mode, where the licensing fence means none of those exist.
637
634
  *
638
- * The roster is consulted **for landings only**. Widening the package set the
635
+ * The roster is consulted **for front pages only**. Widening the package set the
639
636
  * other rules read would make them offer manifest-based advice about packages
640
637
  * no index has been fetched for.
641
638
  *
@@ -738,41 +735,30 @@ function readAddress(url, packages) {
738
735
  * not and cannot: it is published *verbatim* by every publishing mode, including
739
736
  * the homepage-only mode two fan-licensed packages ship under, where the content
740
737
  * tree is never walked and there is no index for a wikilink to resolve against.
741
- * So a landing addresses the web the way the web does — markdown links and
742
- * `url:` fields — and nothing was looking at those. SoHL's landing pointed at
743
- * `kb/creature/` and `kb/character/` from the day those types merged into
744
- * `being`: two 404s on the package's front page, through every build.
738
+ * So a homepage addresses the web the way the web does — markdown links in its
739
+ * body — and this is what looks at those. A dead link on the page a reader
740
+ * arrives at is the one nothing else would report.
745
741
  *
746
742
  * **What is checkable, stated plainly.** Only an address into this site is, and
747
743
  * only against facts this build already holds:
748
744
  *
749
745
  * - A **retired content type** in the path. The engine knows the retired names
750
- * and what replaced it, so this is a fact rather than a guess — and it is
751
- * exactly the SoHL defect.
746
+ * and what replaced it, so this is a fact rather than a guess.
752
747
  * - A **hardcoded absolute URL** into this package's own prefix, or into one a
753
- * a fetched index names. Every one of them has a better form to write, which
748
+ * fetched index names. Every one of them has a better form to write, which
754
749
  * is why every one is reported — including a bare `/<package>/`, which names
755
- * another package's landing.
756
- *
757
- * That last case was exempt until the better form was identified, on the
758
- * reasoning that a landing is in no link manifest so nothing could resolve it.
759
- * True, and beside the point: it does not need resolving. A landing's address
760
- * *is* its package prefix, so `/<package>/` is the absolute URL with the host
761
- * struck off — host-free, emitted verbatim, and needing no index, which is
762
- * what lets it hold in homepage-only mode where the tree is never walked. The
763
- * form was already accepted here; nothing had ever named it as the one to use.
764
- * - A **root-relative `url:`**, which the theme's `relURL` prefixes a second
765
- * time. `href:` means "already resolved, use verbatim", so the same leading
766
- * slash is correct there and is not reported.
750
+ * another package's front page. A front page's address *is* its package
751
+ * prefix, so `/<package>/` is the absolute URL with the host struck off —
752
+ * host-free, emitted verbatim, and needing no index, which is what lets it
753
+ * hold in homepage-only mode where the tree is never walked.
767
754
  * - A **wikilink**, which nothing on this page will ever resolve.
768
755
  *
769
756
  * **What is not checkable, and is not attempted.** Whether an external URL
770
757
  * answers — there is no network at build time, and a build must not fail because
771
758
  * a third party is down. And whether a live in-site address names a page that
772
- * exists: several of the surfaces a landing routes to are produced by other
773
- * tools entirely (generated API documentation, hand-authored Hugo sections), so
774
- * this build does not hold the set of published pages and would report a working
775
- * link as dead.
759
+ * exists: several of the surfaces a homepage routes to are produced by other
760
+ * tools entirely (generated API documentation, say), so this build does not
761
+ * hold the set of published pages and would report a working link as dead.
776
762
  *
777
763
  * @param {ReturnType<typeof buildLinkIndex>} index - The built index.
778
764
  * @returns {Array<{note: object, field: string, url: string, text: string,
@@ -814,7 +800,7 @@ export function auditHomepageLinks(index) {
814
800
  );
815
801
  }
816
802
 
817
- for (const { field, url, kind } of homepageAddresses(note.fm, note.body)) {
803
+ for (const { field, url } of homepageAddresses(note.body)) {
818
804
  // Counted for every address, checked or not, so the count is
819
805
  // the literal's nth appearance in the file rather than the nth
820
806
  // *finding* about it — two rules can fire on one address.
@@ -853,34 +839,13 @@ export function auditHomepageLinks(index) {
853
839
  prefix === index.contentPackage ?
854
840
  `hardcoded absolute URL into this package's own ` +
855
841
  `address — write the package-relative ` +
856
- `"${rest}/", which the landing resolves ` +
857
- `against the site so the page follows the mount`
842
+ `"${rest}/", which a browser resolves against ` +
843
+ `the homepage's own address, the package root`
858
844
  : `hardcoded absolute URL into package "${prefix}" ` +
859
845
  `— resolve it through that package's link ` +
860
846
  `manifest, whose entries carry the address, so a ` +
861
847
  `relocation does not leave this page behind`,
862
848
  );
863
- } else if (shape === "rooted" && kind === "url") {
864
- const rest = prefix ? segments.slice(1).join("/") : segments.join("/");
865
- report(
866
- field,
867
- url,
868
- url,
869
- occurrence,
870
- // A `url:` is package-relative by construction, so it
871
- // cannot address anything outside this package at all —
872
- // there is no relative spelling of another package's root.
873
- // `href:` is the field for an address already resolved.
874
- !rest ?
875
- `url "${url}" addresses ` +
876
- (prefix ? `package "${prefix}"'s landing` : `the site root`) +
877
- `, but a landing's url: is package-relative and ` +
878
- `cannot leave this package — write ` +
879
- `href: "${url}", which is used verbatim`
880
- : `url "${url}" is root-relative, but a landing's url: ` +
881
- `is resolved against the site — write "${rest}/", ` +
882
- `or href: for an address that is already resolved`,
883
- );
884
849
  }
885
850
 
886
851
  // The retired-type rule reads the path *inside* the package, so an
@@ -297,7 +297,7 @@ export function positionOfLiteral(text, needle, occurrence = 1) {
297
297
  *
298
298
  * **`key: true` addresses the declaration rather than the value.** A finding
299
299
  * about a *value* — this pack name is not in `packs[]` — belongs on the value,
300
- * which is the default. A finding that names a **field** — `\`site.sections.x\`
300
+ * which is the default. A finding that names a **field** — `\`site.notfound.x\`
301
301
  * is not a recognized option` — sends the reader to look for that field, so the
302
302
  * position should be the field's own, and in a flow mapping
303
303
  * (`{ title: X, banner: Y }`) the two are different columns on one line. The
@@ -347,7 +347,7 @@ export function positionOfYamlPath(text, keyPath, { key = false } = {}) {
347
347
  * The YAML key path a **dotted field path** addresses.
348
348
  *
349
349
  * Configuration checks report the offending key as the path a reader would
350
- * write it — `packs[1].name`, `site.sections.affliction.title` — because that
350
+ * write it — `packs[1].name`, `site.notfound.links[0].url` — because that
351
351
  * is what the message has to say. {@link positionOfYamlPath} addresses a node
352
352
  * by segments instead, so this is the one translation between them: `.`
353
353
  * separates map keys, and a bracketed suffix is a sequence index.
@@ -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,141 @@ 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
+ const page = matter.stringify(body, envelope);
446
+ // `matter.stringify` closes the frontmatter fence directly onto the
447
+ // body's first line; Prettier's markdown printer requires a blank line
448
+ // between them, so a page written without one fails a consumer's
449
+ // `lint:format` the moment it is committed. Insert it here rather than
450
+ // let the generator and the formatter rewrite the file back and forth.
451
+ return page.replace(/^(---\n[\s\S]*?\n---\n)/, "$1\n");
452
+ }