@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.
- package/CHANGELOG.md +840 -0
- package/CONTENT.md +21 -1
- package/bin/content-build.mjs +105 -10
- package/bin/package-build.mjs +114 -1
- package/config.mjs +62 -3
- package/content-config.mjs +254 -22
- package/engine/base-compiler.mjs +25 -0
- package/engine/content-links.mjs +132 -27
- package/engine/diagnostics.mjs +61 -1
- package/engine/foreign-catalog.mjs +47 -0
- package/engine/generate.mjs +10 -5
- package/engine/helpers.mjs +38 -0
- package/engine/journals.mjs +8 -1
- package/engine/macros.mjs +2 -0
- package/engine/pack-config.mjs +143 -13
- package/engine/prose-lint.mjs +10 -2
- package/engine/scenes.mjs +2 -2
- package/engine/schema-check.mjs +332 -0
- package/engine/schema-extract.mjs +611 -0
- package/engine/web-wikilinks.mjs +13 -4
- package/engine/wikilink-syntax.mjs +25 -0
- package/engine/wikilinks.mjs +6 -3
- package/manifest.mjs +37 -2
- package/package.json +5 -3
- package/sohl/actors.mjs +23 -10
- package/sohl/item-fields.mjs +0 -35
- package/sohl/items.mjs +1 -1
- package/types/content-config.d.mts +14 -0
- package/types/engine/base-compiler.d.mts +18 -1
- package/types/engine/content-links.d.mts +11 -3
- package/types/engine/diagnostics.d.mts +33 -1
- package/types/engine/foreign-catalog.d.mts +15 -0
- package/types/engine/generate.d.mts +3 -2
- package/types/engine/helpers.d.mts +29 -3
- package/types/engine/journals.d.mts +7 -1
- package/types/engine/pack-config.d.mts +22 -0
- package/types/engine/prose-lint.d.mts +10 -2
- package/types/engine/schema-check.d.mts +176 -0
- package/types/engine/schema-extract.d.mts +61 -0
- package/types/engine/wikilink-syntax.d.mts +24 -0
- 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
|
+
}
|
package/engine/web-wikilinks.mjs
CHANGED
|
@@ -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 {
|
|
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 `[${
|
|
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
|
-
|
|
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(
|
|
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
|
*
|