@heroiclands/package-build 10.0.1 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +375 -0
  2. package/CONTENT.md +234 -71
  3. package/MIGRATING.md +64 -0
  4. package/bin/content-build.mjs +79 -75
  5. package/content-config.mjs +28 -0
  6. package/docs/content-format.md +90 -67
  7. package/engine/base-compiler.mjs +7 -1
  8. package/engine/content-address.mjs +71 -18
  9. package/engine/content-format-check.mjs +1 -1
  10. package/engine/content-links.mjs +93 -112
  11. package/engine/content-lint.mjs +14 -10
  12. package/engine/content-slug.mjs +39 -105
  13. package/engine/diagnostics.mjs +16 -2
  14. package/engine/frontmatter-lint.mjs +161 -18
  15. package/engine/helpers.mjs +31 -68
  16. package/engine/homepage.mjs +131 -86
  17. package/engine/index.mjs +2 -5
  18. package/engine/manifest-emit.mjs +23 -4
  19. package/engine/note-vocabulary.mjs +58 -1
  20. package/engine/retired-fields.mjs +117 -6
  21. package/engine/site-build.mjs +182 -59
  22. package/engine/site-index.mjs +57 -102
  23. package/engine/web-wikilinks.mjs +183 -127
  24. package/engine/wikilink-syntax.mjs +174 -34
  25. package/engine/wikilinks.mjs +159 -117
  26. package/package.json +1 -1
  27. package/types/content-config.d.mts +24 -0
  28. package/types/engine/base-compiler.d.mts +1 -1
  29. package/types/engine/content-address.d.mts +46 -14
  30. package/types/engine/content-links.d.mts +13 -17
  31. package/types/engine/content-slug.d.mts +11 -48
  32. package/types/engine/diagnostics.d.mts +14 -1
  33. package/types/engine/frontmatter-lint.d.mts +27 -2
  34. package/types/engine/helpers.d.mts +4 -3
  35. package/types/engine/homepage.d.mts +96 -60
  36. package/types/engine/index.d.mts +0 -1
  37. package/types/engine/note-vocabulary.d.mts +43 -0
  38. package/types/engine/retired-fields.d.mts +78 -1
  39. package/types/engine/site-build.d.mts +70 -17
  40. package/types/engine/site-index.d.mts +19 -21
  41. package/types/engine/web-wikilinks.d.mts +29 -28
  42. package/types/engine/wikilink-syntax.d.mts +126 -40
  43. package/types/engine/wikilinks.d.mts +29 -24
  44. package/engine/abbreviations.mjs +0 -0
  45. package/engine/alias-index.mjs +0 -153
  46. package/types/engine/abbreviations.d.mts +0 -44
  47. package/types/engine/alias-index.d.mts +0 -122
@@ -12,50 +12,39 @@
12
12
  */
13
13
 
