@heroiclands/package-build 11.1.0 → 13.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 +183 -0
- package/CONTENT.md +96 -65
- package/MIGRATING.md +65 -0
- package/bin/content-build.mjs +0 -20
- package/content-config.mjs +51 -50
- package/docs/content-format.md +51 -27
- package/engine/base-compiler.mjs +6 -1
- package/engine/content-address.mjs +24 -73
- package/engine/frontmatter-lint.mjs +91 -130
- package/engine/manifest-emit.mjs +16 -18
- package/engine/note-vocabulary.mjs +159 -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 +29 -41
- package/types/engine/content-address.d.mts +22 -34
- package/types/engine/frontmatter-lint.d.mts +2 -27
- package/types/engine/note-vocabulary.d.mts +76 -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,19 @@
|
|
|
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 {
|
|
65
|
+
declaredTags,
|
|
66
|
+
retiredSubType,
|
|
67
|
+
retiredSubTypeMessage,
|
|
68
|
+
subTypeCharsetMessage,
|
|
69
|
+
typeCharsetMessage,
|
|
70
|
+
} from "./note-vocabulary.mjs";
|
|
67
71
|
import {
|
|
68
72
|
RETIRED_FIELD_ALIASES,
|
|
69
73
|
declaresRetiredAlias,
|
|
@@ -72,6 +76,7 @@ import {
|
|
|
72
76
|
draftRetiredMessage,
|
|
73
77
|
readAliasedField,
|
|
74
78
|
retiredAliasMessage,
|
|
79
|
+
sectionRetiredMessage,
|
|
75
80
|
} from "./retired-fields.mjs";
|
|
76
81
|
|
|
77
82
|
/**
|
|
@@ -316,83 +321,40 @@ function checkDataContainer(note, { type, fields }) {
|
|
|
316
321
|
return findings;
|
|
317
322
|
}
|
|
318
323
|
|
|
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
324
|
/**
|
|
346
325
|
* Check a note's top-level `subType` against the values its type declares
|
|
347
|
-
* (#128)
|
|
348
|
-
* (#197, #200).
|
|
326
|
+
* (#128).
|
|
349
327
|
*
|
|
350
328
|
* `subType` stays at the top level — it is what each system's map reads to
|
|
351
329
|
* derive a document type, so it describes the note rather than the subject —
|
|
352
330
|
* but it is not open like the rest of that region: a type either declares a
|
|
353
331
|
* `subType` or does not, and a type that does declares its values.
|
|
354
332
|
*
|
|
355
|
-
* **
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* of its landings were refused for naming their own content type (#200).
|
|
373
|
-
*
|
|
374
|
-
* **The guard survives.** A misspelling is none of the three, so it still
|
|
375
|
-
* fails, with the near miss drawn from the whole union. What is deliberately no
|
|
376
|
-
* longer caught is a landing for a section that exists but is empty — which is
|
|
377
|
-
* legitimate, and is why the set is the types the format *declares* rather than
|
|
378
|
-
* the types *present in the tree*: `sohl` ships two such landings above tables
|
|
379
|
-
* that stay empty until the first note of each type does.
|
|
333
|
+
* **It is a genre, and only a genre.** #197 gave the field a second reading: a
|
|
334
|
+
* `README.md` was its section's landing page, and the segment it landed at was
|
|
335
|
+
* its `subType`, so the value had to be checked against the sections that could
|
|
336
|
+
* exist — every content type, plus whatever a repository configured — rather
|
|
337
|
+
* than against the genres its type declares. Two vocabularies in one field is
|
|
338
|
+
* what #198, #200 and #201 were each spent on, and #204 removed the cause rather
|
|
339
|
+
* than the symptom: a section is a Hugo directory the note format does not
|
|
340
|
+
* carry, and a page introducing a type is an ordinary note addressed
|
|
341
|
+
* `doc-<type>`. So the closed list answers for every note, whatever it is
|
|
342
|
+
* called, and `rules`, `userguide`, `reference` mean three genres and nothing
|
|
343
|
+
* else.
|
|
344
|
+
*
|
|
345
|
+
* **Three checks, in this order** — retired spelling, then charset, then the
|
|
346
|
+
* closed set (#206, #204). Each is ahead of the next because it is the more
|
|
347
|
+
* specific statement about the same value: a retired spelling has a named
|
|
348
|
+
* replacement, a hyphenated value is unaddressable whatever the type declares,
|
|
349
|
+
* and only then is the type's own list the reason.
|
|
380
350
|
*
|
|
381
351
|
* @param {object} note - The note.
|
|
382
352
|
* @param {object} opts
|
|
383
353
|
* @param {string} opts.type - The note's type, for the message.
|
|
384
354
|
* @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
355
|
* @returns {object[]} Findings.
|
|
394
356
|
*/
|
|
395
|
-
function checkSubType(note, { type, entry
|
|
357
|
+
function checkSubType(note, { type, entry }) {
|
|
396
358
|
const fm = note.fm ?? {};
|
|
397
359
|
if (!Object.hasOwn(fm, "subType") || fm.subType == null || fm.subType === "") return [];
|
|
398
360
|
|
|
@@ -412,33 +374,46 @@ function checkSubType(note, { type, entry, asSection = false, types = [], sectio
|
|
|
412
374
|
];
|
|
413
375
|
}
|
|
414
376
|
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
377
|
+
// A retired spelling is **accepted**, and said out loud (#206). Checked
|
|
378
|
+
// before the charset, because the note is not wrong about the charset in
|
|
379
|
+
// some general way — it is wrong about one value, and naming the
|
|
380
|
+
// replacement is the whole of what the author needs. A warning rather than
|
|
381
|
+
// an error for the same reason the retired field aliases below are: the
|
|
382
|
+
// note compiles to the correct page, and erroring would red every tree the
|
|
383
|
+
// moment it took this release, ahead of any chance to sweep.
|
|
384
|
+
const replacement = retiredSubType(type, value);
|
|
385
|
+
if (replacement) {
|
|
386
|
+
return [
|
|
387
|
+
{
|
|
388
|
+
file: note.file,
|
|
389
|
+
...at,
|
|
390
|
+
severity: "warning",
|
|
391
|
+
message: retiredSubTypeMessage(type, value, replacement),
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
}
|
|
419
395
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
396
|
+
// The charset, before the closed set: a hyphenated value is unaddressable
|
|
397
|
+
// whatever the type declares, and the type's list is not the reason it is
|
|
398
|
+
// refused. Reported here rather than only for an enumerated type, so a
|
|
399
|
+
// `subTypes: null` type — whose values nothing may yet check — is still
|
|
400
|
+
// held to the one rule that does not depend on knowing them.
|
|
401
|
+
if (!isAddressSegment(value)) {
|
|
423
402
|
return [
|
|
424
403
|
{
|
|
425
404
|
file: note.file,
|
|
426
405
|
...at,
|
|
427
406
|
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}"?` : ""),
|
|
407
|
+
message: subTypeCharsetMessage(value),
|
|
438
408
|
},
|
|
439
409
|
];
|
|
440
410
|
}
|
|
441
411
|
|
|
412
|
+
const values = entry.subTypes;
|
|
413
|
+
// `null` is "declared, values not yet enumerated" — presence is legal and
|
|
414
|
+
// the value is nobody's to check yet.
|
|
415
|
+
if (values == null || values.includes(value)) return [];
|
|
416
|
+
|
|
442
417
|
const guess = nearest(value, values);
|
|
443
418
|
return [
|
|
444
419
|
{
|
|
@@ -521,33 +496,9 @@ function checkTags(note, { type }) {
|
|
|
521
496
|
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
522
497
|
* The system blocks to check, and what each accepts. See
|
|
523
498
|
* {@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
499
|
* @returns {object[]} Findings, each with a locator where one is obtainable.
|
|
538
500
|
*/
|
|
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
|
-
) {
|
|
501
|
+
export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
|
|
551
502
|
const findings = [];
|
|
552
503
|
const fm = note.fm ?? {};
|
|
553
504
|
const type = String(fm.type ?? "");
|
|
@@ -578,6 +529,17 @@ export function lintNote(
|
|
|
578
529
|
message: draftRetiredMessage(),
|
|
579
530
|
});
|
|
580
531
|
}
|
|
532
|
+
// Anchored at column 1 for the same reason `aliases` is: `section` names a
|
|
533
|
+
// configuration key too (`site.trees[].section`), and a nested one under
|
|
534
|
+
// some other block is not this field (#202).
|
|
535
|
+
if (Object.hasOwn(fm, "section")) {
|
|
536
|
+
findings.push({
|
|
537
|
+
file: note.file,
|
|
538
|
+
...positionInFrontmatter(raw(), "section", undefined, { topLevel: true }),
|
|
539
|
+
severity: "error",
|
|
540
|
+
message: sectionRetiredMessage(),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
581
543
|
// Only the top-level `aliases` is retired. `name.aliases` writes the same
|
|
582
544
|
// key indented under `name:` and is **permitted** — reserved and unread —
|
|
583
545
|
// so both the test and the locator are anchored at column 1 (#180).
|
|
@@ -609,6 +571,25 @@ export function lintNote(
|
|
|
609
571
|
});
|
|
610
572
|
}
|
|
611
573
|
|
|
574
|
+
// The type's charset, before anything that looks the type up (#206). A
|
|
575
|
+
// hyphenated type is unaddressable, and every lookup below would report it
|
|
576
|
+
// as a type nobody declared — true, but not the reason, and it would send
|
|
577
|
+
// the author to declare one rather than to rename it.
|
|
578
|
+
//
|
|
579
|
+
// Guarded on a non-empty type: an absent one is a missing key, not a
|
|
580
|
+
// charset violation, and is reported as the missing schema it causes.
|
|
581
|
+
// There is **no transitional path** here, deliberately: no tree authors a
|
|
582
|
+
// hyphenated type, so an acceptance would guard a case that does not exist.
|
|
583
|
+
if (type && !isAddressSegment(type)) {
|
|
584
|
+
findings.push({
|
|
585
|
+
file: note.file,
|
|
586
|
+
...at("type", type),
|
|
587
|
+
severity: "error",
|
|
588
|
+
message: typeCharsetMessage(type),
|
|
589
|
+
});
|
|
590
|
+
return findings;
|
|
591
|
+
}
|
|
592
|
+
|
|
612
593
|
const replacement = RETIRED_TYPES[type];
|
|
613
594
|
if (replacement) {
|
|
614
595
|
findings.push({
|
|
@@ -644,15 +625,7 @@ export function lintNote(
|
|
|
644
625
|
const entry = vocabulary?.[type];
|
|
645
626
|
if (entry) {
|
|
646
627
|
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
|
-
);
|
|
628
|
+
findings.push(...checkSubType(note, { type, entry }));
|
|
656
629
|
}
|
|
657
630
|
|
|
658
631
|
const fields = authoredFields(schema);
|
|
@@ -809,19 +782,10 @@ export function lintNote(
|
|
|
809
782
|
* @param {boolean} [opts.references=true] - Whether to check references.
|
|
810
783
|
* @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
|
|
811
784
|
* 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
785
|
* @returns {{findings: object[], notes: number}} The findings, and how many
|
|
819
786
|
* notes were inspected.
|
|
820
787
|
*/
|
|
821
|
-
export function lintFrontmatter(
|
|
822
|
-
index,
|
|
823
|
-
{ schemas, vocabulary, references = true, systems, landing, types, sections },
|
|
824
|
-
) {
|
|
788
|
+
export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
|
|
825
789
|
const findings = [];
|
|
826
790
|
const notes = [...index.notes].sort((a, b) =>
|
|
827
791
|
a.file < b.file ? -1
|
|
@@ -835,9 +799,6 @@ export function lintFrontmatter(
|
|
|
835
799
|
vocabulary,
|
|
836
800
|
index: references ? index : undefined,
|
|
837
801
|
...(systems ? { systems } : {}),
|
|
838
|
-
...(landing ? { landing } : {}),
|
|
839
|
-
...(types ? { types } : {}),
|
|
840
|
-
...(sections ? { sections } : {}),
|
|
841
802
|
}),
|
|
842
803
|
);
|
|
843
804
|
}
|
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";
|
|
@@ -233,6 +234,7 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
233
234
|
});
|
|
234
235
|
assertNoDraftField(fm, { file: rel, absPath });
|
|
235
236
|
assertNoAliasesField(fm, { file: rel, absPath });
|
|
237
|
+
assertNoSectionField(fm, { file: rel, absPath });
|
|
236
238
|
if (!fm.type || !fm.shortcode) continue;
|
|
237
239
|
// A homepage is addressed like every other note since #182, and a
|
|
238
240
|
// shortcode alone would now put it here. It stays out for the reason it
|
|
@@ -243,15 +245,11 @@ export function collectManifestEntries(contentBase, ctx) {
|
|
|
243
245
|
// no index.
|
|
244
246
|
if (isHomepage(fm)) continue;
|
|
245
247
|
|
|
246
|
-
const base = path.basename(absPath);
|
|
247
248
|
const name = fm.name?.full ?? path.basename(absPath, ".md");
|
|
248
249
|
|
|
249
250
|
let address;
|
|
250
251
|
try {
|
|
251
|
-
address = packageAddress(fm, {
|
|
252
|
-
isReadme: base.toLowerCase() === "readme.md",
|
|
253
|
-
scheme: ctx.scheme,
|
|
254
|
-
});
|
|
252
|
+
address = packageAddress(fm, { scheme: ctx.scheme });
|
|
255
253
|
} catch (err) {
|
|
256
254
|
skipped.push({ file: rel, reason: err.message });
|
|
257
255
|
continue;
|
|
@@ -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 `doc` routes by its subType, so the
|
|
667
|
+
// value is a path segment, and a segment carries no hyphen (#206). The
|
|
668
|
+
// old spelling is accepted transitionally — see {@link RETIRED_SUBTYPES}
|
|
669
|
+
// — but it is not declared here, because this list is what the format
|
|
670
|
+
// says a note *should* write.
|
|
671
|
+
subTypes: Object.freeze(["rules", "userguide", "reference"]),
|
|
645
672
|
data: Object.freeze([]),
|
|
646
673
|
}),
|
|
647
674
|
|
|
@@ -704,6 +731,137 @@ export const NOTE_VOCABULARY = Object.freeze({
|
|
|
704
731
|
}),
|
|
705
732
|
});
|
|
706
733
|
|
|
734
|
+
/**
|
|
735
|
+
* The retired spelling of a subType a type declares → what to write now (#206).
|
|
736
|
+
*
|
|
737
|
+
* Keyed by type, because a retirement is a statement about *that type's*
|
|
738
|
+
* vocabulary: `user-guide` on a `doc` is the old spelling of `userguide`, while
|
|
739
|
+
* the same string on any other type is nothing but a charset violation, and
|
|
740
|
+
* saying "did you mean userguide" there would be a guess dressed as a fact.
|
|
741
|
+
*
|
|
742
|
+
* **Recorded here rather than left in `subTypes`** so the declared list stays
|
|
743
|
+
* the list of values a note *should* write. A retired value is accepted, not
|
|
744
|
+
* declared — the difference is exactly what makes the finding possible.
|
|
745
|
+
*
|
|
746
|
+
* **Deliberately not the shape of a type rename** ({@link
|
|
747
|
+
* import("./ids.mjs").RETIRED_TYPES}), which is an error: a retired type routes
|
|
748
|
+
* a note to the wrong pack, whereas a retired subType still compiles to the
|
|
749
|
+
* correct page. The sweep is the consumer's, and the ordering is the reverse of
|
|
750
|
+
* the usual — the acceptance ships *first*, because declaring only the new
|
|
751
|
+
* spelling while 43 `sohl` notes still author the old one would invalidate all
|
|
752
|
+
* 43 with a release they had no chance to sweep ahead of. A later change
|
|
753
|
+
* removes this map, and the old spelling then falls through to the ordinary
|
|
754
|
+
* undeclared-value error with no code left to remove.
|
|
755
|
+
*
|
|
756
|
+
* @type {Readonly<Record<string, Readonly<Record<string, string>>>>}
|
|
757
|
+
*/
|
|
758
|
+
export const RETIRED_SUBTYPES = Object.freeze({
|
|
759
|
+
doc: Object.freeze({ "user-guide": "userguide" }),
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* What to write in place of a retired subType value, if it is one.
|
|
764
|
+
*
|
|
765
|
+
* @param {string} type - The note's `type`.
|
|
766
|
+
* @param {string} value - The authored `subType`.
|
|
767
|
+
* @param {Readonly<Record<string, Readonly<Record<string, string>>>>} [retired]
|
|
768
|
+
* The map to read, defaulting to {@link RETIRED_SUBTYPES}.
|
|
769
|
+
* @returns {string|undefined} The current spelling, or `undefined` when the
|
|
770
|
+
* value is not a retired one — which is not the same as it being valid.
|
|
771
|
+
*/
|
|
772
|
+
export function retiredSubType(type, value, retired = RETIRED_SUBTYPES) {
|
|
773
|
+
const forType = retired?.[type];
|
|
774
|
+
if (!forType || !Object.hasOwn(forType, value)) return undefined;
|
|
775
|
+
return forType[value];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* What a note carrying a retired subType is told.
|
|
780
|
+
*
|
|
781
|
+
* One message, so the lint and any later refusal cannot describe the same
|
|
782
|
+
* retirement differently.
|
|
783
|
+
*
|
|
784
|
+
* @param {string} type - The note's `type`.
|
|
785
|
+
* @param {string} value - The retired spelling the note carries.
|
|
786
|
+
* @param {string} replacement - What to write instead.
|
|
787
|
+
* @returns {string} The message.
|
|
788
|
+
*/
|
|
789
|
+
export function retiredSubTypeMessage(type, value, replacement) {
|
|
790
|
+
return (
|
|
791
|
+
`\`subType\` "${value}" is a retired spelling of "${replacement}" on a ` +
|
|
792
|
+
`${type}; write "${replacement}". A subType is an address segment, and ` +
|
|
793
|
+
`a segment is ${ADDRESS_SEGMENT_PATTERN.source} — the hyphen separates ` +
|
|
794
|
+
`segments, so it can never occur inside one. The old spelling is still ` +
|
|
795
|
+
`accepted, and will stop being accepted once the trees have swept`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* What a note carrying a subType outside the address charset is told.
|
|
801
|
+
*
|
|
802
|
+
* @param {string} value - The authored `subType`.
|
|
803
|
+
* @returns {string} The message.
|
|
804
|
+
*/
|
|
805
|
+
export function subTypeCharsetMessage(value) {
|
|
806
|
+
return (
|
|
807
|
+
`\`subType\` "${value}" is not an address segment — a subType is ` +
|
|
808
|
+
`letters and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same ` +
|
|
809
|
+
`charset a shortcode is held to. The hyphen separates the segments of ` +
|
|
810
|
+
`an address, so a value containing one is read back as two segments ` +
|
|
811
|
+
`and resolves to nothing`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* What a note carrying a type outside the address charset is told.
|
|
817
|
+
*
|
|
818
|
+
* @param {string} type - The authored `type`.
|
|
819
|
+
* @returns {string} The message.
|
|
820
|
+
*/
|
|
821
|
+
export function typeCharsetMessage(type) {
|
|
822
|
+
return (
|
|
823
|
+
`content type "${type}" is not an address segment — a type is letters ` +
|
|
824
|
+
`and digits only (${ADDRESS_SEGMENT_PATTERN.source}), the same charset ` +
|
|
825
|
+
`a shortcode is held to. A type is the first segment of every address ` +
|
|
826
|
+
`("type-shortcode"), so a hyphenated one is read back as two segments ` +
|
|
827
|
+
`and resolves to nothing`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Refuse a vocabulary that declares a type or subType outside the charset.
|
|
833
|
+
*
|
|
834
|
+
* Run over {@link NOTE_VOCABULARY} as this module loads, so a declaration that
|
|
835
|
+
* breaks the rule cannot be imported. That is stricter than a lint on purpose:
|
|
836
|
+
* a note's bad value is one author's mistake and belongs in a report, while a
|
|
837
|
+
* bad *declaration* would tell every author to write something unaddressable.
|
|
838
|
+
*
|
|
839
|
+
* @param {Readonly<Record<string, TypeVocabulary>>} vocabulary - The registry.
|
|
840
|
+
* @param {string} [where] - What declares it, for the message.
|
|
841
|
+
* @throws {Error} Naming every offending type and subType at once, rather than
|
|
842
|
+
* stopping at the first — a reader fixing a list wants the whole list.
|
|
843
|
+
*/
|
|
844
|
+
export function assertVocabularyCharset(vocabulary, where = "the note vocabulary") {
|
|
845
|
+
const bad = [];
|
|
846
|
+
for (const [type, entry] of Object.entries(vocabulary ?? {})) {
|
|
847
|
+
if (!isAddressSegment(type)) bad.push(`type "${type}"`);
|
|
848
|
+
const values = entry?.subTypes;
|
|
849
|
+
if (!Array.isArray(values)) continue;
|
|
850
|
+
for (const value of values) {
|
|
851
|
+
if (!isAddressSegment(value)) bad.push(`subType "${value}" on ${type}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (!bad.length) return;
|
|
855
|
+
throw new Error(
|
|
856
|
+
`${where} declares ${bad.join(", ")}, which ${bad.length === 1 ? "is" : "are"} ` +
|
|
857
|
+
`not ${ADDRESS_SEGMENT_PATTERN.source}. A type and a subType are both ` +
|
|
858
|
+
`address segments, and the hyphen separates segments rather than ` +
|
|
859
|
+
`occurring inside one.`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
assertVocabularyCharset(NOTE_VOCABULARY);
|
|
864
|
+
|
|
707
865
|
/**
|
|
708
866
|
* The `data:` keys a note type may carry.
|
|
709
867
|
*
|