@heroiclands/package-build 10.0.0 → 11.0.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 +306 -0
- package/CONTENT.md +218 -71
- package/MIGRATING.md +64 -0
- package/bin/content-build.mjs +59 -75
- package/docs/content-format.md +90 -67
- package/engine/base-compiler.mjs +7 -1
- package/engine/content-address.mjs +71 -18
- package/engine/content-format-check.mjs +1 -1
- package/engine/content-links.mjs +93 -112
- package/engine/content-lint.mjs +14 -10
- package/engine/content-slug.mjs +39 -105
- package/engine/diagnostics.mjs +16 -2
- package/engine/frontmatter-lint.mjs +27 -7
- package/engine/helpers.mjs +31 -68
- package/engine/homepage.mjs +131 -86
- package/engine/index.mjs +2 -5
- package/engine/manifest-emit.mjs +23 -4
- package/engine/note-vocabulary.mjs +58 -1
- package/engine/retired-fields.mjs +117 -6
- package/engine/site-build.mjs +182 -59
- package/engine/site-index.mjs +57 -102
- package/engine/web-wikilinks.mjs +183 -127
- package/engine/wikilink-syntax.mjs +174 -34
- package/engine/wikilinks.mjs +159 -117
- package/package.json +1 -1
- package/types/engine/base-compiler.d.mts +1 -1
- package/types/engine/content-address.d.mts +46 -14
- package/types/engine/content-links.d.mts +13 -17
- package/types/engine/content-slug.d.mts +11 -48
- package/types/engine/diagnostics.d.mts +14 -1
- package/types/engine/helpers.d.mts +4 -3
- package/types/engine/homepage.d.mts +96 -60
- package/types/engine/index.d.mts +0 -1
- package/types/engine/note-vocabulary.d.mts +43 -0
- package/types/engine/retired-fields.d.mts +78 -1
- package/types/engine/site-build.d.mts +70 -17
- package/types/engine/site-index.d.mts +19 -21
- package/types/engine/web-wikilinks.d.mts +29 -28
- package/types/engine/wikilink-syntax.d.mts +126 -40
- package/types/engine/wikilinks.d.mts +29 -24
- package/engine/abbreviations.mjs +0 -0
- package/engine/alias-index.mjs +0 -153
- package/types/engine/abbreviations.d.mts +0 -44
- package/types/engine/alias-index.d.mts +0 -122
|
@@ -64,6 +64,8 @@ import { declaredTags } from "./note-vocabulary.mjs";
|
|
|
64
64
|
import {
|
|
65
65
|
RETIRED_FIELD_ALIASES,
|
|
66
66
|
declaresRetiredAlias,
|
|
67
|
+
aliasesRetiredMessage,
|
|
68
|
+
declaresRetiredAliasesField,
|
|
67
69
|
draftRetiredMessage,
|
|
68
70
|
readAliasedField,
|
|
69
71
|
retiredAliasMessage,
|
|
@@ -466,21 +468,32 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
466
468
|
message: draftRetiredMessage(),
|
|
467
469
|
});
|
|
468
470
|
}
|
|
471
|
+
// Only the top-level `aliases` is retired. `name.aliases` writes the same
|
|
472
|
+
// key indented under `name:` and is **permitted** — reserved and unread —
|
|
473
|
+
// so both the test and the locator are anchored at column 1 (#180).
|
|
474
|
+
if (declaresRetiredAliasesField(fm)) {
|
|
475
|
+
findings.push({
|
|
476
|
+
file: note.file,
|
|
477
|
+
...positionInFrontmatter(raw(), "aliases", undefined, { topLevel: true }),
|
|
478
|
+
severity: "error",
|
|
479
|
+
message: aliasesRetiredMessage(),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
469
482
|
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
// fields above because it is the same kind of statement
|
|
473
|
-
//
|
|
483
|
+
// What the address rule says about a homepage's top-level fields: the
|
|
484
|
+
// `shortcode` it owes, and the `id` it may not write (#53, #182). Reported
|
|
485
|
+
// beside the retired fields above because it is the same kind of statement
|
|
486
|
+
// about the same note, and, like them, it must survive the two early
|
|
474
487
|
// returns below: the finding stands whatever else the type is.
|
|
475
488
|
// Tags are checked here for the same reason: a classifying tag is not a
|
|
476
489
|
// type's property — `draft` belongs to any note and `village` to a place —
|
|
477
490
|
// so the finding must survive the early returns below.
|
|
478
491
|
findings.push(...checkTags(note, { type }));
|
|
479
492
|
|
|
480
|
-
for (const {
|
|
493
|
+
for (const { locator, message } of checkHomepageAddressFields(fm)) {
|
|
481
494
|
findings.push({
|
|
482
495
|
file: note.file,
|
|
483
|
-
...at(key),
|
|
496
|
+
...at(locator.key, locator.literal),
|
|
484
497
|
severity: "error",
|
|
485
498
|
message,
|
|
486
499
|
});
|
|
@@ -642,9 +655,16 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
|
|
|
642
655
|
// link index's own resolver, so a cross-package reference answered by a
|
|
643
656
|
// vendored manifest lands exactly as the same address in a wikilink
|
|
644
657
|
// would — rather than through a second, subtly different rule.
|
|
658
|
+
//
|
|
659
|
+
// **As an address, always** — which is now the only namespace there
|
|
660
|
+
// is (#180). A frontmatter reference is a bare address by construction:
|
|
661
|
+
// there is no pipe to read intent from, and the field supplies the
|
|
662
|
+
// type. The resolver once took a namespace argument, and omitting it
|
|
663
|
+
// read every `ref:` value as an alias, which `type-shortcode` never was
|
|
664
|
+
// (#176).
|
|
645
665
|
if (field.ref && index && typeof value === "string" && value) {
|
|
646
666
|
const target = `${field.ref}-${value}`;
|
|
647
|
-
if (!index.resolve(
|
|
667
|
+
if (!index.resolve(target) && !index.manifestHit(target)) {
|
|
648
668
|
findings.push({
|
|
649
669
|
file: note.file,
|
|
650
670
|
...at(head, value),
|
package/engine/helpers.mjs
CHANGED
|
@@ -39,10 +39,14 @@ import { contentPackage, foundryPackageId } from "./content-package.mjs";
|
|
|
39
39
|
import { searchableFrontmatter } from "./note-package.mjs";
|
|
40
40
|
import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
|
|
41
41
|
import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
|
|
42
|
-
//
|
|
43
|
-
|
|
42
|
+
// One vocabulary of link findings, and one message per class, so the three
|
|
43
|
+
// resolvers cannot word the same defect differently (#184).
|
|
44
|
+
import { linkFindingMessage } from "./wikilink-syntax.mjs";
|
|
45
|
+
// The declared tag vocabulary (#172), which is where `draft` is stated. Read
|
|
46
|
+
// from there rather than respelt, so the tag and its one reader cannot drift.
|
|
47
|
+
import { isDraftNote } from "./note-vocabulary.mjs";
|
|
44
48
|
import { expandContentTables } from "./content-tables.mjs";
|
|
45
|
-
import {
|
|
49
|
+
import { positionInBody } from "./diagnostics.mjs";
|
|
46
50
|
// The pure `sohl:` frontmatter readers live in a leaf module so the item-type
|
|
47
51
|
// registry can import them without reaching back through this one (#1504).
|
|
48
52
|
// Re-exported here so every existing importer keeps its single import path.
|
|
@@ -429,7 +433,7 @@ import { assertTypeNotRetired, packForType } from "./ids.mjs";
|
|
|
429
433
|
* @param {object} [router] - The pack router. Supplied by the calling pass so
|
|
430
434
|
* the index and the compile agree about where each note landed; defaults to
|
|
431
435
|
* this repository's own.
|
|
432
|
-
* @returns {{byShortcode: Map,
|
|
436
|
+
* @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
|
|
433
437
|
*/
|
|
434
438
|
export function buildContentLinkIndex(contentBase, router = packRouter()) {
|
|
435
439
|
const docs = [];
|
|
@@ -450,10 +454,11 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
|
|
|
450
454
|
docPack: router.resolveOrNull(fm, "JournalEntry"),
|
|
451
455
|
shortcode: fm.shortcode ?? null,
|
|
452
456
|
name: fm.name?.full ?? base,
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
+
// Whether the note is tagged `draft` (#183). Read from the tag
|
|
458
|
+
// vocabulary that declares it, and used for one thing: a link
|
|
459
|
+
// *into* this note renders marked. It takes no part in resolution,
|
|
460
|
+
// so the note is indexed, compiled and published as any other.
|
|
461
|
+
draft: isDraftNote(fm),
|
|
457
462
|
});
|
|
458
463
|
}
|
|
459
464
|
// Packages this build links *into* but does not publish. Their manifests
|
|
@@ -500,7 +505,8 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
|
|
|
500
505
|
* entry. Position is carried by `{ file, bodyLine, bodyColumn, lineMap }`,
|
|
501
506
|
* the last from {@link expandNoteTables}.
|
|
502
507
|
* @returns {{markdown: string, unresolved: Array<object>}}
|
|
503
|
-
* @throws {Error} On
|
|
508
|
+
* @throws {Error} On any link that does not resolve — an unlabelled one, a
|
|
509
|
+
* target that is not an address, or an address nothing publishes. The error
|
|
504
510
|
* carries `file` and `position`, so a caller reports it in the same form
|
|
505
511
|
* rather than re-deriving one.
|
|
506
512
|
*/
|
|
@@ -539,70 +545,27 @@ export function convertNoteWikilinks(
|
|
|
539
545
|
* @returns {never}
|
|
540
546
|
*/
|
|
541
547
|
const fail = (u, message) => {
|
|
542
|
-
const
|
|
548
|
+
const at = locate(u);
|
|
549
|
+
// A link this build wrote is not at any authored position, so say
|
|
550
|
+
// where it came from rather than implying an edit site.
|
|
551
|
+
const err = new Error(
|
|
552
|
+
at.generated ? `${message} Emitted by the content table on this line.` : message,
|
|
553
|
+
);
|
|
543
554
|
err.file = file;
|
|
544
|
-
err.position =
|
|
555
|
+
err.position = at;
|
|
545
556
|
throw err;
|
|
546
557
|
};
|
|
547
558
|
|
|
548
559
|
for (const u of result.unresolved) {
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
// the
|
|
552
|
-
// the
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const named =
|
|
559
|
-
claims.length ?
|
|
560
|
-
claims.map((c) => `"${c.name}" (${c.type}-${c.shortcode})`).join(" and ")
|
|
561
|
-
: "two or more notes";
|
|
562
|
-
fail(
|
|
563
|
-
u,
|
|
564
|
-
`ambiguous wikilink ${u.link} in "${name}" — claimed by ` +
|
|
565
|
-
`${named}. Rename one alias, or address the intended one ` +
|
|
566
|
-
`as [[type-shortcode|Text]].`,
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
// The author wrote a pipe, so they meant an address — and this target
|
|
570
|
-
// is not one. Its own message, because the correction is its own: a
|
|
571
|
-
// note *name* has to become an address, which is not the same job as
|
|
572
|
-
// fixing a shortcode that resolves nowhere (#131).
|
|
573
|
-
if (u.reason === "not-an-address") {
|
|
574
|
-
fail(
|
|
575
|
-
u,
|
|
576
|
-
`wikilink ${u.link} in "${name}" is written as an address — ` +
|
|
577
|
-
`the "|" says so — but "${u.target}" is not one. Write ` +
|
|
578
|
-
`[[type-shortcode|Text]], or drop the "|" to name it as ` +
|
|
579
|
-
`an alias within this note's own type.`,
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
// A qualified address resolving nowhere is a typo, now that every
|
|
583
|
-
// linkable package is either built here or vendored (#1499) — so it
|
|
584
|
-
// fails the note rather than degrading to text. A bare alias stays a
|
|
585
|
-
// warning: it may be ordinary prose that merely looks like a link.
|
|
586
|
-
if (u.addressed) {
|
|
587
|
-
fail(
|
|
588
|
-
u,
|
|
589
|
-
`unresolved address ${u.link} in "${name}" — no package ` +
|
|
590
|
-
`publishes it. Fix the shortcode, or re-vendor that ` +
|
|
591
|
-
`package's manifest into assets/manifests/.`,
|
|
592
|
-
);
|
|
593
|
-
}
|
|
594
|
-
const at = locate(u);
|
|
595
|
-
emitDiagnostic({
|
|
596
|
-
file,
|
|
597
|
-
line: at.line,
|
|
598
|
-
column: at.column,
|
|
599
|
-
severity: "warning",
|
|
600
|
-
message:
|
|
601
|
-
`unresolved wikilink ${u.link} (${u.reason}) in "${name}"` +
|
|
602
|
-
// A link this build wrote is not at any authored position, so
|
|
603
|
-
// say where it came from instead of implying an edit site.
|
|
604
|
-
(at.generated ? " — emitted by the content table on this line" : ""),
|
|
605
|
-
});
|
|
560
|
+
// Every class fails, and every class is worded by the shared table
|
|
561
|
+
// (#184). The three resolvers read one authored link, so an author who
|
|
562
|
+
// ran the pack build first and the link checker second must not be told
|
|
563
|
+
// two different things about the same mistake — and a class the pack
|
|
564
|
+
// build alone knew how to describe is how they came apart before.
|
|
565
|
+
//
|
|
566
|
+
// The note's name is appended rather than woven in: the message is the
|
|
567
|
+
// defect, the name is the context this build can add.
|
|
568
|
+
fail(u, `${linkFindingMessage(u)} — in "${name}".`);
|
|
606
569
|
}
|
|
607
570
|
return result;
|
|
608
571
|
}
|
package/engine/homepage.mjs
CHANGED
|
@@ -47,18 +47,27 @@
|
|
|
47
47
|
* type living in the SoHL registry would be unavailable to HM3 and to every HM3
|
|
48
48
|
* module, which is most of the packages that need a homepage and nothing else.
|
|
49
49
|
*
|
|
50
|
-
* **
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
+
* (#182). 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 (#181), 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.
|
|
56
64
|
*
|
|
57
65
|
* @module
|
|
58
66
|
*/
|
|
59
67
|
|
|
60
68
|
import fs from "node:fs";
|
|
61
69
|
|
|
70
|
+
import { addressSlug } from "./content-address.mjs";
|
|
62
71
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
63
72
|
import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
|
|
64
73
|
|
|
@@ -74,23 +83,44 @@ export const HOMEPAGE_TYPE = "homepage";
|
|
|
74
83
|
*
|
|
75
84
|
* Empty on purpose, and declared rather than omitted: a type with no vocabulary
|
|
76
85
|
* and a type that is unknown are different findings, and only the second is an
|
|
77
|
-
* authoring error. The whole envelope is the
|
|
78
|
-
* optional `title`; there is no game-system data
|
|
79
|
-
* document.
|
|
86
|
+
* authoring error. The whole envelope is the top-level keys `type` and
|
|
87
|
+
* `shortcode`, plus an optional `title` or `name`; there is no game-system data
|
|
88
|
+
* on a page that compiles to no document.
|
|
80
89
|
*
|
|
81
90
|
* @type {readonly import("./field-spec.mjs").FieldSpec[]}
|
|
82
91
|
*/
|
|
83
92
|
export const HOMEPAGE_FIELDS = Object.freeze([]);
|
|
84
93
|
|
|
85
94
|
/**
|
|
86
|
-
*
|
|
95
|
+
* The shortcode a package landing conventionally takes.
|
|
87
96
|
*
|
|
88
|
-
*
|
|
89
|
-
* package
|
|
97
|
+
* A **convention, not a rule.** The address only has to be unique within the
|
|
98
|
+
* package, which `(type, shortcode)` already guarantees, and nothing here knows
|
|
99
|
+
* better than an author what their landing is called. What the constant buys is
|
|
100
|
+
* one spelling shared by the diagnostic, the documentation and the six trees —
|
|
101
|
+
* so `[[homepage-root|…]]` is the same link in every package.
|
|
90
102
|
*
|
|
91
103
|
* @type {string}
|
|
92
104
|
*/
|
|
93
|
-
export const
|
|
105
|
+
export const HOMEPAGE_SHORTCODE = "root";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The file a homepage is written to, relative to the package's site root.
|
|
109
|
+
*
|
|
110
|
+
* Its **address**, flat at the package root, and stated in the page's own `url`
|
|
111
|
+
* — the same separation every other page has since #181, 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.
|
|
116
|
+
*
|
|
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.
|
|
120
|
+
*/
|
|
121
|
+
export function homepageDestination(fm) {
|
|
122
|
+
return `${addressSlug(fm)}.md`;
|
|
123
|
+
}
|
|
94
124
|
|
|
95
125
|
/**
|
|
96
126
|
* Whether a note's frontmatter declares the homepage type.
|
|
@@ -103,58 +133,33 @@ export function isHomepage(fm) {
|
|
|
103
133
|
}
|
|
104
134
|
|
|
105
135
|
/**
|
|
106
|
-
* The top-level
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* `
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* that says nothing. It also inflates `content-build lint`'s address tally, so
|
|
121
|
-
* the lint and the link manifest disagree about what the package publishes.
|
|
122
|
-
*
|
|
123
|
-
* **A named class, not an allow-list, and that boundary is the decision.** The
|
|
124
|
-
* documented envelope is `type` plus an optional `title`, and `landing`,
|
|
125
|
-
* `description` and `banner` are legitimate beside them — but a homepage's
|
|
126
|
-
* frontmatter is *emitted into the published page*
|
|
136
|
+
* The top-level field a homepage refuses, and what it would decide (#53).
|
|
137
|
+
*
|
|
138
|
+
* **One field, where there used to be three.** `name` and `shortcode` were
|
|
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 (#181) and a homepage
|
|
142
|
+
* publishes at its own, so both fields decide exactly what they decide
|
|
143
|
+
* everywhere else and are permitted (#182).
|
|
144
|
+
*
|
|
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.
|
|
147
|
+
*
|
|
148
|
+
* **A named class, not an allow-list, and that boundary is the decision.** A
|
|
149
|
+
* homepage's frontmatter is *emitted into the published page*
|
|
127
150
|
* ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
|
|
128
151
|
* parameter this build has never heard of and has no standing to refuse.
|
|
129
152
|
* Rejecting unknown keys would make every new theme parameter wait on a
|
|
130
|
-
* package-build release.
|
|
131
|
-
* false claim about *where this page is*.
|
|
153
|
+
* package-build release.
|
|
132
154
|
*
|
|
133
|
-
* `aliases` is deliberately not in the class:
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* belief about this one's address.
|
|
155
|
+
* `aliases` is deliberately not in the class: it is a **retired** field, refused
|
|
156
|
+
* on every note whatever its type (#180), so it is answered there rather than
|
|
157
|
+
* here.
|
|
137
158
|
*
|
|
138
159
|
* @type {ReadonlyMap<string, string>}
|
|
139
160
|
*/
|
|
140
161
|
export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
|
|
141
162
|
new Map([
|
|
142
|
-
[
|
|
143
|
-
"name",
|
|
144
|
-
"`name` decides nothing on a `type: homepage` note: a page's slug " +
|
|
145
|
-
"derives from `name.full`, and a homepage's destination is " +
|
|
146
|
-
`fixed — it is written to \`${HOMEPAGE_DESTINATION}\` at the ` +
|
|
147
|
-
"package's own address, `/<package>/`. Write `title:` for what " +
|
|
148
|
-
"the page is called, and delete `name`",
|
|
149
|
-
],
|
|
150
|
-
[
|
|
151
|
-
"shortcode",
|
|
152
|
-
"`shortcode` decides nothing on a `type: homepage` note: this " +
|
|
153
|
-
"page's address is the package's own, `/<package>/`, fixed by " +
|
|
154
|
-
"the package id. It is not ignored either — it puts the note " +
|
|
155
|
-
"in the address index, so `[[homepage-<shortcode>]]` resolves " +
|
|
156
|
-
"to a page the site build never writes. Delete it",
|
|
157
|
-
],
|
|
158
163
|
[
|
|
159
164
|
"id",
|
|
160
165
|
"`id` decides nothing on a `type: homepage` note: it is the " +
|
|
@@ -166,30 +171,54 @@ export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
|
|
|
166
171
|
);
|
|
167
172
|
|
|
168
173
|
/**
|
|
169
|
-
*
|
|
174
|
+
* What the address rule says about one note's top-level fields.
|
|
170
175
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* reader and a compiler-output parser both expect.
|
|
176
|
+
* Two statements about the same thing, so they are made together: the field a
|
|
177
|
+
* homepage **owes** and the field it may **not** write.
|
|
174
178
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
179
|
+
* The missing `shortcode` comes first, and is located at `type:` rather than at
|
|
180
|
+
* a key that is not there — the `homepage` value is what makes the field
|
|
181
|
+
* required, it is a real position in the file, and inventing a `1:1` for an
|
|
182
|
+
* absent key would put the author on the opening fence. The refused fields
|
|
183
|
+
* follow in the order the note authored them, so a caller emitting one
|
|
184
|
+
* diagnostic per finding walks down the file.
|
|
177
185
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
186
|
+
* 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.
|
|
189
|
+
*
|
|
190
|
+
* Each finding carries the `locator` key to position it at, because the two
|
|
191
|
+
* things that would resolve one — the raw note text and the position helper —
|
|
192
|
+
* belong to the caller. This mirrors {@link module:engine/retired-fields},
|
|
193
|
+
* whose retired-field messages are likewise positioned by whoever reports them.
|
|
182
194
|
*
|
|
183
195
|
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
184
|
-
* @returns {Array<{
|
|
185
|
-
*
|
|
196
|
+
* @returns {Array<{field: string, locator: {key: string, literal?: string},
|
|
197
|
+
* message: string}>} One entry per finding, empty for any note that is not a
|
|
198
|
+
* homepage and declares nothing wrong.
|
|
186
199
|
*/
|
|
187
200
|
export function checkHomepageAddressFields(fm) {
|
|
188
201
|
if (!isHomepage(fm)) return [];
|
|
189
202
|
const out = [];
|
|
203
|
+
|
|
204
|
+
const shortcode = typeof fm.shortcode === "string" ? fm.shortcode.trim() : "";
|
|
205
|
+
if (!shortcode) {
|
|
206
|
+
out.push({
|
|
207
|
+
field: "shortcode",
|
|
208
|
+
locator: { key: "type", literal: HOMEPAGE_TYPE },
|
|
209
|
+
message:
|
|
210
|
+
"a `type: homepage` note declares a `shortcode`, like every " +
|
|
211
|
+
"other note: it is addressed as `homepage-<shortcode>` and " +
|
|
212
|
+
"published at `/<package>/homepage-<shortcode>/`, which is " +
|
|
213
|
+
"where `[[homepage-<shortcode>|Text]]` lands. Write " +
|
|
214
|
+
`\`shortcode: ${HOMEPAGE_SHORTCODE}\` — the package landing is ` +
|
|
215
|
+
`\`homepage-${HOMEPAGE_SHORTCODE}\` in every package`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
190
219
|
for (const key of Object.keys(fm)) {
|
|
191
220
|
const message = HOMEPAGE_REFUSED_FIELDS.get(key);
|
|
192
|
-
if (message) out.push({ key, message });
|
|
221
|
+
if (message) out.push({ field: key, locator: { key }, message });
|
|
193
222
|
}
|
|
194
223
|
return out;
|
|
195
224
|
}
|
|
@@ -203,12 +232,16 @@ export function checkHomepageAddressFields(fm) {
|
|
|
203
232
|
* - _None_ and the package serves nothing at `/<package>/`. That is the failure
|
|
204
233
|
* #50 exists to prevent, and it is silent — the site build reports `wrote 0
|
|
205
234
|
* homepage(s)` and exits 0.
|
|
206
|
-
* - _Two_ and it serves a page nobody chose.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
235
|
+
* - _Two_ and it serves a page nobody chose. **This is a cardinality rule, and
|
|
236
|
+
* since #182 it is only that.** It used to rest on the fixed destination
|
|
237
|
+
* every homepage shared — the second overwrote the first — so the address
|
|
238
|
+
* rule enforced it as a side effect. A homepage is written at its own address
|
|
239
|
+
* now, so two of them publish two pages and collide over nothing; the
|
|
240
|
+
* duplicate-address check catches only the pair that happen to share a
|
|
241
|
+
* shortcode, and says nothing at all about a `homepage-root` beside a
|
|
242
|
+
* `homepage-front`. Which of the two the redirect at `/<package>/` should
|
|
243
|
+
* name is a question nothing here can answer, and both being reachable is
|
|
244
|
+
* not an answer to it.
|
|
212
245
|
*
|
|
213
246
|
* Neither has a safe default, so neither is a warning. A warning is the right
|
|
214
247
|
* severity for something a build can proceed past correctly, and a build that
|
|
@@ -270,9 +303,10 @@ export function checkHomepageCount(found, { contentBase, contentPackage }) {
|
|
|
270
303
|
message:
|
|
271
304
|
`duplicate \`type: homepage\` note, also declared by ` +
|
|
272
305
|
`${others.join(", ")}; a package has one front page` +
|
|
273
|
-
`${contentPackage ? `, at${address}
|
|
274
|
-
`
|
|
275
|
-
`
|
|
306
|
+
`${contentPackage ? `, at${address}` : ""}, and each of these ` +
|
|
307
|
+
`publishes at an address of its own — so nothing here can say ` +
|
|
308
|
+
`which one that address should redirect to. Keep one, and make ` +
|
|
309
|
+
`the rest ordinary notes`,
|
|
276
310
|
};
|
|
277
311
|
});
|
|
278
312
|
}
|
|
@@ -325,24 +359,35 @@ export function homepageTitle(fm, config) {
|
|
|
325
359
|
/**
|
|
326
360
|
* The frontmatter a homepage publishes with.
|
|
327
361
|
*
|
|
328
|
-
* The note's own, plus the
|
|
329
|
-
* resolved `title`,
|
|
362
|
+
* The note's own, plus the derived values every emitted page carries: the
|
|
363
|
+
* resolved `title`, the package the build derived — no note declares one
|
|
330
364
|
* (`package:` is retired, #56) and the theme's breadcrumb partial reads
|
|
331
|
-
* `.Params.package
|
|
365
|
+
* `.Params.package` — and its **address**.
|
|
366
|
+
*
|
|
367
|
+
* The address is stated as `url` for the same reason every other page states
|
|
368
|
+
* one (#181): Hugo publishes a page where its file sits unless told otherwise,
|
|
369
|
+
* and a homepage's file sits at the package's site root. `slug` is written
|
|
370
|
+
* beside it because it is the last segment of that address and Hugo's own key
|
|
371
|
+
* for one; it decides nothing while `url` is present, but a page carrying only
|
|
372
|
+
* `url` would report a slug Hugo had inferred from the filename.
|
|
332
373
|
*
|
|
333
374
|
* An authored `aliases` is dropped for the same reason it is on every other
|
|
334
|
-
* page:
|
|
335
|
-
*
|
|
336
|
-
*
|
|
375
|
+
* page: Hugo reads it as URL redirects, so passing it through would publish a
|
|
376
|
+
* redirect stub at each one. The field is retired (#180) and refused before a
|
|
377
|
+
* build reaches here, which makes this a guard rather than a working path.
|
|
337
378
|
*
|
|
338
379
|
* @param {object} fm - The note's frontmatter.
|
|
339
380
|
* @param {object} options - Options.
|
|
340
381
|
* @param {string} options.contentPackage - The package this build publishes.
|
|
341
382
|
* @param {string} options.title - The resolved title.
|
|
383
|
+
* @param {string} options.base - Where the package is served, with both
|
|
384
|
+
* slashes — `/<package>/`.
|
|
342
385
|
* @returns {object} The frontmatter to write.
|
|
386
|
+
* @throws {Error} When the note declares no shortcode, and so has no address.
|
|
343
387
|
*/
|
|
344
|
-
export function homepageFrontmatter(fm, { contentPackage, title }) {
|
|
345
|
-
const
|
|
388
|
+
export function homepageFrontmatter(fm, { contentPackage, title, base }) {
|
|
389
|
+
const slug = addressSlug(fm);
|
|
390
|
+
const data = { ...fm, package: contentPackage, title, slug, url: `${base}${slug}/` };
|
|
346
391
|
delete data.aliases;
|
|
347
392
|
return data;
|
|
348
393
|
}
|
package/engine/index.mjs
CHANGED
|
@@ -77,10 +77,10 @@ export * as noteVocabulary from "./note-vocabulary.mjs";
|
|
|
77
77
|
|
|
78
78
|
/** The shipped Foundry manifest: locating it, reading it, guarding its id. */
|
|
79
79
|
|
|
80
|
-
/** The
|
|
80
|
+
/** The one normalisation this build makes: prose to a URL-safe token. */
|
|
81
81
|
export * as contentSlug from "./content-slug.mjs";
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/** Where a content note publishes: its section, and its `type-shortcode` URL. */
|
|
84
84
|
export * as contentAddress from "./content-address.mjs";
|
|
85
85
|
|
|
86
86
|
/** Whether a vendored manifest can still be addressed, not merely read. */
|
|
@@ -125,9 +125,6 @@ export * as wikilinks from "./wikilinks.mjs";
|
|
|
125
125
|
/** What a `[[…]]` is, before either resolver decides where it points. */
|
|
126
126
|
export * as wikilinkSyntax from "./wikilink-syntax.mjs";
|
|
127
127
|
|
|
128
|
-
/** The alias namespace: what a note may be called, and who may claim a name. */
|
|
129
|
-
export * as aliasIndex from "./alias-index.mjs";
|
|
130
|
-
|
|
131
128
|
/** The address index a site build resolves its wikilinks against. */
|
|
132
129
|
export * as siteIndex from "./site-index.mjs";
|
|
133
130
|
|
package/engine/manifest-emit.mjs
CHANGED
|
@@ -29,11 +29,20 @@
|
|
|
29
29
|
* start, by {@link packageAddress}, and the emitting build's mount point is not
|
|
30
30
|
* a fact it has to be told (#1465).
|
|
31
31
|
*
|
|
32
|
+
* **An entry's `path` is derivable from the key it is filed under** (#181).
|
|
33
|
+
* `sohl-affliction-aconite` publishes at `affliction-aconite/`, because a page's
|
|
34
|
+
* URL *is* its address; nothing in it comes from a display name, so a rename
|
|
35
|
+
* moves no URL and no uniqueness check stands between the two. The field is
|
|
36
|
+
* still written rather than left for a consumer to compute, because a landing
|
|
37
|
+
* page is the one entry that is not derivable — it addresses its section under
|
|
38
|
+
* the configured mount — and because an absent `path` already means something
|
|
39
|
+
* else entirely (a package that publishes no pages).
|
|
40
|
+
*
|
|
32
41
|
* **The address scheme is configuration, and it is shared with the site build.**
|
|
33
42
|
* Where the content tree mounts inside the package and which note is a section's
|
|
34
43
|
* landing page differ between repositories and are both load-bearing — `sohl`
|
|
35
|
-
* records `kb/
|
|
36
|
-
* `affiliation
|
|
44
|
+
* records `kb/rules/` for the landing of its rules section and `thalorna`
|
|
45
|
+
* records `affiliation/` for its own. Reading one setting here and in the page
|
|
37
46
|
* emitter is what stops a manifest asserting an address the site does not
|
|
38
47
|
* publish, which resolves at build time and 404s for the reader.
|
|
39
48
|
*
|
|
@@ -54,8 +63,9 @@ import { canonicalKey, writeManifests } from "./kb-manifest.mjs";
|
|
|
54
63
|
import { walkMarkdownTree } from "./helpers.mjs";
|
|
55
64
|
import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
|
|
56
65
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
66
|
+
import { isHomepage } from "./homepage.mjs";
|
|
57
67
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
58
|
-
import { assertNoDraftField } from "./retired-fields.mjs";
|
|
68
|
+
import { assertNoAliasesField, assertNoDraftField } from "./retired-fields.mjs";
|
|
59
69
|
import { journalPageId, splitPages } from "./journals.mjs";
|
|
60
70
|
import { routerFor } from "./pack-router.mjs";
|
|
61
71
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
@@ -222,14 +232,23 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
222
232
|
configured: ctx.contentPackage,
|
|
223
233
|
});
|
|
224
234
|
assertNoDraftField(fm, { file: rel, absPath });
|
|
235
|
+
assertNoAliasesField(fm, { file: rel, absPath });
|
|
225
236
|
if (!fm.type || !fm.shortcode) continue;
|
|
237
|
+
// A homepage is addressed like every other note since #182, and a
|
|
238
|
+
// shortcode alone would now put it here. It stays out for the reason it
|
|
239
|
+
// always did, which that change does not touch: a manifest entry is how
|
|
240
|
+
// another package resolves a **document**, and a homepage compiles into
|
|
241
|
+
// none — the same ground `id` is refused on. A cross-package link to a
|
|
242
|
+
// package's front page is its bare `/<package>/` address, which needs
|
|
243
|
+
// no index.
|
|
244
|
+
if (isHomepage(fm)) continue;
|
|
226
245
|
|
|
227
246
|
const base = path.basename(absPath);
|
|
228
247
|
const name = fm.name?.full ?? path.basename(absPath, ".md");
|
|
229
248
|
|
|
230
249
|
let address;
|
|
231
250
|
try {
|
|
232
|
-
address = packageAddress(fm,
|
|
251
|
+
address = packageAddress(fm, {
|
|
233
252
|
isReadme: base.toLowerCase() === "readme.md",
|
|
234
253
|
scheme: ctx.scheme,
|
|
235
254
|
});
|