@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 +99 -0
- package/bin/package-build.mjs +73 -0
- package/engine/content-index.mjs +197 -12
- package/engine/manifest-emit.mjs +27 -2
- package/labels.mjs +191 -0
- package/package.json +2 -1
- package/types/engine/content-index.d.mts +12 -33
- package/types/engine/manifest-emit.d.mts +19 -0
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
|
package/bin/package-build.mjs
CHANGED
|
@@ -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())
|
package/engine/content-index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
}
|
package/engine/manifest-emit.mjs
CHANGED
|
@@ -152,7 +152,9 @@ export function entriesForNote(fm, name, address, body, ctx) {
|
|
|
152
152
|
)
|
|
153
153
|
: undefined;
|
|
154
154
|
|
|
155
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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;
|