@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.
@@ -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 { declaredTags } from "./note-vocabulary.mjs";
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), or — for a `README` landing — against the sections that can exist
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
- * **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.
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, asSection = false, types = [], sections = [] }) {
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
- 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 [];
419
-
420
- if (asSection) {
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
  }
@@ -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";
@@ -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 and scheme an emission runs against, from configuration.
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
- * scheme: {prefix: string, landing: string}, web: boolean,
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
- subTypes: Object.freeze(["rules", "user-guide", "reference"]),
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:` and the top-level
26
- * `aliases:` have no such home — there is no surviving concept either was part
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
  *