@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.
- package/CHANGELOG.md +59 -0
- package/CONTENT.md +182 -306
- package/bin/content-build.mjs +40 -23
- package/bin/package-build.mjs +4 -3
- package/content-config.mjs +82 -180
- package/docs/api.md +27 -32
- package/docs/commands.md +54 -19
- package/docs/configuration.md +102 -132
- package/docs/content-format.md +25 -15
- package/docs/getting-started.md +5 -4
- package/docs/project-setup.md +1 -1
- package/engine/content-links.mjs +25 -60
- package/engine/diagnostics.mjs +2 -2
- package/engine/field-reference.mjs +141 -0
- package/engine/homepage.mjs +91 -172
- package/engine/metadata-index.mjs +7 -3
- package/engine/note-vocabulary.mjs +0 -20
- package/engine/pdf-build.mjs +2 -2
- package/engine/site-build.mjs +64 -225
- package/engine/site-config.mjs +15 -53
- package/engine/site-root.mjs +26 -70
- package/package.json +1 -1
- package/types/content-config.d.mts +14 -8
- package/types/engine/content-links.d.mts +12 -23
- package/types/engine/diagnostics.d.mts +2 -2
- package/types/engine/field-reference.d.mts +78 -0
- package/types/engine/homepage.d.mts +51 -91
- package/types/engine/note-vocabulary.d.mts +0 -12
- package/types/engine/site-build.d.mts +28 -129
- package/types/engine/site-config.d.mts +10 -19
- package/types/engine/site-root.d.mts +11 -36
package/engine/content-links.mjs
CHANGED
|
@@ -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
|
|
624
|
+
* Every package front page this build can name, as `package` → base.
|
|
625
625
|
*
|
|
626
|
-
* **A
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
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
|
|
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
|
|
742
|
-
*
|
|
743
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
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
|
|
773
|
-
* tools entirely (generated API documentation,
|
|
774
|
-
*
|
|
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
|
|
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
|
|
857
|
-
`
|
|
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
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|