@heroiclands/package-build 6.0.0 → 6.1.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 +184 -0
- package/CONTENT.md +207 -3
- package/bin/content-build.mjs +168 -1
- package/content-config.mjs +29 -7
- package/engine/address-diff.mjs +290 -0
- package/engine/content-lint.mjs +23 -2
- package/engine/frontmatter-lint.mjs +22 -0
- package/engine/homepage.mjs +206 -2
- package/engine/site-build.mjs +74 -19
- package/package.json +1 -1
- package/types/engine/address-diff.d.mts +108 -0
- package/types/engine/content-lint.d.mts +4 -1
- package/types/engine/homepage.d.mts +117 -0
- package/types/engine/site-build.d.mts +28 -3
package/engine/homepage.mjs
CHANGED
|
@@ -49,14 +49,18 @@
|
|
|
49
49
|
*
|
|
50
50
|
* **Its address is the package's, not the note's.** A homepage publishes at
|
|
51
51
|
* `/<contentPackage>/` because that is where the package is, so `name.full`,
|
|
52
|
-
* `shortcode` and `id` decide nothing on it
|
|
53
|
-
*
|
|
52
|
+
* `shortcode` and `id` decide nothing on it — nothing here reads them, and
|
|
53
|
+
* {@link HOMEPAGE_REFUSED_FIELDS} refuses them outright rather than leaving an
|
|
54
|
+
* author to believe they worked (#53). It compiles into no document, so it carries
|
|
54
55
|
* no compendium UUID and appears in no pack and in no link-manifest entry.
|
|
55
56
|
*
|
|
56
57
|
* @module
|
|
57
58
|
*/
|
|
58
59
|
|
|
60
|
+
import fs from "node:fs";
|
|
61
|
+
|
|
59
62
|
import { matchAllOutsideCode } from "./code-fences.mjs";
|
|
63
|
+
import { formatLocator, positionInFrontmatter } from "./diagnostics.mjs";
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* The note type that compiles to the package homepage.
|
|
@@ -98,6 +102,206 @@ export function isHomepage(fm) {
|
|
|
98
102
|
return Boolean(fm) && fm.type === HOMEPAGE_TYPE;
|
|
99
103
|
}
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The top-level fields a homepage refuses, and what each one would decide (#53).
|
|
107
|
+
*
|
|
108
|
+
* A note's URL derives from `name.full` and its identity from
|
|
109
|
+
* `(type, shortcode)`. The homepage is the one page for which neither holds: it
|
|
110
|
+
* publishes at `/<package>/`, fixed by the package id. An author fluent in the
|
|
111
|
+
* conventions writes them here expecting exactly what they do everywhere else,
|
|
112
|
+
* and gets none of it.
|
|
113
|
+
*
|
|
114
|
+
* **They were never inert, which is why ignoring them was the wrong answer.** A
|
|
115
|
+
* `shortcode` puts the note in the address index and in the `dataview` link
|
|
116
|
+
* universe, so `[[homepage-<shortcode>]]` resolves *green* — to
|
|
117
|
+
* `homepage/<slug>/`, an address derived from `name.full` and published by
|
|
118
|
+
* nothing, because a homepage is written to {@link HOMEPAGE_DESTINATION} at the
|
|
119
|
+
* package root. A build that reports a live link to a 404 is worse than one
|
|
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*
|
|
127
|
+
* ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
|
|
128
|
+
* parameter this build has never heard of and has no standing to refuse.
|
|
129
|
+
* Rejecting unknown keys would make every new theme parameter wait on a
|
|
130
|
+
* package-build release. What is refused is the specific class that makes a
|
|
131
|
+
* false claim about *where this page is*.
|
|
132
|
+
*
|
|
133
|
+
* `aliases` is deliberately not in the class: {@link homepageFrontmatter}
|
|
134
|
+
* already drops it from every emitted page, with a reason of its own, so
|
|
135
|
+
* authoring one is the same no-op it is on any other page rather than a wrong
|
|
136
|
+
* belief about this one's address.
|
|
137
|
+
*
|
|
138
|
+
* @type {ReadonlyMap<string, string>}
|
|
139
|
+
*/
|
|
140
|
+
export const HOMEPAGE_REFUSED_FIELDS = Object.freeze(
|
|
141
|
+
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
|
+
[
|
|
159
|
+
"id",
|
|
160
|
+
"`id` decides nothing on a `type: homepage` note: it is the " +
|
|
161
|
+
"Foundry document id a compendium UUID is built from, and a " +
|
|
162
|
+
"homepage compiles into no document — it appears in no pack " +
|
|
163
|
+
"and in no link manifest. Delete it",
|
|
164
|
+
],
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The address-bearing fields one note authors, in the order it authored them.
|
|
170
|
+
*
|
|
171
|
+
* Authoring order rather than declaration order, so a caller emitting one
|
|
172
|
+
* diagnostic per finding emits them top to bottom down the file — the order a
|
|
173
|
+
* reader and a compiler-output parser both expect.
|
|
174
|
+
*
|
|
175
|
+
* Presence is the whole test: `shortcode:` authored empty still says "this page
|
|
176
|
+
* has an address of its own", and a value cannot make the claim true.
|
|
177
|
+
*
|
|
178
|
+
* Returned without a locator, because the two things that would supply one —
|
|
179
|
+
* the raw note text and the position helper — belong to the caller. This
|
|
180
|
+
* mirrors {@link module:engine/retired-fields}, whose retired-field messages
|
|
181
|
+
* are likewise positioned by whoever reports them.
|
|
182
|
+
*
|
|
183
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
184
|
+
* @returns {Array<{key: string, message: string}>} One entry per field the note
|
|
185
|
+
* authored, empty for any note that is not a homepage.
|
|
186
|
+
*/
|
|
187
|
+
export function checkHomepageAddressFields(fm) {
|
|
188
|
+
if (!isHomepage(fm)) return [];
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const key of Object.keys(fm)) {
|
|
191
|
+
const message = HOMEPAGE_REFUSED_FIELDS.get(key);
|
|
192
|
+
if (message) out.push({ key, message });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Require exactly one homepage note in a content tree (#52).
|
|
199
|
+
*
|
|
200
|
+
* "Exactly one" is two rules, and they are **one severity** because they are
|
|
201
|
+
* one defect: a package whose front page is not the page a person chose.
|
|
202
|
+
*
|
|
203
|
+
* - _None_ and the package serves nothing at `/<package>/`. That is the failure
|
|
204
|
+
* #50 exists to prevent, and it is silent — the site build reports `wrote 0
|
|
205
|
+
* homepage(s)` and exits 0.
|
|
206
|
+
* - _Two_ and it serves a page nobody chose. Every homepage is written to the
|
|
207
|
+
* same {@link HOMEPAGE_DESTINATION}, so the second overwrites the first and
|
|
208
|
+
* the package's front page is decided by the order the walk happened to reach
|
|
209
|
+
* the files in — by *filename*, on a type whose whole point is that it is
|
|
210
|
+
* routed by frontmatter. There is no "first wins" convention to fall back on,
|
|
211
|
+
* so nothing here can pick the right one.
|
|
212
|
+
*
|
|
213
|
+
* Neither has a safe default, so neither is a warning. A warning is the right
|
|
214
|
+
* severity for something a build can proceed past correctly, and a build that
|
|
215
|
+
* proceeds past either of these publishes the wrong front page while reporting
|
|
216
|
+
* success — which is the exact outcome a warning would be tolerating.
|
|
217
|
+
*
|
|
218
|
+
* **Two is reported once per note, not once for the tree.** Each note is a
|
|
219
|
+
* place an author has to open and edit, and a single finding saying "there are
|
|
220
|
+
* two" sends them hunting for the second.
|
|
221
|
+
*
|
|
222
|
+
* **None is located at the tree, honestly.** There is no file to name, so the
|
|
223
|
+
* locator is the content root — the directory the note is missing from, which
|
|
224
|
+
* is a real path and the one the author adds it to. No line and no column are
|
|
225
|
+
* invented for it, per the diagnostic rules in
|
|
226
|
+
* {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
|
|
227
|
+
* empty walk against the same locator.
|
|
228
|
+
*
|
|
229
|
+
* The rule reads no `site:` configuration and does not vary by
|
|
230
|
+
* `publish.site`: that setting chooses whether the *content* surfaces are
|
|
231
|
+
* published, and the homepage is the floor underneath both modes.
|
|
232
|
+
*
|
|
233
|
+
* @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
|
|
234
|
+
* order. Paths may be absolute or relative to the working directory.
|
|
235
|
+
* @param {object} options - Options.
|
|
236
|
+
* @param {string} options.contentBase - Root of the content tree, for the
|
|
237
|
+
* locator when there is no file to name.
|
|
238
|
+
* @param {string} [options.contentPackage] - The package this tree builds.
|
|
239
|
+
* Dropped from the message when unknown rather than guessed.
|
|
240
|
+
* @returns {Array<{file: string, line?: number, column?: number,
|
|
241
|
+
* severity: "error", message: string}>} The findings, one per offending note.
|
|
242
|
+
*/
|
|
243
|
+
export function checkHomepageCount(found, { contentBase, contentPackage }) {
|
|
244
|
+
const pages = found ?? [];
|
|
245
|
+
const named = contentPackage ? ` "${contentPackage}"` : "";
|
|
246
|
+
const address = contentPackage ? ` /${contentPackage}/` : "";
|
|
247
|
+
|
|
248
|
+
if (pages.length === 0) {
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
file: contentBase,
|
|
252
|
+
severity: "error",
|
|
253
|
+
message:
|
|
254
|
+
`holds no \`type: homepage\` note, so ` +
|
|
255
|
+
`${contentPackage ? `package${named}` : "this package"} ` +
|
|
256
|
+
`publishes nothing at its own address${address} — a ` +
|
|
257
|
+
`package's front page is one authored note in this tree, ` +
|
|
258
|
+
`routed by \`type:\` rather than by filename`,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
if (pages.length === 1) return [];
|
|
263
|
+
|
|
264
|
+
return pages.map((page) => {
|
|
265
|
+
const others = pages
|
|
266
|
+
.filter((p) => p !== page)
|
|
267
|
+
.map((p) => formatLocator({ file: p.file }));
|
|
268
|
+
return {
|
|
269
|
+
file: page.file,
|
|
270
|
+
...positionOfType(page.file),
|
|
271
|
+
severity: "error",
|
|
272
|
+
message:
|
|
273
|
+
`duplicate \`type: homepage\` note, also declared by ` +
|
|
274
|
+
`${others.join(", ")}; a package has one front page` +
|
|
275
|
+
`${contentPackage ? `, at${address},` : ""} and every ` +
|
|
276
|
+
`homepage is written to the same \`${HOMEPAGE_DESTINATION}\` — ` +
|
|
277
|
+
`so the one the walk reaches last silently overwrites the rest`,
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Where a note declares `type: homepage`, when the file can still be read.
|
|
284
|
+
*
|
|
285
|
+
* A separate read rather than a raw text threaded through every caller: the
|
|
286
|
+
* two call sites hold different shapes (a lint note, a collected page) and this
|
|
287
|
+
* runs only on a tree that is already failing.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} file - Path to the note.
|
|
290
|
+
* @returns {{line?: number, column?: number}} Spreadable position fields, empty
|
|
291
|
+
* when the file cannot be read — dropped rather than guessed.
|
|
292
|
+
*/
|
|
293
|
+
function positionOfType(file) {
|
|
294
|
+
try {
|
|
295
|
+
return positionInFrontmatter(
|
|
296
|
+
fs.readFileSync(file, "utf8"),
|
|
297
|
+
"type",
|
|
298
|
+
HOMEPAGE_TYPE,
|
|
299
|
+
);
|
|
300
|
+
} catch {
|
|
301
|
+
return {};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
101
305
|
/**
|
|
102
306
|
* The title a homepage publishes under.
|
|
103
307
|
*
|
package/engine/site-build.mjs
CHANGED
|
@@ -65,6 +65,7 @@ import { loadPackConfig } from "./pack-config.mjs";
|
|
|
65
65
|
import { searchableFrontmatter } from "./note-package.mjs";
|
|
66
66
|
import {
|
|
67
67
|
HOMEPAGE_DESTINATION,
|
|
68
|
+
checkHomepageCount,
|
|
68
69
|
homepageFrontmatter,
|
|
69
70
|
homepageTitle,
|
|
70
71
|
isHomepage,
|
|
@@ -269,9 +270,9 @@ export function collectTreePages(tree, ctx) {
|
|
|
269
270
|
* packages ship under is a property of the code path rather than of a
|
|
270
271
|
* configuration that happens to be empty (#55).
|
|
271
272
|
*
|
|
272
|
-
* Returned as a list rather than as the one note there should be
|
|
273
|
-
*
|
|
274
|
-
* found
|
|
273
|
+
* Returned as a list rather than as the one note there should be, because the
|
|
274
|
+
* count is what {@link checkHomepageCount} judges (#52) — this walk reports
|
|
275
|
+
* what it found, and {@link buildSite} decides whether that is one.
|
|
275
276
|
*
|
|
276
277
|
* @param {string} contentBase - Absolute path to the content tree.
|
|
277
278
|
* @param {object} ctx - `{ skipDirectories }`.
|
|
@@ -345,6 +346,10 @@ export function writeHomepages(outRoot, pages, config) {
|
|
|
345
346
|
*/
|
|
346
347
|
export function siteGates(pages, findings, { manifestDir }) {
|
|
347
348
|
const out = {
|
|
349
|
+
// Always empty here: the homepage count is decided in `buildSite`
|
|
350
|
+
// before the content walk, and a failing count returns without ever
|
|
351
|
+
// reaching these gates (#52). Present so every caller reads one shape.
|
|
352
|
+
homepages: [],
|
|
348
353
|
frontmatterLinks: findings.fmLinkFindings ?? [],
|
|
349
354
|
slugErrors: findings.slugFindings ?? [],
|
|
350
355
|
collisions: [],
|
|
@@ -403,6 +408,7 @@ export function siteGates(pages, findings, { manifestDir }) {
|
|
|
403
408
|
*/
|
|
404
409
|
export function emptyGates() {
|
|
405
410
|
return {
|
|
411
|
+
homepages: [],
|
|
406
412
|
frontmatterLinks: [],
|
|
407
413
|
slugErrors: [],
|
|
408
414
|
collisions: [],
|
|
@@ -418,6 +424,7 @@ export function emptyGates() {
|
|
|
418
424
|
/** Whether any gate produced a finding. */
|
|
419
425
|
export function gatesFailed(gates) {
|
|
420
426
|
return Boolean(
|
|
427
|
+
gates.homepages.length ||
|
|
421
428
|
gates.frontmatterLinks.length ||
|
|
422
429
|
gates.slugErrors.length ||
|
|
423
430
|
gates.collisions.length ||
|
|
@@ -455,6 +462,39 @@ export function tableUniverse(pages) {
|
|
|
455
462
|
return byPackage;
|
|
456
463
|
}
|
|
457
464
|
|
|
465
|
+
/**
|
|
466
|
+
* The front matter a section's landing states about itself.
|
|
467
|
+
*
|
|
468
|
+
* The section metadata a configuration resolved, ready to be written or merged
|
|
469
|
+
* onto a page. Two things happen here and nothing else does:
|
|
470
|
+
*
|
|
471
|
+
* - **`title` leads.** It is the one key every landing has carried since the
|
|
472
|
+
* first one, and a landing whose block opened with `banner:` would be a
|
|
473
|
+
* gratuitous diff on every consumer's tree.
|
|
474
|
+
* - **An absent value is left off**, not written as `undefined` — which is not
|
|
475
|
+
* a value YAML can carry, and would abort the serializer.
|
|
476
|
+
*
|
|
477
|
+
* Everything else the section declared is passed through. That is the point of
|
|
478
|
+
* the function: before #91 both writers transcribed `title` and `banner` by
|
|
479
|
+
* name, so the vocabulary lived in three places — the schema that admits a key
|
|
480
|
+
* and the two writers that copy it — and a key added to the schema alone
|
|
481
|
+
* validated cleanly and then reached no page. The *schema* is the bound worth
|
|
482
|
+
* keeping (see `normalizeSectionMeta`, which refuses a key it does not know and
|
|
483
|
+
* names it); a second, silent bound in the writers is not.
|
|
484
|
+
*
|
|
485
|
+
* @param {object} meta - A resolved `site.sections` / `site.readmeSections`
|
|
486
|
+
* entry.
|
|
487
|
+
* @returns {object} Its front matter, `title` first.
|
|
488
|
+
*/
|
|
489
|
+
export function sectionFrontmatter(meta) {
|
|
490
|
+
const data = { title: meta.title };
|
|
491
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
492
|
+
if (key === "title" || value === undefined) continue;
|
|
493
|
+
data[key] = value;
|
|
494
|
+
}
|
|
495
|
+
return data;
|
|
496
|
+
}
|
|
497
|
+
|
|
458
498
|
/**
|
|
459
499
|
* The frontmatter a page publishes with.
|
|
460
500
|
*
|
|
@@ -494,12 +534,11 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
494
534
|
if (decorate) decorate(data, page);
|
|
495
535
|
if (isReadme) {
|
|
496
536
|
const meta = readmeSections[sec];
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
537
|
+
// What the section says about itself wins over what its README
|
|
538
|
+
// happens to carry — the landing has to match the card linking to
|
|
539
|
+
// it. Assigned rather than transcribed key by key, so a section's
|
|
540
|
+
// vocabulary is decided in one place (#91).
|
|
541
|
+
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
503
542
|
}
|
|
504
543
|
} else {
|
|
505
544
|
// A tree's own landing describes the *mount*, and nothing beneath it. A
|
|
@@ -509,7 +548,7 @@ export function pageFrontmatter(page, { readmeSections = {}, decorate }) {
|
|
|
509
548
|
const isSectionRoot = path.posix.dirname(page.rel) === ".";
|
|
510
549
|
const meta = isReadme && isSectionRoot ? readmeSections[sec] : null;
|
|
511
550
|
data = { ...fm, title: meta?.title ?? fm.title ?? name };
|
|
512
|
-
if (meta
|
|
551
|
+
if (meta) Object.assign(data, sectionFrontmatter(meta));
|
|
513
552
|
}
|
|
514
553
|
delete data.aliases;
|
|
515
554
|
return data;
|
|
@@ -660,15 +699,11 @@ export function writeSectionLandings(
|
|
|
660
699
|
for (const [sec, meta] of Object.entries(sections)) {
|
|
661
700
|
const dir = path.join(outRoot, sec);
|
|
662
701
|
fs.mkdirSync(dir, { recursive: true });
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// can carry, so the key is left off entirely.
|
|
702
|
+
// Whatever the section declared, not a list of keys named here — see
|
|
703
|
+
// {@link sectionFrontmatter} for why the two lists were one too many.
|
|
666
704
|
fs.writeFileSync(
|
|
667
705
|
path.join(dir, "_index.md"),
|
|
668
|
-
matter.stringify("",
|
|
669
|
-
title: meta.title,
|
|
670
|
-
...(meta.banner ? { banner: meta.banner } : {}),
|
|
671
|
-
}),
|
|
706
|
+
matter.stringify("", sectionFrontmatter(meta)),
|
|
672
707
|
);
|
|
673
708
|
written += 1;
|
|
674
709
|
}
|
|
@@ -848,12 +883,32 @@ export function buildSite({ config, outRoot } = {}) {
|
|
|
848
883
|
scheme,
|
|
849
884
|
};
|
|
850
885
|
|
|
886
|
+
const homepages = collectHomepages(resolved.paths.content, ctx).pages;
|
|
887
|
+
|
|
888
|
+
// Exactly one homepage, and checked here — before the output tree is
|
|
889
|
+
// cleared and before either mode branches (#52). Before the clear, because
|
|
890
|
+
// a gate that fired after it would have destroyed a good site to report a
|
|
891
|
+
// bad tree. Before the branch, because the requirement does not vary by
|
|
892
|
+
// mode: `publish.site` chooses whether the *content* surfaces are
|
|
893
|
+
// published, and the homepage is the floor beneath both.
|
|
894
|
+
const homepageFindings = checkHomepageCount(homepages, {
|
|
895
|
+
contentBase: resolved.paths.content,
|
|
896
|
+
contentPackage: resolved.contentPackage,
|
|
897
|
+
});
|
|
898
|
+
if (homepageFindings.length) {
|
|
899
|
+
return {
|
|
900
|
+
gates: { ...emptyGates(), homepages: homepageFindings },
|
|
901
|
+
manifests: null,
|
|
902
|
+
tableErrors: [],
|
|
903
|
+
wikiErrors: [],
|
|
904
|
+
stats: null,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
851
908
|
// The whole tree is a build artifact, regenerated every run: a page whose
|
|
852
909
|
// note was deleted or renamed would otherwise linger and keep publishing.
|
|
853
910
|
fs.rmSync(outBase, { recursive: true, force: true });
|
|
854
911
|
|
|
855
|
-
const homepages = collectHomepages(resolved.paths.content, ctx).pages;
|
|
856
|
-
|
|
857
912
|
// Homepage-only stops here, and stopping is the point: nothing below reads
|
|
858
913
|
// the content tree for pages, so `sohl-kethira-basic` and `harn-adventures`
|
|
859
914
|
// cannot publish one whatever else their `site:` block declares (#55).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heroiclands/package-build",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The address space a set of compiled Item pack directories publishes.
|
|
3
|
+
*
|
|
4
|
+
* The directories are read as one space for the same reason the actors pass
|
|
5
|
+
* reads them as one: a being names an item by `(type, shortcode)` and never by
|
|
6
|
+
* the pack it happens to ship in. Both sides of a diff are built by this one
|
|
7
|
+
* function, so a released catalogue extracted by `deps fetch` and a freshly
|
|
8
|
+
* compiled pack are indexed identically and a difference between them is a real
|
|
9
|
+
* one rather than an artefact of two readers.
|
|
10
|
+
*
|
|
11
|
+
* A missing directory throws rather than reading as an empty space: an empty
|
|
12
|
+
* baseline would report every address in the package as withdrawn, and an empty
|
|
13
|
+
* current side would report every address as gone — the loudest possible
|
|
14
|
+
* output from the quietest possible mistake.
|
|
15
|
+
*
|
|
16
|
+
* @param {readonly string[]} dirs - Directories of item JSON.
|
|
17
|
+
* @returns {Map<string, {id: string, name: string, type: string, shortcode: string, file: string}>}
|
|
18
|
+
* Every item, keyed `type:shortcode`.
|
|
19
|
+
*/
|
|
20
|
+
export function readItemAddresses(dirs: readonly string[]): Map<string, {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
type: string;
|
|
24
|
+
shortcode: string;
|
|
25
|
+
file: string;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Every address the baseline published that this build does not.
|
|
29
|
+
*
|
|
30
|
+
* An address that merely *arrived* is not a finding: adding one breaks nobody.
|
|
31
|
+
* The arrivals are read only to answer the one question that matters about a
|
|
32
|
+
* departure — is the document still here under another name?
|
|
33
|
+
*
|
|
34
|
+
* @param {Map<string, object>} baseline - The released address space.
|
|
35
|
+
* @param {Map<string, object>} current - This build's address space.
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {string} opts.baseline - What the baseline is, for the message —
|
|
38
|
+
* conventionally `<package>@<version>`.
|
|
39
|
+
* @returns {Array<object>} One finding per departed address, in address order
|
|
40
|
+
* so two runs read the same. `kind` is `"renamed"` (with `to`) or
|
|
41
|
+
* `"withdrawn"`.
|
|
42
|
+
*/
|
|
43
|
+
export function diffItemAddresses(baseline: Map<string, object>, current: Map<string, object>, { baseline: label }: {
|
|
44
|
+
baseline: string;
|
|
45
|
+
}): Array<object>;
|
|
46
|
+
/**
|
|
47
|
+
* Every content note in a tree, indexed by the document id it authors.
|
|
48
|
+
*
|
|
49
|
+
* The address space is read from compiled output because that is what actually
|
|
50
|
+
* ships; the tree is read only to place a finding somewhere a reader can open
|
|
51
|
+
* and fix it. Each source answers the question it is good at, and the id is the
|
|
52
|
+
* exact key that joins them.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} contentBase - Root of the content tree.
|
|
55
|
+
* @param {object} [opts]
|
|
56
|
+
* @param {readonly string[]} [opts.skipDirectories] - Passed to the walk.
|
|
57
|
+
* @returns {Map<string, string>} Document id → the note's absolute path.
|
|
58
|
+
*/
|
|
59
|
+
export function noteFilesById(contentBase: string, { skipDirectories }?: {
|
|
60
|
+
skipDirectories?: readonly string[] | undefined;
|
|
61
|
+
}): Map<string, string>;
|
|
62
|
+
/**
|
|
63
|
+
* Where to send the reader for one finding.
|
|
64
|
+
*
|
|
65
|
+
* A rename is fixed in the note that made it, so a finding whose id is still in
|
|
66
|
+
* this tree is reported at that note's `shortcode:` line — the line the author
|
|
67
|
+
* just edited. A withdrawal has no such note by definition, so it degrades to
|
|
68
|
+
* the baseline document, which is the only artefact left that records the
|
|
69
|
+
* address existing. When neither is readable the position is **dropped**, never
|
|
70
|
+
* defaulted to `1:1`.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
73
|
+
* @param {Map<string, string>} noteFiles - From {@link noteFilesById}.
|
|
74
|
+
* @returns {{file?: string, line?: number, column?: number}} Spreadable
|
|
75
|
+
* position fields for {@link formatDiagnostic}.
|
|
76
|
+
*/
|
|
77
|
+
export function locateAddressFinding(finding: object, noteFiles: Map<string, string>): {
|
|
78
|
+
file?: string;
|
|
79
|
+
line?: number;
|
|
80
|
+
column?: number;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* What one finding says, without a locator or a severity.
|
|
84
|
+
*
|
|
85
|
+
* The rename message names the identity it matched on, because that is what
|
|
86
|
+
* separates this from a spelling suggestion: the reader can check the id in
|
|
87
|
+
* both artefacts. The withdrawal message names no successor, because none is
|
|
88
|
+
* known — and says so, rather than leaving the reader to wonder whether one was
|
|
89
|
+
* looked for.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
92
|
+
* @returns {string} The message.
|
|
93
|
+
*/
|
|
94
|
+
export function addressFindingMessage(finding: object): string;
|
|
95
|
+
/**
|
|
96
|
+
* One finding, in the standard `file:line:column: severity: message` form.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} finding - One finding from {@link diffItemAddresses}.
|
|
99
|
+
* @param {{file?: string, line?: number, column?: number}} at - From
|
|
100
|
+
* {@link locateAddressFinding}.
|
|
101
|
+
* @param {"warning"|"error"} [severity] - `error` when the caller is gating.
|
|
102
|
+
* @returns {string} The formatted diagnostic, path first on the line.
|
|
103
|
+
*/
|
|
104
|
+
export function formatAddressFinding(finding: object, at: {
|
|
105
|
+
file?: string;
|
|
106
|
+
line?: number;
|
|
107
|
+
column?: number;
|
|
108
|
+
}, severity?: "warning" | "error"): string;
|
|
@@ -16,12 +16,15 @@ export function isValidShortcode(value: unknown): boolean;
|
|
|
16
16
|
* @param {object} [opts]
|
|
17
17
|
* @param {readonly string[]} [opts.skipDirectories] - Directory names the walk
|
|
18
18
|
* ignores. Defaults to the configured list.
|
|
19
|
+
* @param {string} [opts.contentPackage] - The package this tree builds, for the
|
|
20
|
+
* homepage rule. Dropped from that finding when unknown rather than guessed.
|
|
19
21
|
* @returns {{findings: Array<{file: string, line?: number, column?: number,
|
|
20
22
|
* severity: "error"|"warning", message: string}>, notes: number,
|
|
21
23
|
* keys: number}} The findings, and what was inspected to produce them.
|
|
22
24
|
*/
|
|
23
|
-
export function lintContentTree(contentBase: string, { skipDirectories }?: {
|
|
25
|
+
export function lintContentTree(contentBase: string, { skipDirectories, contentPackage }?: {
|
|
24
26
|
skipDirectories?: readonly string[] | undefined;
|
|
27
|
+
contentPackage?: string | undefined;
|
|
25
28
|
}): {
|
|
26
29
|
findings: Array<{
|
|
27
30
|
file: string;
|
|
@@ -5,6 +5,87 @@
|
|
|
5
5
|
* @returns {boolean} Whether it is a homepage note.
|
|
6
6
|
*/
|
|
7
7
|
export function isHomepage(fm: object | null | undefined): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The address-bearing fields one note authors, in the order it authored them.
|
|
10
|
+
*
|
|
11
|
+
* Authoring order rather than declaration order, so a caller emitting one
|
|
12
|
+
* diagnostic per finding emits them top to bottom down the file — the order a
|
|
13
|
+
* reader and a compiler-output parser both expect.
|
|
14
|
+
*
|
|
15
|
+
* Presence is the whole test: `shortcode:` authored empty still says "this page
|
|
16
|
+
* has an address of its own", and a value cannot make the claim true.
|
|
17
|
+
*
|
|
18
|
+
* Returned without a locator, because the two things that would supply one —
|
|
19
|
+
* the raw note text and the position helper — belong to the caller. This
|
|
20
|
+
* mirrors {@link module:engine/retired-fields}, whose retired-field messages
|
|
21
|
+
* are likewise positioned by whoever reports them.
|
|
22
|
+
*
|
|
23
|
+
* @param {object|null|undefined} fm - Parsed frontmatter.
|
|
24
|
+
* @returns {Array<{key: string, message: string}>} One entry per field the note
|
|
25
|
+
* authored, empty for any note that is not a homepage.
|
|
26
|
+
*/
|
|
27
|
+
export function checkHomepageAddressFields(fm: object | null | undefined): Array<{
|
|
28
|
+
key: string;
|
|
29
|
+
message: string;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* Require exactly one homepage note in a content tree (#52).
|
|
33
|
+
*
|
|
34
|
+
* "Exactly one" is two rules, and they are **one severity** because they are
|
|
35
|
+
* one defect: a package whose front page is not the page a person chose.
|
|
36
|
+
*
|
|
37
|
+
* - _None_ and the package serves nothing at `/<package>/`. That is the failure
|
|
38
|
+
* #50 exists to prevent, and it is silent — the site build reports `wrote 0
|
|
39
|
+
* homepage(s)` and exits 0.
|
|
40
|
+
* - _Two_ and it serves a page nobody chose. Every homepage is written to the
|
|
41
|
+
* same {@link HOMEPAGE_DESTINATION}, so the second overwrites the first and
|
|
42
|
+
* the package's front page is decided by the order the walk happened to reach
|
|
43
|
+
* the files in — by *filename*, on a type whose whole point is that it is
|
|
44
|
+
* routed by frontmatter. There is no "first wins" convention to fall back on,
|
|
45
|
+
* so nothing here can pick the right one.
|
|
46
|
+
*
|
|
47
|
+
* Neither has a safe default, so neither is a warning. A warning is the right
|
|
48
|
+
* severity for something a build can proceed past correctly, and a build that
|
|
49
|
+
* proceeds past either of these publishes the wrong front page while reporting
|
|
50
|
+
* success — which is the exact outcome a warning would be tolerating.
|
|
51
|
+
*
|
|
52
|
+
* **Two is reported once per note, not once for the tree.** Each note is a
|
|
53
|
+
* place an author has to open and edit, and a single finding saying "there are
|
|
54
|
+
* two" sends them hunting for the second.
|
|
55
|
+
*
|
|
56
|
+
* **None is located at the tree, honestly.** There is no file to name, so the
|
|
57
|
+
* locator is the content root — the directory the note is missing from, which
|
|
58
|
+
* is a real path and the one the author adds it to. No line and no column are
|
|
59
|
+
* invented for it, per the diagnostic rules in
|
|
60
|
+
* {@link module:engine/diagnostics}. {@link lintContentTree} already reports an
|
|
61
|
+
* empty walk against the same locator.
|
|
62
|
+
*
|
|
63
|
+
* The rule reads no `site:` configuration and does not vary by
|
|
64
|
+
* `publish.site`: that setting chooses whether the *content* surfaces are
|
|
65
|
+
* published, and the homepage is the floor underneath both modes.
|
|
66
|
+
*
|
|
67
|
+
* @param {ReadonlyArray<{file: string}>} found - The homepage notes, in walk
|
|
68
|
+
* order. Paths may be absolute or relative to the working directory.
|
|
69
|
+
* @param {object} options - Options.
|
|
70
|
+
* @param {string} options.contentBase - Root of the content tree, for the
|
|
71
|
+
* locator when there is no file to name.
|
|
72
|
+
* @param {string} [options.contentPackage] - The package this tree builds.
|
|
73
|
+
* Dropped from the message when unknown rather than guessed.
|
|
74
|
+
* @returns {Array<{file: string, line?: number, column?: number,
|
|
75
|
+
* severity: "error", message: string}>} The findings, one per offending note.
|
|
76
|
+
*/
|
|
77
|
+
export function checkHomepageCount(found: ReadonlyArray<{
|
|
78
|
+
file: string;
|
|
79
|
+
}>, { contentBase, contentPackage }: {
|
|
80
|
+
contentBase: string;
|
|
81
|
+
contentPackage?: string | undefined;
|
|
82
|
+
}): Array<{
|
|
83
|
+
file: string;
|
|
84
|
+
line?: number;
|
|
85
|
+
column?: number;
|
|
86
|
+
severity: "error";
|
|
87
|
+
message: string;
|
|
88
|
+
}>;
|
|
8
89
|
/**
|
|
9
90
|
* The title a homepage publishes under.
|
|
10
91
|
*
|
|
@@ -107,6 +188,42 @@ export const HOMEPAGE_FIELDS: readonly import("./field-spec.mjs").FieldSpec[];
|
|
|
107
188
|
* @type {string}
|
|
108
189
|
*/
|
|
109
190
|
export const HOMEPAGE_DESTINATION: string;
|
|
191
|
+
/**
|
|
192
|
+
* The top-level fields a homepage refuses, and what each one would decide (#53).
|
|
193
|
+
*
|
|
194
|
+
* A note's URL derives from `name.full` and its identity from
|
|
195
|
+
* `(type, shortcode)`. The homepage is the one page for which neither holds: it
|
|
196
|
+
* publishes at `/<package>/`, fixed by the package id. An author fluent in the
|
|
197
|
+
* conventions writes them here expecting exactly what they do everywhere else,
|
|
198
|
+
* and gets none of it.
|
|
199
|
+
*
|
|
200
|
+
* **They were never inert, which is why ignoring them was the wrong answer.** A
|
|
201
|
+
* `shortcode` puts the note in the address index and in the `dataview` link
|
|
202
|
+
* universe, so `[[homepage-<shortcode>]]` resolves *green* — to
|
|
203
|
+
* `homepage/<slug>/`, an address derived from `name.full` and published by
|
|
204
|
+
* nothing, because a homepage is written to {@link HOMEPAGE_DESTINATION} at the
|
|
205
|
+
* package root. A build that reports a live link to a 404 is worse than one
|
|
206
|
+
* that says nothing. It also inflates `content-build lint`'s address tally, so
|
|
207
|
+
* the lint and the link manifest disagree about what the package publishes.
|
|
208
|
+
*
|
|
209
|
+
* **A named class, not an allow-list, and that boundary is the decision.** The
|
|
210
|
+
* documented envelope is `type` plus an optional `title`, and `landing`,
|
|
211
|
+
* `description` and `banner` are legitimate beside them — but a homepage's
|
|
212
|
+
* frontmatter is *emitted into the published page*
|
|
213
|
+
* ({@link homepageFrontmatter}), so an unrecognised key is a Hugo or theme
|
|
214
|
+
* parameter this build has never heard of and has no standing to refuse.
|
|
215
|
+
* Rejecting unknown keys would make every new theme parameter wait on a
|
|
216
|
+
* package-build release. What is refused is the specific class that makes a
|
|
217
|
+
* false claim about *where this page is*.
|
|
218
|
+
*
|
|
219
|
+
* `aliases` is deliberately not in the class: {@link homepageFrontmatter}
|
|
220
|
+
* already drops it from every emitted page, with a reason of its own, so
|
|
221
|
+
* authoring one is the same no-op it is on any other page rather than a wrong
|
|
222
|
+
* belief about this one's address.
|
|
223
|
+
*
|
|
224
|
+
* @type {ReadonlyMap<string, string>}
|
|
225
|
+
*/
|
|
226
|
+
export const HOMEPAGE_REFUSED_FIELDS: ReadonlyMap<string, string>;
|
|
110
227
|
/**
|
|
111
228
|
* The two frontmatter keys that hold an address, and what each one means.
|
|
112
229
|
*
|