@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.
- package/CHANGELOG.md +375 -0
- package/CONTENT.md +234 -71
- package/MIGRATING.md +64 -0
- package/bin/content-build.mjs +79 -75
- package/content-config.mjs +28 -0
- package/docs/content-format.md +90 -67
- package/engine/base-compiler.mjs +7 -1
- package/engine/content-address.mjs +71 -18
- package/engine/content-format-check.mjs +1 -1
- package/engine/content-links.mjs +93 -112
- package/engine/content-lint.mjs +14 -10
- package/engine/content-slug.mjs +39 -105
- package/engine/diagnostics.mjs +16 -2
- package/engine/frontmatter-lint.mjs +161 -18
- package/engine/helpers.mjs +31 -68
- package/engine/homepage.mjs +131 -86
- package/engine/index.mjs +2 -5
- package/engine/manifest-emit.mjs +23 -4
- package/engine/note-vocabulary.mjs +58 -1
- package/engine/retired-fields.mjs +117 -6
- package/engine/site-build.mjs +182 -59
- package/engine/site-index.mjs +57 -102
- package/engine/web-wikilinks.mjs +183 -127
- package/engine/wikilink-syntax.mjs +174 -34
- package/engine/wikilinks.mjs +159 -117
- package/package.json +1 -1
- package/types/content-config.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +1 -1
- package/types/engine/content-address.d.mts +46 -14
- package/types/engine/content-links.d.mts +13 -17
- package/types/engine/content-slug.d.mts +11 -48
- package/types/engine/diagnostics.d.mts +14 -1
- package/types/engine/frontmatter-lint.d.mts +27 -2
- package/types/engine/helpers.d.mts +4 -3
- package/types/engine/homepage.d.mts +96 -60
- package/types/engine/index.d.mts +0 -1
- package/types/engine/note-vocabulary.d.mts +43 -0
- package/types/engine/retired-fields.d.mts +78 -1
- package/types/engine/site-build.d.mts +70 -17
- package/types/engine/site-index.d.mts +19 -21
- package/types/engine/web-wikilinks.d.mts +29 -28
- package/types/engine/wikilink-syntax.d.mts +126 -40
- package/types/engine/wikilinks.d.mts +29 -24
- package/engine/abbreviations.mjs +0 -0
- package/engine/alias-index.mjs +0 -153
- package/types/engine/abbreviations.d.mts +0 -44
- package/types/engine/alias-index.d.mts +0 -122
package/engine/content-slug.mjs
CHANGED
|
@@ -12,50 +12,39 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* -
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
63
|
+
* The URL-safe token a piece of prose reduces to.
|
|
77
64
|
*
|
|
78
|
-
* The
|
|
79
|
-
* is carried across rather than dropped
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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`)
|
|
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}
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
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 (
|
|
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
|
-
}
|
package/engine/diagnostics.mjs
CHANGED
|
@@ -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 (
|
|
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(
|
|
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
|
-
//
|
|
471
|
-
//
|
|
472
|
-
// fields above because it is the same kind of statement
|
|
473
|
-
//
|
|
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 {
|
|
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(
|
|
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
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
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(
|
|
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(
|
|
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
|
}
|
package/engine/helpers.mjs
CHANGED
|
@@ -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
|
-
//
|
|
43
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
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
|
|
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
|
|
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 =
|
|
555
|
+
err.position = at;
|
|
545
556
|
throw err;
|
|
546
557
|
};
|
|
547
558
|
|
|
548
559
|
for (const u of result.unresolved) {
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
// the
|
|
552
|
-
// the
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
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
|
}
|