14
14
  /**
15
- * The URL segment of a content note — derived from its name.
16
- *
17
- * Content notes carry no authored `slug`: it was a hand-maintained second
18
- * spelling of something already determined, free to drift from the page it
19
- * named. The URL is derived instead.
20
- *
21
- * It is derived from the **name**, deliberately not from the `shortcode`, even
22
- * though `(type, shortcode)` is unique by rule and would be a tempting key. A
23
- * shortcode is *identity*: it is referenced from saved world data actions,
24
- * cohorts, expressions, archetypes, pack lookups so binding the public URL to
25
- * it would make a cosmetic URL change into a data migration. A URL is
26
- * presentation, and it should read like one (`/creature/nusvorroth/`, not
27
- * `/creature/nsvrroth/`). Renames are what a URL must survive, and they do:
28
- * every change appends to the legacy-URL map, which emits a redirect.
29
- *
30
- * **One normalisation, for every slug this build makes.** {@link slugify} is it.
31
- * Two things are layered on top of it for **document identity** only, in
32
- * {@link contentSlug}:
33
- *
34
- * - it must produce something, and throws when a name yields no slug;
35
- * - it **abbreviates**see {@link ABBREVIATIONS}.
36
- *
37
- * Abbreviation stops at the document's own address on purpose. A heading anchor
38
- * is not a name the build invents: an author writes the matching key by hand —
39
- * a map note pins `locations.stair-foot` at a heading called *Stair Foot* — so a
40
- * slug that silently became `stair-ft` would break a reference nobody could
41
- * have predicted. The same goes for a pack filename, which is only ever read
42
- * back by the unpacker. Shortening either buys nothing and costs the author's
43
- * ability to guess the key.
44
- *
45
- * This header used to claim the opposite — that anchor slugs, filename slugs and
46
- * this one were deliberately separate operations. Three of them had drifted into
47
- * dropping non-ASCII letters instead of transliterating them, so `Kûrbúl Helm`
48
- * addressed a page at `kurbul-helm` while its pack file was `k-rb-l-helm` and a
49
- * link to a heading of the same name pointed at `#k-rb-l-helm`. Twenty-two of
50
- * this repository's notes were affected. That was not a design; it was three
51
- * copies of a regex, and the differences between them were all mistakes.
15
+ * One normalisation, for every slug this build makes.
16
+ *
17
+ * {@link slugify} reduces a piece of prose a heading, a document name — to a
18
+ * URL-safe token. It is **not** how a page is addressed: a note's URL is its
19
+ * address, `type-shortcode`, derived in `engine/content-address.mjs` and
20
+ * touching no display string at all (#181).
21
+ *
22
+ * That used to be the other way round. This module carried a `contentSlug` that
23
+ * derived a page's URL from `name.full`, abbreviating it through a table of 200
24
+ * words so the result stayed short, and a `findSlugCollisions` to catch the two
25
+ * notes that would then claim one URL. Its own header justified the readability
26
+ * cost with a rename-survival story *"every change appends to the legacy-URL
27
+ * map, which emits a redirect"* and no such map was ever written, in this
28
+ * package or in any consumer. So a rename silently 404'd every existing link,
29
+ * a display string was load-bearing, and a uniqueness check was needed to keep
30
+ * it correct. An address has none of those properties, so all three went.
31
+ *
32
+ * What is left is the normalisation the rest of the build still needs, in the
33
+ * two places it was always right for:
34
+ *
35
+ * - **heading anchors**, where an author writes the matching key by hand a map
36
+ * note pins `locations.stair-foot` at a heading called *Stair Foot*;
37
+ * - **pack filenames**, read back only by the unpacker.
38
+ *
39
+ * Neither is abbreviated, and neither ever was: abbreviation existed solely to
40
+ * shorten a name-derived URL, so it left with it.
52
41
  *
53
42
  * What the rule does, and why:
54
43
  *
55
44
  * - **Transliterate, don't discard.** `unidecode` carries every non-ASCII letter
56
45
  * to its ASCII sense — `æ` → `ae`, `þ` → `th`, `œ` → `oe`, `ß` → `ss`,
57
46
  * `ö` → `o`, `¾` → `3/4`. A rule that merely strips them turns a name into
58
- * punctuation.
47
+ * punctuation, which is how `Kûrbúl Helm` once became `k-rb-l-helm`.
59
48
  * - **An apostrophe elides.** `’` and `'` mark a pronunciation break — a glottal
60
49
  * stop — inside one word, so `Kenbet’Pat` is `kenbetpat`, not `kenbet-pat`.
61
50
  * - **Everything else non-alphanumeric becomes a hyphen**, collapsed and
@@ -70,31 +59,26 @@
70
59
 
71
60
  import unidecode from "unidecode";
72
61
 
73
- import { abbreviateTokens } from "./abbreviations.mjs";
74
-
75
62
  /**
76
- * The URL segment for one content note.
63
+ * The URL-safe token a piece of prose reduces to.
77
64
  *
78
- * The name is **transliterated** before it is reduced, so an accented character
79
- * is carried across rather than dropped dropping is what turned `Nüsvōrroth`
80
- * into `n-sv-rroth` and forced a hand-written slug. Ligatures expand the way a
81
- * reader would spell them out: `þ`→`th`, `æ`→`ae`, `œ`→`oe`, `ß`→`ss`, `ij`→`ij`,
82
- * `fi`→`fi`, and eth (`ð`) follows the Icelandic convention of a bare `d`.
65
+ * The text is **transliterated** before it is reduced, so an accented character
66
+ * is carried across rather than dropped. Ligatures expand the way a reader would
67
+ * spell them out: `þ`→`th`, `æ`→`ae`, `œ`→`oe`, `ß`→`ss`, `ij`→`ij`, `fi`→`fi`,
68
+ * and eth (`ð`) follows the Icelandic convention of a bare `d`.
83
69
  *
84
70
  * Two reductions are ours rather than the transliterator's:
85
71
  *
86
72
  * - **apostrophes are removed**, not treated as separators (`Armorer's Kit` →
87
- * `armorers-kit`), matching the URLs these pages already publish at;
73
+ * `armorers-kit`);
88
74
  * - **a fraction keeps its digits together** — a vulgar fraction expands to
89
75
  * `3/4`, and the solidus would otherwise split it into `3-4`, so a slash
90
76
  * *between digits* is closed up (`Kûrbúl ¾-Helm` → `kurbul-34-helm`).
91
77
  *
92
- * @param {string | undefined} name - The note's display name (`name.full`),
93
- * which a malformed note may not have at all.
94
- * @returns {string} The URL segment (never empty).
95
- * @throws {Error} When there is no name, or the name carries no URL-safe
96
- * characters — either way the note cannot be addressed, which is a content
97
- * error rather than something to paper over with a fallback.
78
+ * @param {string | undefined} text - The prose to reduce.
79
+ * @returns {string} The token, or `""` when the text carries nothing URL-safe.
80
+ * Empty is an ordinary answer here: nothing is addressed by a slug any more,
81
+ * so an anchor that reduces to nothing is the caller's to judge.
98
82
  */
