@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.
Files changed (153) hide show
  1. package/CHANGELOG.md +1773 -0
  2. package/CONTENT.md +288 -72
  3. package/README.md +1 -1
  4. package/bin/content-build.mjs +218 -137
  5. package/bin/package-build.mjs +43 -0
  6. package/content-config.mjs +62 -67
  7. package/docs/content-format.md +768 -58
  8. package/engine/actor-compiler.mjs +586 -0
  9. package/engine/address-charset.mjs +11 -8
  10. package/engine/address-diff.mjs +266 -22
  11. package/engine/anchored-sections.mjs +83 -0
  12. package/engine/anchors.mjs +83 -0
  13. package/engine/base-compiler.mjs +148 -18
  14. package/engine/bundle-notes.mjs +276 -0
  15. package/engine/bundles.mjs +307 -0
  16. package/engine/code-fences.mjs +103 -0
  17. package/engine/compile-corpus.mjs +89 -0
  18. package/engine/content-address.mjs +267 -5
  19. package/engine/content-format-check.mjs +54 -11
  20. package/engine/content-format.mjs +37 -5
  21. package/engine/content-index.mjs +394 -111
  22. package/engine/content-links.mjs +185 -77
  23. package/engine/content-lint.mjs +232 -14
  24. package/engine/content-package.mjs +2 -1
  25. package/engine/content-tables.mjs +99 -3
  26. package/engine/document-subtypes.mjs +49 -6
  27. package/engine/field-reference.mjs +1 -1
  28. package/engine/field-spec.mjs +56 -6
  29. package/engine/folder-notes.mjs +470 -0
  30. package/engine/foreign-catalog.mjs +195 -4
  31. package/engine/{manifest-emit.mjs → foundry-entries.mjs} +78 -81
  32. package/engine/frontmatter-lint.mjs +245 -19
  33. package/engine/frontmatter.mjs +88 -8
  34. package/engine/generate.mjs +304 -13
  35. package/engine/helpers.mjs +293 -64
  36. package/engine/ids.mjs +137 -2
  37. package/engine/index-records.mjs +126 -0
  38. package/engine/index.mjs +23 -5
  39. package/engine/item-compiler.mjs +349 -0
  40. package/engine/item-docs.mjs +7 -1
  41. package/engine/item-registry.mjs +6 -0
  42. package/engine/journals.mjs +93 -27
  43. package/engine/macros.mjs +4 -2
  44. package/engine/metadata-index.mjs +495 -0
  45. package/engine/note-claims.mjs +146 -25
  46. package/engine/note-ids.mjs +112 -0
  47. package/engine/note-renames.mjs +134 -0
  48. package/engine/note-schemas.mjs +25 -0
  49. package/engine/note-vocabulary.mjs +110 -27
  50. package/engine/pack-config.mjs +4 -0
  51. package/engine/pack-router.mjs +23 -0
  52. package/engine/retired-fields.mjs +137 -3
  53. package/engine/scenes.mjs +66 -34
  54. package/engine/schema-check.mjs +58 -17
  55. package/engine/site-build.mjs +71 -12
  56. package/engine/site-index.mjs +20 -7
  57. package/engine/sql-tables.mjs +485 -0
  58. package/engine/subtype-registry.mjs +102 -0
  59. package/engine/system-block.mjs +96 -12
  60. package/engine/systems.mjs +201 -0
  61. package/engine/web-wikilinks.mjs +50 -20
  62. package/engine/wikilink-syntax.mjs +17 -8
  63. package/engine/wikilinks.mjs +124 -51
  64. package/engine/yaml-lint.mjs +282 -0
  65. package/hm3/actors.mjs +352 -0
  66. package/hm3/default-item-art.mjs +75 -0
  67. package/hm3/document-subtypes.mjs +134 -0
  68. package/hm3/index.mjs +56 -0
  69. package/hm3/item-builders.mjs +84 -0
  70. package/hm3/item-fields.mjs +180 -0
  71. package/hm3/items.mjs +96 -0
  72. package/hm3/template-priority.mjs +85 -0
  73. package/labels.mjs +191 -0
  74. package/manifest.mjs +43 -2
  75. package/package.json +19 -4
  76. package/release.mjs +62 -7
  77. package/sohl/actors.mjs +33 -487
  78. package/sohl/being-info.mjs +16 -7
  79. package/sohl/default-item-art.mjs +14 -3
  80. package/sohl/document-subtypes.mjs +16 -10
  81. package/sohl/item-builders.mjs +14 -5
  82. package/sohl/item-fields.mjs +68 -7
  83. package/sohl/items.mjs +44 -258
  84. package/sohl/note-schemas.mjs +2 -2
  85. package/types/content-config.d.mts +21 -42
  86. package/types/engine/actor-compiler.d.mts +204 -0
  87. package/types/engine/address-charset.d.mts +11 -8
  88. package/types/engine/address-diff.d.mts +53 -5
  89. package/types/engine/anchored-sections.d.mts +21 -0
  90. package/types/engine/anchors.d.mts +20 -0
  91. package/types/engine/base-compiler.d.mts +17 -17
  92. package/types/engine/bundle-notes.d.mts +173 -0
  93. package/types/engine/bundles.d.mts +60 -0
  94. package/types/engine/code-fences.d.mts +43 -0
  95. package/types/engine/compile-corpus.d.mts +32 -0
  96. package/types/engine/content-address.d.mts +205 -5
  97. package/types/engine/content-format-check.d.mts +6 -2
  98. package/types/engine/content-format.d.mts +57 -1
  99. package/types/engine/content-index.d.mts +64 -80
  100. package/types/engine/content-links.d.mts +52 -7
  101. package/types/engine/content-lint.d.mts +10 -1
  102. package/types/engine/content-package.d.mts +2 -1
  103. package/types/engine/content-tables.d.mts +21 -39
  104. package/types/engine/document-subtypes.d.mts +37 -3
  105. package/types/engine/field-spec.d.mts +76 -5
  106. package/types/engine/folder-notes.d.mts +159 -0
  107. package/types/engine/foreign-catalog.d.mts +53 -0
  108. package/types/engine/{manifest-emit.d.mts → foundry-entries.d.mts} +16 -38
  109. package/types/engine/frontmatter-lint.d.mts +10 -2
  110. package/types/engine/frontmatter.d.mts +64 -0
  111. package/types/engine/generate.d.mts +38 -0
  112. package/types/engine/helpers.d.mts +94 -30
  113. package/types/engine/ids.d.mts +96 -0
  114. package/types/engine/index-records.d.mts +68 -0
  115. package/types/engine/index.d.mts +9 -3
  116. package/types/engine/item-compiler.d.mts +131 -0
  117. package/types/engine/journals.d.mts +47 -9
  118. package/types/engine/metadata-index.d.mts +226 -0
  119. package/types/engine/note-claims.d.mts +57 -11
  120. package/types/engine/note-ids.d.mts +38 -0
  121. package/types/engine/note-renames.d.mts +102 -0
  122. package/types/engine/note-vocabulary.d.mts +44 -8
  123. package/types/engine/retired-fields.d.mts +75 -0
  124. package/types/engine/scenes.d.mts +3 -2
  125. package/types/engine/schema-check.d.mts +25 -4
  126. package/types/engine/site-build.d.mts +4 -4
  127. package/types/engine/site-index.d.mts +1 -1
  128. package/types/engine/sql-tables.d.mts +185 -0
  129. package/types/engine/subtype-registry.d.mts +49 -0
  130. package/types/engine/system-block.d.mts +40 -1
  131. package/types/engine/systems.d.mts +106 -0
  132. package/types/engine/web-wikilinks.d.mts +4 -2
  133. package/types/engine/wikilink-syntax.d.mts +10 -3
  134. package/types/engine/wikilinks.d.mts +41 -13
  135. package/types/engine/yaml-lint.d.mts +107 -0
  136. package/types/hm3/actors.d.mts +48 -0
  137. package/types/hm3/default-item-art.d.mts +42 -0
  138. package/types/hm3/document-subtypes.d.mts +24 -0
  139. package/types/hm3/index.d.mts +7 -0
  140. package/types/hm3/item-builders.d.mts +11 -0
  141. package/types/hm3/item-fields.d.mts +12 -0
  142. package/types/hm3/items.d.mts +23 -0
  143. package/types/hm3/template-priority.d.mts +21 -0
  144. package/types/manifest.d.mts +22 -1
  145. package/types/release.d.mts +9 -4
  146. package/types/sohl/actors.d.mts +7 -74
  147. package/types/sohl/being-info.d.mts +8 -27
  148. package/types/sohl/default-item-art.d.mts +5 -3
  149. package/types/sohl/items.d.mts +17 -32
  150. package/engine/foreign-manifests.mjs +0 -126
  151. package/engine/kb-manifest.mjs +0 -490
  152. package/types/engine/foreign-manifests.d.mts +0 -43
  153. package/types/engine/kb-manifest.d.mts +0 -241
