@heroiclands/package-build 17.0.0 → 17.2.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 CHANGED
@@ -1,5 +1,104 @@
1
1
  # @heroiclands/package-build
2
2
 
3
+ ## 17.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ce40274: **The content index now carries every address the link manifest does**, which
8
+ is the substance of folding the two artifacts into one (#239).
9
+
10
+ Each record gains a `foundry` block — `{ uuid, anchors }` — and **an item note
11
+ now emits two records**: the item, and its documentation journal. The journal is
12
+ a document in its own right, with its own canonical address
13
+ (`doc<type>/<shortcode>`), its own UUID and its own pages, so it gets its own
14
+ record rather than being nested inside the item's. Resolving
15
+ `docaffliction/blkdth` is then the same lookup as resolving anything else,
16
+ instead of the one address in the index reachable only by knowing to look
17
+ somewhere else.
18
+
19
+ The two are linked in both directions: the item carries `documentation`, the
20
+ journal carries `documents`.
21
+
22
+ **The journal's record is lean, and deliberately not the note's frontmatter.**
23
+ The item's `sohl:` block describes the item; copying it onto the journal would
24
+ assert things about the journal that are not true, and double the file to do it.
25
+ The journal carries its addresses, its name, its anchors and the file it came
26
+ from.
27
+
28
+ **Derived by the manifest's own code, not a second implementation.** A UUID is a
29
+ function of the note's `type`, its authored `id` and the pack router —
30
+ frontmatter and configuration, nothing from a compiled pack — so the index's
31
+ existing walk already had every input. Verified against `sohl`: **2,988 UUIDs
32
+ and 1,510 anchor maps, matching the manifest exactly, with none missing on
33
+ either side.**
34
+
35
+ Two smaller changes fall out of it:
36
+
37
+ - `emitContentIndex` reports `notes` and `records` separately, because an item
38
+ note is one note and two records and reporting one as the other overstates the
39
+ tree. Records sort by path, then canonical address, then id — the address
40
+ before the id, because an item's two records share a file and only one carries
41
+ an id.
42
+ - `entriesForNote` takes `docEntryTypes` from its context instead of reading the
43
+ ambient configuration. It is what `manifestContext` already promised — "the
44
+ pass itself is a pure function of its context" — and it was not true: the
45
+ emitter consulted whichever configuration `loadPackConfig()` found, not the one
46
+ it was handed. A fixture that never declared the type it asserted was passing
47
+ on that leak.
48
+
49
+ ### Patch Changes
50
+
51
+ - 6a3d083: **`package-build labels check` crashed for every consumer in 17.1.0.**
52
+ `labels.mjs` was added and not listed in `package.json` `files`, which is an
53
+ explicit whitelist, so the module never reached the tarball and the command
54
+ threw `ERR_MODULE_NOT_FOUND` on the import the release notes had just
55
+ announced.
56
+
57
+ Adds the module to `files`, and a guard so the class cannot recur: a new test
58
+ reads the root-relative imports out of `bin/` and requires each one to be
59
+ published, by name or by a containing directory entry. Verified by removing
60
+ `labels.mjs` from `files` again and watching it fail.
61
+
62
+ The failure could only appear in a consumer, after publish — locally the file
63
+ is simply there, so every check passed.
64
+
65
+ ## 17.1.0
66
+
67
+ ### Minor Changes
68
+
69
+ - 9d6a52f: **New: `package-build labels check`.** The issue-label registry has two faces
70
+ that must agree — `.github/labels.yml`, which is synced to GitHub, and the §3
71
+ table in `.github/ISSUE_REPORTING.md`, which is what a person reads. Neither
72
+ derives from the other, so either can drift, and nothing notices until an issue
73
+ is filed against a label that does not exist or the sync pushes one the
74
+ documentation never mentions.
75
+
76
+ It arrives here because **every repository wants it and only the paths ever
77
+ differed** — it was a `utils/check-labels.mjs` copied per repository, which is
78
+ the shape a shared check takes just before its copies start disagreeing. Same
79
+ argument that moved the no-attribution check to a shared action.
80
+
81
+ Two things improve in the move:
82
+
83
+ - **Findings are compiler-parseable and located.** The original printed prose
84
+ to stderr; this reports `.github/labels.yml:73:9: error: …` against the file
85
+ each finding belongs to, so a missing row is located in the documentation and
86
+ a missing entry in the registry.
87
+ - **The documentation path is an option.** `--doc` defaults to
88
+ `.github/ISSUE_REPORTING.md`; `Song-of-Heroic-Lands-FoundryVTT` keeps its at
89
+ `kb/dev-docs/how-to/issue-reporting.md` and now needs no separate script.
90
+ `--registry` likewise.
91
+
92
+ A missing §3 is reported once, as its own failure, rather than as every label
93
+ having drifted. An over-long description is caught here too — GitHub answers a
94
+ bare 422 naming neither the label nor the limit.
95
+
96
+ Verified against every repository that has a registry: `sohl-thalorna` (12),
97
+ `sohl-kethira-basic` (11), `harn-adventures` (11), `harn-ensemble` (12) and
98
+ `Song-of-Heroic-Lands-FoundryVTT` (16) all agree — and it found real drift in
99
+ `HarnMaster-3-FoundryVTT`, where `good first issue` and `help wanted` are in
100
+ the registry and absent from §3.
101
+
3
102
  ## 17.0.0
4
103
 
5
104
  ### Major Changes
@@ -83,6 +83,7 @@ import { cleanBuildArtifacts, stageAssets } from "../stage.mjs";
83
83
  import { buildSchemaArtifact } from "../engine/schema-extract.mjs";
84
84
  import { SCHEMA_ARTIFACT_FILE } from "../engine/foreign-catalog.mjs";
85
85
  import { validateLangSource } from "../lang.mjs";
86
+ import { checkLabelRegistry } from "../labels.mjs";
86
87
  import {
87
88
  analyzeCoverage,
88
89
  collectScriptReferences,
@@ -712,6 +713,77 @@ function langHardcoded(config) {
712
713
  *
713
714
  * @returns {object} The yargs command module.
714
715
  */
716
+ /**
717
+ * `labels check` — do the machine registry and the documented table agree?
718
+ *
719
+ * `.github/labels.yml` is synced to GitHub and the §3 table in
720
+ * `.github/ISSUE_REPORTING.md` is what a person reads. Neither derives from the
721
+ * other, so either can drift, and nothing notices until an issue is filed
722
+ * against a label that does not exist.
723
+ *
724
+ * Every repository wants this and only the paths ever differed, which is why it
725
+ * is here rather than copied into each as a `utils/` script.
726
+ *
727
+ * @returns {object} The yargs command module.
728
+ */
729
+ function labelsCommand() {
730
+ return {
731
+ command: "labels <action>",
732
+ describe: "Issue-label registry checks",
733
+ builder: (y) =>
734
+ y
735
+ .positional("action", {
736
+ choices: ["check"],
737
+ describe: "check: the registry and the documented table name the same labels",
738
+ })
739
+ .option("registry", {
740
+ type: "string",
741
+ default: ".github/labels.yml",
742
+ describe: "The machine registry synced to GitHub",
743
+ })
744
+ .option("doc", {
745
+ type: "string",
746
+ default: ".github/ISSUE_REPORTING.md",
747
+ describe: "The documented reference whose §3 table lists the same labels",
748
+ }),
749
+ handler: handler(async (args) => labelsCheck(args)),
750
+ };
751
+ }
752
+
753
+ /**
754
+ * Read both faces of the registry and report where they disagree.
755
+ *
756
+ * Findings are reported against the file each belongs to, so a missing row is
757
+ * located in the documentation and a missing entry in the registry, rather than
758
+ * both being attributed to whichever file was read first.
759
+ *
760
+ * @param {object} args - Parsed CLI arguments.
761
+ */
762
+ function labelsCheck(args) {
763
+ for (const target of [args.registry, args.doc]) {
764
+ if (!fs.existsSync(target)) die(`labels check: ${target} does not exist.`);
765
+ }
766
+ const registryText = fs.readFileSync(args.registry, "utf8");
767
+ const docText = fs.readFileSync(args.doc, "utf8");
768
+
769
+ const { registry, doc, count } = checkLabelRegistry({
770
+ registryText,
771
+ docText,
772
+ docPath: args.doc,
773
+ });
774
+
775
+ const errors =
776
+ reportFindings(registry, { file: args.registry }) + reportFindings(doc, { file: args.doc });
777
+ if (errors) {
778
+ console.error(
779
+ `\nThe registry and ${args.doc} §3 are edited together — ` +
780
+ `change one and change the other.`,
781
+ );
782
+ process.exit(1);
783
+ }
784
+ console.log(`package-build: registry and §3 agree (${count} labels).`);
785
+ }
786
+
715
787
  function langCommand() {
716
788
  return {
717
789
  command: "lang <action>",
@@ -992,6 +1064,7 @@ yargs(hideBin(process.argv))
992
1064
  .command(manifestCommand())
993
1065
  .command(schemaCommand())
994
1066
  .command(langCommand())
1067
+ .command(labelsCommand())
995
1068
  .command(bundleCommand())
996
1069
  .command(releaseCommand())
997
1070
  .command(deployCommand())
@@ -71,6 +71,7 @@ import unidecode from "unidecode";
71
71
 
72
72
  import { addressSlug } from "./content-address.mjs";
73
73
  import { canonicalKey } from "./kb-manifest.mjs";
74
+ import { entriesForNote, foundryIdentities } from "./manifest-emit.mjs";
74
75
  import { walkMarkdownTree } from "./helpers.mjs";
75
76
  import { loadPackConfig } from "./pack-config.mjs";
76
77
 
@@ -97,6 +98,9 @@ export const DERIVED_KEYS = Object.freeze([
97
98
  "anchors",
98
99
  "nameAscii",
99
100
  "aliasesAscii",
101
+ "foundry",
102
+ "documentation",
103
+ "documents",
100
104
  ]);
101
105
 
102
106
  /**
@@ -294,7 +298,84 @@ export function asciiAliases(aliases) {
294
298
  * @throws {Error} When the note carries a key this module derives, which would
295
299
  * otherwise be overwritten without a word.
296
300
  */
297
- export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, bodyLine }) {
301
+ /**
302
+ * This note's Foundry addresses, or `null` where it has none.
303
+ *
304
+ * **Derived by the manifest's own code, not a second implementation of it.**
305
+ * {@link module:engine/manifest-emit.entriesForNote} is what the link manifest
306
+ * emits from, and a UUID is a function of the note's `type` and authored `id`
307
+ * plus the pack router — frontmatter and configuration, nothing from a compiled
308
+ * pack — so the index's frontmatter walk already has every input. Deriving it
309
+ * twice is how two artifacts describing one note start disagreeing, which is
310
+ * the failure the merge is meant to end (#239).
311
+ *
312
+ * The shape flattens the manifest's *two* entries for an item note onto the one
313
+ * record the index keeps per note. An item compiles into a document **and** a
314
+ * documentation journal, and both are addressable — so the item's own UUID sits
315
+ * at the top and the journal's beside it under `doc`, with the anchor map that
316
+ * addresses its pages. A note that is itself a journal carries that map
317
+ * directly.
318
+ *
319
+ * Every address is independently optional, exactly as the manifest has it: a
320
+ * note that compiles to no document has no UUID, and inventing one would assert
321
+ * a target that does not exist.
322
+ *
323
+ * @param {object} args - Arguments.
324
+ * @param {object} args.frontmatter - The note's frontmatter.
325
+ * @param {object|null} args.address - Its resolved address, or null.
326
+ * @param {string} args.body - The note body, for anchor discovery.
327
+ * @param {object|null} args.manifest - The manifest context, when available.
328
+ * @returns {object|null} `{ uuid?, anchors?, doc? }`, or null when the note has
329
+ * no Foundry address at all.
330
+ */
331
+ function foundryEntries({ frontmatter, address, body, manifest }) {
332
+ // No address is not an error here — the index records every note, including
333
+ // ones that publish nothing, and the manifest reports that case separately.
334
+ if (!manifest || !address) return null;
335
+
336
+ let entries;
337
+ try {
338
+ // The slug, not the address object: the manifest emitter takes the
339
+ // published path as a string and builds its `url` from it.
340
+ entries = entriesForNote(
341
+ frontmatter,
342
+ frontmatter?.name?.full ?? "",
343
+ address.slug,
344
+ body ?? "",
345
+ manifest,
346
+ );
347
+ } catch {
348
+ // A note the manifest cannot address is still a note. The index says so
349
+ // by carrying no `foundry` block rather than by failing the walk.
350
+ return null;
351
+ }
352
+ if (!entries?.length) return null;
353
+ const [own, docEntry] = entries;
354
+ return { own: own ?? null, doc: docEntry ?? null };
355
+ }
356
+
357
+ /**
358
+ * The `foundry` block for one manifest entry.
359
+ *
360
+ * @param {object|null} entry - A manifest entry.
361
+ * @returns {object|null} `{ uuid?, anchors? }`, or null when it addresses nothing.
362
+ */
363
+ function foundryBlock(entry) {
364
+ if (!entry) return null;
365
+ const block = {};
366
+ if (entry.uuid) block.uuid = entry.uuid;
367
+ if (entry.anchors) block.anchors = entry.anchors;
368
+ return Object.keys(block).length ? block : null;
369
+ }
370
+
371
+ export function buildIndexRecord({
372
+ frontmatter,
373
+ relPath,
374
+ contentPackage,
375
+ body,
376
+ bodyLine,
377
+ manifest,
378
+ }) {
298
379
  for (const key of DERIVED_KEYS) {
299
380
  if (Object.hasOwn(frontmatter ?? {}, key)) {
300
381
  throw new Error(
@@ -307,6 +388,7 @@ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, b
307
388
  const posix = relPath.split(path.sep).join("/");
308
389
  const folder = posix.includes("/") ? posix.slice(0, posix.lastIndexOf("/")) : "";
309
390
  const address = noteAddress(frontmatter, contentPackage);
391
+ const entries = foundryEntries({ frontmatter, address, body, manifest });
310
392
 
311
393
  return /** @type {Record<string, any>} */ (
312
394
  sortKeysDeep({
@@ -323,6 +405,11 @@ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, b
323
405
  ...a,
324
406
  link: address ? `${address.slug}#${a.slug}` : null,
325
407
  })),
408
+ foundry: foundryBlock(entries?.own),
409
+ // Forward link to the note's documentation journal, which is its
410
+ // own record. Named rather than nested, because the journal is a
411
+ // separate document with its own address — see `buildDocRecord`.
412
+ documentation: entries?.doc?.key ?? null,
326
413
  file: {
327
414
  // Relative to the content root, and deliberately not absolute.
328
415
  // An absolute path is a fact about the machine that built the
@@ -350,7 +437,57 @@ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, b
350
437
  * @returns {Array<Record<string, any>>} The records, in a total order that does
351
438
  * not depend on directory-read order.
352
439
  */
353
- export function collectContentIndex(contentBase, { contentPackage, skipDirectories }) {
440
+ /**
441
+ * The record for an item note's **documentation journal**.
442
+ *
443
+ * An item note compiles into two documents — the item, and a JournalEntry
444
+ * holding its prose — and the second is a document in its own right: its own
445
+ * canonical address (`doc<type>/<shortcode>`), its own UUID, its own pages.
446
+ * So it gets its own record, and resolving `docaffliction/blkdth` is the same
447
+ * lookup as resolving anything else. Nested inside the item's record it would
448
+ * be the one address in the index reachable only by knowing to look somewhere
449
+ * else, which every consumer would have to special-case.
450
+ *
451
+ * **Lean, and deliberately not the note's frontmatter.** The item's `sohl:`
452
+ * block describes the *item*; copying it onto the journal would assert things
453
+ * about the journal that are not true, and double the file to do it. What the
454
+ * journal has of its own is its addresses, its name, and the file it came from
455
+ * — plus `documents`, naming the record it is the documentation for, so the
456
+ * link is navigable in both directions.
457
+ *
458
+ * On the web both addresses resolve to one page — the item note renders as the
459
+ * page that *is* its documentation — so the slug is shared and only the
460
+ * canonical key differs.
461
+ *
462
+ * @param {object} args - Arguments.
463
+ * @param {object} args.frontmatter - The item note's frontmatter.
464
+ * @param {object} args.address - The item's own address.
465
+ * @param {object} args.entry - The manifest's doc entry.
466
+ * @param {object} args.file - The `file` block of the item's record.
467
+ * @param {string} args.contentPackage - The package the note belongs to.
468
+ * @param {Array<object>} args.anchors - The web anchors of the note body.
469
+ * @returns {Record<string, any>} The documentation journal's index record.
470
+ */
471
+ function buildDocRecord({ frontmatter, address, entry, file, contentPackage, anchors }) {
472
+ return /** @type {Record<string, any>} */ (
473
+ sortKeysDeep({
474
+ package: contentPackage,
475
+ type: `doc${frontmatter.type}`,
476
+ shortcode: frontmatter.shortcode,
477
+ name: frontmatter.name,
478
+ nameAscii: asciiName(frontmatter?.name?.full),
479
+ address: { slug: address.slug, canonical: entry.key },
480
+ // The record this is the documentation *for*. `documentation` is
481
+ // the forward link on that record, so either end reaches the other.
482
+ documents: address.canonical,
483
+ anchors,
484
+ foundry: foundryBlock(entry),
485
+ file,
486
+ })
487
+ );
488
+ }
489
+
490
+ export function collectContentIndex(contentBase, { contentPackage, skipDirectories, manifest }) {
354
491
  const records = [];
355
492
  const walkOpts = skipDirectories ? { skipDirectories } : {};
356
493
 
@@ -358,23 +495,55 @@ export function collectContentIndex(contentBase, { contentPackage, skipDirectori
358
495
  contentBase,
359
496
  walkOpts,
360
497
  )) {
361
- records.push(
362
- buildIndexRecord({
363
- frontmatter: frontmatter ?? {},
364
- relPath: path.relative(contentBase, absPath),
365
- contentPackage,
366
- body,
367
- bodyLine,
368
- }),
369
- );
498
+ const fm = frontmatter ?? {};
499
+ const relPath = path.relative(contentBase, absPath);
500
+ const record = buildIndexRecord({
501
+ frontmatter: fm,
502
+ relPath,
503
+ contentPackage,
504
+ body,
505
+ bodyLine,
506
+ manifest,
507
+ });
508
+ records.push(record);
509
+
510
+ // An item note is two documents, so it is two records (#239).
511
+ const doc = foundryEntries({
512
+ frontmatter: fm,
513
+ address: record.address,
514
+ body,
515
+ manifest,
516
+ })?.doc;
517
+ if (doc?.key && record.address) {
518
+ records.push(
519
+ buildDocRecord({
520
+ frontmatter: fm,
521
+ address: record.address,
522
+ entry: doc,
523
+ file: record.file,
524
+ contentPackage,
525
+ anchors: record.anchors,
526
+ }),
527
+ );
528
+ }
370
529
  }
371
530
 
372
531
  // Content path, then the note id. The walk yields in directory-read order,
373
532
  // which is not a fact about the content, and a rebuild that reorders lines
374
533
  // would make every regeneration look like a change.
534
+ // Content path, then the canonical address, then the note id. The walk
535
+ // yields in directory-read order, which is not a fact about the content,
536
+ // and a rebuild that reordered lines would make every regeneration look
537
+ // like a change. The address comes before the id because an item note's two
538
+ // records share a file and only one of them carries an id — ordering on the
539
+ // id first would put the documentation ahead of the item it documents.
375
540
  records.sort(
376
541
  (a, b) =>
377
542
  String(a.file.path).localeCompare(String(b.file.path), "en") ||
543
+ String(a.address?.canonical ?? "").localeCompare(
544
+ String(b.address?.canonical ?? ""),
545
+ "en",
546
+ ) ||
378
547
  String(a.id ?? "").localeCompare(String(b.id ?? ""), "en"),
379
548
  );
380
549
  return records;
@@ -419,9 +588,21 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
419
588
  throw new Error(`no content tree at ${tree}`);
420
589
  }
421
590
 
591
+ // The identities a Foundry address is derived against. Resolved once and
592
+ // passed down, the way the manifest emission does it, so the walk stays a
593
+ // pure function of its context. A configuration that names no Foundry
594
+ // package yields a context whose notes simply carry no UUID.
595
+ // Only the identities a UUID is a function of — the package id and the pack
596
+ // router. Deliberately not the manifest's full context: whether a package
597
+ // publishes pages is no part of an address, and depending on it would make
598
+ // the index refuse to build for a configuration that is perfectly able to
599
+ // state one.
600
+ const manifest = foundryIdentities(resolved);
601
+
422
602
  const records = collectContentIndex(tree, {
423
603
  contentPackage,
424
604
  skipDirectories: resolved.skipDirectories,
605
+ manifest,
425
606
  });
426
607
  if (records.length === 0) {
427
608
  throw new Error(
@@ -435,5 +616,9 @@ export function emitContentIndex({ contentBase, outDir, config } = {}) {
435
616
  fs.mkdirSync(dir, { recursive: true });
436
617
  fs.writeFileSync(file, text);
437
618
 
438
- return { file, notes: records.length, bytes: Buffer.byteLength(text) };
619
+ // Counted separately because they are genuinely different numbers: an item
620
+ // note yields a second record for its documentation journal, so reporting
621
+ // records as notes would overstate how large the tree is.
622
+ const notes = records.filter((r) => !r.documents).length;
623
+ return { file, notes, records: records.length, bytes: Buffer.byteLength(text) };
439
624
  }
@@ -152,7 +152,9 @@ export function entriesForNote(fm, name, address, body, ctx) {
152
152
  )
153
153
  : undefined;
154
154
 
155
- if (hasDocEntry(fm.type)) {
155
+ const carriesDoc =
156
+ ctx.docEntryTypes ? ctx.docEntryTypes.has(String(fm.type)) : hasDocEntry(fm.type);
157
+ if (carriesDoc) {
156
158
  const docKey = canonicalKey(contentPackage, `doc${fm.type}`, fm.shortcode);
157
159
  const docEntryId = fm.id ? itemDocEntryId(fm.id) : undefined;
158
160
  const docUuid = uuidFor("doc", docEntryId);
@@ -270,11 +272,34 @@ export function collectManifestEntries(contentBase, ctx) {
270
272
  * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
271
273
  * web: boolean, skipDirectories: readonly string[]}}
272
274
  */
273
- export function manifestContext(config = loadPackConfig()) {
275
+ export function foundryIdentities(config = loadPackConfig()) {
274
276
  return {
275
277
  contentPackage: config.contentPackage,
276
278
  foundryPackageId: config.foundryPackage,
277
279
  packRouter: routerFor(config),
280
+ // Carried in the context rather than read from the global config at the
281
+ // call site, so the pass really is a pure function of what it is handed
282
+ // — which is what lets the content index drive the same derivation with
283
+ // a configuration it resolved itself (#239).
284
+ docEntryTypes: config.docEntryTypes,
285
+ };
286
+ }
287
+
288
+ /**
289
+ * The identities an emission runs against, from configuration.
290
+ *
291
+ * {@link foundryIdentities} plus what only a *manifest* emission needs. The
292
+ * split is what lets the content index derive the same Foundry addresses from
293
+ * the same code without also depending on whether the package publishes pages,
294
+ * which is no part of a UUID (#239).
295
+ *
296
+ * @param {object} [config] - A resolved configuration; loaded when omitted.
297
+ * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
298
+ * web: boolean, skipDirectories: readonly string[]}}
299
+ */
300
+ export function manifestContext(config = loadPackConfig()) {
301
+ return {
302
+ ...foundryIdentities(config),
278
303
  web: publishesContentPages(config),
279
304
  // The walk's own configuration, threaded through rather than left to
280
305
  // its default, so a caller that passes a config drives every read.
package/labels.mjs ADDED
@@ -0,0 +1,191 @@
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
+ * The label registry has two faces that must agree.
16
+ *
17
+ * `.github/labels.yml` is the machine source synced to GitHub, and the §3 table
18
+ * in `.github/ISSUE_REPORTING.md` is the documented reference a person reads.
19
+ * Neither is derived from the other, so either can drift — or invent a label
20
+ * the other has never heard of — and nothing notices until someone files an
21
+ * issue against a label that does not exist, or the sync pushes a label the
22
+ * documentation never mentions.
23
+ *
24
+ * **This lives here because every repository wants it and only the paths ever
25
+ * differed.** It was a `utils/check-labels.mjs` copied per repository, which is
26
+ * the shape a shared check takes just before it starts drifting between copies
27
+ * — the same argument that moved the no-attribution check to a shared action.
28
+ *
29
+ * The checks are pure and text-in: the caller reads the two files and decides
30
+ * what to do with the findings, so this module needs no filesystem and is
31
+ * testable without one.
32
+ *
33
+ * @module
34
+ */
35
+
36
+ import { parse } from "yaml";
37
+
38
+ /** GitHub rejects a label description beyond this with a 422. */
39
+ export const MAX_DESCRIPTION = 100;
40
+
41
+ /**
42
+ * The line a label's `name:` entry sits on, 1-based.
43
+ *
44
+ * Located rather than tracked, because `yaml`'s document API would have to be
45
+ * threaded through the parse for a value this simple to recover: a registry is
46
+ * a flat list of `- name: <label>`, so the first line declaring that name is
47
+ * the entry. A name that cannot be found yields no position, which
48
+ * `reportFindings` drops rather than guessing.
49
+ *
50
+ * @param {string} text - The registry file's contents.
51
+ * @param {string} name - The label name.
52
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
53
+ */
54
+ function positionOfLabel(text, name) {
55
+ const lines = text.split("\n");
56
+ for (let i = 0; i < lines.length; i += 1) {
57
+ const m = lines[i].match(/^\s*-?\s*name:\s*["']?(.+?)["']?\s*$/);
58
+ if (m && m[1] === name) return { line: i + 1, column: lines[i].indexOf(name) + 1 };
59
+ }
60
+ return {};
61
+ }
62
+
63
+ /**
64
+ * The line a label's row sits on in the documented table, 1-based.
65
+ *
66
+ * @param {string} text - The documentation file's contents.
67
+ * @param {string} name - The label name.
68
+ * @returns {{line?: number, column?: number}} Spreadable position fields.
69
+ */
70
+ function positionOfDocRow(text, name) {
71
+ const lines = text.split("\n");
72
+ for (let i = 0; i < lines.length; i += 1) {
73
+ const m = lines[i].match(/^\|\s*`([a-z][a-z-]*)`\s*\|/);
74
+ if (m && m[1] === name) return { line: i + 1, column: lines[i].indexOf(name) + 1 };
75
+ }
76
+ return {};
77
+ }
78
+
79
+ /**
80
+ * The label names the documented §3 table lists.
81
+ *
82
+ * A registry row is a table row whose first cell is a backticked label name,
83
+ * which is narrow on purpose: §3 carries prose and other tables, and a looser
84
+ * match would read a heading or an example as a label.
85
+ *
86
+ * @param {string} text - The documentation file's contents.
87
+ * @returns {{names: Set<string>, found: boolean}} The names, and whether §3 was
88
+ * located at all — an absent section is a different failure from an empty one.
89
+ */
90
+ export function documentedLabels(text) {
91
+ const lines = text.split("\n");
92
+ const start = lines.findIndex((l) => /^##\s+3\./.test(l));
93
+ if (start < 0) return { names: new Set(), found: false };
94
+ const after = lines.findIndex((l, i) => i > start && /^##\s+\d/.test(l));
95
+ const section = lines.slice(start, after < 0 ? lines.length : after);
96
+ const names = new Set();
97
+ for (const line of section) {
98
+ const m = line.match(/^\|\s*`([a-z][a-z-]*)`\s*\|/);
99
+ if (m) names.add(m[1]);
100
+ }
101
+ return { names, found: true };
102
+ }
103
+
104
+ /**
105
+ * Check the machine registry against the documented table.
106
+ *
107
+ * Findings are returned per file rather than merged, because each names a
108
+ * position in a different document and the caller reports them against the
109
+ * file they belong to.
110
+ *
111
+ * @param {object} sources
112
+ * @param {string} sources.registryText - `.github/labels.yml`.
113
+ * @param {string} sources.docText - `.github/ISSUE_REPORTING.md`.
114
+ * @param {string} [sources.docPath] - The doc's path, for the message naming it.
115
+ * @returns {{registry: object[], doc: object[], count: number}} Findings for
116
+ * each file, and how many labels the registry declares.
117
+ */
118
+ export function checkLabelRegistry({
119
+ registryText,
120
+ docText,
121
+ docPath = ".github/ISSUE_REPORTING.md",
122
+ }) {
123
+ const registryFindings = [];
124
+ const docFindings = [];
125
+
126
+ let entries;
127
+ try {
128
+ entries = parse(registryText);
129
+ } catch (error) {
130
+ registryFindings.push({
131
+ severity: "error",
132
+ message: `label registry is not valid YAML — ${error.message}`,
133
+ });
134
+ return { registry: registryFindings, doc: docFindings, count: 0 };
135
+ }
136
+ if (!Array.isArray(entries)) {
137
+ registryFindings.push({
138
+ severity: "error",
139
+ message: "label registry must be a list of `{ name, description }` entries",
140
+ });
141
+ return { registry: registryFindings, doc: docFindings, count: 0 };
142
+ }
143
+
144
+ // Caught here rather than mid-sync, where it surfaces as a bare 422 from
145
+ // the GitHub API naming neither the label nor the limit.
146
+ for (const entry of entries) {
147
+ const length = (entry?.description ?? "").length;
148
+ if (length > MAX_DESCRIPTION) {
149
+ registryFindings.push({
150
+ ...positionOfLabel(registryText, entry.name),
151
+ severity: "error",
152
+ message:
153
+ `"${entry.name}" has a ${length}-character description; ` +
154
+ `GitHub rejects anything over ${MAX_DESCRIPTION}`,
155
+ });
156
+ }
157
+ }
158
+
159
+ const registryNames = new Set(entries.map((e) => e?.name).filter(Boolean));
160
+ const { names: docNames, found } = documentedLabels(docText);
161
+ if (!found) {
162
+ docFindings.push({
163
+ severity: "error",
164
+ message: `no §3 section found in ${docPath}, so the registry has nothing to agree with`,
165
+ });
166
+ return { registry: registryFindings, doc: docFindings, count: registryNames.size };
167
+ }
168
+
169
+ for (const name of registryNames) {
170
+ if (docNames.has(name)) continue;
171
+ registryFindings.push({
172
+ ...positionOfLabel(registryText, name),
173
+ severity: "error",
174
+ message:
175
+ `"${name}" is in the registry but not in ${docPath} §3; ` +
176
+ `the two are edited together, so add the row`,
177
+ });
178
+ }
179
+ for (const name of docNames) {
180
+ if (registryNames.has(name)) continue;
181
+ docFindings.push({
182
+ ...positionOfDocRow(docText, name),
183
+ severity: "error",
184
+ message:
185
+ `"${name}" is documented in §3 but absent from the registry, ` +
186
+ `so it is never synced and an issue cannot carry it`,
187
+ });
188
+ }
189
+
190
+ return { registry: registryFindings, doc: docFindings, count: registryNames.size };
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heroiclands/package-build",
3
- "version": "17.0.0",
3
+ "version": "17.2.0",
4
4
  "description": "Shared toolchain for building and shipping a HeroicLands Foundry VTT package — content compilation, manifest, localization, staging, bundle, release and deployment.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -107,6 +107,7 @@
107
107
  "engine",
108
108
  "index.mjs",
109
109
  "lang.mjs",
110
+ "labels.mjs",
110
111
  "manifest.mjs",
111
112
  "markdownlint-config.mjs",
112
113
  "prettier-config.mjs",
@@ -107,40 +107,19 @@ export function asciiName(name: unknown): string | null;
107
107
  * a consumer iterating it should not have to check first.
108
108
  */
109
109
  export function asciiAliases(aliases: unknown): Array<string>;
110
- /**
111
- * Build one index record from a note's frontmatter and its place in the tree.
112
- *
113
- * @param {object} options - Options.
114
- * @param {Record<string, any>} options.frontmatter - The note's parsed frontmatter.
115
- * @param {string} options.relPath - Its path below the content root, POSIX-separated.
116
- * @param {string} options.contentPackage - The package the tree compiles as.
117
- * @param {string} [options.body] - The note's markdown body, for its anchors.
118
- * @param {number} [options.bodyLine] - The 1-based file line the body starts on.
119
- * @returns {Record<string, any>} The record, keys sorted at every depth.
120
- * @throws {Error} When the note carries a key this module derives, which would
121
- * otherwise be overwritten without a word.
122
- */
123
- export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, bodyLine }: {
124
- frontmatter: Record<string, any>;
125
- relPath: string;
126
- contentPackage: string;
127
- body?: string | undefined;
128
- bodyLine?: number | undefined;
110
+ export function buildIndexRecord({ frontmatter, relPath, contentPackage, body, bodyLine, manifest, }: {
111
+ frontmatter: any;
112
+ relPath: any;
113
+ contentPackage: any;
114
+ body: any;
115
+ bodyLine: any;
116
+ manifest: any;
129
117
  }): Record<string, any>;
130
- /**
131
- * Read a content tree into index records, in the order they will be written.
132
- *
133
- * @param {string} contentBase - The content tree to walk.
134
- * @param {object} options - Options.
135
- * @param {string} options.contentPackage - The package the tree compiles as.
136
- * @param {Array<string>} [options.skipDirectories] - Directory names to skip.
137
- * @returns {Array<Record<string, any>>} The records, in a total order that does
138
- * not depend on directory-read order.
139
- */
140
- export function collectContentIndex(contentBase: string, { contentPackage, skipDirectories }: {
141
- contentPackage: string;
142
- skipDirectories?: string[] | undefined;
143
- }): Array<Record<string, any>>;
118
+ export function collectContentIndex(contentBase: any, { contentPackage, skipDirectories, manifest }: {
119
+ contentPackage: any;
120
+ skipDirectories: any;
121
+ manifest: any;
122
+ }): Record<string, any>[];
144
123
  /**
145
124
  * Serialize records as JSON Lines.
146
125
  *
@@ -74,6 +74,25 @@ export function collectManifestEntries(contentBase: string, ctx: object): {
74
74
  * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
75
75
  * web: boolean, skipDirectories: readonly string[]}}
76
76
  */
77
+ export function foundryIdentities(config?: object): {
78
+ contentPackage: string;
79
+ foundryPackageId: string;
80
+ packRouter: object;
81
+ web: boolean;
82
+ skipDirectories: readonly string[];
83
+ };
84
+ /**
85
+ * The identities an emission runs against, from configuration.
86
+ *
87
+ * {@link foundryIdentities} plus what only a *manifest* emission needs. The
88
+ * split is what lets the content index derive the same Foundry addresses from
89
+ * the same code without also depending on whether the package publishes pages,
90
+ * which is no part of a UUID (#239).
91
+ *
92
+ * @param {object} [config] - A resolved configuration; loaded when omitted.
93
+ * @returns {{contentPackage: string, foundryPackageId: string, packRouter: object,
94
+ * web: boolean, skipDirectories: readonly string[]}}
95
+ */
77
96
  export function manifestContext(config?: object): {
78
97
  contentPackage: string;
79
98
  foundryPackageId: string;