create-kerf-component 5.0.0-beta.8 → 5.0.0-beta.80
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/README.md +75 -3
- package/catalog.js +937 -0
- package/component-composition.schema.json +408 -0
- package/component-metadata.schema.json +282 -0
- package/index.js +30 -3
- package/package.json +9 -2
- package/template/README.md +53 -10
- package/template/component-composition.json +144 -0
- package/template/kerf.components.json +71 -0
- package/template/package.json +15 -3
- package/template/src/counter.css +38 -0
- package/template/src/counter.tsx +18 -3
package/catalog.js
ADDED
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
12
|
+
import process from 'node:process';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
const CONFIG_KEY = 'kerfComponentCatalog';
|
|
17
|
+
const ignoredDirectories = new Set(['.git', 'dist', 'node_modules']);
|
|
18
|
+
const scriptRoot = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const metadataSchema = JSON.parse(
|
|
20
|
+
readFileSync(join(scriptRoot, 'component-metadata.schema.json'), 'utf8'),
|
|
21
|
+
);
|
|
22
|
+
const catalogSchema = JSON.parse(
|
|
23
|
+
readFileSync(join(scriptRoot, 'component-composition.schema.json'), 'utf8'),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export class CatalogError extends Error {
|
|
27
|
+
constructor(diagnostics) {
|
|
28
|
+
super(diagnostics.join('\n'));
|
|
29
|
+
this.name = 'CatalogError';
|
|
30
|
+
this.diagnostics = diagnostics;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJson(path, label, diagnostics) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
diagnostics.push(`${label}: ${error.message}`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function schemaReference(rootSchema, reference) {
|
|
44
|
+
if (!reference.startsWith('#/'))
|
|
45
|
+
throw new Error(`unsupported schema reference ${reference}`);
|
|
46
|
+
return reference
|
|
47
|
+
.slice(2)
|
|
48
|
+
.split('/')
|
|
49
|
+
.map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))
|
|
50
|
+
.reduce((value, part) => value?.[part], rootSchema);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function schemaTypeMatches(value, type) {
|
|
54
|
+
if (type === 'array') return Array.isArray(value);
|
|
55
|
+
if (type === 'object')
|
|
56
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
57
|
+
if (type === 'integer') return Number.isInteger(value);
|
|
58
|
+
return typeof value === type;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateSchemaValue(value, rule, path, rootSchema, diagnostics) {
|
|
62
|
+
if (rule.$ref) {
|
|
63
|
+
const referenced = schemaReference(rootSchema, rule.$ref);
|
|
64
|
+
if (!referenced) {
|
|
65
|
+
diagnostics.push(`${path}: schema reference ${rule.$ref} does not exist`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
validateSchemaValue(value, referenced, path, rootSchema, diagnostics);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (rule.allOf) {
|
|
72
|
+
for (const candidate of rule.allOf)
|
|
73
|
+
validateSchemaValue(value, candidate, path, rootSchema, diagnostics);
|
|
74
|
+
}
|
|
75
|
+
if (rule.anyOf) {
|
|
76
|
+
const alternatives = rule.anyOf.map((candidate) => {
|
|
77
|
+
const failures = [];
|
|
78
|
+
validateSchemaValue(value, candidate, path, rootSchema, failures);
|
|
79
|
+
return failures;
|
|
80
|
+
});
|
|
81
|
+
if (alternatives.every((failures) => failures.length))
|
|
82
|
+
diagnostics.push(`${path}: does not match any allowed schema variant`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (rule.const !== undefined && value !== rule.const)
|
|
86
|
+
diagnostics.push(`${path}: expected ${JSON.stringify(rule.const)}`);
|
|
87
|
+
if (rule.enum && !rule.enum.includes(value))
|
|
88
|
+
diagnostics.push(
|
|
89
|
+
`${path}: expected one of ${rule.enum.map((item) => JSON.stringify(item)).join(', ')}`,
|
|
90
|
+
);
|
|
91
|
+
if (rule.type && !schemaTypeMatches(value, rule.type)) {
|
|
92
|
+
diagnostics.push(`${path}: expected ${rule.type}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (typeof value === 'string') {
|
|
96
|
+
if (rule.minLength !== undefined && value.length < rule.minLength)
|
|
97
|
+
diagnostics.push(
|
|
98
|
+
`${path}: expected at least ${rule.minLength} character(s)`,
|
|
99
|
+
);
|
|
100
|
+
if (rule.pattern && !new RegExp(rule.pattern).test(value))
|
|
101
|
+
diagnostics.push(`${path}: must match ${rule.pattern}`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof value === 'number' && rule.minimum !== undefined) {
|
|
104
|
+
if (value < rule.minimum)
|
|
105
|
+
diagnostics.push(`${path}: expected a value >= ${rule.minimum}`);
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
if (rule.minItems !== undefined && value.length < rule.minItems)
|
|
109
|
+
diagnostics.push(`${path}: expected at least ${rule.minItems} item(s)`);
|
|
110
|
+
if (rule.uniqueItems) {
|
|
111
|
+
const serialized = value.map((item) => JSON.stringify(item));
|
|
112
|
+
if (new Set(serialized).size !== serialized.length)
|
|
113
|
+
diagnostics.push(`${path}: expected unique items`);
|
|
114
|
+
}
|
|
115
|
+
if (rule.items)
|
|
116
|
+
value.forEach((item, index) =>
|
|
117
|
+
validateSchemaValue(
|
|
118
|
+
item,
|
|
119
|
+
rule.items,
|
|
120
|
+
`${path}[${index}]`,
|
|
121
|
+
rootSchema,
|
|
122
|
+
diagnostics,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
127
|
+
for (const key of rule.required ?? [])
|
|
128
|
+
if (!(key in value)) diagnostics.push(`${path}.${key}: is required`);
|
|
129
|
+
const properties = rule.properties ?? {};
|
|
130
|
+
for (const [key, item] of Object.entries(value)) {
|
|
131
|
+
if (key in properties)
|
|
132
|
+
validateSchemaValue(
|
|
133
|
+
item,
|
|
134
|
+
properties[key],
|
|
135
|
+
`${path}.${key}`,
|
|
136
|
+
rootSchema,
|
|
137
|
+
diagnostics,
|
|
138
|
+
);
|
|
139
|
+
else if (rule.additionalProperties === false)
|
|
140
|
+
diagnostics.push(`${path}.${key}: additional property is not allowed`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function validateDocumentAgainstSchema(value, schema, path) {
|
|
146
|
+
const diagnostics = [];
|
|
147
|
+
validateSchemaValue(value, schema, path, schema, diagnostics);
|
|
148
|
+
return diagnostics;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function expandWorkspace(root, pattern) {
|
|
152
|
+
const normalized = pattern.replaceAll('/', sep);
|
|
153
|
+
if (!normalized.includes('*')) return [resolve(root, normalized)];
|
|
154
|
+
const star = normalized.indexOf('*');
|
|
155
|
+
const parent = resolve(root, normalized.slice(0, star));
|
|
156
|
+
const suffix = normalized.slice(star + 1);
|
|
157
|
+
if (!existsSync(parent)) return [];
|
|
158
|
+
return readdirSync(parent, { withFileTypes: true })
|
|
159
|
+
.filter(
|
|
160
|
+
(entry) => entry.isDirectory() && !ignoredDirectories.has(entry.name),
|
|
161
|
+
)
|
|
162
|
+
.map((entry) => resolve(parent, entry.name + suffix))
|
|
163
|
+
.filter((path) => existsSync(path) && statSync(path).isDirectory());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function configuredPackages(root, diagnostics) {
|
|
167
|
+
const rootPackagePath = join(root, 'package.json');
|
|
168
|
+
const rootPackage = readJson(rootPackagePath, rootPackagePath, diagnostics);
|
|
169
|
+
if (!rootPackage) return [];
|
|
170
|
+
const packages = rootPackage[CONFIG_KEY] ? [root] : [];
|
|
171
|
+
const workspacePatterns = Array.isArray(rootPackage.workspaces)
|
|
172
|
+
? rootPackage.workspaces
|
|
173
|
+
: (rootPackage.workspaces?.packages ?? []);
|
|
174
|
+
for (const pattern of workspacePatterns) {
|
|
175
|
+
for (const packageRoot of expandWorkspace(root, pattern)) {
|
|
176
|
+
const packagePath = join(packageRoot, 'package.json');
|
|
177
|
+
if (!existsSync(packagePath)) continue;
|
|
178
|
+
const packageJson = readJson(packagePath, packagePath, diagnostics);
|
|
179
|
+
if (packageJson?.[CONFIG_KEY]) packages.push(packageRoot);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return [...new Set(packages)].sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function valueAt(value, path) {
|
|
186
|
+
return path.split('.').reduce((current, part) => current?.[part], value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Paths where an explicit `null` is itself the author's decision: a component
|
|
190
|
+
// whose root element belongs to another catalog (a `rendersAs` wrapper) or that
|
|
191
|
+
// exposes no public root class.
|
|
192
|
+
const NULLABLE_DECISIONS = new Set(['boundaries.rootClass']);
|
|
193
|
+
|
|
194
|
+
function requireDecision(component, path, at, diagnostics) {
|
|
195
|
+
const value = valueAt(component, path);
|
|
196
|
+
const explicitNull =
|
|
197
|
+
value === null &&
|
|
198
|
+
NULLABLE_DECISIONS.has(path) &&
|
|
199
|
+
Object.hasOwn(
|
|
200
|
+
valueAt(component, path.slice(0, path.lastIndexOf('.'))) ?? {},
|
|
201
|
+
path.slice(path.lastIndexOf('.') + 1),
|
|
202
|
+
);
|
|
203
|
+
if (explicitNull) return;
|
|
204
|
+
if (value === undefined || value === null || value === '') {
|
|
205
|
+
diagnostics.push(
|
|
206
|
+
`${at}.${path}: author decision required; the generator does not infer semantics or geometry from rendered appearance`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let typescript;
|
|
212
|
+
|
|
213
|
+
function typescriptApi() {
|
|
214
|
+
if (typescript) return typescript;
|
|
215
|
+
try {
|
|
216
|
+
typescript = createRequire(import.meta.url)('typescript');
|
|
217
|
+
return typescript;
|
|
218
|
+
} catch {
|
|
219
|
+
throw new Error(
|
|
220
|
+
'TypeScript is required for syntax-aware export verification; run npm install before catalog generation.',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasModifier(ts, node, kind) {
|
|
226
|
+
return Boolean(node.modifiers?.some((modifier) => modifier.kind === kind));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function bindingNames(ts, name, names) {
|
|
230
|
+
if (ts.isIdentifier(name)) names.add(name.text);
|
|
231
|
+
else
|
|
232
|
+
for (const element of name.elements ?? [])
|
|
233
|
+
if (!ts.isOmittedExpression(element))
|
|
234
|
+
bindingNames(ts, element.name, names);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function exportedNamesFromSource(source, fileName = 'component.tsx') {
|
|
238
|
+
const ts = typescriptApi();
|
|
239
|
+
const sourceFile = ts.createSourceFile(
|
|
240
|
+
fileName,
|
|
241
|
+
source,
|
|
242
|
+
ts.ScriptTarget.Latest,
|
|
243
|
+
true,
|
|
244
|
+
fileName.endsWith('.tsx') || fileName.endsWith('.jsx')
|
|
245
|
+
? ts.ScriptKind.TSX
|
|
246
|
+
: ts.ScriptKind.TS,
|
|
247
|
+
);
|
|
248
|
+
if (sourceFile.parseDiagnostics.length) {
|
|
249
|
+
const diagnostic = sourceFile.parseDiagnostics[0];
|
|
250
|
+
const point = sourceFile.getLineAndCharacterOfPosition(
|
|
251
|
+
diagnostic.start ?? 0,
|
|
252
|
+
);
|
|
253
|
+
throw new Error(
|
|
254
|
+
`${fileName}:${point.line + 1}:${point.character + 1}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const names = new Set();
|
|
258
|
+
for (const statement of sourceFile.statements) {
|
|
259
|
+
if (ts.isExportDeclaration(statement)) {
|
|
260
|
+
if (!statement.exportClause) continue;
|
|
261
|
+
if (ts.isNamedExports(statement.exportClause))
|
|
262
|
+
for (const element of statement.exportClause.elements)
|
|
263
|
+
names.add(element.name.text);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
!hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword) ||
|
|
268
|
+
hasModifier(ts, statement, ts.SyntaxKind.DefaultKeyword)
|
|
269
|
+
)
|
|
270
|
+
continue;
|
|
271
|
+
if (ts.isVariableStatement(statement)) {
|
|
272
|
+
for (const declaration of statement.declarationList.declarations)
|
|
273
|
+
bindingNames(ts, declaration.name, names);
|
|
274
|
+
} else if (
|
|
275
|
+
(ts.isFunctionDeclaration(statement) ||
|
|
276
|
+
ts.isClassDeclaration(statement) ||
|
|
277
|
+
ts.isEnumDeclaration(statement) ||
|
|
278
|
+
ts.isModuleDeclaration(statement) ||
|
|
279
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
280
|
+
ts.isTypeAliasDeclaration(statement)) &&
|
|
281
|
+
statement.name
|
|
282
|
+
)
|
|
283
|
+
names.add(statement.name.text);
|
|
284
|
+
}
|
|
285
|
+
return names;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function exportedNamesFromFile(path) {
|
|
289
|
+
return exportedNamesFromSource(readFileSync(path, 'utf8'), path);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function sourceEntryForExport(packageRoot, definition) {
|
|
293
|
+
const target =
|
|
294
|
+
typeof definition === 'string'
|
|
295
|
+
? definition
|
|
296
|
+
: (definition?.import ?? definition?.default);
|
|
297
|
+
if (typeof target !== 'string' || !target.startsWith('./dist/')) return null;
|
|
298
|
+
const stem = target.slice('./dist/'.length).replace(/\.(?:mjs|cjs|js)$/, '');
|
|
299
|
+
for (const extension of ['.ts', '.tsx', '.mts', '.js', '.jsx', '.mjs']) {
|
|
300
|
+
const candidate = join(packageRoot, 'src', `${stem}${extension}`);
|
|
301
|
+
if (existsSync(candidate)) return candidate;
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function validateStringList(value, at, diagnostics, { nonempty = false } = {}) {
|
|
307
|
+
if (
|
|
308
|
+
!Array.isArray(value) ||
|
|
309
|
+
(nonempty && value.length === 0) ||
|
|
310
|
+
value.some((item) => typeof item !== 'string' || item.length === 0) ||
|
|
311
|
+
new Set(value).size !== value.length
|
|
312
|
+
)
|
|
313
|
+
diagnostics.push(
|
|
314
|
+
`${at}: expected a${nonempty ? ' non-empty' : ''} unique string list`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function validateComposition(component, at, diagnostics) {
|
|
319
|
+
const composition = component.composition ?? {};
|
|
320
|
+
if (!['any', 'root', 'listed'].includes(composition.parents?.mode))
|
|
321
|
+
diagnostics.push(`${at}.composition.parents.mode: invalid parent mode`);
|
|
322
|
+
validateStringList(
|
|
323
|
+
composition.parents?.entries,
|
|
324
|
+
`${at}.composition.parents.entries`,
|
|
325
|
+
diagnostics,
|
|
326
|
+
{ nonempty: composition.parents?.mode === 'listed' },
|
|
327
|
+
);
|
|
328
|
+
validateStringList(
|
|
329
|
+
composition.contexts,
|
|
330
|
+
`${at}.composition.contexts`,
|
|
331
|
+
diagnostics,
|
|
332
|
+
);
|
|
333
|
+
if (!Array.isArray(composition.zones))
|
|
334
|
+
diagnostics.push(`${at}.composition.zones: expected an array`);
|
|
335
|
+
const zoneIds = new Set();
|
|
336
|
+
for (const zone of composition.zones ?? []) {
|
|
337
|
+
const zoneAt = `${at}.composition.zones.${zone?.id ?? '<unknown>'}`;
|
|
338
|
+
if (!zone?.id || zoneIds.has(zone.id))
|
|
339
|
+
diagnostics.push(`${zoneAt}.id: expected a unique non-empty id`);
|
|
340
|
+
zoneIds.add(zone?.id);
|
|
341
|
+
validateStringList(zone?.accepts, `${zoneAt}.accepts`, diagnostics, {
|
|
342
|
+
nonempty: true,
|
|
343
|
+
});
|
|
344
|
+
const { min, max } = zone?.cardinality ?? {};
|
|
345
|
+
if (!Number.isInteger(min) || min < 0)
|
|
346
|
+
diagnostics.push(
|
|
347
|
+
`${zoneAt}.cardinality.min: expected a non-negative integer`,
|
|
348
|
+
);
|
|
349
|
+
if (max !== 'unbounded' && (!Number.isInteger(max) || max < 1 || max < min))
|
|
350
|
+
diagnostics.push(
|
|
351
|
+
`${zoneAt}.cardinality.max: expected unbounded or an integer >= min`,
|
|
352
|
+
);
|
|
353
|
+
validateStringList(
|
|
354
|
+
zone?.exclusiveWith,
|
|
355
|
+
`${zoneAt}.exclusiveWith`,
|
|
356
|
+
diagnostics,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
for (const zone of composition.zones ?? [])
|
|
360
|
+
for (const other of zone?.exclusiveWith ?? [])
|
|
361
|
+
if (other === zone.id || !zoneIds.has(other))
|
|
362
|
+
diagnostics.push(
|
|
363
|
+
`${at}.composition.zones.${zone.id}.exclusiveWith: unknown or self-referential zone ${other}`,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
if (!['any', 'none', 'listed'].includes(composition.children?.mode))
|
|
367
|
+
diagnostics.push(`${at}.composition.children.mode: invalid child mode`);
|
|
368
|
+
validateStringList(
|
|
369
|
+
composition.children?.concepts,
|
|
370
|
+
`${at}.composition.children.concepts`,
|
|
371
|
+
diagnostics,
|
|
372
|
+
{ nonempty: composition.children?.mode === 'listed' },
|
|
373
|
+
);
|
|
374
|
+
validateStringList(
|
|
375
|
+
composition.children?.requiredConcepts,
|
|
376
|
+
`${at}.composition.children.requiredConcepts`,
|
|
377
|
+
diagnostics,
|
|
378
|
+
);
|
|
379
|
+
for (const required of composition.children?.requiredConcepts ?? [])
|
|
380
|
+
if (!composition.children.concepts?.includes(required))
|
|
381
|
+
diagnostics.push(
|
|
382
|
+
`${at}.composition.children.requiredConcepts: ${required} is not an allowed concept`,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
if (!Array.isArray(composition.state))
|
|
386
|
+
diagnostics.push(`${at}.composition.state: expected an array`);
|
|
387
|
+
const stateIds = new Set();
|
|
388
|
+
for (const state of composition.state ?? []) {
|
|
389
|
+
if (!state?.id || stateIds.has(state.id))
|
|
390
|
+
diagnostics.push(
|
|
391
|
+
`${at}.composition.state: state ids must be unique and non-empty`,
|
|
392
|
+
);
|
|
393
|
+
stateIds.add(state?.id);
|
|
394
|
+
if (!['application', 'controlled', 'component'].includes(state?.owner))
|
|
395
|
+
diagnostics.push(
|
|
396
|
+
`${at}.composition.state.${state?.id}.owner: invalid owner`,
|
|
397
|
+
);
|
|
398
|
+
if (typeof state?.required !== 'boolean')
|
|
399
|
+
diagnostics.push(
|
|
400
|
+
`${at}.composition.state.${state?.id}.required: expected boolean`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
if (typeof composition.wiring?.required !== 'boolean')
|
|
404
|
+
diagnostics.push(`${at}.composition.wiring.required: expected boolean`);
|
|
405
|
+
validateStringList(
|
|
406
|
+
composition.wiring?.helpers,
|
|
407
|
+
`${at}.composition.wiring.helpers`,
|
|
408
|
+
diagnostics,
|
|
409
|
+
{ nonempty: composition.wiring?.required === true },
|
|
410
|
+
);
|
|
411
|
+
validateStringList(
|
|
412
|
+
composition.wiring?.obligations,
|
|
413
|
+
`${at}.composition.wiring.obligations`,
|
|
414
|
+
diagnostics,
|
|
415
|
+
);
|
|
416
|
+
const stateAttributes = composition.wiring?.stateAttributes;
|
|
417
|
+
if (stateAttributes !== undefined && !Array.isArray(stateAttributes))
|
|
418
|
+
diagnostics.push(
|
|
419
|
+
`${at}.composition.wiring.stateAttributes: expected an array`,
|
|
420
|
+
);
|
|
421
|
+
const stateAttributeNames = new Set();
|
|
422
|
+
for (const attribute of Array.isArray(stateAttributes)
|
|
423
|
+
? stateAttributes
|
|
424
|
+
: []) {
|
|
425
|
+
const name = attribute?.name;
|
|
426
|
+
const attributeAt = `${at}.composition.wiring.stateAttributes.${name}`;
|
|
427
|
+
if (typeof name !== 'string' || !/^data-[a-z0-9]+(-[a-z0-9]+)*$/.test(name))
|
|
428
|
+
diagnostics.push(`${attributeAt}.name: expected a data-* attribute name`);
|
|
429
|
+
if (stateAttributeNames.has(name))
|
|
430
|
+
diagnostics.push(`${attributeAt}.name: declared more than once`);
|
|
431
|
+
stateAttributeNames.add(name);
|
|
432
|
+
for (const field of ['on', 'meaning'])
|
|
433
|
+
if (typeof attribute?.[field] !== 'string' || !attribute[field])
|
|
434
|
+
diagnostics.push(
|
|
435
|
+
`${attributeAt}.${field}: expected a non-empty string`,
|
|
436
|
+
);
|
|
437
|
+
if (!composition.wiring.helpers?.includes(attribute?.helper))
|
|
438
|
+
diagnostics.push(
|
|
439
|
+
`${attributeAt}.helper: ${attribute?.helper} is not listed in composition.wiring.helpers`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (
|
|
443
|
+
!['application', 'component', 'shared', 'not-applicable'].includes(
|
|
444
|
+
composition.responsive?.owner,
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
diagnostics.push(`${at}.composition.responsive.owner: invalid owner`);
|
|
448
|
+
validateStringList(
|
|
449
|
+
composition.responsive?.behaviors,
|
|
450
|
+
`${at}.composition.responsive.behaviors`,
|
|
451
|
+
diagnostics,
|
|
452
|
+
);
|
|
453
|
+
validateStringList(
|
|
454
|
+
composition.layout?.roles,
|
|
455
|
+
`${at}.composition.layout.roles`,
|
|
456
|
+
diagnostics,
|
|
457
|
+
{ nonempty: true },
|
|
458
|
+
);
|
|
459
|
+
const geometryOwners = [
|
|
460
|
+
'self',
|
|
461
|
+
'parent',
|
|
462
|
+
'child',
|
|
463
|
+
'none',
|
|
464
|
+
'conditional',
|
|
465
|
+
'composed',
|
|
466
|
+
];
|
|
467
|
+
for (const dimension of ['margin', 'border', 'padding'])
|
|
468
|
+
if (!geometryOwners.includes(composition.layout?.geometry?.[dimension]))
|
|
469
|
+
diagnostics.push(
|
|
470
|
+
`${at}.composition.layout.geometry.${dimension}: invalid geometry owner`,
|
|
471
|
+
);
|
|
472
|
+
if ('notes' in (composition.layout?.geometry ?? {}))
|
|
473
|
+
validateStringList(
|
|
474
|
+
composition.layout.geometry.notes,
|
|
475
|
+
`${at}.composition.layout.geometry.notes`,
|
|
476
|
+
diagnostics,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function validateComponent(
|
|
481
|
+
component,
|
|
482
|
+
packageRoot,
|
|
483
|
+
packageJson,
|
|
484
|
+
ids,
|
|
485
|
+
diagnosticIds,
|
|
486
|
+
diagnostics,
|
|
487
|
+
) {
|
|
488
|
+
const at = `${packageJson.name}:${component?.id ?? '<unknown>'}`;
|
|
489
|
+
for (const path of [
|
|
490
|
+
'id',
|
|
491
|
+
'name',
|
|
492
|
+
'kind',
|
|
493
|
+
'purpose',
|
|
494
|
+
'source',
|
|
495
|
+
'publicExports',
|
|
496
|
+
'sourceLinks',
|
|
497
|
+
'composition.parents.mode',
|
|
498
|
+
'composition.parents.entries',
|
|
499
|
+
'composition.contexts',
|
|
500
|
+
'composition.zones',
|
|
501
|
+
'composition.children.mode',
|
|
502
|
+
'composition.children.concepts',
|
|
503
|
+
'composition.children.requiredConcepts',
|
|
504
|
+
'composition.state',
|
|
505
|
+
'composition.wiring.required',
|
|
506
|
+
'composition.wiring.helpers',
|
|
507
|
+
'composition.wiring.obligations',
|
|
508
|
+
'composition.responsive.owner',
|
|
509
|
+
'composition.responsive.behaviors',
|
|
510
|
+
'composition.layout.roles',
|
|
511
|
+
'composition.layout.geometry.margin',
|
|
512
|
+
'composition.layout.geometry.border',
|
|
513
|
+
'composition.layout.geometry.padding',
|
|
514
|
+
'boundaries.rootClass',
|
|
515
|
+
'boundaries.publicClasses',
|
|
516
|
+
'boundaries.publicTokens',
|
|
517
|
+
'accessibility.obligations',
|
|
518
|
+
'diagnostics',
|
|
519
|
+
'provenance.selection',
|
|
520
|
+
'provenance.composition',
|
|
521
|
+
])
|
|
522
|
+
requireDecision(component, path, at, diagnostics);
|
|
523
|
+
|
|
524
|
+
if (!component?.id) return;
|
|
525
|
+
if (ids.has(component.id))
|
|
526
|
+
diagnostics.push(`${at}.id: duplicate component id`);
|
|
527
|
+
ids.add(component.id);
|
|
528
|
+
if (!['component', 'composition', 'recipe'].includes(component.kind))
|
|
529
|
+
diagnostics.push(`${at}.kind: expected component, composition, or recipe`);
|
|
530
|
+
const sourcePath = resolve(packageRoot, component.source ?? '');
|
|
531
|
+
if (!component.source || !existsSync(sourcePath))
|
|
532
|
+
diagnostics.push(`${at}.source: file does not exist: ${component.source}`);
|
|
533
|
+
|
|
534
|
+
const exports = packageJson.exports ?? {};
|
|
535
|
+
// A private application (package.json#private) is bundled, never imported
|
|
536
|
+
// by package name, and has no per-file dist entries. Its exports may omit
|
|
537
|
+
// `subpath`; each name is then verified against the component's own
|
|
538
|
+
// `source` file, which is how the Kerf UI rules resolve its wrappers.
|
|
539
|
+
const privateApplication = packageJson.private === true;
|
|
540
|
+
if (
|
|
541
|
+
!Array.isArray(component.publicExports) ||
|
|
542
|
+
!component.publicExports.length
|
|
543
|
+
)
|
|
544
|
+
diagnostics.push(`${at}.publicExports: expected a non-empty array`);
|
|
545
|
+
else {
|
|
546
|
+
const seen = new Set();
|
|
547
|
+
for (const item of component.publicExports) {
|
|
548
|
+
const exportAt = `${at}.publicExports`;
|
|
549
|
+
const sourceOnly =
|
|
550
|
+
privateApplication &&
|
|
551
|
+
Boolean(item?.name) &&
|
|
552
|
+
!Object.hasOwn(item, 'subpath');
|
|
553
|
+
if (!item?.name || (!sourceOnly && typeof item?.subpath !== 'string')) {
|
|
554
|
+
diagnostics.push(
|
|
555
|
+
`${exportAt}: every export requires name and subpath (only a private application, package.json#private, may omit subpath)`,
|
|
556
|
+
);
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const key = sourceOnly
|
|
560
|
+
? `source:${item.name}`
|
|
561
|
+
: `${item.subpath}:${item.name}`;
|
|
562
|
+
if (seen.has(key))
|
|
563
|
+
diagnostics.push(
|
|
564
|
+
`${exportAt}: duplicate ${sourceOnly ? item.name : key}`,
|
|
565
|
+
);
|
|
566
|
+
seen.add(key);
|
|
567
|
+
if (sourceOnly) {
|
|
568
|
+
if (
|
|
569
|
+
existsSync(sourcePath) &&
|
|
570
|
+
!exportedNamesFromFile(sourcePath).has(item.name)
|
|
571
|
+
)
|
|
572
|
+
diagnostics.push(
|
|
573
|
+
`${exportAt}: ${item.name} is not exported by ${component.source}`,
|
|
574
|
+
);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (!(item.subpath in exports))
|
|
578
|
+
diagnostics.push(
|
|
579
|
+
`${exportAt}: package.json does not export subpath ${item.subpath}`,
|
|
580
|
+
);
|
|
581
|
+
const sourceEntry = sourceEntryForExport(
|
|
582
|
+
packageRoot,
|
|
583
|
+
exports[item.subpath],
|
|
584
|
+
);
|
|
585
|
+
if (!sourceEntry)
|
|
586
|
+
diagnostics.push(
|
|
587
|
+
`${exportAt}: cannot map ${item.subpath} to an existing src/ entry`,
|
|
588
|
+
);
|
|
589
|
+
else if (!exportedNamesFromFile(sourceEntry).has(item.name))
|
|
590
|
+
diagnostics.push(
|
|
591
|
+
`${exportAt}: ${item.name} is not exported by ${relative(packageRoot, sourceEntry)}`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
validateStringList(component.sourceLinks, `${at}.sourceLinks`, diagnostics, {
|
|
596
|
+
nonempty: true,
|
|
597
|
+
});
|
|
598
|
+
for (const link of component.sourceLinks ?? []) {
|
|
599
|
+
if (/^[a-z]+:/i.test(link)) continue;
|
|
600
|
+
const localPath = link.split('#')[0];
|
|
601
|
+
if (localPath && !existsSync(resolve(packageRoot, localPath)))
|
|
602
|
+
diagnostics.push(
|
|
603
|
+
`${at}.sourceLinks: local source link does not exist: ${link}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
validateComposition(component, at, diagnostics);
|
|
607
|
+
validateStringList(
|
|
608
|
+
component.boundaries?.publicClasses,
|
|
609
|
+
`${at}.boundaries.publicClasses`,
|
|
610
|
+
diagnostics,
|
|
611
|
+
);
|
|
612
|
+
if (
|
|
613
|
+
component.boundaries?.rootClass !== null &&
|
|
614
|
+
(typeof component.boundaries?.rootClass !== 'string' ||
|
|
615
|
+
!component.boundaries.publicClasses?.includes(
|
|
616
|
+
component.boundaries.rootClass,
|
|
617
|
+
))
|
|
618
|
+
)
|
|
619
|
+
diagnostics.push(
|
|
620
|
+
`${at}.boundaries.rootClass: expected null or one publicClasses entry`,
|
|
621
|
+
);
|
|
622
|
+
// An application may place only the declared subset of public classes on
|
|
623
|
+
// its own elements; every other public class is the component's rendered
|
|
624
|
+
// anatomy (eslint-plugin-kerfjs KUI-L103). `rootElement` names the element
|
|
625
|
+
// the component renders around its placeable classes.
|
|
626
|
+
if (component.boundaries && 'placeableClasses' in component.boundaries) {
|
|
627
|
+
validateStringList(
|
|
628
|
+
component.boundaries.placeableClasses,
|
|
629
|
+
`${at}.boundaries.placeableClasses`,
|
|
630
|
+
diagnostics,
|
|
631
|
+
);
|
|
632
|
+
for (const className of Array.isArray(component.boundaries.placeableClasses)
|
|
633
|
+
? component.boundaries.placeableClasses
|
|
634
|
+
: [])
|
|
635
|
+
if (!component.boundaries.publicClasses?.includes(className))
|
|
636
|
+
diagnostics.push(
|
|
637
|
+
`${at}.boundaries.placeableClasses: ${className} is not one of publicClasses`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
if (
|
|
641
|
+
component.boundaries?.rootElement !== undefined &&
|
|
642
|
+
!component.boundaries.placeableClasses?.length
|
|
643
|
+
)
|
|
644
|
+
diagnostics.push(
|
|
645
|
+
`${at}.boundaries.rootElement: requires a non-empty placeableClasses`,
|
|
646
|
+
);
|
|
647
|
+
validateStringList(
|
|
648
|
+
component.boundaries?.publicTokens,
|
|
649
|
+
`${at}.boundaries.publicTokens`,
|
|
650
|
+
diagnostics,
|
|
651
|
+
);
|
|
652
|
+
validateStringList(
|
|
653
|
+
component.accessibility?.obligations,
|
|
654
|
+
`${at}.accessibility.obligations`,
|
|
655
|
+
diagnostics,
|
|
656
|
+
);
|
|
657
|
+
if (!Array.isArray(component.diagnostics))
|
|
658
|
+
diagnostics.push(`${at}.diagnostics: expected an array`);
|
|
659
|
+
for (const diagnostic of component.diagnostics ?? []) {
|
|
660
|
+
if (
|
|
661
|
+
!/^KUI-C[0-9]{3}$/.test(diagnostic?.id ?? '') ||
|
|
662
|
+
diagnosticIds.has(diagnostic.id)
|
|
663
|
+
)
|
|
664
|
+
diagnostics.push(`${at}.diagnostics: ids must be unique KUI-C### values`);
|
|
665
|
+
diagnosticIds.add(diagnostic?.id);
|
|
666
|
+
if (!['error', 'warning'].includes(diagnostic?.severity))
|
|
667
|
+
diagnostics.push(
|
|
668
|
+
`${at}.diagnostics.${diagnostic?.id}.severity: invalid severity`,
|
|
669
|
+
);
|
|
670
|
+
if (!diagnostic?.when || !diagnostic?.message)
|
|
671
|
+
diagnostics.push(
|
|
672
|
+
`${at}.diagnostics.${diagnostic?.id}: when and message are required`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function toEntry(packageName, component) {
|
|
678
|
+
return {
|
|
679
|
+
key: `${packageName}:${component.id}`,
|
|
680
|
+
package: packageName,
|
|
681
|
+
id: component.id,
|
|
682
|
+
name: component.name,
|
|
683
|
+
kind: component.kind,
|
|
684
|
+
purpose: component.purpose,
|
|
685
|
+
publicExports: component.publicExports,
|
|
686
|
+
sourceLinks: component.sourceLinks,
|
|
687
|
+
source: component.source,
|
|
688
|
+
...(component.composition.rendersAs
|
|
689
|
+
? { rendersAs: component.composition.rendersAs }
|
|
690
|
+
: {}),
|
|
691
|
+
parents: component.composition.parents,
|
|
692
|
+
contexts: component.composition.contexts,
|
|
693
|
+
zones: component.composition.zones,
|
|
694
|
+
children: component.composition.children,
|
|
695
|
+
state: component.composition.state,
|
|
696
|
+
wiring: component.composition.wiring,
|
|
697
|
+
responsive: component.composition.responsive,
|
|
698
|
+
layout: component.composition.layout,
|
|
699
|
+
accessibility: component.accessibility,
|
|
700
|
+
boundaries: component.boundaries,
|
|
701
|
+
diagnostics: component.diagnostics,
|
|
702
|
+
provenance: component.provenance,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// The catalog keys a package's `rendersAs` may name: entries generated in this
|
|
707
|
+
// run, or those of an installed dependency's shipped catalog (its
|
|
708
|
+
// package.json#kerfComponentCatalog.output, or @kerfjs/ui's ai/ catalog).
|
|
709
|
+
function dependencyCatalogKeys(packageRoot, packageName, cache) {
|
|
710
|
+
const cacheKey = `${packageRoot}\0${packageName}`;
|
|
711
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
712
|
+
let keys;
|
|
713
|
+
for (let dir = packageRoot; ; dir = dirname(dir)) {
|
|
714
|
+
const manifestPath = join(dir, 'node_modules', packageName, 'package.json');
|
|
715
|
+
if (existsSync(manifestPath)) {
|
|
716
|
+
const dependencyRoot = dirname(manifestPath);
|
|
717
|
+
let catalogPath;
|
|
718
|
+
try {
|
|
719
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
720
|
+
if (manifest[CONFIG_KEY]?.output)
|
|
721
|
+
catalogPath = resolve(dependencyRoot, manifest[CONFIG_KEY].output);
|
|
722
|
+
} catch {
|
|
723
|
+
// An unreadable manifest leaves the package unresolved below.
|
|
724
|
+
}
|
|
725
|
+
catalogPath ??= join(dependencyRoot, 'ai', 'component-composition.json');
|
|
726
|
+
try {
|
|
727
|
+
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
|
728
|
+
keys = new Set(
|
|
729
|
+
(catalog.entries ?? []).map(
|
|
730
|
+
(entry) => entry.key ?? `${catalog.package}:${entry.id}`,
|
|
731
|
+
),
|
|
732
|
+
);
|
|
733
|
+
} catch {
|
|
734
|
+
// No readable catalog: the package stays unresolved.
|
|
735
|
+
}
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
if (dirname(dir) === dir) break;
|
|
739
|
+
}
|
|
740
|
+
cache.set(cacheKey, keys);
|
|
741
|
+
return keys;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function validateRendersAs(results, diagnostics) {
|
|
745
|
+
const generated = new Map(
|
|
746
|
+
results.map(({ catalog }) => [
|
|
747
|
+
catalog.package,
|
|
748
|
+
new Set(catalog.entries.map((entry) => entry.key)),
|
|
749
|
+
]),
|
|
750
|
+
);
|
|
751
|
+
const cache = new Map();
|
|
752
|
+
for (const { packageRoot, metadataPath, catalog } of results)
|
|
753
|
+
for (const entry of catalog.entries)
|
|
754
|
+
for (const root of entry.rendersAs ?? []) {
|
|
755
|
+
const at = `${metadataPath}: components.${entry.id}.composition.rendersAs`;
|
|
756
|
+
const rootPackage = root.slice(0, root.lastIndexOf(':'));
|
|
757
|
+
if (root === entry.key) {
|
|
758
|
+
diagnostics.push(`${at}: ${root} names the component itself`);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const keys =
|
|
762
|
+
generated.get(rootPackage) ??
|
|
763
|
+
dependencyCatalogKeys(packageRoot, rootPackage, cache);
|
|
764
|
+
if (!keys)
|
|
765
|
+
diagnostics.push(
|
|
766
|
+
`${at}: cannot resolve a component catalog for package ${rootPackage}; install it or generate its catalog`,
|
|
767
|
+
);
|
|
768
|
+
else if (!keys.has(root))
|
|
769
|
+
diagnostics.push(
|
|
770
|
+
`${at}: ${root} is not an entry in ${rootPackage}'s catalog`,
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export function generateCatalogs(root = process.cwd()) {
|
|
776
|
+
const diagnostics = [];
|
|
777
|
+
const packages = configuredPackages(resolve(root), diagnostics);
|
|
778
|
+
if (!packages.length && !diagnostics.length)
|
|
779
|
+
diagnostics.push(
|
|
780
|
+
`${relative(process.cwd(), resolve(root)) || '.'}: no package declares package.json#${CONFIG_KEY}`,
|
|
781
|
+
);
|
|
782
|
+
const results = [];
|
|
783
|
+
const packageNames = new Set();
|
|
784
|
+
for (const packageRoot of packages) {
|
|
785
|
+
const packagePath = join(packageRoot, 'package.json');
|
|
786
|
+
const packageJson = readJson(packagePath, packagePath, diagnostics);
|
|
787
|
+
if (!packageJson) continue;
|
|
788
|
+
const config = packageJson[CONFIG_KEY];
|
|
789
|
+
if (!packageJson.name)
|
|
790
|
+
diagnostics.push(`${packagePath}: package name is required`);
|
|
791
|
+
if (packageNames.has(packageJson.name))
|
|
792
|
+
diagnostics.push(
|
|
793
|
+
`${packagePath}: duplicate configured package name ${packageJson.name}`,
|
|
794
|
+
);
|
|
795
|
+
packageNames.add(packageJson.name);
|
|
796
|
+
for (const key of ['source', 'output'])
|
|
797
|
+
if (!config?.[key])
|
|
798
|
+
diagnostics.push(`${packagePath}#${CONFIG_KEY}.${key}: is required`);
|
|
799
|
+
if (!config?.source || !config?.output) continue;
|
|
800
|
+
const metadataPath = resolve(packageRoot, config.source);
|
|
801
|
+
const metadata = readJson(metadataPath, metadataPath, diagnostics);
|
|
802
|
+
if (!metadata) continue;
|
|
803
|
+
const metadataDiagnostics = validateDocumentAgainstSchema(
|
|
804
|
+
metadata,
|
|
805
|
+
metadataSchema,
|
|
806
|
+
metadataPath,
|
|
807
|
+
);
|
|
808
|
+
diagnostics.push(...metadataDiagnostics);
|
|
809
|
+
if (metadataDiagnostics.length) {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const ids = new Set();
|
|
813
|
+
const diagnosticIds = new Set();
|
|
814
|
+
for (const component of metadata.components)
|
|
815
|
+
validateComponent(
|
|
816
|
+
component,
|
|
817
|
+
packageRoot,
|
|
818
|
+
packageJson,
|
|
819
|
+
ids,
|
|
820
|
+
diagnosticIds,
|
|
821
|
+
diagnostics,
|
|
822
|
+
);
|
|
823
|
+
const entries = [...metadata.components]
|
|
824
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
825
|
+
.map((component) => toEntry(packageJson.name, component));
|
|
826
|
+
const catalog = {
|
|
827
|
+
$schema:
|
|
828
|
+
'https://raw.githubusercontent.com/brianwestphal/kerf/main/ui/ai/component-composition-extension.schema.json',
|
|
829
|
+
schemaVersion: 1,
|
|
830
|
+
package: packageJson.name,
|
|
831
|
+
compatibility: {
|
|
832
|
+
componentCatalog: metadata.componentCatalog ?? 'not-applicable',
|
|
833
|
+
identity: 'package:id',
|
|
834
|
+
},
|
|
835
|
+
entries,
|
|
836
|
+
};
|
|
837
|
+
diagnostics.push(
|
|
838
|
+
...validateDocumentAgainstSchema(
|
|
839
|
+
catalog,
|
|
840
|
+
catalogSchema,
|
|
841
|
+
resolve(packageRoot, config.output),
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
results.push({
|
|
845
|
+
packageRoot,
|
|
846
|
+
metadataPath,
|
|
847
|
+
outputPath: resolve(packageRoot, config.output),
|
|
848
|
+
catalog,
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
validateRendersAs(results, diagnostics);
|
|
852
|
+
if (diagnostics.length) throw new CatalogError(diagnostics.sort());
|
|
853
|
+
return results;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export function formatCatalog(catalog) {
|
|
857
|
+
return `${JSON.stringify(catalog, null, 2)}\n`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function runCatalogCommand({
|
|
861
|
+
root = process.cwd(),
|
|
862
|
+
check = false,
|
|
863
|
+
} = {}) {
|
|
864
|
+
const results = generateCatalogs(root);
|
|
865
|
+
const diagnostics = [];
|
|
866
|
+
for (const result of results) {
|
|
867
|
+
const expected = formatCatalog(result.catalog);
|
|
868
|
+
if (check) {
|
|
869
|
+
if (!existsSync(result.outputPath))
|
|
870
|
+
diagnostics.push(`${result.outputPath}: generated catalog is missing`);
|
|
871
|
+
else {
|
|
872
|
+
const actualSource = readFileSync(result.outputPath, 'utf8');
|
|
873
|
+
const actual = readJson(
|
|
874
|
+
result.outputPath,
|
|
875
|
+
result.outputPath,
|
|
876
|
+
diagnostics,
|
|
877
|
+
);
|
|
878
|
+
if (actual)
|
|
879
|
+
diagnostics.push(
|
|
880
|
+
...validateDocumentAgainstSchema(
|
|
881
|
+
actual,
|
|
882
|
+
catalogSchema,
|
|
883
|
+
result.outputPath,
|
|
884
|
+
),
|
|
885
|
+
);
|
|
886
|
+
if (actualSource !== expected)
|
|
887
|
+
diagnostics.push(
|
|
888
|
+
`${result.outputPath}: generated catalog is stale; run catalog:generate`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
writeFileSync(result.outputPath, expected);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (diagnostics.length) throw new CatalogError(diagnostics);
|
|
896
|
+
return results;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function parseArgs(argv) {
|
|
900
|
+
let root = process.cwd();
|
|
901
|
+
let check = false;
|
|
902
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
903
|
+
const arg = argv[index];
|
|
904
|
+
if (arg === '--check') check = true;
|
|
905
|
+
else if (arg === '--write') check = false;
|
|
906
|
+
else if (arg === '--root' && argv[index + 1]) root = resolve(argv[++index]);
|
|
907
|
+
else if (arg === '--help' || arg === '-h') {
|
|
908
|
+
process.stdout.write(
|
|
909
|
+
'Usage: kerf-component-catalog [--write|--check] [--root <package-or-workspace>]\n',
|
|
910
|
+
);
|
|
911
|
+
process.exit(0);
|
|
912
|
+
} else throw new CatalogError([`unknown argument: ${arg}`]);
|
|
913
|
+
}
|
|
914
|
+
return { root, check };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const isMain =
|
|
918
|
+
process.argv[1] &&
|
|
919
|
+
realpathSync(process.argv[1]) ===
|
|
920
|
+
realpathSync(fileURLToPath(import.meta.url));
|
|
921
|
+
if (isMain) {
|
|
922
|
+
try {
|
|
923
|
+
const options = parseArgs(process.argv);
|
|
924
|
+
const results = runCatalogCommand(options);
|
|
925
|
+
const verb = options.check ? 'verified' : 'wrote';
|
|
926
|
+
for (const result of results)
|
|
927
|
+
process.stdout.write(
|
|
928
|
+
`${verb} ${relative(options.root, result.outputPath)}\n`,
|
|
929
|
+
);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
const diagnostics =
|
|
932
|
+
error instanceof CatalogError ? error.diagnostics : [error.message];
|
|
933
|
+
for (const diagnostic of diagnostics)
|
|
934
|
+
process.stderr.write(`kerf-component-catalog: ${diagnostic}\n`);
|
|
935
|
+
process.exitCode = 1;
|
|
936
|
+
}
|
|
937
|
+
}
|