99
83
  export function slugify(text) {
100
84
  const raw = typeof text === "string" ? text.trim() : "";
@@ -102,10 +86,10 @@ export function slugify(text) {
102
86
  const tokens = unidecode(raw)
103
87
  .toLowerCase()
104
88
  // An apostrophe marks a pronunciation break, not a word boundary:
105
- // `Kenbet\u2019Pat` is one name said with a catch in it, so it elides
89
+ // `Kenbet’Pat` is one name said with a catch in it, so it elides
106
90
  // rather than becoming a hyphen.
107
91
  .replace(/['\u2019]/g, "")
108
- // A vulgar fraction transliterates to its digits (`\u00be` \u2192 `3/4`); the
92
+ // A vulgar fraction transliterates to its digits (`¾` `3/4`); the
109
93
  // solidus between them is not a word boundary either.
110
94
  .replace(/(\d)\/(\d)/g, "$1$2")
111
95
  .split(/[^a-z0-9]+/)
@@ -113,53 +97,3 @@ export function slugify(text) {
113
97
 
114
98
  return tokens.join("-");
115
99
  }
116
-
117
- /**
118
- * The URL segment a content note publishes at.
119
- *
120
- * {@link slugify} with the rule that a document *must* be addressable: a note
121
- * that yields no slug is a content error, not something to paper over with a
122
- * fallback, because the alternative is a page nobody can reach.
123
- *
124
- * @param {string | undefined} name - The note's display name (`name.full`),
125
- * which a malformed note may not have at all.
126
- * @returns {string} The URL segment (never empty).
127
- * @throws {Error} When there is no name, or the name carries no URL-safe
128
- * characters.
129
- */
130
- export function contentSlug(name) {
131
- const raw = typeof name === "string" ? name.trim() : "";
132
- if (!raw) {
133
- throw new Error("content note has no name, so it has no URL");
134
- }
135
- const normalised = slugify(raw);
136
- const slug = abbreviateTokens(normalised.split("-").filter(Boolean)).join("-");
137
- if (!slug) {
138
- throw new Error(`name "${raw}" has no URL-safe characters, so it cannot address a page`);
139
- }
140
- return slug;
141
- }
142
-
143
- /**
144
- * Find pages that would publish to the same URL.
145
- *
146
- * Nothing constrains two notes in one section from sharing a name, and a
147
- * collision silently overwrites one page with the other. This turns it into a
148
- * build failure that names every claimant, so the fix is a more specific title.
149
- * (The content tree has no collisions today.)
150
- *
151
- * @param {Array<{sec: string, slug: string, src: string}>} pages
152
- * @returns {Array<{url: string, sources: string[]}>} One entry per collision, in
153
- * first-claim order; empty when every URL is unique.
154
- */
155
- export function findSlugCollisions(pages) {
156
- const byUrl = new Map();
157
- for (const { sec, slug, src } of pages) {
158
- const url = `/${sec}/${slug}/`;
159
- if (!byUrl.has(url)) byUrl.set(url, []);
160
- byUrl.get(url).push(src);
161
- }
162
- return [...byUrl.entries()]
163
- .filter(([, sources]) => sources.length > 1)
164
- .map(([url, sources]) => ({ url, sources }));
165
- }
@@ -181,16 +181,27 @@ export function positionInBody(body, offset, { bodyLine = 1, bodyColumn = 1, lin
181
181
  * a line of prose — sending the reader to a position that is not the problem,
182
182
  * which is the one thing the located form exists to prevent.
183
183
  *
184
+ * The key match tolerates leading whitespace by default, so a nested key of the
185
+ * same name answers when no top-level one is present — which is usually what a
186
+ * reader wants, the key being unique in nearly every note. Pass `topLevel` where
187
+ * it is not: `aliases` is both a retired top-level field and a **permitted**
188
+ * `name.aliases` (#180), and a finding about the first must never open on the
189
+ * second, which would tell an author to delete a field they are allowed to
190
+ * write.
191
+ *
184
192
  * @param {string} raw - The file's full contents, frontmatter included.
185
193
  * @param {string} key - The top-level frontmatter key.
186
194
  * @param {string} [value] - When given, prefer the occurrence whose line also
187
195
  * carries this text. A list-valued key (`aliases`) is reported at the entry
188
196
  * that is wrong, not at the key that introduces it.
197
+ * @param {object} [options] - Options.
198
+ * @param {boolean} [options.topLevel=false] - Require the key at column 1, so
199
+ * an identically named nested key cannot answer for it.
189
200
  * @returns {{line?: number, column?: number}} Spreadable position fields, empty
190
201
  * when the key cannot be located — dropped rather than guessed, as
191
202
  * {@link formatDiagnostic} requires.
192
203
  */
193
- export function positionInFrontmatter(raw, key, value = undefined) {
204
+ export function positionInFrontmatter(raw, key, value = undefined, { topLevel = false } = {}) {
194
205
  if (typeof raw !== "string" || !key) return {};
195
206
  const fence = raw.match(/^---\n([\s\S]*?)\n---/);
196
207
  if (!fence) return {};
@@ -205,7 +216,10 @@ export function positionInFrontmatter(raw, key, value = undefined) {
205
216
  keyLine = i;
206
217
  break;
207
218
  }
208
- if (keyLine === -1 && new RegExp(`^\\s*${escape(key)}\\s*:`).test(lines[i])) {
219
+ if (
220
+ keyLine === -1 &&
221
+ new RegExp(`^${topLevel ? "" : "\\s*"}${escape(key)}\\s*:`).test(lines[i])
222
+ ) {
209
223
  keyLine = i;
210
224
  if (wanted == null) break;
211
225
  }
@@ -55,7 +55,10 @@
55
55
  * @module
56
56
  */
57
57
 
58
+ import path from "node:path";
59
+
58
60
  import { authoredFields } from "./field-spec.mjs";
61
+ import { DEFAULT_ADDRESS_SCHEME, sectionOf } from "./content-address.mjs";
59
62
  import { resolveFieldValue, SYSTEM_BLOCK_KEYS, unknownBlockKeys } from "./system-block.mjs";
60
63
  import { positionInFrontmatter, positionOfFrontmatterPath } from "./diagnostics.mjs";
61
64
  import { checkHomepageAddressFields } from "./homepage.mjs";
@@ -64,6 +67,8 @@ import { declaredTags } from "./note-vocabulary.mjs";
64
67
  import {
65
68
  RETIRED_FIELD_ALIASES,
66
69
  declaresRetiredAlias,
70
+ aliasesRetiredMessage,
71
+ declaresRetiredAliasesField,
67
72
  draftRetiredMessage,
68
73
  readAliasedField,
69
74
  retiredAliasMessage,
@@ -311,22 +316,83 @@ function checkDataContainer(note, { type, fields }) {
311
316
  return findings;
312
317
  }
313
318
 
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
+
314
345
  /**
315
346
  * Check a note's top-level `subType` against the values its type declares
316
- * (#128).
347
+ * (#128), or — for a `README` landing — against the sections that can exist
348
+ * (#197, #200).
317
349
  *
318
350
  * `subType` stays at the top level — it is what each system's map reads to
319
351
  * derive a document type, so it describes the note rather than the subject —
320
352
  * but it is not open like the rest of that region: a type either declares a
321
353
  * `subType` or does not, and a type that does declares its values.
322
354
  *
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.
380
+ *
323
381
  * @param {object} note - The note.
324
382
  * @param {object} opts
325
383
  * @param {string} opts.type - The note's type, for the message.
326
384
  * @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.
327
393
  * @returns {object[]} Findings.
328
394
  */
329
- function checkSubType(note, { type, entry }) {
395
+ function checkSubType(note, { type, entry, asSection = false, types = [], sections = [] }) {
330
396
  const fm = note.fm ?? {};
331
397
  if (!Object.hasOwn(fm, "subType") || fm.subType == null || fm.subType === "") return [];
332
398
 
@@ -351,6 +417,28 @@ function checkSubType(note, { type, entry }) {
351
417
  // the value is nobody's to check yet.
352
418
  if (values == null || values.includes(value)) return [];
353
419
 
420
+ if (asSection) {
421
+ if (types.includes(value) || sections.includes(value)) return [];
422
+ const guess = nearest(value, [...types, ...values, ...sections]);
423
+ return [
424
+ {
425
+ file: note.file,
426
+ ...at,
427
+ 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}"?` : ""),
438
+ },
439
+ ];
440
+ }
441
+
354
442
  const guess = nearest(value, values);
355
443
  return [
356
444
  {
@@ -433,9 +521,33 @@ function checkTags(note, { type }) {
433
521
  * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
434
522
  * The system blocks to check, and what each accepts. See
435
523
  * {@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.
436
537
  * @returns {object[]} Findings, each with a locator where one is obtainable.
437
538
  */
438
- export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_SYSTEM_BLOCKS }) {
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
+ ) {
439
551
  const findings = [];
440
552
  const fm = note.fm ?? {};
441
553
  const type = String(fm.type ?? "");
@@ -466,21 +578,32 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
466
578
  message: draftRetiredMessage(),
467
579
  });
468
580
  }
581
+ // Only the top-level `aliases` is retired. `name.aliases` writes the same
582
+ // key indented under `name:` and is **permitted** — reserved and unread —
583
+ // so both the test and the locator are anchored at column 1 (#180).
584
+ if (declaresRetiredAliasesField(fm)) {
585
+ findings.push({
586
+ file: note.file,
587
+ ...positionInFrontmatter(raw(), "aliases", undefined, { topLevel: true }),
588
+ severity: "error",
589
+ message: aliasesRetiredMessage(),
590
+ });
591
+ }
469
592
 
470
- // A homepage's address is its package's, so the top-level fields that
471
- // decide an address decide nothing on it (#53). Reported beside the retired
472
- // fields above because it is the same kind of statement — a top-level key
473
- // this note may not write — and, like them, it must survive the two early
593
+ // What the address rule says about a homepage's top-level fields: the
594
+ // `shortcode` it owes, and the `id` it may not write (#53, #182). Reported
595
+ // beside the retired fields above because it is the same kind of statement
596
+ // about the same note, and, like them, it must survive the two early
474
597
  // returns below: the finding stands whatever else the type is.
475
598
  // Tags are checked here for the same reason: a classifying tag is not a
476
599
  // type's property — `draft` belongs to any note and `village` to a place —
477
600
  // so the finding must survive the early returns below.
478
601
  findings.push(...checkTags(note, { type }));
479
602
 
480
- for (const { key, message } of checkHomepageAddressFields(fm)) {
603
+ for (const { locator, message } of checkHomepageAddressFields(fm)) {
481
604
  findings.push({
482
605
  file: note.file,
483
- ...at(key),
606
+ ...at(locator.key, locator.literal),
484
607
  severity: "error",
485
608
  message,
486
609
  });
@@ -521,7 +644,15 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
521
644
  const entry = vocabulary?.[type];
522
645
  if (entry) {
523
646
  findings.push(...checkDataContainer(note, { type, fields: entry.data ?? [] }));
524
- findings.push(...checkSubType(note, { type, entry }));
647
+ findings.push(
648
+ ...checkSubType(note, {
649
+ type,
650
+ entry,
651
+ asSection: subTypeIsSection(note, landing),
652
+ types,
653
+ sections,
654
+ }),
655
+ );
525
656
  }
526
657
 
527
658
  const fields = authoredFields(schema);
@@ -643,15 +774,15 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
643
774
  // vendored manifest lands exactly as the same address in a wikilink
644
775
  // would — rather than through a second, subtly different rule.
645
776
  //
646
- // **As an address, always.** The resolver's third argument chooses the
647
- // namespace and has no fallback (#144): omitted, it reads the target as
648
- // an *alias*, and `type-shortcode` is never an alias, so every value a
649
- // `ref:` field carries was reported unresolvable (#176). A frontmatter
650
- // reference is a bare address by construction there is no pipe to
651
- // read intent from, and the field supplies the type — so it says so.
777
+ // **As an address, always** which is now the only namespace there
778
+ // is (#180). A frontmatter reference is a bare address by construction:
779
+ // there is no pipe to read intent from, and the field supplies the
780
+ // type. The resolver once took a namespace argument, and omitting it
781
+ // read every `ref:` value as an alias, which `type-shortcode` never was
782
+ // (#176).
652
783
  if (field.ref && index && typeof value === "string" && value) {
653
784
  const target = `${field.ref}-${value}`;
654
- if (!index.resolve(note, target, true) && !index.manifestHit(target)) {
785
+ if (!index.resolve(target) && !index.manifestHit(target)) {
655
786
  findings.push({
656
787
  file: note.file,
657
788
  ...at(head, value),
@@ -678,10 +809,19 @@ export function lintNote(note, { schemas, index, vocabulary, systems = DEFAULT_S
678
809
  * @param {boolean} [opts.references=true] - Whether to check references.
679
810
  * @param {Readonly<Record<string, {known?: readonly string[], fieldVocabulary?: boolean}>>} [opts.systems]
680
811
  * 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}.
681
818
  * @returns {{findings: object[], notes: number}} The findings, and how many
682
819
  * notes were inspected.
683
820
  */
684
- export function lintFrontmatter(index, { schemas, vocabulary, references = true, systems }) {
821
+ export function lintFrontmatter(
822
+ index,
823
+ { schemas, vocabulary, references = true, systems, landing, types, sections },
824
+ ) {
685
825
  const findings = [];
686
826
  const notes = [...index.notes].sort((a, b) =>
687
827
  a.file < b.file ? -1
@@ -695,6 +835,9 @@ export function lintFrontmatter(index, { schemas, vocabulary, references = true,
695
835
  vocabulary,
696
836
  index: references ? index : undefined,
697
837
  ...(systems ? { systems } : {}),
838
+ ...(landing ? { landing } : {}),
839
+ ...(types ? { types } : {}),
840
+ ...(sections ? { sections } : {}),
698
841
  }),
699
842
  );
700
843
  }
@@ -39,10 +39,14 @@ import { contentPackage, foundryPackageId } from "./content-package.mjs";
39
39
  import { searchableFrontmatter } from "./note-package.mjs";
40
40
  import { loadForeignManifests, PACKAGE_BASE } from "./kb-manifest.mjs";
41
41
  import { buildWikilinkIndex, convertWikilinks } from "./wikilinks.mjs";
42
- // The alias sources every index shares (#131).
43
- import { aliasesOf } from "./alias-index.mjs";
42
+ // One vocabulary of link findings, and one message per class, so the three
43
+ // resolvers cannot word the same defect differently (#184).
44
+ import { linkFindingMessage } from "./wikilink-syntax.mjs";
45
+ // The declared tag vocabulary (#172), which is where `draft` is stated. Read
46
+ // from there rather than respelt, so the tag and its one reader cannot drift.
47
+ import { isDraftNote } from "./note-vocabulary.mjs";
44
48
  import { expandContentTables } from "./content-tables.mjs";
45
- import { emitDiagnostic, positionInBody } from "./diagnostics.mjs";
49
+ import { positionInBody } from "./diagnostics.mjs";
46
50
  // The pure `sohl:` frontmatter readers live in a leaf module so the item-type
47
51
  // registry can import them without reaching back through this one (#1504).
48
52
  // Re-exported here so every existing importer keeps its single import path.
@@ -429,7 +433,7 @@ import { assertTypeNotRetired, packForType } from "./ids.mjs";
429
433
  * @param {object} [router] - The pack router. Supplied by the calling pass so
430
434
  * the index and the compile agree about where each note landed; defaults to
431
435
  * this repository's own.
432
- * @returns {{byShortcode: Map, byAlias: Map}} From `buildWikilinkIndex`.
436
+ * @returns {{byShortcode: Map, types: Set}} From `buildWikilinkIndex`.
433
437
  */
434
438
  export function buildContentLinkIndex(contentBase, router = packRouter()) {
435
439
  const docs = [];
@@ -450,10 +454,11 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
450
454
  docPack: router.resolveOrNull(fm, "JournalEntry"),
451
455
  shortcode: fm.shortcode ?? null,
452
456
  name: fm.name?.full ?? base,
453
- // The shared alias sources (#131): `aliases`, `name.aliases` and
454
- // `name.full`, and deliberately not the filename see
455
- // {@link aliasesOf} for what that admitted and why it went.
456
- aliases: aliasesOf(fm),
457
+ // Whether the note is tagged `draft` (#183). Read from the tag
458
+ // vocabulary that declares it, and used for one thing: a link
459
+ // *into* this note renders marked. It takes no part in resolution,
460
+ // so the note is indexed, compiled and published as any other.
461
+ draft: isDraftNote(fm),
457
462
  });
458
463
  }
459
464
  // Packages this build links *into* but does not publish. Their manifests
@@ -500,7 +505,8 @@ export function buildContentLinkIndex(contentBase, router = packRouter()) {
500
505
  * entry. Position is carried by `{ file, bodyLine, bodyColumn, lineMap }`,
501
506
  * the last from {@link expandNoteTables}.
502
507
  * @returns {{markdown: string, unresolved: Array<object>}}
503
- * @throws {Error} On an ambiguous alias or a dead qualified address. The error
508
+ * @throws {Error} On any link that does not resolve an unlabelled one, a
509
+ * target that is not an address, or an address nothing publishes. The error
504
510
  * carries `file` and `position`, so a caller reports it in the same form
505
511
  * rather than re-deriving one.
506
512
  */
@@ -539,70 +545,27 @@ export function convertNoteWikilinks(
539
545
  * @returns {never}
540
546
  */
541
547
  const fail = (u, message) => {
542
- const err = new Error(message);
548
+ const at = locate(u);
549
+ // A link this build wrote is not at any authored position, so say
550
+ // where it came from rather than implying an edit site.
551
+ const err = new Error(
552
+ at.generated ? `${message} Emitted by the content table on this line.` : message,
553
+ );
543
554
  err.file = file;
544
- err.position = locate(u);
555
+ err.position = at;
545
556
  throw err;
546
557
  };
547
558
 
548
559
  for (const u of result.unresolved) {
549
- // An ambiguous alias matched real content twice. There is no
550
- // defensible way to pick one, and the correction is mechanical: write
551
- // the qualified form. So it fails rather than warning, which also puts
552
- // the failure in front of whoever created the collision instead of
553
- // leaving it in a log line attributed to an innocent citing note.
554
- // The knowledgebase build has always treated this as fatal; agreeing
555
- // means one authored note cannot get two verdicts (#13).
556
- if (u.reason === "ambiguous") {
557
- const claims = u.candidates ?? [];
558
- const named =
559
- claims.length ?
560
- claims.map((c) => `"${c.name}" (${c.type}-${c.shortcode})`).join(" and ")
561
- : "two or more notes";
562
- fail(
563
- u,
564
- `ambiguous wikilink ${u.link} in "${name}" — claimed by ` +
565
- `${named}. Rename one alias, or address the intended one ` +
566
- `as [[type-shortcode|Text]].`,
567
- );
568
- }
569
- // The author wrote a pipe, so they meant an address — and this target
570
- // is not one. Its own message, because the correction is its own: a
571
- // note *name* has to become an address, which is not the same job as
572
- // fixing a shortcode that resolves nowhere (#131).
573
- if (u.reason === "not-an-address") {
574
- fail(
575
- u,
576
- `wikilink ${u.link} in "${name}" is written as an address — ` +
577
- `the "|" says so — but "${u.target}" is not one. Write ` +
578
- `[[type-shortcode|Text]], or drop the "|" to name it as ` +
579
- `an alias within this note's own type.`,
580
- );
581
- }
582
- // A qualified address resolving nowhere is a typo, now that every
583
- // linkable package is either built here or vendored (#1499) — so it
584
- // fails the note rather than degrading to text. A bare alias stays a
585
- // warning: it may be ordinary prose that merely looks like a link.
586
- if (u.addressed) {
587
- fail(
588
- u,
589
- `unresolved address ${u.link} in "${name}" — no package ` +
590
- `publishes it. Fix the shortcode, or re-vendor that ` +
591
- `package's manifest into assets/manifests/.`,
592
- );
593
- }
594
- const at = locate(u);
595
- emitDiagnostic({
596
- file,
597
- line: at.line,
598
- column: at.column,
599
- severity: "warning",
600
- message:
601
- `unresolved wikilink ${u.link} (${u.reason}) in "${name}"` +
602
- // A link this build wrote is not at any authored position, so
603
- // say where it came from instead of implying an edit site.
604
- (at.generated ? " — emitted by the content table on this line" : ""),
605
- });
560
+ // Every class fails, and every class is worded by the shared table
561
+ // (#184). The three resolvers read one authored link, so an author who
562
+ // ran the pack build first and the link checker second must not be told
563
+ // two different things about the same mistake and a class the pack
564
+ // build alone knew how to describe is how they came apart before.
565
+ //
566
+ // The note's name is appended rather than woven in: the message is the
567
+ // defect, the name is the context this build can add.
568
+ fail(u, `${linkFindingMessage(u)} in "${name}".`);
606
569
  }
607
570
  return result;
608
571
  }