@heroiclands/package-build 23.4.1 → 23.5.1

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 (54) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +29 -25
  3. package/assets/images/provenance.yaml +1 -0
  4. package/bin/package-build.mjs +259 -101
  5. package/ci/prettier.mjs +73 -0
  6. package/config.mjs +10 -0
  7. package/container.mjs +232 -1
  8. package/content-config.mjs +5 -1
  9. package/docs/api.md +100 -52
  10. package/docs/authoring/assets.md +16 -1
  11. package/docs/commands.md +154 -51
  12. package/docs/configuration.md +39 -2
  13. package/docs/content-format.md +30 -6
  14. package/docs/diagnostics.md +1 -2
  15. package/docs/guides/e2e.md +38 -0
  16. package/docs/guides/icons.md +5 -0
  17. package/docs/guides/module-project.md +1 -1
  18. package/docs/guides/release-and-deploy.md +36 -1
  19. package/docs/project-setup.md +16 -59
  20. package/docs/reference/format-details.md +180 -34
  21. package/docs/reference/note-types.md +69 -69
  22. package/engine/asset-index.mjs +63 -22
  23. package/engine/asset-references.mjs +355 -0
  24. package/engine/asset-shipping.mjs +363 -0
  25. package/engine/asset-types.mjs +70 -0
  26. package/engine/base-compiler.mjs +24 -1
  27. package/engine/cdn-publish.mjs +643 -0
  28. package/engine/index.mjs +6 -0
  29. package/engine/note-vocabulary.mjs +4 -1
  30. package/engine/pathnames.mjs +21 -10
  31. package/engine/project-init.mjs +4 -29
  32. package/engine/rank-ladder.mjs +40 -16
  33. package/engine/scenes.mjs +1 -1
  34. package/engine/standing-terms.mjs +3 -1
  35. package/package.json +5 -6
  36. package/packagebuild-metadata.jsonl +13 -13
  37. package/release.mjs +69 -7
  38. package/sohl/actors.mjs +12 -4
  39. package/sohl/default-item-art.mjs +23 -19
  40. package/types/config.d.mts +7 -0
  41. package/types/container.d.mts +137 -1
  42. package/types/engine/asset-index.d.mts +3 -2
  43. package/types/engine/asset-references.d.mts +136 -0
  44. package/types/engine/asset-shipping.d.mts +240 -0
  45. package/types/engine/asset-types.d.mts +36 -0
  46. package/types/engine/base-compiler.d.mts +7 -1
  47. package/types/engine/cdn-publish.d.mts +312 -0
  48. package/types/engine/index.d.mts +2 -0
  49. package/types/engine/pathnames.d.mts +2 -1
  50. package/types/engine/rank-ladder.d.mts +25 -6
  51. package/types/release.d.mts +5 -3
  52. package/types/sohl/default-item-art.d.mts +2 -1
  53. package/labels.mjs +0 -191
  54. package/types/labels.d.mts +0 -40
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The media type a file is uploaded with.
3
+ *
4
+ * @param {string} name - A file name or key.
5
+ * @returns {string} Its media type, `application/octet-stream` when unknown.
6
+ */
7
+ export function contentTypeOf(name: string): string;
8
+ /**
9
+ * Why a `--set` value cannot name a set file, or `""` when it can.
10
+ *
11
+ * @param {unknown} file - The value given.
12
+ * @returns {string} The problem, as a clause, or `""`.
13
+ */
14
+ export function setFileProblem(file: unknown): string;
15
+ /**
16
+ * The store's settings from the environment.
17
+ *
18
+ * @param {Record<string, string|undefined>} env - The environment.
19
+ * @returns {{endpoint: string, bucket: string, accessKeyId: string,
20
+ * secretAccessKey: string}|null} The settings, or `null` when none of the
21
+ * variables is set.
22
+ * @throws {Error} When some are set and others are not, naming the missing
23
+ * ones and never a value.
24
+ */
25
+ export function cdnCredentials(env: Record<string, string | undefined>): {
26
+ endpoint: string;
27
+ bucket: string;
28
+ accessKeyId: string;
29
+ secretAccessKey: string;
30
+ } | null;
31
+ /**
32
+ * The S3-compatible store the CDN is served from, signed with AWS SigV4.
33
+ *
34
+ * Addressed path-style, `<endpoint>/<bucket>/<key>`, which R2 and every other
35
+ * S3-compatible server accept.
36
+ *
37
+ * @param {object} opts
38
+ * @param {string} opts.endpoint - The S3 endpoint.
39
+ * @param {string} opts.bucket - The bucket.
40
+ * @param {string} opts.accessKeyId - The access key id.
41
+ * @param {string} opts.secretAccessKey - The secret access key.
42
+ * @param {typeof fetch} [opts.fetch] - The fetch to sign requests for.
43
+ * @returns {CdnStore} The store.
44
+ */
45
+ export function s3Store({ endpoint, bucket, accessKeyId, secretAccessKey, fetch: fetchImpl }: {
46
+ endpoint: string;
47
+ bucket: string;
48
+ accessKeyId: string;
49
+ secretAccessKey: string;
50
+ fetch?: typeof fetch | undefined;
51
+ }): CdnStore;
52
+ /**
53
+ * The CDN read through its public address — what a dry run without
54
+ * credentials compares against. It cannot write.
55
+ *
56
+ * @param {string} host - `site.assets`, the URL the bucket is served at.
57
+ * @param {typeof fetch} [fetchImpl] - The fetch to use.
58
+ * @returns {CdnStore} The read-only store.
59
+ */
60
+ export function publicStore(host: string, fetchImpl?: typeof fetch): CdnStore;
61
+ /**
62
+ * One picture to publish.
63
+ *
64
+ * @typedef {object} CdnUpload
65
+ * @property {object} record - The asset record.
66
+ * @property {string} address - Its canonical address.
67
+ * @property {string} target - `<icons|images>/<basename>`.
68
+ * @property {string} key - The object key, `<package>/<target>`.
69
+ * @property {Buffer} body - The bytes, transformed as the stage transforms
70
+ * them.
71
+ * @property {string} contentType - The media type it is served with.
72
+ * @property {boolean} shipped - Whether the Foundry package ships it.
73
+ */
74
+ /**
75
+ * Everything a publish uploads, worked out before anything is sent.
76
+ *
77
+ * @typedef {object} CdnPlan
78
+ * @property {object[]} findings - What stops the publish; nothing is planned
79
+ * when non-empty.
80
+ * @property {object[]} records - Every asset record the package has.
81
+ * @property {CdnUpload[]} uploads - One per picture, in key order.
82
+ * @property {{key: string, body: Buffer, set: object}|null} set - The set file.
83
+ * @property {string} cdnRoot - `<site.assets>/<package>/`.
84
+ */
85
+ /**
86
+ * Work out what a publish uploads.
87
+ *
88
+ * @param {object} opts
89
+ * @param {string} opts.assetsBase - The package's asset directory.
90
+ * @param {string} opts.contentPackage - The content package.
91
+ * @param {string} opts.host - `site.assets`.
92
+ * @param {string} opts.setFile - The set file's name.
93
+ * @param {string} opts.setName - The set's display name.
94
+ * @param {(sourcePath: string) => string|null|undefined} [opts.transform] -
95
+ * The repository's per-file transform.
96
+ * @param {readonly string[]} [opts.shippingLicenses] - The licences the
97
+ * package adds to the shipping list, so the set leaves out what the stage
98
+ * leaves out.
99
+ * @returns {CdnPlan} The plan.
100
+ */
101
+ export function planCdnPublish({ assetsBase, contentPackage, host, setFile, setName, transform, shippingLicenses, }: {
102
+ assetsBase: string;
103
+ contentPackage: string;
104
+ host: string;
105
+ setFile: string;
106
+ setName: string;
107
+ transform?: ((sourcePath: string) => string | null | undefined) | undefined;
108
+ shippingLicenses?: readonly string[] | undefined;
109
+ }): CdnPlan;
110
+ /**
111
+ * Upload a plan.
112
+ *
113
+ * A picture that fails is recorded and the rest carry on, so one refused file
114
+ * costs one file rather than the run. The set file is written only when every
115
+ * picture it lists is on the CDN: a set naming a file the CDN lacks sends a
116
+ * world to fetch nothing.
117
+ *
118
+ * @param {CdnPlan} plan - From {@link planCdnPublish}, with no findings.
119
+ * @param {object} opts
120
+ * @param {CdnStore} opts.store - Where the objects go.
121
+ * @param {boolean} [opts.dryRun] - Compare only; write nothing.
122
+ * @param {(line: string) => void} [opts.log] - One line per object.
123
+ * @returns {Promise<{uploaded: CdnUpload[], skipped: CdnUpload[],
124
+ * failed: {upload: CdnUpload, message: string}[],
125
+ * set: "uploaded"|"skipped"|"withheld", published: Set<string>}>} What was
126
+ * (or, in a dry run, would be) uploaded, skipped and refused; what became of
127
+ * the set file; and every address whose picture is on the CDN at its
128
+ * planned size once the run ends — or would be, in a dry run.
129
+ */
130
+ export function publishToCdn(plan: CdnPlan, { store, dryRun, log }: {
131
+ store: CdnStore;
132
+ dryRun?: boolean | undefined;
133
+ log?: ((line: string) => void) | undefined;
134
+ }): Promise<{
135
+ uploaded: CdnUpload[];
136
+ skipped: CdnUpload[];
137
+ failed: {
138
+ upload: CdnUpload;
139
+ message: string;
140
+ }[];
141
+ set: "uploaded" | "skipped" | "withheld";
142
+ published: Set<string>;
143
+ }>;
144
+ /**
145
+ * Every place a package's notes name one of its own pictures, and what each
146
+ * names.
147
+ *
148
+ * Reads the three places a note names art: an art slot under `data:`, an
149
+ * embed (`![[address|label]]`) and a body image whose pathname is the
150
+ * package's own. A reference naming another package, or one that does not
151
+ * parse as an address, is another check's business and is not returned.
152
+ *
153
+ * @param {object} opts
154
+ * @param {string} opts.contentBase - The content tree.
155
+ * @param {readonly string[]} opts.skipDirectories - Directory names the walk
156
+ * ignores.
157
+ * @param {string} opts.contentPackage - The content package.
158
+ * @param {readonly object[]} opts.records - The package's asset records.
159
+ * @returns {Array<{file: string, line?: number, column?: number,
160
+ * written: string, record: object|null}>} One per reference, `record` `null`
161
+ * where the package has no such picture. `file` is relative to the working
162
+ * directory.
163
+ */
164
+ export function noteArtReferences({ contentBase, skipDirectories, contentPackage, records }: {
165
+ contentBase: string;
166
+ skipDirectories: readonly string[];
167
+ contentPackage: string;
168
+ records: readonly object[];
169
+ }): Array<{
170
+ file: string;
171
+ line?: number;
172
+ column?: number;
173
+ written: string;
174
+ record: object | null;
175
+ }>;
176
+ /**
177
+ * The findings a publish reports against the package's notes.
178
+ *
179
+ * Both are warnings:
180
+ *
181
+ * - a note names a picture the package does not have, so no address answers
182
+ * it and nothing is published for it;
183
+ * - a note names a picture the package leaves out, and the CDN does not hold
184
+ * it, so a world adding the set gets no file for that document.
185
+ *
186
+ * @param {ReturnType<typeof noteArtReferences>} references - From
187
+ * {@link noteArtReferences}.
188
+ * @param {object} opts
189
+ * @param {string} opts.contentPackage - The content package.
190
+ * @param {ReadonlySet<string>} opts.published - Every address on the CDN at
191
+ * its planned size.
192
+ * @param {ReadonlySet<object>} opts.excluded - The records the package leaves
193
+ * out.
194
+ * @returns {Array<{file: string, line?: number, column?: number,
195
+ * severity: "warning", message: string}>} In note order.
196
+ */
197
+ export function noteArtFindings(references: ReturnType<typeof noteArtReferences>, { contentPackage, published, excluded }: {
198
+ contentPackage: string;
199
+ published: ReadonlySet<string>;
200
+ excluded: ReadonlySet<object>;
201
+ }): Array<{
202
+ file: string;
203
+ line?: number;
204
+ column?: number;
205
+ severity: "warning";
206
+ message: string;
207
+ }>;
208
+ /**
209
+ * The environment variables naming the S3-compatible store behind the CDN.
210
+ *
211
+ * For Cloudflare R2, `CDN_ENDPOINT` is the account's S3 endpoint,
212
+ * `https://<account id>.r2.cloudflarestorage.com`, and the key pair is an R2
213
+ * API token's.
214
+ *
215
+ * @type {Readonly<{endpoint: string, bucket: string, accessKeyId: string, secretAccessKey: string}>}
216
+ */
217
+ export const CDN_ENV: Readonly<{
218
+ endpoint: string;
219
+ bucket: string;
220
+ accessKeyId: string;
221
+ secretAccessKey: string;
222
+ }>;
223
+ /**
224
+ * One picture to publish.
225
+ */
226
+ export type CdnUpload = {
227
+ /**
228
+ * - The asset record.
229
+ */
230
+ record: object;
231
+ /**
232
+ * - Its canonical address.
233
+ */
234
+ address: string;
235
+ /**
236
+ * - `<icons|images>/<basename>`.
237
+ */
238
+ target: string;
239
+ /**
240
+ * - The object key, `<package>/<target>`.
241
+ */
242
+ key: string;
243
+ /**
244
+ * - The bytes, transformed as the stage transforms
245
+ * them.
246
+ */
247
+ body: Buffer;
248
+ /**
249
+ * - The media type it is served with.
250
+ */
251
+ contentType: string;
252
+ /**
253
+ * - Whether the Foundry package ships it.
254
+ */
255
+ shipped: boolean;
256
+ };
257
+ /**
258
+ * Everything a publish uploads, worked out before anything is sent.
259
+ */
260
+ export type CdnPlan = {
261
+ /**
262
+ * - What stops the publish; nothing is planned
263
+ * when non-empty.
264
+ */
265
+ findings: object[];
266
+ /**
267
+ * - Every asset record the package has.
268
+ */
269
+ records: object[];
270
+ /**
271
+ * - One per picture, in key order.
272
+ */
273
+ uploads: CdnUpload[];
274
+ /**
275
+ * - The set file.
276
+ */
277
+ set: {
278
+ key: string;
279
+ body: Buffer;
280
+ set: object;
281
+ } | null;
282
+ /**
283
+ * - `<site.assets>/<package>/`.
284
+ */
285
+ cdnRoot: string;
286
+ };
287
+ /**
288
+ * A store holding the CDN's objects.
289
+ */
290
+ export type CdnStore = {
291
+ /**
292
+ * - An object's
293
+ * size in bytes, `undefined` when there is none or the store does not state
294
+ * it.
295
+ */
296
+ size: (key: string) => Promise<number | undefined>;
297
+ /**
298
+ * - An object's
299
+ * bytes, `undefined` when there is none.
300
+ */
301
+ read: (key: string) => Promise<Buffer | undefined>;
302
+ /**
303
+ * -
304
+ * Store an object; absent on a read-only store.
305
+ */
306
+ write: ((key: string, body: Buffer, contentType: string) => Promise<void>) | undefined;
307
+ /**
308
+ * - What the store is, for the log. Never a
309
+ * secret.
310
+ */
311
+ describe: string;
312
+ };
@@ -52,6 +52,8 @@ export * as foundryEntries from "./foundry-entries.mjs";
52
52
  export * as contentIndex from "./content-index.mjs";