@@ -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`** the canonical form (#1398). Obsidian reads `/` inside
153
- * a wikilink as a *path* and resolves it against the vault's folders, so a
154
- * slash-qualified link is a broken link in the editor where the content is now
155
- * authored. A hyphen qualifies **only when what precedes it is a known type**:
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
- * A leading **package** segment is optional and outermost: `sohl-skill-lang` is
167
- * `skill-lang` in the `sohl` package. It is read only when `packages` is given
168
- * and names the segment, and only when the remainder is itself a valid address,
169
- * so a note called "Grukar-ahk" is not mistaken for one (#1499).
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, reason?: undefined} | {reason: "unknown-type"} | null}
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
- // A leading **package** segment is the optional outermost qualifier:
182
- // `sohl-skill-lang` is `skill-lang` in the `sohl` package. It is stripped
183
- // here so everything below reads the same `type`/`shortcode` it always did,
184
- // and it is recognised only when what precedes the hyphen is a package this
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 hyphen = target.indexOf("-");
206
- if (hyphen > 0) {
207
- // A hyphen qualifies only on a known type; otherwise it is part of a
208
- // name, and a name is not an address.
209
- return readTypeAndCode(target.slice(0, hyphen), target.slice(hyphen + 1), types);
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 package-qualified address is one lookup. An unqualified one names no
367
- * package, so it
368
- * resolves against whichever foreign package publishes it and only when
369
- * exactly one does. Claimed by two, it is genuinely ambiguous and the author
370
- * writes the qualified form; guessing would make the build depend on which
371
- * manifest happened to load first.
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
- if (read.package) {
401
- const one = index.foreign.get(`${read.package}-${wanted}-${shortcode}`.toLowerCase());
402
- return one ? [one] : [];
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, v] of index.foreign) {
406
- const parts = key.split("-");
407
- if (parts.length !== 3) continue;
408
- if (parts[1] === wanted && parts[2] === shortcode) hits.push(v);
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
+ }