@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.
@@ -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 { declaredTags } from "./note-vocabulary.mjs";
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), or — for a `README` landing — against the sections that can exist
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
- * **A landing page's is an address, and an address is checked against the
356
- * addresses that exist.** A `README` under `landing: readme` addresses its
357
- * section through this field, and a section is `weapongear` or `being` as
358
- * readily as it is `rules`. What can legitimately appear there is the range of
359
- * {@link sectionOf} over every note the format permits, plus whatever the
360
- * repository names:
361
- *
362
- * 1. **Every declared content type.** `sectionOf` returns `fm.type` for a
363
- * non-`doc` note, so `being`, `lore` and `weapongear` are sections *by
364
- * construction*, configured or not.
365
- * 2. **The type's own subtypes** — `rules`, `user-guide`, `reference` for a
366
- * `doc`, since a `doc` routes by its subtype.
367
- * 3. **The configured sections**, which may name one that is neither: `sohl`
368
- * configures `credits` and `dev-docs`.
369
- *
370
- * Checking only (3) was #197's fix and keyed on configuration a consumer may
371
- * legitimately not have: `sohl-thalorna` has no `site:` block at all, so five
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, asSection = false, types = [], sections = [] }) {
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
- const values = entry.subTypes;
416
- // `null` is "declared, values not yet enumerated" presence is legal and
417
- // the value is nobody's to check yet.
418
- if (values == null || values.includes(value)) return [];
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
- if (asSection) {
421
- if (types.includes(value) || sections.includes(value)) return [];
422
- const guess = nearest(value, [...types, ...values, ...sections]);
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
  }
@@ -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. The field is
36
- * still written rather than left for a consumer to compute, because a landing
37
- * page is the one entry that is not derivable it addresses its section under
38
- * the configured mount — and because an absent `path` already means something
39
- * else entirely (a package that publishes no pages).
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 scheme is configuration, and it is shared with the site build.**
42
- * Where the content tree mounts inside the package and which note is a section's
43
- * landing page differ between repositories and are both load-bearing `sohl`
44
- * records `kb/rules/` for the landing of its rules section and `thalorna`
45
- * records `affiliation/` for its own. Reading one setting here and in the page
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 { assertNoAliasesField, assertNoDraftField } from "./retired-fields.mjs";
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
- subTypes: Object.freeze(["rules", "user-guide", "reference"]),
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
  *