@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.
@@ -20,24 +20,19 @@
20
20
  * written by a person: what the module is, which system it needs, how to install
21
21
  * it, where its source lives. Nothing about it is derived.
22
22
  *
23
- * **Authored, not assembled.** An earlier sketch generated the page in tiers
24
- * identity and licence from the manifest, install URL from the release address,
25
- * "requires" links from `relationships`, a card per configured section. It would
26
- * have worked and needed almost no authoring, and it produces a page nobody
27
- * chose the contents of. The things that matter most on these pages cannot be
28
- * derived: that Kethira requires buying the book from Keléstia, what Thalorna's
29
- * setting *is*, which of twenty sections a reader should start with. So the only
30
- * thing defaulted here is the title, from `packageBuild.manifest.title`, so that
31
- * the package's name is not written twice.
23
+ * **Authored, not assembled.** The things that matter most on a front page
24
+ * cannot be derived: that Kethira requires buying the book from Keléstia, what
25
+ * Thalorna's setting *is*, which of twenty pages a reader should start with. So
26
+ * the only thing defaulted here is the title, from `packageBuild.manifest.title`,
27
+ * so that the package's name is not written twice. Everything else is the body,
28
+ * which is why a `landing:` card block is refused: the homepage is a page with a
29
+ * body, rendered as one, and an index of what the package publishes is a `doc`
30
+ * note carrying a content table.
32
31
  *
33
32
  * **Dispatched by `type`, not by filename.** A fixed `homepage.md` the walker
34
33
  * special-cased would be the anomaly: notes are routed by frontmatter, not by
35
34
  * location, and `NOTE_SCHEMAS` already routes `doc`, `macro`, `being` and the
36
- * map types. `homepage` is one more entry whose compile step emits a page. It is
37
- * deliberately not `README.md`: `landing: readme` already means "a `README.md`
38
- * is its section's landing page", and `sohl-thalorna/assets/content/README.md`
39
- * is a developer explainer about the source tree — adopting that name would make
40
- * Thalorna's public front page its build documentation.
35
+ * map types. `homepage` is one more entry whose compile step emits a page.
41
36
  *
42
37
  * **Engine, not `sohl/`.** The `engine/` ÷ `sohl/` line separates *note-format*
43
38
  * knowledge from *game-system* knowledge, and a homepage is note format: it
@@ -47,27 +42,24 @@
47
42
  * type living in the SoHL registry would be unavailable to HM3 and to every HM3
48
43
  * module, which is most of the packages that need a homepage and nothing else.
49
44
  *
50
- * **Addressed like every other note.** A homepage declares a `shortcode` —
51
- * conventionally {@link HOMEPAGE_SHORTCODE} and publishes at its address,
52
- * `/<package>/<type>-<shortcode>/`, written by the same rule as everything else.
53
- * It used to publish at `/<package>/` from a fixed destination, and
54
- * that is why it refused `name` and `shortcode`: a URL derived from `name.full`
55
- * while the destination did not, so `[[homepage-<shortcode>]]` resolved *green*
56
- * to a page nothing wrote. A page's URL is its address now, so the
57
- * computed address is the published one and there is nothing left to refuse.
58
- * The package's own `/<package>/` becomes a redirect its repository authors —
59
- * see `CONTENT.md` rather than a page this build writes.
60
- *
61
- * `id` is still refused, on ground the change does not touch: a homepage
62
- * compiles into no document, so it carries no compendium UUID and appears in no
63
- * pack and in no link-manifest entry.
45
+ * **Published at the package root, addressed like every other note.** A
46
+ * homepage is written as the mount's `_index.md`, so Hugo's `home` kind renders
47
+ * it at `/<package>/` the address a reader expects a package's front page at,
48
+ * and the one `package.json`'s `homepage` states. It still declares a
49
+ * `shortcode` conventionally {@link HOMEPAGE_SHORTCODE} because that is what
50
+ * a link is written with: `[[homepage-root|Text]]` is an ordinary wikilink, and
51
+ * it resolves to `/<package>/`. The shortcode names the page in links; the
52
+ * address is the package root.
53
+ *
54
+ * `id` is refused, on its own ground: a homepage compiles into no document, so
55
+ * it carries no compendium UUID and appears in no pack and in no link-manifest
56
+ * entry.
64
57
  *
