@heroiclands/package-build 6.1.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +840 -0
  2. package/CONTENT.md +21 -1
  3. package/bin/content-build.mjs +105 -10
  4. package/bin/package-build.mjs +114 -1
  5. package/config.mjs +62 -3
  6. package/content-config.mjs +254 -22
  7. package/engine/base-compiler.mjs +25 -0
  8. package/engine/content-links.mjs +132 -27
  9. package/engine/diagnostics.mjs +61 -1
  10. package/engine/foreign-catalog.mjs +47 -0
  11. package/engine/generate.mjs +10 -5
  12. package/engine/helpers.mjs +38 -0
  13. package/engine/journals.mjs +8 -1
  14. package/engine/macros.mjs +2 -0
  15. package/engine/pack-config.mjs +143 -13
  16. package/engine/prose-lint.mjs +10 -2
  17. package/engine/scenes.mjs +2 -2
  18. package/engine/schema-check.mjs +332 -0
  19. package/engine/schema-extract.mjs +611 -0
  20. package/engine/web-wikilinks.mjs +13 -4
  21. package/engine/wikilink-syntax.mjs +25 -0
  22. package/engine/wikilinks.mjs +6 -3
  23. package/manifest.mjs +37 -2
  24. package/package.json +5 -3
  25. package/sohl/actors.mjs +23 -10
  26. package/sohl/item-fields.mjs +0 -35
  27. package/sohl/items.mjs +1 -1
  28. package/types/content-config.d.mts +14 -0
  29. package/types/engine/base-compiler.d.mts +18 -1
  30. package/types/engine/content-links.d.mts +11 -3
  31. package/types/engine/diagnostics.d.mts +33 -1
  32. package/types/engine/foreign-catalog.d.mts +15 -0
  33. package/types/engine/generate.d.mts +3 -2
  34. package/types/engine/helpers.d.mts +29 -3
  35. package/types/engine/journals.d.mts +7 -1
  36. package/types/engine/pack-config.d.mts +22 -0
  37. package/types/engine/prose-lint.d.mts +10 -2
  38. package/types/engine/schema-check.d.mts +176 -0
  39. package/types/engine/schema-extract.d.mts +61 -0
  40. package/types/engine/wikilink-syntax.d.mts +24 -0
  41. package/types/sohl/actors.d.mts +3 -3
