@krapwoo/ds-viewer 0.2.0 → 0.3.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.
@@ -0,0 +1,356 @@
1
+ import * as ts from 'typescript';
2
+ import { readFileSync } from 'node:fs';
3
+ const TRANSPILE_OPTIONS = {
4
+ compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
5
+ reportDiagnostics: true,
6
+ };
7
+ /** Reads every page file's literal-checkable fields (design §4 "Static checking"). Never imports
8
+ * the file and never builds a `ts.Program` across files — no module resolution happens at all, so
9
+ * this cannot fail because of an unresolved import, a missing `node_modules`, or a stale type.
10
+ * One broken file never stops the rest (Global Constraints, Error-handling rule): each file is
11
+ * read independently. */
12
+ export function readStaticPages(pageFiles) {
13
+ // Error-handling rule (design, Global Constraints): one broken file never stops the rest of a
14
+ // read. `readStaticPage` already guards its own `readFileSync` call, but an unexpected throw
15
+ // from deeper in its AST walk (e.g. a pathological same-file `const` chain overflowing the call
16
+ // stack) must be caught per file too, not just per-read, or it aborts every other page in the
17
+ // same `doctor`/`explain` run.
18
+ return pageFiles.map((file) => {
19
+ try {
20
+ return readStaticPage(file);
21
+ }
22
+ catch (error) {
23
+ return { file, checkable: false, parseError: error.message };
24
+ }
25
+ });
26
+ }
27
+ function readStaticPage(file) {
28
+ let text;
29
+ try {
30
+ text = readFileSync(file, 'utf8');
31
+ }
32
+ catch (error) {
33
+ return { file, checkable: false, parseError: error.message };
34
+ }
35
+ // `transpileModule` parses a single file with no module resolution at all — its `diagnostics`
36
+ // are syntactic only (it never type-checks), which is exactly "fails to parse" (design §4's
37
+ // error rule) and nothing more; a semantic/type error in a page file is out of this reader's
38
+ // scope (it never had enough information — no resolved `node_modules` — to find one anyway).
39
+ const { diagnostics } = ts.transpileModule(text, TRANSPILE_OPTIONS);
40
+ if (diagnostics && diagnostics.length > 0) {
41
+ return { file, checkable: false, parseError: ts.flattenDiagnosticMessageText(diagnostics[0].messageText, '\n') };
42
+ }
43
+ const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TSX);
44
+ const imports = importedNames(sourceFile, '@krapwoo/ds-viewer');
45
+ const defineCatalogPageNames = localNamesFor(imports, 'defineCatalogPage');
46
+ const gridNames = localNamesFor(imports, 'grid');
47
+ const call = defaultExportCall(sourceFile);
48
+ if (!call || !ts.isIdentifier(call.expression) || !defineCatalogPageNames.has(call.expression.text) || call.arguments.length !== 1) {
49
+ // Not a recognized `defineCatalogPage(...)` default export — e.g. a hand-written object, or a
50
+ // shadowed/renamed import this reader can't trace. Not a parse error: the file is valid
51
+ // TypeScript, just not literal-checkable.
52
+ return { file, checkable: false };
53
+ }
54
+ const consts = topLevelConsts(sourceFile);
55
+ const pageObject = resolve(call.arguments[0], consts, new Set());
56
+ if (!ts.isObjectLiteralExpression(pageObject))
57
+ return { file, checkable: false };
58
+ const page = { file, checkable: true };
59
+ const idProp = findProp(pageObject, 'id');
60
+ const componentProp = findProp(pageObject, 'component');
61
+ const groupProp = findProp(pageObject, 'group');
62
+ const specimenSizeProp = findProp(pageObject, 'specimenSize');
63
+ const tokenGalleryProp = findProp(pageObject, 'tokenGallery');
64
+ if (idProp)
65
+ page.id = stringLiteral(resolve(idProp.initializer, consts, new Set()));
66
+ if (componentProp)
67
+ page.component = stringLiteral(resolve(componentProp.initializer, consts, new Set()));
68
+ if (groupProp)
69
+ page.group = stringLiteral(resolve(groupProp.initializer, consts, new Set()));
70
+ if (specimenSizeProp)
71
+ page.specimenSize = stringLiteral(resolve(specimenSizeProp.initializer, consts, new Set()));
72
+ if (tokenGalleryProp)
73
+ page.tokenGallery = resolve(tokenGalleryProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword;
74
+ page.hasWhenToUse = findProp(pageObject, 'whenToUse') !== undefined;
75
+ page.hasA11y = findProp(pageObject, 'a11y') !== undefined;
76
+ page.hasRender = findProp(pageObject, 'render') !== undefined;
77
+ const hideProp = findProp(pageObject, 'hide');
78
+ if (hideProp) {
79
+ const hideObject = resolve(hideProp.initializer, consts, new Set());
80
+ const literalBool = (name) => {
81
+ const prop = ts.isObjectLiteralExpression(hideObject) ? findProp(hideObject, name) : undefined;
82
+ return prop ? resolve(prop.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword : false;
83
+ };
84
+ page.hidesAccessibility = literalBool('accessibility');
85
+ page.hideVariants = literalBool('variants');
86
+ page.hideStates = literalBool('states');
87
+ }
88
+ const previewWidthsProp = findProp(pageObject, 'previewWidths');
89
+ if (previewWidthsProp) {
90
+ const resolved = resolve(previewWidthsProp.initializer, consts, new Set());
91
+ if (ts.isStringLiteral(resolved) && resolved.text === 'full') {
92
+ page.previewWidths = 'full';
93
+ }
94
+ else if (ts.isArrayLiteralExpression(resolved) && resolved.elements.every(ts.isNumericLiteral)) {
95
+ page.previewWidths = resolved.elements.map((element) => Number(element.text));
96
+ }
97
+ // Anything else (a function call, a non-literal expression) is simply left unset — `previewWidths`
98
+ // isn't in design §4's literal-checkable list, so it's never a reason to flip `checkable`.
99
+ }
100
+ const fullWidthLabelProp = findProp(pageObject, 'fullWidthLabel');
101
+ if (fullWidthLabelProp)
102
+ page.fullWidthLabel = stringLiteral(resolve(fullWidthLabelProp.initializer, consts, new Set()));
103
+ const propNotesProp = findProp(pageObject, 'propNotes');
104
+ if (propNotesProp) {
105
+ const resolved = resolve(propNotesProp.initializer, consts, new Set());
106
+ if (ts.isObjectLiteralExpression(resolved)) {
107
+ page.propNoteKeys = resolved.properties.filter(ts.isPropertyAssignment).map((p) => propertyName(p.name));
108
+ }
109
+ else {
110
+ page.checkable = false;
111
+ }
112
+ }
113
+ const comparisonProp = findProp(pageObject, 'comparison');
114
+ if (comparisonProp) {
115
+ const comparison = readComparison(comparisonProp.initializer, consts, gridNames);
116
+ if (comparison)
117
+ page.comparison = comparison;
118
+ else
119
+ page.checkable = false;
120
+ }
121
+ const variants = readSlotItems(findProp(pageObject, 'variants'), consts);
122
+ if (!variants.checkable)
123
+ page.checkable = false;
124
+ else {
125
+ page.variantsItems = variants.items;
126
+ page.variantsItemsFill = variants.itemsFill;
127
+ }
128
+ const states = readSlotItems(findProp(pageObject, 'states'), consts);
129
+ if (!states.checkable)
130
+ page.checkable = false;
131
+ else {
132
+ page.statesItems = states.items;
133
+ page.statesItemsFill = states.itemsFill;
134
+ }
135
+ return page;
136
+ }
137
+ /** Local name -> imported name, for every named import from `moduleSpecifier`. */
138
+ function importedNames(sourceFile, moduleSpecifier) {
139
+ const names = new Map();
140
+ for (const statement of sourceFile.statements) {
141
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== moduleSpecifier) {
142
+ continue;
143
+ }
144
+ const bindings = statement.importClause?.namedBindings;
145
+ if (!bindings || !ts.isNamedImports(bindings))
146
+ continue;
147
+ for (const element of bindings.elements) {
148
+ names.set(element.name.text, element.propertyName ? element.propertyName.text : element.name.text);
149
+ }
150
+ }
151
+ return names;
152
+ }
153
+ function localNamesFor(imports, importedName) {
154
+ const locals = new Set();
155
+ for (const [local, imported] of imports)
156
+ if (imported === importedName)
157
+ locals.add(local);
158
+ return locals;
159
+ }
160
+ function defaultExportCall(sourceFile) {
161
+ for (const statement of sourceFile.statements) {
162
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals && ts.isCallExpression(statement.expression)) {
163
+ return statement.expression;
164
+ }
165
+ }
166
+ return undefined;
167
+ }
168
+ function topLevelConsts(sourceFile) {
169
+ const consts = new Map();
170
+ for (const statement of sourceFile.statements) {
171
+ if (!ts.isVariableStatement(statement))
172
+ continue;
173
+ for (const declaration of statement.declarationList.declarations) {
174
+ if (ts.isIdentifier(declaration.name) && declaration.initializer)
175
+ consts.set(declaration.name.text, declaration.initializer);
176
+ }
177
+ }
178
+ return consts;
179
+ }
180
+ /** Unwraps `as`/parenthesized/`satisfies` expressions and chases a same-file top-level `const`
181
+ * reference to its own initializer (same pattern as `cli/tokens.ts`'s `readExpression`) — returns
182
+ * the resolved expression node itself, not a value, so callers can pattern-match on its kind
183
+ * (array/object literal, call expression, …). `seen` guards a reference cycle. */
184
+ function resolve(node, consts, seen) {
185
+ if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) {
186
+ return resolve(node.expression, consts, seen);
187
+ }
188
+ if (ts.isIdentifier(node) && !seen.has(node.text)) {
189
+ const target = consts.get(node.text);
190
+ if (target)
191
+ return resolve(target, consts, new Set(seen).add(node.text));
192
+ }
193
+ return node;
194
+ }
195
+ function stringLiteral(node) {
196
+ return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined;
197
+ }
198
+ function propertyName(name) {
199
+ return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText();
200
+ }
201
+ function findProp(obj, name) {
202
+ const prop = obj.properties.find((p) => ts.isPropertyAssignment(p) && propertyName(p.name) === name);
203
+ return prop;
204
+ }
205
+ function propertyStringLiteral(obj, name, consts) {
206
+ const prop = findProp(obj, name);
207
+ return prop ? stringLiteral(resolve(prop.initializer, consts, new Set())) : undefined;
208
+ }
209
+ /** A literal array of `{ key, label }` object literals — each element may itself be a same-file
210
+ * const reference. Undefined (not literal) for anything else: a `.map()`/other call, a spread, a
211
+ * cross-file import. */
212
+ function readAxisItemArray(node, consts) {
213
+ const resolved = resolve(node, consts, new Set());
214
+ if (!ts.isArrayLiteralExpression(resolved))
215
+ return undefined;
216
+ const items = [];
217
+ for (const element of resolved.elements) {
218
+ const resolvedElement = resolve(element, consts, new Set());
219
+ if (!ts.isObjectLiteralExpression(resolvedElement))
220
+ return undefined;
221
+ const key = propertyStringLiteral(resolvedElement, 'key', consts);
222
+ const label = propertyStringLiteral(resolvedElement, 'label', consts);
223
+ if (key === undefined || label === undefined)
224
+ return undefined;
225
+ items.push({ key, label });
226
+ }
227
+ return items;
228
+ }
229
+ /** A `GridAxis` (design §4): a plain literal array, or `{ prop, items }` (`items` itself read by
230
+ * `readAxisItemArray`). Undefined for anything else. */
231
+ function readAxis(node, consts) {
232
+ const resolved = resolve(node, consts, new Set());
233
+ if (ts.isArrayLiteralExpression(resolved)) {
234
+ const items = readAxisItemArray(resolved, consts);
235
+ return items ? { items } : undefined;
236
+ }
237
+ if (ts.isObjectLiteralExpression(resolved)) {
238
+ const propProp = findProp(resolved, 'prop');
239
+ const itemsProp = findProp(resolved, 'items');
240
+ if (!propProp || !itemsProp)
241
+ return undefined;
242
+ const prop = stringLiteral(resolve(propProp.initializer, consts, new Set()));
243
+ const items = readAxisItemArray(itemsProp.initializer, consts);
244
+ if (prop === undefined || !items)
245
+ return undefined;
246
+ return { prop, items };
247
+ }
248
+ return undefined;
249
+ }
250
+ /** A page's `comparison` field: either a direct `ComparisonDef`-shaped object literal, or (the
251
+ * starter kit's own convention since Task 1/2) a call to this package's own `grid()` helper — the
252
+ * one call shape this reader specially recognizes, reading its `rows`/`columns` arguments and
253
+ * skipping its `cell` argument entirely (design §4: "Specimen nodes and `cell(...)` functions are
254
+ * not evaluated"). `gridNames` is the set of local identifiers that import resolved to `grid` from
255
+ * `@krapwoo/ds-viewer` — a page's own unrelated local function named `grid` is never matched. */
256
+ function readComparison(node, consts, gridNames) {
257
+ const resolved = resolve(node, consts, new Set());
258
+ if (ts.isCallExpression(resolved) && ts.isIdentifier(resolved.expression) && gridNames.has(resolved.expression.text)) {
259
+ const [rowLabelArg, columnLabelArg, rowsArg, columnsArg, , sizeArg] = resolved.arguments;
260
+ const rows = rowsArg && readAxis(rowsArg, consts);
261
+ const columns = columnsArg && readAxis(columnsArg, consts);
262
+ if (!rows || !columns)
263
+ return undefined;
264
+ return {
265
+ rowLabel: rowLabelArg ? stringLiteral(resolve(rowLabelArg, consts, new Set())) : undefined,
266
+ columnLabel: columnLabelArg ? stringLiteral(resolve(columnLabelArg, consts, new Set())) : undefined,
267
+ rows,
268
+ columns,
269
+ size: sizeArg ? stringLiteral(resolve(sizeArg, consts, new Set())) : undefined,
270
+ };
271
+ }
272
+ if (ts.isObjectLiteralExpression(resolved)) {
273
+ const rowsProp = findProp(resolved, 'rows');
274
+ const columnsProp = findProp(resolved, 'columns');
275
+ if (!rowsProp || !columnsProp)
276
+ return undefined;
277
+ const rows = readAxis(rowsProp.initializer, consts);
278
+ const columns = readAxis(columnsProp.initializer, consts);
279
+ if (!rows || !columns)
280
+ return undefined;
281
+ const rowLabelProp = findProp(resolved, 'rowLabel');
282
+ const columnLabelProp = findProp(resolved, 'columnLabel');
283
+ const sizeProp = findProp(resolved, 'size');
284
+ return {
285
+ rowLabel: rowLabelProp ? stringLiteral(resolve(rowLabelProp.initializer, consts, new Set())) : undefined,
286
+ columnLabel: columnLabelProp ? stringLiteral(resolve(columnLabelProp.initializer, consts, new Set())) : undefined,
287
+ rows,
288
+ columns,
289
+ size: sizeProp ? stringLiteral(resolve(sizeProp.initializer, consts, new Set())) : undefined,
290
+ };
291
+ }
292
+ return undefined;
293
+ }
294
+ /** A literal array of list-item object literals — `key`/`name` required; `props` (read as a flat
295
+ * literal map, non-literal values simply omitted) and `group` optional. Each item's `node` field
296
+ * (always present, always JSX) is never inspected — reading the sibling fields never depends on it. */
297
+ function readListItems(node, consts) {
298
+ const resolved = resolve(node, consts, new Set());
299
+ if (!ts.isArrayLiteralExpression(resolved))
300
+ return undefined;
301
+ const items = [];
302
+ for (const element of resolved.elements) {
303
+ const resolvedElement = resolve(element, consts, new Set());
304
+ if (!ts.isObjectLiteralExpression(resolvedElement))
305
+ return undefined;
306
+ const key = propertyStringLiteral(resolvedElement, 'key', consts);
307
+ const name = propertyStringLiteral(resolvedElement, 'name', consts);
308
+ if (key === undefined || name === undefined)
309
+ return undefined;
310
+ const group = propertyStringLiteral(resolvedElement, 'group', consts);
311
+ const propsProp = findProp(resolvedElement, 'props');
312
+ items.push({ key, name, group, props: propsProp ? readLiteralProps(propsProp.initializer, consts) : undefined });
313
+ }
314
+ return items;
315
+ }
316
+ function readLiteralProps(node, consts) {
317
+ const resolved = resolve(node, consts, new Set());
318
+ if (!ts.isObjectLiteralExpression(resolved))
319
+ return undefined;
320
+ const result = {};
321
+ for (const property of resolved.properties) {
322
+ if (!ts.isPropertyAssignment(property))
323
+ continue;
324
+ const value = resolve(property.initializer, consts, new Set());
325
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
326
+ result[propertyName(property.name)] = value.text;
327
+ else if (ts.isNumericLiteral(value))
328
+ result[propertyName(property.name)] = Number(value.text);
329
+ else if (value.kind === ts.SyntaxKind.TrueKeyword)
330
+ result[propertyName(property.name)] = true;
331
+ else if (value.kind === ts.SyntaxKind.FalseKeyword)
332
+ result[propertyName(property.name)] = false;
333
+ // Anything else (e.g. a token reference) is skipped, not a reason to reject the whole item —
334
+ // `props` tags are only ever compared against a prop's string options (design §4).
335
+ }
336
+ return result;
337
+ }
338
+ /** A `variants`/`states` field: `{ itemsFill?, items: [...] }`, `items` read by `readListItems`.
339
+ * Distinguishes "field absent" (always checkable, everything undefined) from "field present but
340
+ * not literal" (not checkable). `itemsFill` is read only if present and literal — like
341
+ * `previewWidths`/`fullWidthLabel`, it isn't in design §4's literal-checkable list, so a
342
+ * non-literal `itemsFill` is simply left unset, never a reason to reject the whole slot. */
343
+ function readSlotItems(prop, consts) {
344
+ if (!prop)
345
+ return { checkable: true };
346
+ const resolved = resolve(prop.initializer, consts, new Set());
347
+ const itemsProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'items') : undefined;
348
+ if (!itemsProp)
349
+ return { checkable: false };
350
+ const items = readListItems(itemsProp.initializer, consts);
351
+ if (!items)
352
+ return { checkable: false };
353
+ const itemsFillProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'itemsFill') : undefined;
354
+ const itemsFill = itemsFillProp ? resolve(itemsFillProp.initializer, consts, new Set()).kind === ts.SyntaxKind.TrueKeyword : undefined;
355
+ return { items, itemsFill, checkable: true };
356
+ }
package/dist/cli/sync.js CHANGED
@@ -50,11 +50,14 @@ export function sync(config, resolveOptions) {
50
50
  ? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
51
51
  : undefined;
52
52
  const readComponent = createComponentReader(componentEntryFiles, { ...resolveOptions, optionRoots, fallbackPaths });
53
+ const warnings = [];
53
54
  for (const entryFile of componentEntryFiles) {
54
55
  try {
55
56
  for (const record of readComponent(entryFile)) {
56
57
  if (seenNames.has(record.name)) {
57
- console.warn(`Duplicate component name "${record.name}" (${path.relative(config.projectRoot, entryFile)}); the catalog will show only one.`);
58
+ const message = `Duplicate component name "${record.name}" (${path.relative(config.projectRoot, entryFile)}); the catalog will show only one.`;
59
+ console.warn(message);
60
+ warnings.push(message);
58
61
  }
59
62
  seenNames.add(record.name);
60
63
  components.push(record);
@@ -62,7 +65,9 @@ export function sync(config, resolveOptions) {
62
65
  }
63
66
  }
64
67
  catch (error) {
65
- console.warn(`Could not read ${path.relative(config.projectRoot, entryFile)}: ${error.message}`);
68
+ const message = `Could not read ${path.relative(config.projectRoot, entryFile)}: ${error.message}`;
69
+ console.warn(message);
70
+ warnings.push(message);
66
71
  }
67
72
  }
68
73
  components.sort((a, b) => a.name.localeCompare(b.name));
@@ -73,12 +78,14 @@ export function sync(config, resolveOptions) {
73
78
  tokenRecords.push(...readTokenModules([tokenFile]));
74
79
  }
75
80
  catch (error) {
76
- console.warn(`Could not read ${path.relative(config.projectRoot, tokenFile)}: ${error.message}`);
81
+ const message = `Could not read ${path.relative(config.projectRoot, tokenFile)}: ${error.message}`;
82
+ console.warn(message);
83
+ warnings.push(message);
77
84
  }
78
85
  }
79
86
  writeFileSync(path.join(generatedDir, 'tokens.json'), `${JSON.stringify(tokenRecords, null, 2)}\n`);
80
87
  writeFileSync(path.join(generatedDir, 'index.ts'), GENERATED_INDEX);
81
88
  const pageFiles = discoverPages({ components: componentRefs, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
82
89
  writePageIndex(generatedDir, pageFiles);
83
- return { componentCount: components.length, pageCount: pageFiles.length, generatedDir };
90
+ return { componentCount: components.length, pageCount: pageFiles.length, generatedDir, warnings };
84
91
  }