65
58
  * @module
66
59
  */
67
60
 
68
61
  import fs from "node:fs";
69
62
 
70
- import { addressSlug } from "./content-address.mjs";
71
63
  import { matchAllOutsideCode } from "./code-fences.mjs";
72
64
  import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
73
65
 
@@ -107,20 +99,15 @@ export const HOMEPAGE_SHORTCODE = "root";
107
99
  /**
108
100
  * The file a homepage is written to, relative to the package's site root.
109
101
  *
110
- * Its **address**, flat at the package root, and stated in the page's own `url`
111
- * — the same separation every other page has, where the directory
112
- * decides the Hugo section and the front matter decides the URL. Flat rather
113
- * than inside a `homepage/` section directory, because a homepage is not one of
114
- * a kind: a section holding exactly one page would publish a landing at
115
- * `/<package>/kb/homepage/` that nothing links to and nobody wrote.
102
+ * The mount's own `_index.md`: Hugo renders it as the `home` kind, at
103
+ * `baseURL` which is `/<package>/`, the package's own address. One fixed
104
+ * destination rather than one derived from the note's address, because the
105
+ * homepage's address *is* the package root; the note's `shortcode` names the
106
+ * page in links and decides no file.
116
107
  *
117
- * @param {object} fm - Parsed frontmatter.
118
- * @returns {string} The destination filename, e.g. `homepage-root.md`.
119
- * @throws {Error} When the note declares no shortcode, and so has no address.
108
+ * @type {string}
120
109
  */
121
- export function homepageDestination(fm) {
122
- return `${addressSlug(fm)}.md`;
123
- }
110
+ export const HOMEPAGE_DESTINATION = "_index.md";
124
111
 
