@heroiclands/package-build 11.1.0 → 14.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 +360 -0
- package/CONTENT.md +144 -72
- package/MIGRATING.md +71 -0
- package/bin/content-build.mjs +0 -20
- package/content-config.mjs +108 -69
- package/docs/content-format.md +50 -27
- package/engine/base-compiler.mjs +6 -1
- package/engine/content-address.mjs +29 -86
- package/engine/frontmatter-lint.mjs +74 -131
- package/engine/manifest-emit.mjs +19 -24
- package/engine/note-vocabulary.mjs +117 -1
- package/engine/retired-fields.mjs +76 -3
- package/engine/site-build.mjs +70 -78
- package/engine/site-index.mjs +20 -8
- package/engine/web-wikilinks.mjs +1 -1
- package/engine/wikilinks.mjs +1 -1
- package/package.json +1 -1
- package/types/content-config.d.mts +28 -50
- package/types/engine/content-address.d.mts +24 -43
- package/types/engine/frontmatter-lint.d.mts +2 -27
- package/types/engine/manifest-emit.d.mts +3 -9
- package/types/engine/note-vocabulary.d.mts +46 -0
- package/types/engine/retired-fields.d.mts +47 -0
- package/types/engine/site-build.d.mts +56 -31
- package/types/engine/site-index.d.mts +9 -4
|
@@ -55,15 +55,13 @@
|
|
|
55
55
|
* @module
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
|
-
import path from "node:path";
|
|
59
|
-
|
|
60
58
|
import { authoredFields } from "./field-spec.mjs";
|
|
61
|
-
import { DEFAULT_ADDRESS_SCHEME, sectionOf } from "./content-address.mjs";
|
|
62
59
|
import { resolveFieldValue, SYSTEM_BLOCK_KEYS, unknownBlockKeys } from "./system-block.mjs";
|
|
63
60
|
import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
|
|
64
61
|
import { checkHomepageAddressFields } from "./homepage.mjs";
|
|
65
62
|
import { RETIRED_TYPES } from "./ids.mjs";
|
|
66
|
-
import {
|
|
63
|
+
import { isAddressSegment } from "./address-charset.mjs";
|
|
64
|
+
import { declaredTags, subTypeCharsetMessage, typeCharsetMessage } from "./note-vocabulary.mjs";
|
|
67
65
|
import {
|
|
68
66
|
RETIRED_FIELD_ALIASES,
|
|
69
67
|
declaresRetiredAlias,
|
|
@@ -72,6 +70,7 @@ import {
|
|
|
72
70
|
draftRetiredMessage,
|
|
73
71
|
readAliasedField,
|
|
74
72
|
retiredAliasMessage,
|
|
73
|
+
sectionRetiredMessage,
|
|
75
74
|
} from "./retired-fields.mjs";
|
|
76
75
|
|
|
77
76
|
/**
|
|
@@ -316,83 +315,47 @@ function checkDataContainer(note, { type, fields }) {
|
|
|
316
315
|
return findings;
|
|
317
316
|
}
|
|
318
317
|
|
|
319
|
-
/**
|
|
320
|
-
* Whether this note's `subType` names the **section it lands at** rather than a
|
|
321
|
-
* sub-kind of its type (#197).
|
|
322
|
-
*
|
|
323
|
-
* Under `landing: readme` a `README.md` addresses its whole section, and the
|
|
324
|
-
* segment is what `sectionOf` reads — for a `doc`, its `subType`. So that one
|
|
325
|
-
* field is spelled once and read twice: as a genre on an ordinary note, and as
|
|
326
|
-
* an address on a landing. The two vocabularies are not the same set, and the
|
|
327
|
-
* closed one cannot answer for the open one.
|
|
328
|
-
*
|
|
329
|
-
* Asked of {@link sectionOf} rather than by naming a type, because which types
|
|
330
|
-
* route by their `subType` is address knowledge and this module deliberately
|
|
331
|
-
* knows no type names of its own.
|
|
332
|
-
*
|
|
333
|
-
* @param {object} note - The note.
|
|
334
|
-
* @param {string} landing - The repository's landing rule.
|
|
335
|
-
* @returns {boolean} Whether the value is an address.
|
|
336
|
-
*/
|
|
337
|
-
function subTypeIsSection(note, landing) {
|
|
338
|
-
if (landing !== "readme") return false;
|
|
339
|
-
const file = typeof note.file === "string" ? note.file : "";
|
|
340
|
-
if (path.basename(file).toLowerCase() !== "readme.md") return false;
|
|
341
|
-
const fm = note.fm ?? {};
|
|
342
|
-
return typeof fm.subType === "string" && sectionOf(fm) === fm.subType;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
318
|
/**
|
|
346
319
|
* Check a note's top-level `subType` against the values its type declares
|
|
347
|
-
* (#128)
|
|
348
|
-
* (#197, #200).
|
|
320
|
+
* (#128).
|
|
349
321
|
*
|
|
350
322
|
* `subType` stays at the top level — it is what each system's map reads to
|
|
351
323
|
* derive a document type, so it describes the note rather than the subject —
|
|
352
324
|
* but it is not open like the rest of that region: a type either declares a
|
|
353
325
|
* `subType` or does not, and a type that does declares its values.
|
|
354
326
|
*
|
|
355
|
-
* **
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* that stay empty until the first note of each type does.
|
|
327
|
+
* **It is a genre, and only a genre.** #197 gave the field a second reading: a
|
|
328
|
+
* `README.md` was its section's landing page, and the segment it landed at was
|
|
329
|
+
* its `subType`, so the value had to be checked against the sections that could
|
|
330
|
+
* exist — every content type, plus whatever a repository configured — rather
|
|
331
|
+
* than against the genres its type declares. Two vocabularies in one field is
|
|
332
|
+
* what #198, #200 and #201 were each spent on, and #204 removed the cause rather
|
|
333
|
+
* than the symptom: a section is a Hugo directory the note format does not
|
|
334
|
+
* carry, and a page introducing a type is an ordinary note addressed
|
|
335
|
+
* `doc-<type>`. So the closed list answers for every note, whatever it is
|
|
336
|
+
* called, and `rules`, `userguide`, `reference` mean three genres and nothing
|
|
337
|
+
* else.
|
|
338
|
+
*
|
|
339
|
+
* **Two checks, in this order** — the charset, then the closed set (#206,
|
|
340
|
+
* #204). The charset is first because it is the more general statement about
|
|
341
|
+
* the same value: a value outside `^[A-Za-z0-9]+$` is refused whatever the type
|
|
342
|
+
* declares, and only once it is a well-formed term is the type's own list the
|
|
343
|
+
* reason to refuse it.
|
|
344
|
+
*
|
|
345
|
+
* There were three. #206 ran a retired-spelling check ahead of both, accepting
|
|
346
|
+
* `user-guide` as a warning naming `userguide`, so the 43 `sohl` notes
|
|
347
|
+
* authoring it were not invalidated by the release that renamed it. Every
|
|
348
|
+
* consumer tree has swept, so the acceptance guarded nothing and is gone: the
|
|
349
|
+
* old spelling now falls through to the charset check, which refuses it for the
|
|
350
|
+
* reason that always applied — it contains a hyphen (#210).
|
|
380
351
|
*
|
|
381
352
|
* @param {object} note - The note.
|
|
382
353
|
* @param {object} opts
|
|
383
354
|
* @param {string} opts.type - The note's type, for the message.
|
|
384
355
|
* @param {object} opts.entry - The type's vocabulary entry.
|
|
385
|
-
* @param {boolean} [opts.asSection] - Whether the value is a section address —
|
|
386
|
-
* see {@link subTypeIsSection}.
|
|
387
|
-
* @param {readonly string[]} [opts.types] - The content types the format
|
|
388
|
-
* declares. Empty leaves a landing checked against its genres and the
|
|
389
|
-
* configured sections alone.
|
|
390
|
-
* @param {readonly string[]} [opts.sections] - The sections the repository
|
|
391
|
-
* configures. Empty when it configures none, which is ordinary rather than a
|
|
392
|
-
* defect.
|
|
393
356
|
* @returns {object[]} Findings.
|
|
394
357
|
*/
|
|
395
|
-
function checkSubType(note, { type, entry
|
|
358
|
+
function checkSubType(note, { type, entry }) {
|
|
396
359
|
const fm = note.fm ?? {};
|
|
397
360
|
if (!Object.hasOwn(fm, "subType") || fm.subType == null || fm.subType === "") return [];
|
|
398
361
|
|
|
@@ -412,33 +375,27 @@ function checkSubType(note, { type, entry, asSection = false, types = [], sectio
|
|
|
412
375
|
];
|
|
413
376
|
}
|
|
414
377
|
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (
|
|
421
|
-
if (types.includes(value) || sections.includes(value)) return [];
|
|
422
|
-
const guess = nearest(value, [...types, ...values, ...sections]);
|
|
378
|
+
// The charset, before the closed set: a value outside it is refused
|
|
379
|
+
// whatever the type declares, and the type's list is not the reason it is
|
|
380
|
+
// refused. Reported here rather than only for an enumerated type, so a
|
|
381
|
+
// `subTypes: null` type — whose values nothing may yet check — is still
|
|
382
|
+
// held to the one rule that does not depend on knowing them.
|
|
383
|
+
if (!isAddressSegment(value)) {
|
|
423
384
|
return [
|
|
424
385
|
{
|
|
425
386
|
file: note.file,
|
|
426
387
|
...at,
|
|
427
388
|
severity: "error",
|
|
428
|
-
message:
|
|
429
|
-
`\`subType\` "${value}" is the section this README lands ` +
|
|
430
|
-
`at, and nothing declares it. A section is a content type ` +
|
|
431
|
-
`(${types.join(", ")}), a subtype ${type} declares ` +
|
|
432
|
-
`(${values.join(", ")})` +
|
|
433
|
-
(sections.length ?
|
|
434
|
-
`, or a section configured under \`site.sections\` / ` +
|
|
435
|
-
`\`site.readmeSections\` (${sections.join(", ")})`
|
|
436
|
-
: "") +
|
|
437
|
-
(guess ? `. Did you mean "${guess}"?` : ""),
|
|
389
|
+
message: subTypeCharsetMessage(value),
|
|
438
390
|
},
|
|
439
391
|
];
|
|
440
392
|
}
|
|
441
393
|
|
|
394
|
+
const values = entry.subTypes;
|
|
395
|
+
// `null` is "declared, values not yet enumerated" — presence is legal and
|
|
396
|
+
// the value is nobody's to check yet.
|
|
397
|
+
if (values == null || values.includes(value)) return [];
|
|
398
|
+
|
|
442
399
|
const guess = nearest(value, values);
|
|
443
400
|
return [
|
|
444
401
|
{
|
|
@@ -521,33 +478,9 @@ function checkTags(note, { type }) {
|
|
|
521
478
|
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
522
479
|
* The system blocks to check, and what each accepts. See
|
|
523
480
|
* {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
524
|
-
* @param {string} [opts.landing] - The repository's landing rule, from
|
|
525
|
-
* `publish.address.landing`. It decides which note addresses a whole section,
|
|
526
|
-
* and so whether a `subType` is a genre or an address (#197).
|
|
527
|
-
* @param {readonly string[]} [opts.types] - The content types the format
|
|
528
|
-
* declares — the sections that exist by construction (#200). Read from
|
|
529
|
-
* `docs/content-format.md` rather than from `schemas`, because the two answer
|
|
530
|
-
* different questions: whether an address is real, and whether this build can
|
|
531
|
-
* check a note's fields. A type the specification declares and no schema
|
|
532
|
-
* covers is a real section, and its notes are reported on their own account.
|
|
533
|
-
* @param {readonly string[]} [opts.sections] - The sections the repository
|
|
534
|
-
* configures, from `declaredSections`. Supplied by the caller for the same
|
|
535
|
-
* reason `vocabulary` is: this module checks a note against what it is
|
|
536
|
-
* handed.
|
|
537
481
|
* @returns {object[]} Findings, each with a locator where one is obtainable.
|
|
538
482
|
*/
|
|
539
|
-
export function lintNote(
|
|
540
|
-
note,
|
|
541
|
-
{
|
|
542
|
-
schemas,
|
|
543
|
-
index,
|
|
544
|
-
vocabulary,
|
|
545
|
-
systems = DEFAULT_SYSTEM_BLOCKS,
|
|
546
|
-
landing = DEFAULT_ADDRESS_SCHEME.landing,
|
|
547
|
-
types = [],
|
|
548
|
-
sections = [],
|
|
549
|
-
},
|
|
550
|
-
) {
|
|
483
|
+
export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
|
|
551
484
|
const findings = [];
|
|
552
485
|
const fm = note.fm ?? {};
|
|
553
486
|
const type = String(fm.type ?? "");
|
|
@@ -578,6 +511,17 @@ export function lintNote(
|
|
|
578
511
|
message: draftRetiredMessage(),
|
|
579
512
|
});
|
|
580
513
|
}
|
|
514
|
+
// Anchored at column 1 for the same reason `aliases` is: `section` names a
|
|
515
|
+
// configuration key too (`site.trees[].section`), and a nested one under
|
|
516
|
+
// some other block is not this field (#202).
|
|
517
|
+
if (Object.hasOwn(fm, "section")) {
|
|
518
|
+
findings.push({
|
|
519
|
+
file: note.file,
|
|
520
|
+
...positionInFrontmatter(raw(), "section", undefined, { topLevel: true }),
|
|
521
|
+
severity: "error",
|
|
522
|
+
message: sectionRetiredMessage(),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
581
525
|
// Only the top-level `aliases` is retired. `name.aliases` writes the same
|
|
582
526
|
// key indented under `name:` and is **permitted** — reserved and unread —
|
|
583
527
|
// so both the test and the locator are anchored at column 1 (#180).
|
|
@@ -609,6 +553,25 @@ export function lintNote(
|
|
|
609
553
|
});
|
|
610
554
|
}
|
|
611
555
|
|
|
556
|
+
// The type's charset, before anything that looks the type up (#206). A
|
|
557
|
+
// hyphenated type is unaddressable, and every lookup below would report it
|
|
558
|
+
// as a type nobody declared — true, but not the reason, and it would send
|
|
559
|
+
// the author to declare one rather than to rename it.
|
|
560
|
+
//
|
|
561
|
+
// Guarded on a non-empty type: an absent one is a missing key, not a
|
|
562
|
+
// charset violation, and is reported as the missing schema it causes.
|
|
563
|
+
// There is **no transitional path** here, deliberately: no tree authors a
|
|
564
|
+
// hyphenated type, so an acceptance would guard a case that does not exist.
|
|
565
|
+
if (type && !isAddressSegment(type)) {
|
|
566
|
+
findings.push({
|
|
567
|
+
file: note.file,
|
|
568
|
+
...at("type", type),
|
|
569
|
+
severity: "error",
|
|
570
|
+
message: typeCharsetMessage(type),
|
|
571
|
+
});
|
|
572
|
+
return findings;
|
|
573
|
+
}
|
|
574
|
+
|
|
612
575
|
const replacement = RETIRED_TYPES[type];
|
|
613
576
|
if (replacement) {
|
|
614
577
|
findings.push({
|
|
@@ -644,15 +607,7 @@ export function lintNote(
|
|
|
644
607
|
const entry = vocabulary?.[type];
|
|
645
608
|
if (entry) {
|
|
646
609
|
findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [] }));
|
|
647
|
-
findings.push(
|
|
648
|
-
...checkSubType(note, {
|
|
649
|
-
type,
|
|
650
|
-
entry,
|
|
651
|
-
asSection: subTypeIsSection(note, landing),
|
|
652
|
-
types,
|
|
653
|
-
sections,
|
|
654
|
-
}),
|
|
655
|
-
);
|
|
610
|
+
findings.push(...checkSubType(note, { type, entry }));
|
|
656
611
|
}
|
|
657
612
|
|
|
658
613
|
const fields = authoredFields(schema);
|
|
@@ -809,19 +764,10 @@ export function lintNote(
|
|
|
809
764
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
810
765
|
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
811
766
|
* The system blocks to check. See {@link DEFAULT_SYSTEM_BLOCKS}.
|
|
812
|
-
* @param {string} [opts.landing] - The repository's landing rule; see
|
|
813
|
-
* {@link lintNote}.
|
|
814
|
-
* @param {readonly string[]} [opts.types] - The content types the format
|
|
815
|
-
* declares; see {@link lintNote}.
|
|
816
|
-
* @param {readonly string[]} [opts.sections] - The sections the repository
|
|
817
|
-
* configures; see {@link lintNote}.
|
|
818
767
|
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
819
768
|
* notes were inspected.
|
|
820
769
|
*/
|
|
821
|
-
export function lintFrontmatter(
|
|
822
|
-
index,
|
|
823
|
-
{ schemas, vocabulary, references = true, systems, landing, types, sections },
|
|
824
|
-
) {
|
|
770
|
+
export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
|
|
825
771
|
const findings = [];
|
|
826
772
|
const notes = [...index.notes].sort((a, b) =>
|
|
827
773
|
a.file < b.file ? -1
|
|
@@ -835,9 +781,6 @@ export function lintFrontmatter(
|
|
|
835
781
|
vocabulary,
|
|
836
782
|
index: references ? index : undefined,
|
|
837
783
|
...(systems ? { systems } : {}),
|
|
838
|
-
...(landing ? { landing } : {}),
|
|
839
|
-
...(types ? { types } : {}),
|
|
840
|
-
...(sections ? { sections } : {}),
|
|
841
784
|
}),
|
|
842
785
|
);
|
|
843
786
|
}
|
package/engine/manifest-emit.mjs
CHANGED
|
@@ -32,19 +32,16 @@
|
|
|
32
32
|
* **An entry's `path` is derivable from the key it is filed under** (#181).
|
|
33
33
|
* `sohl-affliction-aconite` publishes at `affliction-aconite/`, because a page's
|
|
34
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.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* moves no URL and no uniqueness check stands between the two. Every entry is
|
|
36
|
+
* derivable that way since #204 retired the section landing, which was the one
|
|
37
|
+
* that was not. The field is still written rather than left for a consumer to
|
|
38
|
+
* compute, because an absent `path` already means something else entirely (a
|
|
39
|
+
* package that publishes no pages).
|
|
40
40
|
*
|
|
41
|
-
* **The address
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* records `affiliation/` for its own. Reading one setting here and in the page
|
|
46
|
-
* emitter is what stops a manifest asserting an address the site does not
|
|
47
|
-
* publish, which resolves at build time and 404s for the reader.
|
|
41
|
+
* **The address is derived by one function, shared with the site build.** An
|
|
42
|
+
* address is `(type, shortcode)` in both, so a manifest cannot assert an address
|
|
43
|
+
* the site does not publish — the failure that resolves at build time and 404s
|
|
44
|
+
* for the reader.
|
|
48
45
|
*
|
|
49
46
|
* **Anchors are computed, not approximated.** The pass that splits a note into
|
|
50
47
|
* journal pages is {@link splitPages}, a pure function over the markdown body,
|
|
@@ -65,7 +62,11 @@ import { compendiumUuid, packForType, pageUuid } from "./ids.mjs";
|
|
|
65
62
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
66
63
|
import { isHomepage } from "./homepage.mjs";
|
|
67
64
|
import { assertNoDeclaredPackage } from "./note-package.mjs";
|
|
68
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
assertNoAliasesField,
|
|
67
|
+
assertNoDraftField,
|
|
68
|
+
assertNoSectionField,
|
|
69
|
+
} from "./retired-fields.mjs";
|
|
69
70
|
import { journalPageId, splitPages } from "./journals.mjs";
|
|
70
71
|
import { routerFor } from "./pack-router.mjs";
|
|
71
72
|
import { loadPackConfig } from "./pack-config.mjs";
|
|
@@ -209,8 +210,7 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
209
210
|
* exist.
|
|
210
211
|
*
|
|
211
212
|
* @param {string} contentBase - Absolute path to the content tree.
|
|
212
|
-
* @param {object} ctx - `{ contentPackage, foundryPackageId, packRouter
|
|
213
|
-
* scheme }`.
|
|
213
|
+
* @param {object} ctx - `{ contentPackage, foundryPackageId, packRouter }`.
|
|
214
214
|
* @returns {{entries: Array<object>, notes: number,
|
|
215
215
|
* skipped: Array<{file: string, reason: string}>}}
|
|
216
216
|
*/
|
|
@@ -233,6 +233,7 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
233
233
|
});
|
|
234
234
|
assertNoDraftField(fm, { file: rel, absPath });
|
|
235
235
|
assertNoAliasesField(fm, { file: rel, absPath });
|
|
236
|
+
assertNoSectionField(fm, { file: rel, absPath });
|
|
236
237
|
if (!fm.type || !fm.shortcode) continue;
|
|
237
238
|
// A homepage is addressed like every other note since #182, and a
|
|
238
239
|
// shortcode alone would now put it here. It stays out for the reason it
|
|
@@ -243,15 +244,11 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
243
244
|
// no index.
|
|
244
245
|
if (isHomepage(fm)) continue;
|
|
245
246
|
|
|
246
|
-
const base = path.basename(absPath);
|
|
247
247
|
const name = fm.name?.full ?? path.basename(absPath, ".md");
|
|
248
248
|
|
|
249
249
|
let address;
|
|
250
250
|
try {
|
|
251
|
-
address = packageAddress(fm
|
|
252
|
-
isReadme: base.toLowerCase() === "readme.md",
|
|
253
|
-
scheme: ctx.scheme,
|
|
254
|
-
});
|
|
251
|
+
address = packageAddress(fm);
|
|
255
252
|
} catch (err) {
|
|
256
253
|
skipped.push({ file: rel, reason: err.message });
|
|
257
254
|
continue;
|
|
@@ -263,7 +260,7 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
263
260
|
}
|
|
264
261
|
|
|
265
262
|
/**
|
|
266
|
-
* The identities
|
|
263
|
+
* The identities an emission runs against, from configuration.
|
|
267
264
|
*
|
|
268
265
|
* Resolved in one place and passed down, rather than read at each use, so the
|
|
269
266
|
* pass itself is a pure function of its context and a test can drive it without
|
|
@@ -271,15 +268,13 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
271
268
|
*
|
|
272
269
|
* @param {object} [config] - A resolved configuration; loaded when omitted.
|
|
273
270
|
* @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
|
|
274
|
-
*
|
|
275
|
-
* skipDirectories: readonly string[]}}
|
|
271
|
+
* web: boolean, skipDirectories: readonly string[]}}
|
|
276
272
|
*/
|
|
277
273
|
export function manifestContext(config = loadPackConfig()) {
|
|
278
274
|
return {
|
|
279
275
|
contentPackage: config.contentPackage,
|
|
280
276
|
foundryPackageId: config.foundryPackage,
|
|
281
277
|
packRouter: routerFor(config),
|
|
282
|
-
scheme: config.publish.address,
|
|
283
278
|
web: publishesContentPages(config),
|
|
284
279
|
// The walk's own configuration, threaded through rather than left to
|
|
285
280
|
// its default, so a caller that passes a config drives every read.
|
|
@@ -53,9 +53,31 @@
|
|
|
53
53
|
* one is already reported as a type no schema declares, which is the finding it
|
|
54
54
|
* deserves until the type exists.
|
|
55
55
|
*
|
|
56
|
+
* **A type name and a subType value are held to the address charset** (#206), so
|
|
57
|
+
* both are `^[A-Za-z0-9]+$` — the charset `engine/address-charset.mjs` states
|
|
58
|
+
* and the shortcode is already held to. For a type that is literal: it is the
|
|
59
|
+
* first segment of every address (`type-shortcode`), the hyphen is the
|
|
60
|
+
* separator between segments and can therefore never occur inside one, and a
|
|
61
|
+
* hyphenated name would be read back as two segments and resolve to nothing,
|
|
62
|
+
* reporting nothing about why.
|
|
63
|
+
*
|
|
64
|
+
* A subType reaches no address of its own. It did when this rule was written —
|
|
65
|
+
* a `doc`'s was its section, a path segment — and #204 retired sections from
|
|
66
|
+
* the note format one release later. It keeps the rule regardless, and the
|
|
67
|
+
* reason is not inertia: a subType is a vocabulary term the whole toolchain
|
|
68
|
+
* keys on, it is one closed set away from being an address again, and a charset
|
|
69
|
+
* that held for a type, a shortcode and a package but not for a subType would
|
|
70
|
+
* be a rule nobody could state in one sentence. The registry below is checked
|
|
71
|
+
* against it as this module loads, so a declaration that breaks it cannot be
|
|
72
|
+
* imported, let alone shipped.
|
|
73
|
+
*
|
|
56
74
|
* @module
|
|
57
75
|
*/
|
|
58
76
|
|
|
77
|
+
// The one charset, read rather than restated. A second spelling of the pattern
|
|
78
|
+
// is how the three disagreements found in #202/#203 happened.
|
|
79
|
+
import { ADDRESS_SEGMENT_PATTERN, isAddressSegment } from "./address-charset.mjs";
|
|
80
|
+
|
|
59
81
|
/**
|
|
60
82
|
* One `data:` key a note type may carry.
|
|
61
83
|
*
|
|
@@ -641,7 +663,12 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
641
663
|
/* ----- core documents ------------------------------------------- */
|
|
642
664
|
|
|
643
665
|
doc: Object.freeze({
|
|
644
|
-
|
|
666
|
+
// `userguide`, not `user-guide`: a subType is held to the address
|
|
667
|
+
// charset, and a segment carries no hyphen (#206). The old spelling was
|
|
668
|
+
// accepted transitionally for one release so the consumer trees could
|
|
669
|
+
// sweep; they have, so it is refused by the charset check now, with no
|
|
670
|
+
// retirement-specific code left over (#210).
|
|
671
|
+
subTypes: Object.freeze(["rules", "userguide", "reference"]),
|
|
645
672
|
data: Object.freeze([]),
|
|
646
673
|
}),
|
|
647
674
|
|
|
@@ -704,6 +731,95 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
704
731
|
}),
|
|
705
732
|
});
|
|
706
733
|
|
|
734
|
+
/**
|
|
735
|
+
* What a note carrying a subType outside the address charset is told.
|
|
736
|
+
*
|
|
737
|
+
* **Why the charset holds for a subType, which reaches no address.** #206 said
|
|
738
|
+
* "the hyphen separates the segments of an address", and that was true of a
|
|
739
|
+
* subType when it shipped: `sectionOf` returned a `doc`'s subType, so the value
|
|
740
|
+
* was a URL path segment. #204 retired sections and it is not one now. The rule
|
|
741
|
+
* stays, on its own footing: a subType is a vocabulary term the whole toolchain
|
|
742
|
+
* keys on, and it is one closed set away from being an address segment again —
|
|
743
|
+
* so the reason to spell it in the address charset is that a charset holding
|
|
744
|
+
* for a type, a shortcode and a `contentPackage` but not for a subType is a
|
|
745
|
+
* rule nobody can state in a sentence.
|
|
746
|
+
*
|
|
747
|
+
* Contrast {@link typeCharsetMessage}, which keeps the address reasoning
|
|
748
|
+
* because a type genuinely is the first segment of every address.
|
|
749
|
+
*
|
|
750
|
+
* @param {string} value - The authored `subType`.
|
|
751
|
+
* @returns {string} The message.
|
|
752
|
+
*/
|
|
753
|
+
export function subTypeCharsetMessage(value) {
|
|
754
|
+
return (
|
|
755
|
+
`\`subType\` "${value}" is not a well-formed subType — a subType is ` +
|
|
756
|
+
`letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same ` +
|
|
757
|
+
`charset a type, a shortcode and a contentPackage are held to. It is a ` +
|
|
758
|
+
`vocabulary term the whole toolchain keys on, and one closed set away ` +
|
|
759
|
+
`from being an address segment again, so a charset that held for every ` +
|
|
760
|
+
`term but this one would be a rule nobody could state in a sentence`
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* What a note carrying a type outside the address charset is told.
|
|
766
|
+
*
|
|
767
|
+
* @param {string} type - The authored `type`.
|
|
768
|
+
* @returns {string} The message.
|
|
769
|
+
*/
|
|
770
|
+
export function typeCharsetMessage(type) {
|
|
771
|
+
return (
|
|
772
|
+
`content type "${type}" is not an address segment — a type is letters ` +
|
|
773
|
+
`and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same charset ` +
|
|
774
|
+
`a shortcode is held to. A type is the first segment of every address ` +
|
|
775
|
+
`("type-shortcode"), so a hyphenated one is read back as two segments ` +
|
|
776
|
+
`and resolves to nothing`
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Refuse a vocabulary that declares a type or subType outside the charset.
|
|
782
|
+
*
|
|
783
|
+
* Run over {@link NOTE_VOCABULARY} as this module loads, so a declaration that
|
|
784
|
+
* breaks the rule cannot be imported. That is stricter than a lint on purpose:
|
|
785
|
+
* a note's bad value is one author's mistake and belongs in a report, while a
|
|
786
|
+
* bad *declaration* would tell every author to write something unaddressable.
|
|
787
|
+
*
|
|
788
|
+
* The message states the reason **per key**, as {@link typeCharsetMessage} and
|
|
789
|
+
* {@link subTypeCharsetMessage} do: a type is an address segment, and a subType
|
|
790
|
+
* has not been one since #204 retired sections, so a single claim covering both
|
|
791
|
+
* would be half wrong (#210).
|
|
792
|
+
*
|
|
793
|
+
* @param {Readonly<Record<string, TypeVocabulary>>} vocabulary - The registry.
|
|
794
|
+
* @param {string} [where] - What declares it, for the message.
|
|
795
|
+
* @throws {Error} Naming every offending type and subType at once, rather than
|
|
796
|
+
* stopping at the first — a reader fixing a list wants the whole list.
|
|
797
|
+
*/
|
|
798
|
+
export function assertVocabularyCharset(vocabulary, where = "the note vocabulary") {
|
|
799
|
+
const bad = [];
|
|
800
|
+
for (const [type, entry] of Object.entries(vocabulary ?? {})) {
|
|
801
|
+
if (!isAddressSegment(type)) bad.push(`type "${type}"`);
|
|
802
|
+
const values = entry?.subTypes;
|
|
803
|
+
if (!Array.isArray(values)) continue;
|
|
804
|
+
for (const value of values) {
|
|
805
|
+
if (!isAddressSegment(value)) bad.push(`subType "${value}" on ${type}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (!bad.length) return;
|
|
809
|
+
throw new Error(
|
|
810
|
+
`${where} declares ${bad.join(", ")}, which ${bad.length === 1 ? "is" : "are"} ` +
|
|
811
|
+
`not ${ADDRESS_SEGMENT_PATTERN.source}. A type is an address segment, ` +
|
|
812
|
+
`and the hyphen separates segments rather than occurring inside one. ` +
|
|
813
|
+
`A subType reaches no address since #204 retired sections, and is ` +
|
|
814
|
+
`held to the same charset anyway: it is a vocabulary term the whole ` +
|
|
815
|
+
`toolchain keys on, one closed set away from being a segment again, ` +
|
|
816
|
+
`and a charset holding for every term but that one would be a rule ` +
|
|
817
|
+
`nobody could state in a sentence.`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
assertVocabularyCharset(NOTE_VOCABULARY);
|
|
822
|
+
|
|
707
823
|
/**
|
|
708
824
|
* The `data:` keys a note type may carry.
|
|
709
825
|
*
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
* says what to write instead rather than which value to correct.
|
|
23
23
|
*
|
|
24
24
|
* `package:` is retired the same way and is refused from `note-package.mjs`,
|
|
25
|
-
* where the concept it belonged to still lives. `draft
|
|
26
|
-
* `aliases:` have no such home — there is no surviving concept
|
|
27
|
-
* of — so they are refused here.
|
|
25
|
+
* where the concept it belonged to still lives. `draft:`, the top-level
|
|
26
|
+
* `aliases:` and `section:` have no such home — there is no surviving concept
|
|
27
|
+
* any of them was part of — so they are refused here.
|
|
28
28
|
*
|
|
29
29
|
* **What `draft:` did (#69).** It excluded a note from the compiled packs, from
|
|
30
30
|
* the link manifest and from a consuming site build. Nothing reported the
|
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
* `name.full` and so decided what a note could be named (#179). The form and
|
|
42
42
|
* the index are retired together, leaving the field with no reader at all.
|
|
43
43
|
*
|
|
44
|
+
* **What `section:` did (#202).** It named the section a `collection` note
|
|
45
|
+
* headed, under the `collection` landing rule — its only reader anywhere. That
|
|
46
|
+
* rule is retired, a section being landed by the `README.md` in its directory,
|
|
47
|
+
* so the field has none. No schema or vocabulary ever declared it either, and
|
|
48
|
+
* nothing checks unrecognized top-level keys, so left in place it would be
|
|
49
|
+
* silently ignored rather than reported.
|
|
50
|
+
*
|
|
44
51
|
* **`name.aliases` fed the same index and is nonetheless kept.** It is
|
|
45
52
|
* **reserved** — held for a use that does not exist yet — so it is the one
|
|
46
53
|
* field here that is neither retired nor read. Nothing consults it: no index,
|
|
@@ -216,6 +223,72 @@ export function declaresRetiredAliasesField(fm) {
|
|
|
216
223
|
return Boolean(fm) && typeof fm === "object" && Object.hasOwn(fm, "aliases");
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
/**
|
|
227
|
+
* What a note declaring `section:` is told, in one place.
|
|
228
|
+
*
|
|
229
|
+
* Shared by the compile-time refusal and the frontmatter lint, because an
|
|
230
|
+
* author meets whichever of the two runs first and they should read the same.
|
|
231
|
+
* It names what lands a section now rather than a value to correct: no value
|
|
232
|
+
* makes declaring the field right.
|
|
233
|
+
*
|
|
234
|
+
* **What it did (#202).** It named the section a `collection` note headed,
|
|
235
|
+
* under the `collection` landing rule — the only reader it ever had, in the
|
|
236
|
+
* second branch of `landingOf` (`engine/content-address.mjs`). That rule went
|
|
237
|
+
* first, and the whole mechanism went with it (#204): a section is a Hugo
|
|
238
|
+
* directory the note format does not carry, so no note lands one and a page
|
|
239
|
+
* that introduces a type is an ordinary note addressed `doc-<type>`. Nothing
|
|
240
|
+
* else read the field, and no schema or vocabulary declared it, so left in
|
|
241
|
+
* place it would be ignored in silence — the note saying one thing and the
|
|
242
|
+
* build doing another.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} [file] - The note's path, named in the message. Omit it where
|
|
245
|
+
* the caller emits through a diagnostic, whose locator already starts the
|
|
246
|
+
* line — repeating it prints the path twice.
|
|
247
|
+
* @returns {string} The message, unpunctuated at the end as a finding is.
|
|
248
|
+
*/
|
|
249
|
+
export function sectionRetiredMessage(file) {
|
|
250
|
+
return (
|
|
251
|
+
"`section:` is a retired frontmatter field — delete it" +
|
|
252
|
+
(file ? ` — ${file}` : "") +
|
|
253
|
+
". It named the section a `collection` note headed, and both the rule " +
|
|
254
|
+
"and the sections it routed to are retired: a page that introduces " +
|
|
255
|
+
"the notes of a type is an ordinary note — `type: doc`, " +
|
|
256
|
+
"`subType: reference`, `shortcode: <type>` — addressed `doc-<type>`. " +
|
|
257
|
+
"Nothing else ever read the field"
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Refuse a note that declares `section:` at all.
|
|
263
|
+
*
|
|
264
|
+
* Presence is the whole test, as it is for `draft:` and `aliases:`: an empty
|
|
265
|
+
* value reads as "this note heads a section and names none", a statement about
|
|
266
|
+
* a rule that no longer exists.
|
|
267
|
+
*
|
|
268
|
+
* @param {object|null|undefined} fm - Parsed frontmatter, or nothing when it
|
|
269
|
+
* could not be parsed.
|
|
270
|
+
* @param {object} [options] - Options.
|
|
271
|
+
* @param {string} [options.file] - The note's path, named in the message. Omit
|
|
272
|
+
* it where the caller emits through a diagnostic, which puts the locator at
|
|
273
|
+
* the start of the line already — repeating it prints the path twice.
|
|
274
|
+
* @param {string} [options.absPath] - The note's file on disk, read only on the
|
|
275
|
+
* failing path to locate the offending line and column. The position rides on
|
|
276
|
+
* the thrown error as `position`, for a caller that emits a diagnostic.
|
|
277
|
+
* @returns {void}
|
|
278
|
+
* @throws {Error} When the note declares the field.
|
|
279
|
+
*/
|
|
280
|
+
export function assertNoSectionField(fm, { file, absPath } = {}) {
|
|
281
|
+
if (!fm || typeof fm !== "object" || !Object.hasOwn(fm, "section")) return;
|
|
282
|
+
|
|
283
|
+
const err = new Error(`${sectionRetiredMessage(file)}.`);
|
|
284
|
+
// Anchored at column 1: `site.trees[].section` is a *configuration* key of
|
|
285
|
+
// the same name, and a nested `section:` inside some other block is not
|
|
286
|
+
// this field — a finding about the top-level one must not open on it.
|
|
287
|
+
const position = locateFrontmatterKey(absPath, "section", undefined, { topLevel: true });
|
|
288
|
+
if (position) err.position = position;
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
|
|
219
292
|
/**
|
|
220
293
|
* A frontmatter key's position in a note's file, or nothing.
|
|
221
294
|
*
|