@@ -0,0 +1,611 @@
1
+ /**
2
+ * Read a package's DataModel field sets out of its source, as data.
3
+ *
4
+ * The consuming half of this contract already lives in `schema-check.mjs`: a
5
+ * content build subtracts what its builders emit from what a document will
6
+ * actually receive, because Foundry discards an unknown `system` key at
7
+ * construction and says nothing about it (#60). What was missing is the
8
+ * producing half — until now each system carried its own extractor, and the
9
+ * first one to exist hardcoded {@link SCHEMA_ARTIFACT_VERSION}, a constant this
10
+ * package owns. Two producers stamping a third repository's constant by hand is
11
+ * the drift this module exists to remove: the version is imported, not restated.
12
+ *
13
+ * ## Read from the source, not from a running Foundry
14
+ *
15
+ * A DataModel's schema is only introspectable inside Foundry: `defineSchema()`
16
+ * returns `new StringField(...)` and friends, which do not exist in Node. So
17
+ * this reads the AST rather than a regex, because every shape it has to follow
18
+ * is structural rather than textual.
19
+ *
20
+ * TypeScript's parser reads plain JavaScript too, which is what makes one
21
+ * reader serve both shapes in play — `sohl` is TypeScript, `hm3` is JavaScript.
22
+ * That is also why the dependency sits *here*: this package already pins the
23
+ * compiler for `coverage.mjs`, so a JavaScript-only repository does not acquire
24
+ * a TypeScript pin merely to describe its own data models.
25
+ *
26
+ * ## The four shapes it follows
27
+ *
28
+ * - **A registry, not a directory walk.** The subtype → DataModel map is the
29
+ * canonical statement of which subtypes exist. Walking `*Model.js` instead
30
+ * would publish schemas for classes nothing registers, and would silently
31
+ * miss one whose filename does not match.
32
+ * - **Inheritance, however it is spelled.** `...Super.defineSchema()`,
33
+ * `...super.defineSchema()`, `Object.assign(super.defineSchema(), {…})` and a
34
+ * subclass with no `defineSchema()` at all are four spellings of one idea.
35
+ * All four are followed, because which one a repository uses is a matter of
36
+ * house style and says nothing about the schema.
37
+ * - **Delegation.** `defineSchema() { return defineXDataSchema(); }` is followed
38
+ * to the function that builds the literal.
39
+ * - **`SchemaField` nests.** `charges: new SchemaField({ value, max })` records
40
+ * `charges`, `charges.value` and `charges.max`, because a builder may write
41
+ * the whole object or the leaves. Written bare or as `fields.SchemaField`,
42
+ * since both spellings are in use.
43
+ *
44
+ * Fields reached by inheritance are recorded apart from the subtype's own,
45
+ * because the two answer different questions: a builder must not emit a field
46
+ * nothing declares *anywhere*, but it is not expected to fill the system's own
47
+ * inherited machinery.
48
+ *
49
+ * @module
50
+ */
51
+
52
+ import fs from "node:fs";
53
+ import path from "node:path";
54
+ import ts from "typescript";
55
+
56
+ import { SCHEMA_ARTIFACT_VERSION } from "./schema-check.mjs";
57
+
58
+ /**
59
+ * Which files a specifier may resolve to, in order.
60
+ *
61
+ * `.ts` leads because a TypeScript repository also ships compiled `.js`
62
+ * alongside its sources in some layouts, and the source is the thing being
63
+ * described.
64
+ */
65
+ const EXTENSIONS = [".ts", ".js", ".mjs"];
66
+
67
+ /**
68
+ * Parse one file into an AST, memoised per run.
69
+ *
70
+ * The memo matters more than it looks: a registry file is re-read once per
71
+ * subtype otherwise, and an inheritance chain re-reads its base class once per
72
+ * leaf. Both are quadratic on a file that never changes mid-run.
73
+ *
74
+ * @param {string} file - Absolute path.
75
+ * @param {Map<string, ts.SourceFile>} cache - The per-run memo.
76
+ * @returns {ts.SourceFile} The parsed file.
77
+ */
78
+ function parse(file, cache) {
79
+ const hit = cache.get(file);
80
+ if (hit) return hit;
81
+ const src = ts.createSourceFile(
82
+ file,
83
+ fs.readFileSync(file, "utf8"),
84
+ ts.ScriptTarget.Latest,
85
+ true,
86
+ );
87
+ cache.set(file, src);
88
+ return src;
89
+ }
90
+
91
+ /** A property name, whether written bare, quoted or computed-as-literal. */
92
+ function propName(name) {
93
+ if (ts.isIdentifier(name)) return name.text;
94
+ if (ts.isStringLiteral(name)) return name.text;
95
+ return name.getText();
96
+ }
97
+
98
+ /**
99
+ * The `subtype: ClassName` entries of a registry object literal.
100
+ *
101
+ * @param {ts.SourceFile} src - The parsed file holding the registry.
102
+ * @param {string} name - The registry binding, e.g. `itemModels`.
103
+ * @returns {Map<string, string>} Subtype to DataModel class name.
104
+ */
105
+ export function registryOf(src, name) {
106
+ const out = new Map();
107
+ const visit = (node) => {
108
+ if (
109
+ ts.isVariableDeclaration(node) &&
110
+ ts.isIdentifier(node.name) &&
111
+ node.name.text === name &&
112
+ node.initializer
113
+ ) {
114
+ // `{…} satisfies ItemDMMap` and `{…} as const` both wrap the
115
+ // literal without changing it.
116
+ let init = node.initializer;
117
+ if (ts.isSatisfiesExpression?.(init) || ts.isAsExpression(init)) {
118
+ init = init.expression;
119
+ }
120
+ if (ts.isObjectLiteralExpression(init)) {
121
+ for (const p of init.properties) {
122
+ if (
123
+ ts.isPropertyAssignment(p) &&
124
+ ts.isIdentifier(p.initializer)
125
+ ) {
126
+ out.set(propName(p.name), p.initializer.text);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ ts.forEachChild(node, visit);
132
+ };
133
+ visit(src);
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * The `tsconfig.json` path aliases, read rather than restated.
139
+ *
140
+ * A TypeScript repository may import every DataModel through `@src/…`, so a
141
+ * resolver that understood only relative specifiers would find none of them.
142
+ * Reading the mapping means adding an alias there does not silently make a
143
+ * subtype unreadable here.
144
+ *
145
+ * Absent for a JavaScript repository, which is not an error — it simply has no
146
+ * aliases to resolve.
147
+ *
148
+ * @param {string} rootDir - The repository root.
149
+ * @returns {[string, string][]} Prefix to directory, longest prefix first.
150
+ */
151
+ export function pathAliases(rootDir) {
152
+ const file = path.join(rootDir, "tsconfig.json");
153
+ if (!fs.existsSync(file)) return [];
154
+ // Parsed as plain JSON, deliberately. `tsconfig.json` *permits* comments,
155
+ // and a regex stripping `/* … */` eats from the slash-star inside a path
156
+ // string like `"@types/*"` to the next one, corrupting the file it was
157
+ // meant to clean. If comments ever appear, reach for a JSONC parser.
158
+ let json;
159
+ try {
160
+ json = JSON.parse(fs.readFileSync(file, "utf8"));
161
+ } catch {
162
+ return [];
163
+ }
164
+ const paths = json.compilerOptions?.paths ?? {};
165
+ return Object.entries(paths)
166
+ .filter(([, target]) => Array.isArray(target) && target.length)
167
+ .map(([alias, [target]]) => [
168
+ alias.replace(/\*$/, ""),
169
+ path.resolve(rootDir, String(target).replace(/\*$/, "")),
170
+ ])
171
+ .sort((a, b) => b[0].length - a[0].length);
172
+ }
173
+
174
+ /** Resolve an import specifier — relative or aliased — to a file on disk. */
175
+ function resolveSpecifier(fromDir, spec, aliases) {
176
+ let base = null;
177
+ if (spec.startsWith(".")) {
178
+ base = path.resolve(fromDir, spec.replace(/\.(m?js|ts)$/, ""));
179
+ } else {
180
+ for (const [prefix, dir] of aliases) {
181
+ if (!spec.startsWith(prefix)) continue;
182
+ base = path.join(dir, spec.slice(prefix.length));
183
+ break;
184
+ }
185
+ }
186
+ if (!base) return null;
187
+ for (const ext of EXTENSIONS) {
188
+ for (const candidate of [
189
+ `${base}${ext}`,
190
+ path.join(base, `index${ext}`),
191
+ ]) {
192
+ if (fs.existsSync(candidate)) return candidate;
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * Where an imported name comes from, resolved to a file on disk.
200
+ *
201
+ * @param {ts.SourceFile} src - The importing file.
202
+ * @param {string} name - The imported binding.
203
+ * @param {[string, string][]} aliases - From {@link pathAliases}.
204
+ * @returns {string|null} An absolute path, or `null` when not imported.
205
+ */
206
+ function importSourceOf(src, name, aliases) {
207
+ let found = null;
208
+ ts.forEachChild(src, (node) => {
209
+ if (found || !ts.isImportDeclaration(node)) return;
210
+ const bindings = node.importClause?.namedBindings;
211
+ if (!bindings || !ts.isNamedImports(bindings)) return;
212
+ if (!bindings.elements.some((e) => e.name.text === name)) return;
213
+ const spec = node.moduleSpecifier;
214
+ if (!ts.isStringLiteral(spec)) return;
215
+ found = resolveSpecifier(
216
+ path.dirname(src.fileName),
217
+ spec.text,
218
+ aliases,
219
+ );
220
+ });
221
+ return found;
222
+ }
223
+
224
+ /** The class declaration for `className`, if this file declares it. */
225
+ function classDeclIn(src, className) {
226
+ let found = null;
227
+ const visit = (node) => {
228
+ if (found) return;
229
+ if (ts.isClassDeclaration(node) && node.name?.text === className) {
230
+ found = node;
231
+ return;
232
+ }
233
+ ts.forEachChild(node, visit);
234
+ };
235
+ visit(src);
236
+ return found;
237
+ }
238
+
239
+ /**
240
+ * Find a class, in the file that names it or in the file that file imports it
241
+ * from.
242
+ *
243
+ * Both orders occur: a registry may sit in the same file as the classes it
244
+ * maps, or in a configuration module that imports every one of them. Looking
245
+ * locally first means neither layout needs to be declared.
246
+ *
247
+ * @returns {{file: string, src: ts.SourceFile, decl: ts.ClassDeclaration}|null}
248
+ */
249
+ function locateClass(fromFile, className, aliases, cache) {
250
+ const src = parse(fromFile, cache);
251
+ const decl = classDeclIn(src, className);
252
+ if (decl) return { file: fromFile, src, decl };
253
+
254
+ const imported = importSourceOf(src, className, aliases);
255
+ if (!imported) return null;
256
+ const importedSrc = parse(imported, cache);
257
+ const importedDecl = classDeclIn(importedSrc, className);
258
+ return importedDecl ?
259
+ { file: imported, src: importedSrc, decl: importedDecl }
260
+ : null;
261
+ }
262
+
263
+ /**
264
+ * The name of the class this one extends, when that is a plain identifier.
265
+ *
266
+ * `extends foundry.abstract.TypeDataModel` deliberately yields `null`: the
267
+ * Foundry base is where the walk stops, and it is spelled as a property access
268
+ * rather than an identifier in every repository here, so the shape that ends
269
+ * the chain is also the shape this cannot follow.
270
+ */
271
+ function superClassOf(decl) {
272
+ for (const clause of decl.heritageClauses ?? []) {
273
+ if (clause.token !== ts.SyntaxKind.ExtendsKeyword) continue;
274
+ const [type] = clause.types;
275
+ if (type && ts.isIdentifier(type.expression))
276
+ return type.expression.text;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ /** The expression a function body returns, if it returns one directly. */
282
+ function returnExpression(body) {
283
+ let found = null;
284
+ const visit = (node) => {
285
+ if (found) return;
286
+ if (ts.isReturnStatement(node) && node.expression) {
287
+ found = node.expression;
288
+ return;
289
+ }
290
+ ts.forEachChild(node, visit);
291
+ };
292
+ visit(body);
293
+ return found;
294
+ }
295
+
296
+ /** The object literal a function body returns, if it returns one directly. */
297
+ function returnedLiteral(body) {
298
+ if (ts.isObjectLiteralExpression(body)) return body;
299
+ const expr = returnExpression(body);
300
+ return expr && ts.isObjectLiteralExpression(expr) ? expr : null;
301
+ }
302
+
303
+ /**
304
+ * The object literal a named local function or arrow returns.
305
+ *
306
+ * @param {ts.SourceFile} src - The file being read.
307
+ * @param {string} fnName - The function to resolve.
308
+ * @returns {ts.ObjectLiteralExpression|null} The literal it returns.
309
+ */
310
+ function literalReturnedBy(src, fnName) {
311
+ let literal = null;
312
+ const visit = (node) => {
313
+ if (literal) return;
314
+ const isTarget =
315
+ (ts.isFunctionDeclaration(node) && node.name?.text === fnName) ||
316
+ (ts.isVariableDeclaration(node) &&
317
+ ts.isIdentifier(node.name) &&
318
+ node.name.text === fnName);
319
+ if (isTarget) {
320
+ const body =
321
+ ts.isFunctionDeclaration(node) ? node.body
322
+ : node.initializer && ts.isArrowFunction(node.initializer) ?
323
+ node.initializer.body
324
+ : null;
325
+ if (body) literal = returnedLiteral(body);
326
+ }
327
+ ts.forEachChild(node, visit);
328
+ };
329
+ visit(src);
330
+ return literal;
331
+ }
332
+
333
+ /** `X.defineSchema()` or `super.defineSchema()`, as an inheritance edge. */
334
+ function schemaCallEdge(expr) {
335
+ if (!ts.isCallExpression(expr)) return null;
336
+ const callee = expr.expression;
337
+ if (ts.isIdentifier(callee)) {
338
+ // `...defineSohlItemDataSchema()` — a local builder function.
339
+ return { kind: "local", name: callee.text };
340
+ }
341
+ if (!ts.isPropertyAccessExpression(callee)) return null;
342
+ if (callee.name.text !== "defineSchema") return null;
343
+ if (callee.expression.kind === ts.SyntaxKind.SuperKeyword) {
344
+ return { kind: "super", name: null };
345
+ }
346
+ if (ts.isIdentifier(callee.expression)) {
347
+ return { kind: "class", name: callee.expression.text };
348
+ }
349
+ return null;
350
+ }
351
+
352
+ /** `Object.assign(…)`. */
353
+ function isObjectAssign(expr) {
354
+ return (
355
+ ts.isCallExpression(expr) &&
356
+ ts.isPropertyAccessExpression(expr.expression) &&
357
+ ts.isIdentifier(expr.expression.expression) &&
358
+ expr.expression.expression.text === "Object" &&
359
+ expr.expression.name.text === "assign"
360
+ );
361
+ }
362
+
363
+ /**
364
+ * What a class's `defineSchema()` contributes: its own literals, and the edges
365
+ * it inherits along.
366
+ *
367
+ * A subclass with no `defineSchema()` at all contributes nothing of its own and
368
+ * inherits everything — `class MiscGearModel extends GearModel {}` is a real
369
+ * and meaningful declaration, not a gap in the data.
370
+ *
371
+ * @returns {{literals: ts.ObjectLiteralExpression[], edges: object[]}|null}
372
+ * `null` when the class declares a `defineSchema()` this cannot follow.
373
+ */
374
+ function schemaContributions(src, decl) {
375
+ const method = decl.members.find(
376
+ (m) =>
377
+ ts.isMethodDeclaration(m) &&
378
+ propName(m.name) === "defineSchema" &&
379
+ m.body,
380
+ );
381
+ // No `defineSchema()` — the whole schema is the parent's.
382
+ if (!method)
383
+ return { literals: [], edges: [{ kind: "super", name: null }] };
384
+
385
+ const direct = returnedLiteral(method.body);
386
+ if (direct) return { literals: [direct], edges: [] };
387
+
388
+ const ret = returnExpression(method.body);
389
+ if (!ret) return null;
390
+
391
+ // `return Object.assign(super.defineSchema(), { … })`. Every object
392
+ // literal argument contributes fields — including the `{}` some styles
393
+ // pass as the target — and every `defineSchema()` call is an edge.
394
+ if (isObjectAssign(ret)) {
395
+ const literals = [];
396
+ const edges = [];
397
+ for (const arg of ret.arguments) {
398
+ if (ts.isObjectLiteralExpression(arg)) literals.push(arg);
399
+ const edge = schemaCallEdge(arg);
400
+ if (edge) edges.push(edge);
401
+ }
402
+ return { literals, edges };
403
+ }
404
+
405
+ // `return defineXDataSchema();` — follow the identifier once.
406
+ const edge = schemaCallEdge(ret);
407
+ if (edge?.kind === "local") {
408
+ const lit = literalReturnedBy(src, edge.name);
409
+ if (lit) return { literals: [lit], edges: [] };
410
+ }
411
+ return null;
412
+ }
413
+
414
+ /** Whether an expression is a `SchemaField` construction, however spelled. */
415
+ function isSchemaField(expr) {
416
+ if (!ts.isNewExpression(expr)) return false;
417
+ const callee = expr.expression;
418
+ if (ts.isIdentifier(callee)) return callee.text === "SchemaField";
419
+ // `new fields.SchemaField({ … })`
420
+ if (ts.isPropertyAccessExpression(callee)) {
421
+ return callee.name.text === "SchemaField";
422
+ }
423
+ return false;
424
+ }
425
+
426
+ /** The keys of a `new SchemaField({ … })`, recursively dotted. */
427
+ function nestedKeysOf(expr) {
428
+ if (!isSchemaField(expr)) return [];
429
+ const arg = expr.arguments?.[0];
430
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return [];
431
+ const out = [];
432
+ for (const p of arg.properties) {
433
+ if (!ts.isPropertyAssignment(p)) continue;
434
+ const key = propName(p.name);
435
+ out.push(key);
436
+ for (const child of nestedKeysOf(p.initializer)) {
437
+ out.push(`${key}.${child}`);
438
+ }
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * Every field path an object literal declares, and what it spreads.
445
+ *
446
+ * @returns {{own: string[], edges: object[]}}
447
+ */
448
+ function readLiteral(literal) {
449
+ const own = [];
450
+ const edges = [];
451
+ for (const p of literal.properties) {
452
+ if (ts.isSpreadAssignment(p)) {
453
+ const edge = schemaCallEdge(p.expression);
454
+ if (edge) edges.push(edge);
455
+ continue;
456
+ }
457
+ if (!ts.isPropertyAssignment(p)) continue;
458
+ const key = propName(p.name);
459
+ own.push(key);
460
+ for (const child of nestedKeysOf(p.initializer)) {
461
+ own.push(`${key}.${child}`);
462
+ }
463
+ }
464
+ return { own, edges };
465
+ }
466
+
467
+ /**
468
+ * Resolve one subtype's schema into own and inherited field paths.
469
+ *
470
+ * @param {object} opts - Resolution inputs.
471
+ * @param {string} opts.file - The file naming the class (registry or importer).
472
+ * @param {string} opts.className - The DataModel class.
473
+ * @param {[string, string][]} opts.aliases - From {@link pathAliases}.
474
+ * @param {Map<string, ts.SourceFile>} opts.cache - The per-run parse memo.
475
+ * @param {string} opts.rootDir - For readable error paths.
476
+ * @returns {{own: string[], inherited: string[]}}
477
+ */
478
+ export function fieldsForClass({ file, className, aliases, cache, rootDir }) {
479
+ const own = [];
480
+ const inherited = [];
481
+ const seen = new Set();
482
+
483
+ /**
484
+ * Walk one class, attributing the fields it declares itself to `into` and
485
+ * everything above it to `inherited`.
486
+ */
487
+ const walkClass = (fromFile, name, into) => {
488
+ const key = `class:${fromFile}:${name}`;
489
+ if (seen.has(key)) return;
490
+ seen.add(key);
491
+
492
+ const located = locateClass(fromFile, name, aliases, cache);
493
+ if (!located) return;
494
+ const contributions = schemaContributions(located.src, located.decl);
495
+ if (!contributions) return;
496
+ const superName = superClassOf(located.decl);
497
+
498
+ for (const literal of contributions.literals) {
499
+ walkLiteral(located, literal, into, superName);
500
+ }
501
+ for (const edge of contributions.edges) {
502
+ followEdge(located, edge, superName);
503
+ }
504
+ };
505
+
506
+ /** Walk a schema literal, following the spreads inside it. */
507
+ const walkLiteral = (located, literal, into, superName) => {
508
+ const { own: fields, edges } = readLiteral(literal);
509
+ into.push(...fields);
510
+ for (const edge of edges) followEdge(located, edge, superName);
511
+ };
512
+
513
+ /** Follow one inheritance edge; everything it reaches is inherited. */
514
+ const followEdge = (located, edge, superName) => {
515
+ if (edge.kind === "local") {
516
+ const key = `local:${located.file}:${edge.name}`;
517
+ if (seen.has(key)) return;
518
+ seen.add(key);
519
+ // A local spread inside a subtype's own definition is still the
520
+ // parent's contribution — a shared `defineXDataSchema()` is where
521
+ // the common fields come from — so it lands in `inherited`
522
+ // whichever file it is written in.
523
+ const lit = literalReturnedBy(located.src, edge.name);
524
+ if (lit) walkLiteral(located, lit, inherited, superName);
525
+ return;
526
+ }
527
+ const name = edge.kind === "super" ? superName : edge.name;
528
+ if (name) walkClass(located.file, name, inherited);
529
+ };
530
+
531
+ const located = locateClass(file, className, aliases, cache);
532
+ if (!located) {
533
+ throw new Error(
534
+ `${className} is registered in ` +
535
+ `${path.relative(rootDir, file)} but its declaration cannot be ` +
536
+ `found there or in anything it imports, so its schema cannot ` +
537
+ `be read.`,
538
+ );
539
+ }
540
+ if (!schemaContributions(located.src, located.decl)) {
541
+ throw new Error(
542
+ `${className} (${path.relative(rootDir, located.file)}) declares a ` +
543
+ `defineSchema() this reader cannot follow.`,
544
+ );
545
+ }
546
+
547
+ walkClass(file, className, own);
548
+
549
+ const ownSet = new Set(own);
550
+ return {
551
+ own: [...ownSet].sort(),
552
+ inherited: [...new Set(inherited)].filter((f) => !ownSet.has(f)).sort(),
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Build the whole artifact from a package's source.
558
+ *
559
+ * @param {object} opts - Inputs.
560
+ * @param {string} opts.rootDir - The repository root.
561
+ * @param {object[]} opts.registries - `{documentType, from, registry}` entries.
562
+ * @param {string} opts.packageId - The Foundry package id.
563
+ * @param {string} opts.version - The package version.
564
+ * @returns {object} The artifact `schema-check.mjs` reads.
565
+ */
566
+ export function buildSchemaArtifact({
567
+ rootDir,
568
+ registries,
569
+ packageId,
570
+ version,
571
+ }) {
572
+ const aliases = pathAliases(rootDir);
573
+ const cache = new Map();
574
+ const documents = {};
575
+
576
+ for (const { documentType, from, registry } of registries) {
577
+ const file = path.resolve(rootDir, from);
578
+ if (!fs.existsSync(file)) {
579
+ throw new Error(
580
+ `packageBuild.schema.${documentType}.from names ${from}, ` +
581
+ `which does not exist.`,
582
+ );
583
+ }
584
+ const map = registryOf(parse(file, cache), registry);
585
+ if (!map.size) {
586
+ throw new Error(
587
+ `\`${registry}\` was not found in ${from}, or maps nothing. ` +
588
+ `The registry is what says which subtypes exist, so an ` +
589
+ `empty read would publish a schema that silently covers ` +
590
+ `nothing.`,
591
+ );
592
+ }
593
+ documents[documentType] = {};
594
+ for (const [subtype, className] of [...map].sort()) {
595
+ documents[documentType][subtype] = fieldsForClass({
596
+ file,
597
+ className,
598
+ aliases,
599
+ cache,
600
+ rootDir,
601
+ });
602
+ }
603
+ }
604
+
605
+ return {
606
+ version: SCHEMA_ARTIFACT_VERSION,
607
+ system: packageId,
608
+ systemVersion: version,
609
+ documents,
610
+ };
611
+ }
@@ -46,7 +46,12 @@ import { slugify } from "./content-slug.mjs";
46
46
  // Re-exported so a site build keeps one import path for the whole of link
47
47
  // resolution: the same rule that names a page also names an anchor within it.
48
48
  export { slugify };
49
- import { WIKILINK, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
49
+ import {
50
+ authoredLabel,
51
+ WIKILINK,
52
+ isSamePage,
53
+ parseWikilink,
54
+ } from "./wikilink-syntax.mjs";
50
55
 
51
56
  /** KB heading/anchor slug: lowercase, non-alphanumerics to single hyphens. */
52
57
 
@@ -240,10 +245,14 @@ export function resolveWebWikilinks(body, ctx) {
240
245
  // inline span is source text, not a link (#1505).
241
246
  return replaceOutsideCode(body, WIKILINK, (_m, rawInner) => {
242
247
  const { target, anchor, display } = parseWikilink(rawInner);
248
+ // An empty label is not a label: `[[x|]]` addresses the target and
249
+ // shows its name, so `""` falls through to the same place `null` does
250
+ // (#113). One reading, from {@link authoredLabel}.
251
+ const label = authoredLabel({ display });
243
252
 
244
253
  // `[[#section-slug|Text]]` — a section of this same page.
245
254
  if (isSamePage({ target, anchor })) {
246
- return `[${display ?? anchor}](#${slugify(anchor)})`;
255
+ return `[${label ?? anchor}](#${slugify(anchor)})`;
247
256
  }
248
257
 
249
258
  const key = target.toLowerCase();
@@ -272,7 +281,7 @@ export function resolveWebWikilinks(body, ctx) {
272
281
  // (#1398), and a hyphen inside a note *name* ("Grukar-ahk") is not
273
282
  // one, which is why the rule is the packs' own (#1409).
274
283
  const text =
275
- display ??
284
+ label ??
276
285
  (isAddress(target, ctx.contentTypes) ? hit.name : target);
277
286
  // A pack-only package publishes Foundry addresses and no pages
278
287
  // (#1516), so its entries carry no `path` and resolve to no URL.
@@ -325,6 +334,6 @@ export function resolveWebWikilinks(body, ctx) {
325
334
  reason: "broken type/shortcode",
326
335
  });
327
336
  }
328
- return unresolvedLink(display ?? target, target);
337
+ return unresolvedLink(label ?? target, target);
329
338
  });
330
339
  }
@@ -91,6 +91,31 @@ export function parseWikilink(rawInner) {
91
91
  return { inner: inner.trim(), target, anchor, display, labelled };
92
92
  }
93
93
 
94
+ /**
95
+ * The label an author actually supplied, or `null` when they supplied none.
96
+ *
97
+ * **An empty label is not a label.** `[[x|]]` is deliberately writable — it
98
+ * means "address this target, and show the target's own name" — so `display:
99
+ * ""` has to read as *absent* everywhere a fallback is chosen, exactly as
100
+ * `display: null` does. The two are still distinguishable through
101
+ * {@link ParsedWikilink.labelled}, which is the thing that genuinely differs
102
+ * and which #1409 depends on.
103
+ *
104
+ * Stated here because the two resolvers had already drawn the line in two
105
+ * places and drawn it differently: the packs tested falsiness and were right,
106
+ * the web tested `??` — which falls through on `null` only — and emitted
107
+ * `[](/url/)`, a link with no clickable text, through every build (#113). That
108
+ * is the same drift this module exists to prevent, in the case its own
109
+ * {@link ParsedWikilink} docstring calls out. One reading, one place.
110
+ *
111
+ * @param {{display: string|null}} parsed - A parsed wikilink, or anything
112
+ * carrying its `display`.
113
+ * @returns {string|null} The label, or `null` when there is none to show.
114
+ */
115
+ export function authoredLabel({ display }) {
116
+ return display ? display : null;
117
+ }
118
+
94
119
  /**
95
120
  * Whether a parsed link addresses a section of the page it is written on.
96
121
  *