125
112
  /**
126
113
  * Whether a note's frontmatter declares the homepage type.
@@ -133,17 +120,16 @@ export function isHomepage(fm) {
133
120
  }
134
121
 
135
122
  /**
136
- * The top-level field a homepage refuses, and what it would decide.
123
+ * The top-level fields a homepage refuses, and what each would decide.
137
124
  *
138
- * **One field rather than three.** `name` and `shortcode` are
139
- * refused because a page's URL derived from `name.full` while a homepage's
140
- * destination was fixed, so the address a `shortcode` computed named a page the
141
- * site build never wrote. A page's URL is its address now and a homepage
142
- * publishes at its own, so both fields decide exactly what they decide
143
- * everywhere else and are permitted.
125
+ * `id` is the Foundry document id a compendium UUID is built from, and a
126
+ * homepage compiles into no document. `landing` is a card block; the homepage
127
+ * is a page with a body, rendered as one, so nothing reads it and an index
128
+ * of what the package publishes is a `doc` note carrying a content table,
129
+ * authored where every other page is.
144
130
  *
145
- * `id` is untouched by that, and stays: it is the Foundry document id a
146
- * compendium UUID is built from, and a homepage compiles into no document.
131
+ * `name` and `shortcode` are permitted: the shortcode is what a link is
132
+ * written with, and `name` titles the page like every other note's.
147
133
  *
148
134
  * **A named class, not an allow-list, and that boundary is the decision.** A
149
135
  * homepage's frontmatter is *emitted into the published page*
@@ -167,6 +153,14 @@ export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
167
153
  "homepage compiles into no document — it appears in no pack " +
168
154
  "and in no link manifest. Delete it",
169
155
  ],
156
+ [
157
+ "landing",
158
+ "`landing` decides nothing on a `type: homepage` note: the homepage " +
159
+ "is a page with a body, rendered as one, and no card block is " +
160
+ "read off it. Write the page's links in its body, and author an " +
161
+ "index of what the package publishes as a `doc` note carrying a " +
162
+ "content table. Delete it",
163
+ ],
170
164
  ]),
171
165
  );
172
166
 
@@ -174,7 +168,7 @@ export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
174
168
  * What the address rule says about one note's top-level fields.
175
169
  *
176
170
  * Two statements about the same thing, so they are made together: the field a
177
- * homepage **owes** and the field it may **not** write.
171
+ * homepage **owes** and the fields it may **not** write.
178
172
  *
179
173
  * The missing `shortcode` comes first, and is located at `type:` rather than at
180
174
  * a key that is not there — the `homepage` value is what makes the field
@@ -184,8 +178,9 @@ export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
184
178
  * diagnostic per finding walks down the file.
185
179
  *
186
180
  * Presence is the whole test for a refused field, and absence-or-blank for the
187
- * required one: `shortcode:` authored empty is no address, and a value cannot
188
- * make `id` mean something on a page that compiles to no document.
181
+ * required one: `shortcode:` authored empty is no address, and no value can
182
+ * make `id` or `landing` mean something on a page that compiles to no document
183
+ * and renders as its body.
189
184
  *
190
185
  * Each finding carries the `locator` key to position it at, because the two
191
186
  * things that would resolve one — the raw note text and the position helper —
@@ -220,10 +215,10 @@ export function checkHomepageAddressFields(fm, { isAuthored } = {}) {
220
215
  locator: { key: "type", literal: HOMEPAGE_TYPE },
221
216
  message:
222
217
  "a `type: homepage` note declares a `shortcode`, like every " +
223
- "other note: it is addressed as `homepage-<shortcode>` and " +
224
- "published at `/<package>/homepage-<shortcode>/`, which is " +
225
- "where `[[homepage-<shortcode>|Text]]` lands. Write " +
226
- `\`shortcode: ${HOMEPAGE_SHORTCODE}\` — the package landing is ` +
218
+ "other note: it is addressed as `homepage-<shortcode>`, which " +
219
+ "is what `[[homepage-<shortcode>|Text]]` is written with to " +
220
+ "reach the package's front page at `/<package>/`. Write " +
221
+ `\`shortcode: ${HOMEPAGE_SHORTCODE}\` — the front page is ` +
227
222
  `\`homepage-${HOMEPAGE_SHORTCODE}\` in every package`,
228
223
  });
229
224
  }
@@ -247,13 +242,11 @@ export function checkHomepageAddressFields(fm, { isAuthored } = {}) {
247
242
  * this exists to prevent, and it is silent — the site build reports `wrote 0
248
243
  * homepage(s)` and exits 0.
249
244
  * - _Two_ and it serves a page nobody chose. **This is a cardinality rule, and
250
- * only that.** A homepage is written at its own address, so two of them
251
- * publish two pages and collide over nothing; the
252
- * duplicate-address check catches only the pair that happen to share a
253
- * shortcode, and says nothing at all about a `homepage-root` beside a
254
- * `homepage-front`. Which of the two the redirect at `/<package>/` should
255
- * name is a question nothing here can answer, and both being reachable is
256
- * not an answer to it.
245
+ * only that.** Both are written to the mount's `_index.md`, so the second
246
+ * silently overwrites the first; the duplicate-address check catches only
247
+ * the pair that happen to share a shortcode, and says nothing at all about
248
+ * a `homepage-root` beside a `homepage-front`. Which of the two should be
249
+ * the front page is a question nothing here can answer.
257
250
  *
258
251
  * Neither has a safe default, so neither is a warning. A warning is the right
259
252
  * severity for something a build can proceed past correctly, and a build that
@@ -315,10 +308,9 @@ export function checkHomepageCount(found, { contentBase, contentPackage }) {
315
308
  message:
316
309
  `duplicate \`type: homepage\` note, also declared by ` +
317
310
  `${others.join(", ")}; a package has one front page` +
318
- `${contentPackage ? `, at${address}` : ""}, and each of these ` +
319
- `publishes at an address of its own so nothing here can say ` +
320
- `which one that address should redirect to. Keep one, and make ` +
321
- `the rest ordinary notes`,
311
+ `${contentPackage ? `, at${address}` : ""}, and nothing here ` +
312
+ `can say which of these it should be. Keep one, and make the ` +
313
+ `rest ordinary notes`,
322
314
  };
323
315
  });
324
316
  }
@@ -371,24 +363,15 @@ export function homepageTitle(fm, config) {
371
363
  /**
372
364
  * The frontmatter a homepage publishes with.
373
365
  *
374
- * The note's own, plus the derived values every emitted page carries: the
375
- * resolved `title`, the package the build derived — no note declares one
366
+ * The note's own, plus the two derived values every emitted page carries: the
367
+ * resolved `title`, and the package the build derived — no note declares one
376
368
  * (`package:` is retired) and the theme's breadcrumb partial reads
377
- * `.Params.package` — and its **address**.
378
- *
379
- * The address is stated as `url` for the same reason every other page states
380
- * one: Hugo publishes a page where its file sits unless told otherwise,
381
- * and a homepage's file sits at the package's site root. `slug` is written
382
- * beside it because it is the last segment of that address and Hugo's own key
383
- * for one; it decides nothing while `url` is present, but a page carrying only
384
- * `url` would report a slug Hugo had inferred from the filename.
385
- *
386
- * **Site-root relative, and so carrying no package base**, exactly as
387
- * `pageFrontmatter` states a content page's: Hugo resolves a `url`
388
- * against `baseURL`, whose path is already where the package is served, so a
389
- * stated base was written twice and published the landing at
390
- * `/<package>/<package>/homepage-root/`. Where the package is served is what
391
- * every *href* is composed from and it reaches this page's address not at all.
369
+ * `.Params.package`.
370
+ *
371
+ * **No `url` and no `slug`.** Hugo publishes the `home` kind at `baseURL`,
372
+ * whose path is already where the package is served, so the page has no
373
+ * address to state; a content page states one because its file sits under the
374
+ * mount and its address does not.
392
375
  *
393
376
  * An authored `aliases` is dropped for the same reason it is on every other
394
377
  * page: Hugo reads it as URL redirects, so passing it through would publish a
@@ -400,11 +383,9 @@ export function homepageTitle(fm, config) {
400
383
  * @param {string} options.contentPackage - The package this build publishes.
401
384
  * @param {string} options.title - The resolved title.
402
385
  * @returns {object} The frontmatter to write.
403
- * @throws {Error} When the note declares no shortcode, and so has no address.
404
386
  */
