@heroiclands/package-build 17.1.0 → 18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1773 -0
- package/CONTENT.md +288 -72
- package/README.md +1 -1
- package/bin/content-build.mjs +218 -137
- package/bin/package-build.mjs +43 -0
- package/content-config.mjs +62 -67
- package/docs/content-format.md +768 -58
- package/engine/actor-compiler.mjs +586 -0
- package/engine/address-charset.mjs +11 -8
- package/engine/address-diff.mjs +266 -22
- package/engine/anchored-sections.mjs +83 -0
- package/engine/anchors.mjs +83 -0
- package/engine/base-compiler.mjs +148 -18
- package/engine/bundle-notes.mjs +276 -0
- package/engine/bundles.mjs +307 -0
- package/engine/code-fences.mjs +103 -0
- package/engine/compile-corpus.mjs +89 -0
- package/engine/content-address.mjs +267 -5
- package/engine/content-format-check.mjs +54 -11
- package/engine/content-format.mjs +37 -5
- package/engine/content-index.mjs +394 -111
- package/engine/content-links.mjs +185 -77
- package/engine/content-lint.mjs +232 -14
- package/engine/content-package.mjs +2 -1
- package/engine/content-tables.mjs +99 -3
- package/engine/document-subtypes.mjs +49 -6
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +56 -6
- package/engine/folder-notes.mjs +470 -0
- package/engine/foreign-catalog.mjs +195 -4
- package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
- package/engine/frontmatter-lint.mjs +245 -19
- package/engine/frontmatter.mjs +88 -8
- package/engine/generate.mjs +304 -13
- package/engine/helpers.mjs +293 -64
- package/engine/ids.mjs +137 -2
- package/engine/index-records.mjs +126 -0
- package/engine/index.mjs +23 -5
- package/engine/item-compiler.mjs +349 -0
- package/engine/item-docs.mjs +7 -1
- package/engine/item-registry.mjs +6 -0
- package/engine/journals.mjs +93 -27
- package/engine/macros.mjs +4 -2
- package/engine/metadata-index.mjs +495 -0
- package/engine/note-claims.mjs +146 -25
- package/engine/note-ids.mjs +112 -0
- package/engine/note-renames.mjs +134 -0
- package/engine/note-schemas.mjs +25 -0
- package/engine/note-vocabulary.mjs +110 -27
- package/engine/pack-config.mjs +4 -0
- package/engine/pack-router.mjs +23 -0
- package/engine/retired-fields.mjs +137 -3
- package/engine/scenes.mjs +66 -34
- package/engine/schema-check.mjs +58 -17
- package/engine/site-build.mjs +71 -12
- package/engine/site-index.mjs +20 -7
- package/engine/sql-tables.mjs +485 -0
- package/engine/subtype-registry.mjs +102 -0
- package/engine/system-block.mjs +96 -12
- package/engine/systems.mjs +201 -0
- package/engine/web-wikilinks.mjs +50 -20
- package/engine/wikilink-syntax.mjs +17 -8
- package/engine/wikilinks.mjs +124 -51
- package/engine/yaml-lint.mjs +282 -0
- package/hm3/actors.mjs +352 -0
- package/hm3/default-item-art.mjs +75 -0
- package/hm3/document-subtypes.mjs +134 -0
- package/hm3/index.mjs +56 -0
- package/hm3/item-builders.mjs +84 -0
- package/hm3/item-fields.mjs +180 -0
- package/hm3/items.mjs +96 -0
- package/hm3/template-priority.mjs +85 -0
- package/labels.mjs +191 -0
- package/manifest.mjs +43 -2
- package/package.json +19 -4
- package/release.mjs +62 -7
- package/sohl/actors.mjs +33 -487
- package/sohl/being-info.mjs +16 -7
- package/sohl/default-item-art.mjs +14 -3
- package/sohl/document-subtypes.mjs +16 -10
- package/sohl/item-builders.mjs +14 -5
- package/sohl/item-fields.mjs +68 -7
- package/sohl/items.mjs +44 -258
- package/sohl/note-schemas.mjs +2 -2
- package/types/content-config.d.mts +21 -42
- package/types/engine/actor-compiler.d.mts +204 -0
- package/types/engine/address-charset.d.mts +11 -8
- package/types/engine/address-diff.d.mts +53 -5
- package/types/engine/anchored-sections.d.mts +21 -0
- package/types/engine/anchors.d.mts +20 -0
- package/types/engine/base-compiler.d.mts +17 -17
- package/types/engine/bundle-notes.d.mts +173 -0
- package/types/engine/bundles.d.mts +60 -0
- package/types/engine/code-fences.d.mts +43 -0
- package/types/engine/compile-corpus.d.mts +32 -0
- package/types/engine/content-address.d.mts +205 -5
- package/types/engine/content-format-check.d.mts +6 -2
- package/types/engine/content-format.d.mts +57 -1
- package/types/engine/content-index.d.mts +64 -80
- package/types/engine/content-links.d.mts +52 -7
- package/types/engine/content-lint.d.mts +10 -1
- package/types/engine/content-package.d.mts +2 -1
- package/types/engine/content-tables.d.mts +21 -39
- package/types/engine/document-subtypes.d.mts +37 -3
- package/types/engine/field-spec.d.mts +76 -5
- package/types/engine/folder-notes.d.mts +159 -0
- package/types/engine/foreign-catalog.d.mts +53 -0
- package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
- package/types/engine/frontmatter-lint.d.mts +10 -2
- package/types/engine/frontmatter.d.mts +64 -0
- package/types/engine/generate.d.mts +38 -0
- package/types/engine/helpers.d.mts +94 -30
- package/types/engine/ids.d.mts +96 -0
- package/types/engine/index-records.d.mts +68 -0
- package/types/engine/index.d.mts +9 -3
- package/types/engine/item-compiler.d.mts +131 -0
- package/types/engine/journals.d.mts +47 -9
- package/types/engine/metadata-index.d.mts +226 -0
- package/types/engine/note-claims.d.mts +57 -11
- package/types/engine/note-ids.d.mts +38 -0
- package/types/engine/note-renames.d.mts +102 -0
- package/types/engine/note-vocabulary.d.mts +44 -8
- package/types/engine/retired-fields.d.mts +75 -0
- package/types/engine/scenes.d.mts +3 -2
- package/types/engine/schema-check.d.mts +25 -4
- package/types/engine/site-build.d.mts +4 -4
- package/types/engine/site-index.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +185 -0
- package/types/engine/subtype-registry.d.mts +49 -0
- package/types/engine/system-block.d.mts +40 -1
- package/types/engine/systems.d.mts +106 -0
- package/types/engine/web-wikilinks.d.mts +4 -2
- package/types/engine/wikilink-syntax.d.mts +10 -3
- package/types/engine/wikilinks.d.mts +41 -13
- package/types/engine/yaml-lint.d.mts +107 -0
- package/types/hm3/actors.d.mts +48 -0
- package/types/hm3/default-item-art.d.mts +42 -0
- package/types/hm3/document-subtypes.d.mts +24 -0
- package/types/hm3/index.d.mts +7 -0
- package/types/hm3/item-builders.d.mts +11 -0
- package/types/hm3/item-fields.d.mts +12 -0
- package/types/hm3/items.d.mts +23 -0
- package/types/hm3/template-priority.d.mts +21 -0
- package/types/manifest.d.mts +22 -1
- package/types/release.d.mts +9 -4
- package/types/sohl/actors.d.mts +7 -74
- package/types/sohl/being-info.d.mts +8 -27
- package/types/sohl/default-item-art.d.mts +5 -3
- package/types/sohl/items.d.mts +17 -32
- package/engine/foreign-manifests.mjs +0 -126
- package/engine/kb-manifest.mjs +0 -490
- package/types/engine/foreign-manifests.d.mts +0 -43
- package/types/engine/kb-manifest.d.mts +0 -241
package/engine/wikilinks.mjs
CHANGED
|
@@ -86,6 +86,8 @@
|
|
|
86
86
|
import crypto from "crypto";
|
|
87
87
|
|
|
88
88
|
import { compendiumUuid, ITEM_PACK, packForType, pageUuid, PACK_BY_TYPE } from "./ids.mjs";
|
|
89
|
+
import { readCanonicalKey } from "./content-address.mjs";
|
|
90
|
+
import { isSystemSegment } from "./systems.mjs";
|
|
89
91
|
import { hasDocEntry, itemDocEntryId } from "./item-docs.mjs";
|
|
90
92
|
import { replaceOutsideCode } from "./code-fences.mjs";
|
|
91
93
|
// The syntax lives in `./wikilink-syntax.mjs`, so the web resolver and this
|
|
@@ -149,66 +151,104 @@ export function resolveItemDocType(qualifier, types) {
|
|
|
149
151
|
* Two separators are accepted, and they are **not** interchangeable in how
|
|
150
152
|
* confidently they mark a target as qualified:
|
|
151
153
|
*
|
|
152
|
-
* - **`type-shortcode`**
|
|
153
|
-
* a wikilink as a *path* and resolves it
|
|
154
|
-
* slash-qualified link is a broken link in
|
|
155
|
-
*
|
|
156
|
-
* note names contain hyphens too (`Grukar-ahk`), and a target that is one is
|
|
157
|
-
* reported as not an address rather than split at an arbitrary place. The
|
|
158
|
-
* split is at the **first** hyphen, so a shortcode may itself contain one
|
|
159
|
-
* (`trauma-self-pro` → `trauma` + `self-pro`).
|
|
154
|
+
* - **`type-shortcode`** and its qualified forms — the canonical spelling
|
|
155
|
+
* (#1398). Obsidian reads `/` inside a wikilink as a *path* and resolves it
|
|
156
|
+
* against the vault's folders, so a slash-qualified link is a broken link in
|
|
157
|
+
* the editor where the content is now authored.
|
|
160
158
|
* - **`type/shortcode`** — the legacy form, still resolved so that a link
|
|
161
159
|
* written before the vault migrated does not silently die. A slash is
|
|
162
160
|
* *unconditionally* a qualifier: nothing else uses one, so an unknown type
|
|
163
161
|
* before it is reported rather than guessed at. The split is at the **last**
|
|
164
162
|
* slash, as it always was.
|
|
165
163
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
164
|
+
* **The grammar is strict, and omission runs left to right** (#59):
|
|
165
|
+
*
|
|
166
|
+
* ```text
|
|
167
|
+
* [[[[<package>-]<system>-]<type>-]<shortcode>]
|
|
168
|
+
* ```
|
|
169
|
+
*
|
|
170
|
+
* So the written forms are exactly the suffixes of the canonical address —
|
|
171
|
+
* `type-shortcode`, `system-type-shortcode`, `package-system-type-shortcode` —
|
|
172
|
+
* and **`package-type-shortcode` is not one of them**. A link into another
|
|
173
|
+
* package must therefore be fully qualified, which is the price of the segment
|
|
174
|
+
* being positional rather than tagged.
|
|
175
|
+
*
|
|
176
|
+
* **Parsing is plain positional counting**, the same rule
|
|
177
|
+
* {@link readCanonicalKey} follows, and it is sound for the same reason: every
|
|
178
|
+
* segment is `^[A-Za-z0-9]+$` (`ADDRESS_SEGMENT_PATTERN`, enforced on
|
|
179
|
+
* shortcodes by `content-lint.mjs` since #1397), so the hyphen is purely a
|
|
180
|
+
* separator and the count alone determines every field. Verified across the
|
|
181
|
+
* four content trees: 138,204 authored shortcodes, none carrying a separator.
|
|
182
|
+
*
|
|
183
|
+
* That replaced a first-hyphen split which let a shortcode contain a hyphen
|
|
184
|
+
* (`trauma-self-pro` → `trauma` + `self-pro`). The tolerance predates #1397 and
|
|
185
|
+
* outlived it; no tree has used it, and keeping it would make a three-segment
|
|
186
|
+
* target ambiguous between a system and a hyphenated shortcode.
|
|
187
|
+
*
|
|
188
|
+
* **`sohl` is both a package and a system**, and positional counting is what
|
|
189
|
+
* makes that harmless: three segments is `<system>-<type>-<shortcode>` whatever
|
|
190
|
+
* the first segment could also have named, and four is the full form. Nothing
|
|
191
|
+
* has to guess which sense was meant.
|
|
192
|
+
*
|
|
193
|
+
* **A partial address states what it states, and the rest is not invented.** An
|
|
194
|
+
* omitted **system** is a *wildcard* — most links target items, which belong to
|
|
195
|
+
* a system — so the caller matches on the segments supplied and requires
|
|
196
|
+
* exactly one hit. An omitted **package** is instead *defaulted* to the citing
|
|
197
|
+
* note's own, so an unqualified link resolves locally and only locally.
|
|
170
198
|
*
|
|
171
199
|
* @param {string} target - The link target, anchor already removed.
|
|
172
200
|
* @param {Set<string>} types - Every type the content tree contains.
|
|
173
201
|
* @param {Set<string>} [packages] - Every package an address may name. Omitted
|
|
174
202
|
* by callers that resolve within one package, where the form cannot occur.
|
|
175
203
|
* @returns {{type: string, shortcode: string, itemDoc: boolean,
|
|
176
|
-
* package?: string,
|
|
204
|
+
* package?: string, system?: string, reason?: undefined}
|
|
205
|
+
* | {reason: "unknown-type"} | null}
|
|
177
206
|
* The resolved qualifier; a `reason` when the target is definitely qualified
|
|
178
207
|
* but names no known type; or `null` when it is not an address at all.
|
|
179
208
|
*/
|
|
180
209
|
export function readQualifier(target, types, packages) {
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// build knows *and* the remainder is itself a valid address — so a note
|
|
186
|
-
// named "Sohl-something" is not mistaken for one (#1499).
|
|
187
|
-
if (packages?.size) {
|
|
188
|
-
const hyphen = target.indexOf("-");
|
|
189
|
-
if (hyphen > 0) {
|
|
190
|
-
const pkg = norm(target.slice(0, hyphen));
|
|
191
|
-
if (packages.has(pkg)) {
|
|
192
|
-
const rest = readQualifier(target.slice(hyphen + 1), types);
|
|
193
|
-
if (rest && !rest.reason) return { ...rest, package: pkg };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
210
|
+
// The slash form is legacy and states neither package nor system, so it is
|
|
211
|
+
// read first and separately. A slash is unconditionally a qualifier —
|
|
212
|
+
// nothing else uses one — which is why an unknown type before it is
|
|
213
|
+
// *reported* rather than read as prose.
|
|
198
214
|
const slash = target.lastIndexOf("/");
|
|
199
215
|
if (slash > 0) {
|
|
200
216
|
const read = readTypeAndCode(target.slice(0, slash), target.slice(slash + 1), types);
|
|
201
|
-
// A slash means qualified whether or not the type is real.
|
|
202
217
|
return read ?? { reason: "unknown-type" };
|
|
203
218
|
}
|
|
204
219
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
const parts = target.split("-");
|
|
221
|
+
switch (parts.length) {
|
|
222
|
+
// `<type>-<shortcode>`
|
|
223
|
+
case 2:
|
|
224
|
+
return readTypeAndCode(parts[0], parts[1], types);
|
|
225
|
+
|
|
226
|
+
// `<system>-<type>-<shortcode>` — the package defaults to local.
|
|
227
|
+
case 3: {
|
|
228
|
+
const system = norm(parts[0]);
|
|
229
|
+
if (!isSystemSegment(system)) return null;
|
|
230
|
+
const read = readTypeAndCode(parts[1], parts[2], types);
|
|
231
|
+
return read && { ...read, system };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// `<package>-<system>-<type>-<shortcode>` — the only form that names
|
|
235
|
+
// another package, and the reason a cross-package link must be fully
|
|
236
|
+
// qualified.
|
|
237
|
+
case 4: {
|
|
238
|
+
const pkg = norm(parts[0]);
|
|
239
|
+
if (!packages?.has(pkg)) return null;
|
|
240
|
+
const system = norm(parts[1]);
|
|
241
|
+
if (!isSystemSegment(system)) return null;
|
|
242
|
+
const read = readTypeAndCode(parts[2], parts[3], types);
|
|
243
|
+
return read && { ...read, system, package: pkg };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// One segment is a bare name, which is not an address (#180); five or
|
|
247
|
+
// more is not a hyphenated shortcode but a name that happens to carry
|
|
248
|
+
// separators, since no segment may contain one.
|
|
249
|
+
default:
|
|
250
|
+
return null;
|
|
210
251
|
}
|
|
211
|
-
return null;
|
|
212
252
|
}
|
|
213
253
|
|
|
214
254
|
/**
|
|
@@ -363,12 +403,14 @@ export function buildWikilinkIndex(docs, packageId, foreign, contentPackage) {
|
|
|
363
403
|
/**
|
|
364
404
|
* The foreign manifest entry an address names, or `null`.
|
|
365
405
|
*
|
|
366
|
-
* A
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
406
|
+
* A target is a **partial** address, matched on the segments it supplies with
|
|
407
|
+
* the rest wildcarded (#59) — so a package-qualified one is a scan rather than
|
|
408
|
+
* a single lookup, and may still match one entry per system. An unqualified one
|
|
409
|
+
* names no package either, so it resolves against whichever foreign package
|
|
410
|
+
* publishes it — and, in both readings, only when exactly one entry matches.
|
|
411
|
+
* Claimed by two, it is genuinely ambiguous and the author writes the qualified
|
|
412
|
+
* form; guessing would make the build depend on which manifest happened to load
|
|
413
|
+
* first.
|
|
372
414
|
*
|
|
373
415
|
* @param {object} index - From {@link buildWikilinkIndex}.
|
|
374
416
|
* @param {object|null} read - The parsed qualifier, or `null` when the target
|
|
@@ -397,15 +439,28 @@ function foreignHits(index, read) {
|
|
|
397
439
|
if (!read || read.reason || !index.foreign?.size) return [];
|
|
398
440
|
const wanted = norm(read.itemDoc ? `doc${read.type}` : read.type);
|
|
399
441
|
const shortcode = norm(read.shortcode);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
442
|
+
|
|
443
|
+
// One filter for every reading, because an address is matched **by the
|
|
444
|
+
// segments it supplies** and wildcarded on the ones it does not (#59). A
|
|
445
|
+
// package-qualified target used to take a separate exact-`get` path, which
|
|
446
|
+
// is what let this function carry its own copy of the key grammar — a
|
|
447
|
+
// hand-built `${package}-${type}-${shortcode}` and a literal segment count
|
|
448
|
+
// — and drift from `readCanonicalKey` the moment the grammar gained a
|
|
449
|
+
// system segment. There is one reader now.
|
|
450
|
+
//
|
|
451
|
+
// The system is a wildcard unless the target states one: most authored
|
|
452
|
+
// links name none, and defaulting it to `none` would exclude every link to
|
|
453
|
+
// an item, which is the majority — 1,632 of `sohl`'s own resolve into the
|
|
454
|
+
// items pack. Ambiguity is caught by the caller's single-hit rule rather
|
|
455
|
+
// than pre-empted by a guess here.
|
|
404
456
|
const hits = [];
|
|
405
|
-
for (const [key,
|
|
406
|
-
const parts = key
|
|
407
|
-
if (parts
|
|
408
|
-
if (
|
|
457
|
+
for (const [key, entry] of index.foreign) {
|
|
458
|
+
const parts = readCanonicalKey(key);
|
|
459
|
+
if (!parts) continue;
|
|
460
|
+
if (read.package && parts.package !== norm(read.package)) continue;
|
|
461
|
+
if (read.system && parts.system !== norm(read.system)) continue;
|
|
462
|
+
if (parts.type !== wanted || parts.shortcode !== shortcode) continue;
|
|
463
|
+
hits.push(entry);
|
|
409
464
|
}
|
|
410
465
|
return hits;
|
|
411
466
|
}
|
|
@@ -640,6 +695,24 @@ export function convertWikilinks(markdown, { type, id, pack, docPack, index }) {
|
|
|
640
695
|
// and is dropped. Forging a JournalEntryPage id onto a document that
|
|
641
696
|
// can never hold one is what made such links dead-end (#1362); an
|
|
642
697
|
// item's pages are addressed through its `doc<type>` counterpart.
|
|
698
|
+
// A `#section` the target declares no heading for. Checked here, and
|
|
699
|
+
// not only by `content-build links`, because this is the build that
|
|
700
|
+
// *emits* the link: `anchorPageId` will hash any slug into a page id,
|
|
701
|
+
// so an undeclared one compiles to a `@UUID` that dead-ends for the
|
|
702
|
+
// reader (#193). A foreign anchor has always been checked this way —
|
|
703
|
+
// the manifest carries the map — and a local one now is too, from the
|
|
704
|
+
// anchor set the index carries.
|
|
705
|
+
if (slug && isJournal && doc.anchors && !doc.anchors.has(slug)) {
|
|
706
|
+
unresolved.push({
|
|
707
|
+
link: all,
|
|
708
|
+
target,
|
|
709
|
+
offset,
|
|
710
|
+
reason: "unknown-anchor",
|
|
711
|
+
anchor: slug,
|
|
712
|
+
addressed: true,
|
|
713
|
+
});
|
|
714
|
+
return unresolvedLink(text || doc.name || target, target);
|
|
715
|
+
}
|
|
643
716
|
const uuid =
|
|
644
717
|
slug && isJournal ? pageUuid(entryUuid, anchorPageId(entryId, slug)) : entryUuid;
|
|
645
718
|
const link = `@UUID[${uuid}]{${text}}`;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
|
|
3
|
+
* Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
|
|
4
|
+
*
|
|
5
|
+
* This work is licensed under the GNU General Public License v3.0 (GPLv3).
|
|
6
|
+
* You may copy, modify, and distribute it under the terms of that license.
|
|
7
|
+
*
|
|
8
|
+
* For full terms, see the LICENSE.md file in the project root or visit:
|
|
9
|
+
* https://www.gnu.org/licenses/gpl-3.0.html
|
|
10
|
+
*
|
|
11
|
+
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Lint YAML — a note's frontmatter, and every YAML file in the repository.
|
|
16
|
+
*
|
|
17
|
+
* Frontmatter is load-bearing here in a way ordinary prose is not: it carries a
|
|
18
|
+
* note's type, its shortcode, its address and the system blocks a document is
|
|
19
|
+
* compiled from. Until this existed nothing checked it *as YAML*. The parse
|
|
20
|
+
* happened in {@link module:engine/helpers.parseMarkdownFile}, and a failure was
|
|
21
|
+
* caught, logged at `warn`, and turned into `{frontmatter: null}` — so a note
|
|
22
|
+
* with a duplicate key did not fail a build, it quietly stopped being a note.
|
|
23
|
+
* Every pass downstream then saw a file with no frontmatter and skipped it, and
|
|
24
|
+
* the build reported success.
|
|
25
|
+
*
|
|
26
|
+
* @module
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
|
|
33
|
+
/** File extensions that carry YAML, either wholly or in a frontmatter fence. */
|
|
34
|
+
export const YAML_GLOBS = Object.freeze(["*.md", "*.yaml", "*.yml"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Directories skipped whatever git says, because git does not know about them.
|
|
38
|
+
*
|
|
39
|
+
* `.git` is in nobody's `.gitignore`, and a worktree under `.claude/` is a
|
|
40
|
+
* checkout of this same repository — linting it reports every finding once per
|
|
41
|
+
* worktree, against paths the author cannot edit from here.
|
|
42
|
+
*/
|
|
43
|
+
const ALWAYS_SKIP = Object.freeze([".git/", ".claude/"]);
|
|
44
|
+
|
|
45
|
+
/** A markdown file's leading frontmatter fence. Mirrors `parseMarkdownFile`. */
|
|
46
|
+
const FENCE = /^---\n([\s\S]*?)\n---/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Present a markdown file to ESLint as the YAML it begins with.
|
|
50
|
+
*
|
|
51
|
+
* An ESLint *processor* carves virtual files out of a container file — the
|
|
52
|
+
* mechanism `eslint-plugin-markdown` uses for fenced code blocks. Frontmatter is
|
|
53
|
+
* the easy case of it: the block is always at the top of the file, so mapping a
|
|
54
|
+
* finding back to the file it came from is a constant `+1` for the opening
|
|
55
|
+
* `---`, with no offset table to keep.
|
|
56
|
+
*
|
|
57
|
+
* The virtual file is named `0.yaml` so the flat config's `**` + `*.yaml`
|
|
58
|
+
* patterns select it; ESLint addresses it as `<the note>.md/0.yaml`.
|
|
59
|
+
*
|
|
60
|
+
* @type {{meta: object, supportsAutofix: boolean,
|
|
61
|
+
* preprocess: (text: string) => Array<{text: string, filename: string}>,
|
|
62
|
+
* postprocess: (messages: object[][]) => object[]}}
|
|
63
|
+
*/
|
|
64
|
+
export const frontmatterProcessor = {
|
|
65
|
+
meta: { name: "package-build/frontmatter", version: "1" },
|
|
66
|
+
// Every rule applied here is a correctness rule with no fix, and a fix
|
|
67
|
+
// written back through the processor would have to be re-offset into the
|
|
68
|
+
// container file. Nothing is gained by claiming support for it.
|
|
69
|
+
supportsAutofix: false,
|
|
70
|
+
preprocess(text) {
|
|
71
|
+
const fence = FENCE.exec(text);
|
|
72
|
+
return fence ? [{ text: fence[1], filename: "0.yaml" }] : [];
|
|
73
|
+
},
|
|
74
|
+
postprocess(messages) {
|
|
75
|
+
return (messages[0] ?? []).map((message) => ({
|
|
76
|
+
...message,
|
|
77
|
+
line: message.line + 1,
|
|
78
|
+
...(message.endLine == null ? {} : { endLine: message.endLine + 1 }),
|
|
79
|
+
}));
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The shared rule set, as an ESLint flat configuration.
|
|
85
|
+
*
|
|
86
|
+
* **Deliberately narrow, for the same reason the markdown and stylesheet rule
|
|
87
|
+
* sets are** — Prettier already owns YAML's whitespace, quoting and line
|
|
88
|
+
* breaks, including inside a frontmatter fence, so a rule about any of those
|
|
89
|
+
* would either duplicate the formatter or fight it. What is left is the class
|
|
90
|
+
* the formatter cannot see: text that parses to something other than what it
|
|
91
|
+
* looks like.
|
|
92
|
+
*
|
|
93
|
+
* - **A parse error is reported, not swallowed.** A duplicate key, a tab used
|
|
94
|
+
* as indentation, a mapping whose items start at different columns: the YAML
|
|
95
|
+
* parser detects all three and `parseMarkdownFile` discards all three.
|
|
96
|
+
* - `no-empty-mapping-value` — `folder:` and `folder: null` parse identically
|
|
97
|
+
* and read as opposites: one is a decision, the other is a key somebody began
|
|
98
|
+
* and did not finish. YAML cannot tell them apart, so the distinction has to
|
|
99
|
+
* be made where the text still exists. A key with a block under it is not
|
|
100
|
+
* empty, which is what separates 65 unfinished keys in `sohl-thalorna` from
|
|
101
|
+
* the tens of thousands that are ordinary containers.
|
|
102
|
+
* - `no-irregular-whitespace` — a non-breaking space in a key or an unquoted
|
|
103
|
+
* scalar is invisible in every editor and changes the value.
|
|
104
|
+
* - `no-empty-key`, `no-empty-document` — a fence or a file that parses to
|
|
105
|
+
* nothing at all.
|
|
106
|
+
*
|
|
107
|
+
* **GitHub workflows are exempt from `no-empty-mapping-value`**, because an
|
|
108
|
+
* empty value is the language there: `on:` `push:` and `workflow_dispatch:`
|
|
109
|
+
* carry their meaning by being present, and writing `push: null` to satisfy a
|
|
110
|
+
* linter would be worse YAML, not better.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} plugin - The `eslint-plugin-yml` module.
|
|
113
|
+
* @returns {object[]} A complete flat config, for `overrideConfig`.
|
|
114
|
+
*/
|
|
115
|
+
export function yamlLintConfig(plugin) {
|
|
116
|
+
return [
|
|
117
|
+
...plugin.configs["flat/recommended"],
|
|
118
|
+
{ files: ["**/*.md"], processor: frontmatterProcessor },
|
|
119
|
+
{
|
|
120
|
+
files: ["**/*.yaml", "**/*.yml", "**/*.md/*.yaml"],
|
|
121
|
+
rules: {
|
|
122
|
+
"yml/no-empty-mapping-value": "error",
|
|
123
|
+
"yml/no-irregular-whitespace": "error",
|
|
124
|
+
"yml/no-empty-key": "error",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
files: [
|
|
129
|
+
"**/.github/workflows/*.yml",
|
|
130
|
+
"**/.github/workflows/*.yaml",
|
|
131
|
+
"**/action.yml",
|
|
132
|
+
"**/action.yaml",
|
|
133
|
+
],
|
|
134
|
+
rules: { "yml/no-empty-mapping-value": "off" },
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The files to lint: every YAML file git would consider, and no other.
|
|
141
|
+
*
|
|
142
|
+
* `--cached --others --exclude-standard` is tracked files plus untracked ones
|
|
143
|
+
* that are not ignored, which is the same set `gitignore: true` gives the
|
|
144
|
+
* markdown linter — and it matters more than it sounds. Asking ESLint for
|
|
145
|
+
* `**\/*.md` in `Song-of-Heroic-Lands-FoundryVTT` offers it 60,792 files, almost
|
|
146
|
+
* all of them inside `nogit/` and `.claude/worktrees/`; this offers 1,888, in
|
|
147
|
+
* 20ms. Untracked-but-not-ignored is included so a note gets linted while it is
|
|
148
|
+
* being written, not only once it has been staged.
|
|
149
|
+
*
|
|
150
|
+
* `-z` because git otherwise quotes any path outside ASCII, and these trees are
|
|
151
|
+
* full of them.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} root - Repository root.
|
|
154
|
+
* @param {readonly string[]} [globs] - Path globs, defaulting to
|
|
155
|
+
* {@link YAML_GLOBS}.
|
|
156
|
+
* @returns {string[]} Repository-relative paths.
|
|
157
|
+
*/
|
|
158
|
+
export function candidateFiles(root, globs = YAML_GLOBS) {
|
|
159
|
+
let listed;
|
|
160
|
+
try {
|
|
161
|
+
listed = execFileSync(
|
|
162
|
+
"git",
|
|
163
|
+
["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", ...globs],
|
|
164
|
+
{
|
|
165
|
+
cwd: root,
|
|
166
|
+
encoding: "utf8",
|
|
167
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
168
|
+
// Not a repository is an ordinary outcome here, not a failure
|
|
169
|
+
// worth printing: it falls back to the walk below.
|
|
170
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
} catch {
|
|
174
|
+
// Not a git repository, or git is unavailable: fall back to a walk, so
|
|
175
|
+
// the check still runs somewhere a consumer has unpacked a tarball.
|
|
176
|
+
return walk(root, globs);
|
|
177
|
+
}
|
|
178
|
+
return listed
|
|
179
|
+
.split("\0")
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.filter(
|
|
182
|
+
(file) =>
|
|
183
|
+
!ALWAYS_SKIP.some((skip) => file.startsWith(skip) || file.includes(`/${skip}`)),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Enumerate YAML files without git.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} root - Directory to walk.
|
|
191
|
+
* @param {readonly string[]} globs - Path globs, matched on extension only.
|
|
192
|
+
* @returns {string[]} Repository-relative paths.
|
|
193
|
+
*/
|
|
194
|
+
function walk(root, globs) {
|
|
195
|
+
const extensions = new Set(globs.map((glob) => path.extname(glob)));
|
|
196
|
+
/** @type {string[]} */
|
|
197
|
+
const found = [];
|
|
198
|
+
/** @param {string} dir - Directory to descend. */
|
|
199
|
+
const descend = (dir) => {
|
|
200
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
201
|
+
const full = path.join(dir, entry.name);
|
|
202
|
+
if (entry.isDirectory()) {
|
|
203
|
+
if (entry.name === "node_modules" || entry.name === "build") continue;
|
|
204
|
+
if (ALWAYS_SKIP.includes(`${entry.name}/`)) continue;
|
|
205
|
+
descend(full);
|
|
206
|
+
} else if (extensions.has(path.extname(entry.name))) {
|
|
207
|
+
found.push(path.relative(root, full));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
descend(root);
|
|
212
|
+
return found;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Lint a repository's YAML against the shared rule set.
|
|
217
|
+
*
|
|
218
|
+
* ESLint is run through its Node API with `overrideConfigFile: true`, which
|
|
219
|
+
* stops it looking for an `eslint.config.js` at all. That is what lets this ship
|
|
220
|
+
* as a command rather than as a configuration a consumer has to adopt: a
|
|
221
|
+
* repository needs no ESLint of its own, and one that *has* an ESLint — as
|
|
222
|
+
* `Song-of-Heroic-Lands-FoundryVTT` does, for `src/` — keeps it untouched and
|
|
223
|
+
* unconsulted.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} root - Repository to lint.
|
|
226
|
+
* @param {object} [opts]
|
|
227
|
+
* @param {readonly string[]} [opts.paths] - Globs to lint instead of every
|
|
228
|
+
* YAML file git would consider.
|
|
229
|
+
* @param {object} [opts.plugin] - The `eslint-plugin-yml` module, for tests.
|
|
230
|
+
* @param {Function} [opts.ESLint] - The `ESLint` class, for tests.
|
|
231
|
+
* @returns {Promise<{findings: object[], checked: number}>} The findings, and
|
|
232
|
+
* how many files were linted.
|
|
233
|
+
*/
|
|
234
|
+
export async function lintYaml(root, opts = {}) {
|
|
235
|
+
const directory = path.resolve(root);
|
|
236
|
+
const plugin = opts.plugin ?? (await import("eslint-plugin-yml")).default;
|
|
237
|
+
const ESLint = opts.ESLint ?? (await import("eslint")).ESLint;
|
|
238
|
+
|
|
239
|
+
const files = opts.paths?.length ? [...opts.paths] : candidateFiles(directory);
|
|
240
|
+
if (!files.length) return { findings: [], checked: 0 };
|
|
241
|
+
|
|
242
|
+
const eslint = new ESLint({
|
|
243
|
+
cwd: directory,
|
|
244
|
+
overrideConfigFile: true,
|
|
245
|
+
overrideConfig: yamlLintConfig(plugin),
|
|
246
|
+
errorOnUnmatchedPattern: false,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const results = await eslint.lintFiles(files);
|
|
250
|
+
/** @type {object[]} */
|
|
251
|
+
const findings = [];
|
|
252
|
+
for (const result of results) {
|
|
253
|
+
for (const message of result.messages)
|
|
254
|
+
findings.push(toDiagnostic(directory, result, message));
|
|
255
|
+
}
|
|
256
|
+
return { findings, checked: results.length };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One ESLint message as a diagnostic.
|
|
261
|
+
*
|
|
262
|
+
* A parse error carries no `ruleId` and reports column 0, which is not a
|
|
263
|
+
* position any compiler-parseable format admits — columns are 1-based. It is
|
|
264
|
+
* clamped rather than dropped, because the line is right and the line is what
|
|
265
|
+
* the author needs.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} directory - The root `filePath` is resolved against.
|
|
268
|
+
* @param {object} result - An ESLint `LintResult`.
|
|
269
|
+
* @param {object} message - One of its messages.
|
|
270
|
+
* @returns {{file: string, line: number, column: number, severity: string,
|
|
271
|
+
* message: string}} The diagnostic.
|
|
272
|
+
*/
|
|
273
|
+
function toDiagnostic(directory, result, message) {
|
|
274
|
+
const rule = message.ruleId ? `${message.ruleId} ` : "";
|
|
275
|
+
return {
|
|
276
|
+
file: path.resolve(directory, result.filePath),
|
|
277
|
+
line: message.line,
|
|
278
|
+
column: Math.max(1, message.column ?? 1),
|
|
279
|
+
severity: message.severity === 1 ? "warning" : "error",
|
|
280
|
+
message: `${rule}${message.message}`,
|
|
281
|
+
};
|
|
282
|
+
}
|