53
53
  export * as assetTypes from "./asset-types.mjs";
54
54
  export * as assetIndex from "./asset-index.mjs";
55
+ export * as assetShipping from "./asset-shipping.mjs";
56
+ export * as assetReferences from "./asset-references.mjs";
55
57
  export * as packages from "./packages.mjs";
56
58
  export * as packagebuildIndex from "./packagebuild-index.mjs";
57
59
  export * as siteBuild from "./site-build.mjs";
@@ -148,7 +148,8 @@ export type PathnameForms = {
148
148
  */
149
149
  package: string | null;
150
150
  /**
151
- * The path inside that package's shipped tree.
151
+ * The path inside that package's `assets/`
152
+ * directory, as its repository arranges it.
152
153
  */
153
154
  suffix: string | null;
154
155
  /**
@@ -1,11 +1,30 @@
1
- /** Every affiliation has an ordinary standing that a being can name as rank 1. */
2
- export function checkAffiliationRankFloor(note: any): {
3
- severity: string;
4
- message: string;
1
+ /**
2
+ * Every affiliation's ladder has its two floor rungs: level 0, those the body
3
+ * excludes, and level 1, its ordinary members.
4
+ *
5
+ * A body that can name its members can name those it shuts out, whether or not
6
+ * it ever does, so both rungs are required of every ladder. A missing or empty
7
+ * ladder is one finding naming both; a ladder lacking either rung is one
8
+ * finding per rung it lacks, at the `ranks` key.
9
+ *
10
+ * @param {object} note - The note being checked.
11
+ * @param {string} note.file - Its path, as a diagnostic spells it.
12
+ * @param {string} [note.raw] - Its source, for the position.
13
+ * @param {object} [note.fm] - Its parsed frontmatter.
14
+ * @returns {Array<{file: string, line?: number, column?: number,
15
+ * severity: string, message: string}>} The findings.
16
+ */
17
+ export function checkAffiliationRankFloor(note: {
18
+ file: string;
19
+ raw?: string | undefined;
20
+ fm?: object | undefined;
21
+ }): Array<{
22
+ file: string;
5
23
  line?: number;
6
24
  column?: number;
7
- file: any;
8
- }[];
25
+ severity: string;
26
+ message: string;
27
+ }>;
9
28
  /**
10
29
  * Check one affiliation's `data.governance.ranks` against the rung contract.
11
30
  *
@@ -22,9 +22,10 @@
22
22
  * @param {boolean} [opts.pdf] - Whether to build the books that ship beside the
23
23
  * archive. `true` by default; `false` skips the build and reports the skip.
24
24
  * @returns {Promise<{zip: string, manifest: string, metadata?: string,
25
- * schema?: string, pdfs: string[], pdfFindings: object[], pdfSkipped: string|null,
26
- * bytes: number, version: string}>} The paths written, what the book build
27
- * found, the archive's size, and the version the manifest declares.
25
+ * schema?: string, pictures: string[], pdfs: string[], pdfFindings: object[],
26
+ * pdfSkipped: string|null, bytes: number, version: string}>} The paths
27
+ * written, what the book build found, the archive's size, and the version
28
+ * the manifest declares. `pictures` is each picture archive written.
28
29
  * `metadata` is absent when the manifest advertises no content index,
29
30
  * `schema` is absent when the stage carries no `schema.json`, and `pdfs` is
30
31
  * every book's PDF that was written, in declared order. `pdfSkipped` is
@@ -45,6 +46,7 @@ export function packRelease({ stageDir, outDir, artifact, metadataDir, pdf, }?:
45
46
  manifest: string;
46
47
  metadata?: string;
47
48
  schema?: string;
49
+ pictures: string[];
48
50
  pdfs: string[];
49
51
  pdfFindings: object[];
50
52
  pdfSkipped: string | null;
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * @param {string} type - The SoHL Item document subtype — `armorgear`, not the
12
12
  * `armor` note type that compiles into one.
13
- * @returns {string} the default image path for that subtype.
13
+ * @returns {string} the default image as an authored pathname,
14
+ * `sohl/assets/icons/<basename>`.
14
15
  */
15
16
  export function defaultItemArt(type: string): string;
16
17
  export namespace DEFAULT_ITEM_ART {
package/labels.mjs DELETED
@@ -1,191 +0,0 @@
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-z0-9-]*)`\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-z0-9-]*)`\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
- }
@@ -1,40 +0,0 @@
1
- /**
2
- * The label names the documented §3 table lists.
3
- *
4
- * A registry row is a table row whose first cell is a backticked label name,
5
- * which is narrow on purpose: §3 carries prose and other tables, and a looser
6
- * match would read a heading or an example as a label.
7
- *
8
- * @param {string} text - The documentation file's contents.
9
- * @returns {{names: Set<string>, found: boolean}} The names, and whether §3 was
10
- * located at all — an absent section is a different failure from an empty one.
11
- */
12
- export function documentedLabels(text: string): {
13
- names: Set<string>;
14
- found: boolean;
15
- };
16
- /**
17
- * Check the machine registry against the documented table.
18
- *
19
- * Findings are returned per file rather than merged, because each names a
20
- * position in a different document and the caller reports them against the
21
- * file they belong to.
22
- *
23
- * @param {object} sources
24
- * @param {string} sources.registryText - `.github/labels.yml`.
25
- * @param {string} sources.docText - `.github/ISSUE_REPORTING.md`.
26
- * @param {string} [sources.docPath] - The doc's path, for the message naming it.
27
- * @returns {{registry: object[], doc: object[], count: number}} Findings for
28
- * each file, and how many labels the registry declares.
29
- */
30
- export function checkLabelRegistry({ registryText, docText, docPath, }: {
31
- registryText: string;
32
- docText: string;
33
- docPath?: string | undefined;
34
- }): {
35
- registry: object[];
36
- doc: object[];
37
- count: number;
38
- };
39
- /** GitHub rejects a label description beyond this with a 422. */
40
- export const MAX_DESCRIPTION: 100;