405
387
  export function homepageFrontmatter(fm, { contentPackage, title }) {
406
- const slug = addressSlug(fm);
407
- const data = { ...fm, package: contentPackage, title, slug, url: `/${slug}/` };
388
+ const data = { ...fm, package: contentPackage, title };
408
389
  delete data.aliases;
409
390
  return data;
410
391
  }
@@ -412,103 +393,41 @@ export function homepageFrontmatter(fm, { contentPackage, title }) {
412
393
  /**
413
394
  * An inline markdown link — `[text](target)`, but not an image.
414
395
  *
415
- * Reference-style links are deliberately not matched: a landing's prose fields
416
- * are single YAML scalars with nowhere to put a link definition, so a `[x][y]`
417
- * in one could never resolve and is not an address anybody wrote.
396
+ * Reference-style links are deliberately not matched: a homepage is published
397
+ * verbatim, so a `[x][y]` whose definition sits in the body reaches the reader
398
+ * as Hugo renders it, and one whose definition is missing is a defect the
399
+ * rendered page shows for itself.
418
400
  *
419
401
  * @type {RegExp}
420
402
  */
421
403
  const MARKDOWN_LINK = /(?<!!)\[[^\]]*\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
422
404
 
423
405
  /**
424
- * The two frontmatter keys that hold an address, and what each one means.
425
- *
426
- * They are **not** interchangeable, and a check that treated them as one would
427
- * be wrong about both. The theme resolves a `url` against the site with
428
- * `relURL`, so a package writes `kb/rules/` and is served `/sohl/kb/rules/`
429
- * without ever naming its own prefix. An `href` is an address that is *already*
430
- * resolved and is used verbatim — which is what `cards.source: sections` fills
431
- * in, since a section's permalink already carries the prefix.
432
- *
433
- * So a leading `/` is a defect in a `url` (it is prefixed a second time) and
434
- * correct in an `href`.
435
- *
436
- * @type {ReadonlySet<string>}
437
- */
438
- export const HOMEPAGE_ADDRESS_KEYS = Object.freeze(new Set(["url", "href"]));
439
-
440
- /**
441
- * Collect the markdown links in one prose value.
406
+ * Every address a homepage carries: the markdown links in its body.
442
407
  *
443
- * @param {string} text - The value.
444
- * @param {string} field - Where it came from.
445
- * @param {string} kind - The address kind to record.
446
- * @param {object[]} out - Accumulator.
447
- * @param {boolean} [skipCode] - Whether to ignore links inside code.
448
- */
449
- function collectProse(text, field, kind, out, skipCode = false) {
450
- const pattern = new RegExp(MARKDOWN_LINK.source, "g");
451
- const matches = skipCode ? matchAllOutsideCode(text, pattern) : [...text.matchAll(pattern)];
452
- for (const m of matches) out.push({ field, url: m[1], kind });
453
- }
454
-
455
- /**
456
- * Every address a homepage carries, wherever it is written.
457
- *
458
- * **Both halves of the page are in scope, and that is the finding rather than
459
- * the assumption.** Of the six homepages authored today, four carry every link
460
- * in the body as ordinary markdown and two carry them in `landing:` — and the
461
- * one whose dead links prompted the check has an *empty body*, so a body-only
462
- * reading would have found nothing at all on it. A dead link in a card is
463
- * exactly as broken as one in a paragraph.
408
+ * Nowhere else: the frontmatter holds no address, because a homepage is a
409
+ * page with a body and the card block that once carried links is refused.
410
+ * Top-level `title` and `description` are not walked they are set as text,
411
+ * never rendered as markdown — and `banner:` is not an address: it is an image
412
+ * path resolved through the CDN base, and `banner: none` is a sentinel rather
413
+ * than a target.
464
414
  *
465
- * Three shapes are gathered, and the caller needs to tell them apart because
466
- * the rules differ:
415
+ * A body link is emitted as written and resolved by the browser against the
416
+ * homepage's own address, which *is* the package root, so a package-relative
417
+ * one (`kb/rules/`) lands where a reader expects.
467
418
  *
468
- * - **`url`** package-relative, resolved against the site by the theme.
469
- * - **`href`** already resolved, used verbatim.
470
- * - **prose and body markdown links** — emitted as written and resolved by the
471
- * browser against the landing's own address, which *is* the package root, so
472
- * a relative one means the same thing a `url` does.
419
+ * Links inside code are ignored, so an example in a fenced block is not
420
+ * reported as a dead address.
473
421
  *
474
- * `banner:` is not an address: it is an image path resolved through the CDN
475
- * base, and `banner: none` is a sentinel rather than a target. Top-level
476
- * `title` and `description` are not walked either — they are set as text, never
477
- * rendered as markdown.
478
- *
479
- * @param {object|null|undefined} fm - The note's frontmatter.
480
422
  * @param {string} [body] - The note's markdown body.
481
423
  * @returns {Array<{field: string, url: string, kind: string}>} Every address,
482
- * frontmatter first and then the body, each with the dotted path it was
483
- * written at.
424
+ * in body order, each recorded at `field: "body"` with `kind: "body"`.
484
425
  */
485
- export function homepageAddresses(fm, body = "") {
426
+ export function homepageAddresses(body = "") {
486
427
  const out = [];
487
-
488
- const walk = (value, field) => {
489
- if (typeof value === "string") {
490
- collectProse(value, field, "prose", out);
491
- return;
492
- }
493
- if (Array.isArray(value)) {
494
- value.forEach((v, i) => walk(v, `${field}[${i}]`));
495
- return;
496
- }
497
- if (!value || typeof value !== "object") return;
498
- for (const [key, v] of Object.entries(value)) {
499
- const child = `${field}.${key}`;
500
- // An address field holds an address, not prose: reading it for
501
- // markdown links as well would report the same target twice
502
- // whenever one happened to look like a link.
503
- if (HOMEPAGE_ADDRESS_KEYS.has(key) && typeof v === "string") {
504
- out.push({ field: child, url: v, kind: key });
505
- continue;
506
- }
507
- walk(v, child);
508
- }
509
- };
510
-
511
- walk(fm?.landing, "landing");
512
- collectProse(String(body ?? ""), "body", "body", out, true);
428
+ const pattern = new RegExp(MARKDOWN_LINK.source, "g");
429
+ for (const m of matchAllOutsideCode(String(body ?? ""), pattern)) {
430
+ out.push({ field: "body", url: m[1], kind: "body" });
431
+ }
513
432
  return out;
514
433
  }
@@ -50,6 +50,7 @@ import { packageBuildRecords } from "./packagebuild-index.mjs";
50
50
 
51
51
  export { metadataFileName };
52
52
  import { PACKAGE_BASE, readCanonicalKey, resolvePackageUrl } from "./content-address.mjs";
53
+ import { HOMEPAGE_TYPE } from "./homepage.mjs";
53
54
 
54
55
  /**
55
56
  * Written once a fetch completes, so a half-finished cache is never used.
@@ -365,9 +366,12 @@ export function loadForeignIndexes(config, localPackages, bases = PACKAGE_BASE)
365
366
  // must tolerate that rather than invent an href, exactly as it
366
367
  // already tolerates an entry with no `uuid`.
367
368
  url:
368
- web && record.address.slug ?
369
- resolvePackageUrl(`${record.address.slug}/`, base)
370
- : undefined,
369
+ !web || !record.address.slug ? undefined
370
+ // A package's homepage is its root: the note is the
371
+ // mount's `_index.md`, so `[[thalorna-homepage-root]]`
372
+ // lands on `/thalorna/`, not on a page below it.
373
+ : parts.type === HOMEPAGE_TYPE ? base
374
+ : resolvePackageUrl(`${record.address.slug}/`, base),
371
375
  uuid: foundry?.uuid,
372
376
  doc: record.documentation ?? undefined,
373
377
  anchors: foundry?.anchors,
@@ -496,26 +496,6 @@ export function hasTag(fm, tag) {
496
496
  return false;
497
497
  }
498
498
 
499
- /**
500
- * Whether a note carries any `tags:` at all, however authored.
501
- *
502
- * The one question the site build asks of tags in aggregate — whether the
503
- * tree publishes taxonomy pages — rather than about a particular tag. Reads
504
- * `tags` and `tag` exactly as {@link hasTag} does, and treats an empty list
505
- * or a blank string as carrying none.
506
- *
507
- * @param {object|null|undefined} fm - Parsed frontmatter.
508
- * @returns {boolean} Whether the note carries at least one tag.
509
- */
510
- export function hasAnyTag(fm) {
511
- const raw = fm?.tags ?? fm?.tag;
512
- if (raw == null) return false;
513
- for (const entry of Array.isArray(raw) ? raw : [raw]) {
514
- if (typeof entry === "string" && entry.trim() !== "") return true;
515
- }
516
- return false;
517
- }
518
-
519
499
  /**
520
500
  * Whether a note is tagged as an unfinished **draft**.
521
501
  *
@@ -34,8 +34,8 @@
34
34
  *
35
35
  * ## `publish.site` is the switch, and it is the only switch
36
36
  *
37
- * `homepage` mode fences the content surfaces off: the tree is not walked and
38
- * `sections` and `landing` emit nothing however they are declared. A
37
+ * `homepage` mode fences the content surfaces off: the tree is not walked for
38
+ * pages, whatever else the configuration declares. A
39
39
  * PDF of the content tree is a content surface by any reading — arguably the
40
40
  * most portable one there is — so it is fenced on exactly the same terms, by
41
41
  * asking the same {@link module:content-config.publishesContentPages} the