@krapwoo/ds-viewer 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +111 -18
  2. package/dist/cli/dev.js +31 -6
  3. package/dist/cli/doctor.js +137 -0
  4. package/dist/cli/endpoint.js +76 -1
  5. package/dist/cli/explain.js +44 -6
  6. package/dist/cli/main.js +4 -2
  7. package/dist/cli/props.js +80 -1
  8. package/dist/cli/staticPage.js +228 -6
  9. package/dist/cli/updateCheck.js +174 -43
  10. package/dist/cli/workspace.js +2 -1
  11. package/dist/native/catalog/catalogNavigation.js +11 -0
  12. package/dist/native/catalog/comparison.js +29 -4
  13. package/native/catalog/BoundedOverlayViewport.tsx +112 -0
  14. package/native/catalog/CatalogShell.tsx +69 -12
  15. package/native/catalog/CatalogSidebar.tsx +8 -7
  16. package/native/catalog/ComparisonGrid.tsx +2 -2
  17. package/native/catalog/ComparisonGroups.tsx +2 -2
  18. package/native/catalog/ComparisonList.tsx +2 -2
  19. package/native/catalog/PhoneFrame.tsx +6 -0
  20. package/native/catalog/ReferenceDetails.tsx +75 -20
  21. package/native/catalog/SectionBlock.tsx +66 -4
  22. package/native/catalog/SpecimenSurface.tsx +18 -10
  23. package/native/catalog/UpdatePanel.tsx +161 -5
  24. package/native/catalog/catalogNavigation.ts +14 -1
  25. package/native/catalog/comparison.ts +39 -5
  26. package/native/catalog/index.ts +4 -0
  27. package/native/catalog/overlayViewport.ts +156 -0
  28. package/native/catalog/pageApi.ts +6 -0
  29. package/native/catalog/specimenSurfaceStyle.ts +70 -1
  30. package/native/catalog/types.ts +62 -12
  31. package/native/catalog/updatePanelState.ts +72 -0
  32. package/package.json +1 -1
  33. package/starter-kit/components/Banner/Banner.catalog.tsx +3 -0
  34. package/starter-kit/components/Button/Button.catalog.tsx +3 -0
  35. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +6 -0
  36. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +5 -0
  37. package/starter-kit/components/InputField/InputField.catalog.tsx +3 -0
  38. package/starter-kit/components/List/List.catalog.tsx +3 -0
  39. package/starter-kit/components/Pill/Pill.catalog.tsx +3 -0
  40. package/starter-kit/components/PillRow/PillRow.catalog.tsx +3 -0
  41. package/starter-kit/components/SearchField/SearchField.catalog.tsx +4 -0
  42. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +3 -0
  43. package/starter-kit/components/TextArea/TextArea.catalog.tsx +3 -0
  44. package/starter-kit/components/Toast/Toast.catalog.tsx +7 -0
package/dist/cli/props.js CHANGED
@@ -185,7 +185,86 @@ function readComponentRecord(checker, name, type, declaration, optionRoots, comp
185
185
  }
186
186
  }
187
187
  const inheritedFrom = propsType ? nodeModulesHeritageNames(checker, propsType.isUnion() ? propsType.types : [propsType]) : [];
188
- return { name, file: path.relative(process.cwd(), sourceFile.fileName), props, inheritedFrom };
188
+ const composedOfCandidates = readComposedOfCandidates(checker, declaration);
189
+ return {
190
+ name,
191
+ file: path.relative(process.cwd(), sourceFile.fileName),
192
+ props,
193
+ inheritedFrom,
194
+ ...(composedOfCandidates.length > 0 ? { composedOfCandidates } : {}),
195
+ };
196
+ }
197
+ /** Optional, additive source evidence (guided-intelligence design §4, plan Task 3): every other
198
+ * known, project-local component this component's own implementation renders as a literal JSX
199
+ * tag. Scoped to exactly the implementation function's own body (the same function
200
+ * `defaultsFromImplementation` already unwraps through `React.memo`/`forwardRef`) — a same-file
201
+ * helper's own JSX, or a different export's JSX, is never walked at all, since traversal never
202
+ * starts there. Only a JSX tag that is a bare identifier resolving (through import aliases and
203
+ * barrel re-exports) to an exported symbol satisfying `isComponentType` becomes a candidate; a
204
+ * type-only import can never reach here (it cannot be used as a JSX tag at all), an import that is
205
+ * never written as a JSX tag is never visited, and a locally-computed/conditional tag identifier
206
+ * (not itself an import alias) is left uncertain rather than guessed at. A `node_modules`
207
+ * declaration (a framework primitive, not a project component) is excluded — "known canonical
208
+ * component export" means part of this project's own component surface. */
209
+ function readComposedOfCandidates(checker, declaration) {
210
+ const startNode = ts.isVariableDeclaration(declaration) && declaration.initializer ? declaration.initializer : declaration;
211
+ const fn = resolveFunctionLike(startNode, new Set());
212
+ if (!fn?.body)
213
+ return [];
214
+ const candidates = [];
215
+ const visit = (node) => {
216
+ // A nested named helper (a local `function foo() {}` declaration, or a `const foo = () => {}`/
217
+ // `function expression` assignment) defines its own scope: its JSX belongs to it, not to this
218
+ // component, even when this component calls it as an ordinary function — so its body is never
219
+ // walked at all, matching the top-level sibling-helper behavior this already preserved.
220
+ if (isNestedNamedHelper(node))
221
+ return;
222
+ if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName)) {
223
+ const candidate = resolveComposedOfCandidate(checker, node.tagName);
224
+ if (candidate)
225
+ candidates.push(candidate);
226
+ }
227
+ ts.forEachChild(node, visit);
228
+ };
229
+ visit(fn.body);
230
+ return candidates;
231
+ }
232
+ function isNestedNamedHelper(node) {
233
+ if (ts.isFunctionDeclaration(node) && node.name)
234
+ return true;
235
+ return (ts.isVariableDeclaration(node) &&
236
+ ts.isIdentifier(node.name) &&
237
+ node.initializer !== undefined &&
238
+ (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)));
239
+ }
240
+ /** Resolves one JSX tag identifier to a composedOfCandidate, or undefined when it isn't a known,
241
+ * project-local component export reached through an import alias (directly or through one or more
242
+ * barrel re-exports). */
243
+ function resolveComposedOfCandidate(checker, tagName) {
244
+ let symbol = checker.getSymbolAtLocation(tagName);
245
+ if (!symbol)
246
+ return undefined;
247
+ // Only a tag identifier that is itself bound to an import (an Alias symbol) counts — a locally
248
+ // computed/conditional identifier (e.g. `const Comp = cond ? A : B`) is left uncertain even if its
249
+ // inferred type happens to satisfy `isComponentType`.
250
+ if (!(symbol.flags & ts.SymbolFlags.Alias))
251
+ return undefined;
252
+ const seen = new Set();
253
+ while (symbol.flags & ts.SymbolFlags.Alias && !seen.has(symbol)) {
254
+ seen.add(symbol);
255
+ symbol = checker.getAliasedSymbol(symbol);
256
+ }
257
+ const resolvedDeclaration = symbol.valueDeclaration;
258
+ if (!resolvedDeclaration)
259
+ return undefined;
260
+ const declarationFile = resolvedDeclaration.getSourceFile().fileName;
261
+ if (declarationFile.includes('node_modules'))
262
+ return undefined;
263
+ const type = checker.getTypeOfSymbolAtLocation(symbol, resolvedDeclaration);
264
+ if (!isComponentType(checker, type))
265
+ return undefined;
266
+ const { line } = tagName.getSourceFile().getLineAndCharacterOfPosition(tagName.getStart());
267
+ return { component: symbol.getName(), file: path.relative(process.cwd(), tagName.getSourceFile().fileName), line: line + 1 };
189
268
  }
190
269
  /** Design §3 — "Type column: keeps alias names, not expanded unions." An optional prop's type
191
270
  * (as read via its symbol) includes `undefined` — printed as `"T | undefined"` when `T` has no
@@ -140,7 +140,7 @@ function readStaticPage(file) {
140
140
  else
141
141
  page.checkable = false;
142
142
  }
143
- const variants = readSlotItems(findProp(pageObject, 'variants'), consts);
143
+ const variants = readSlotItems(findProp(pageObject, 'variants'), consts, page.component, sourceFile);
144
144
  if (!variants.checkable)
145
145
  page.checkable = false;
146
146
  else {
@@ -148,7 +148,7 @@ function readStaticPage(file) {
148
148
  page.variantsItemsFill = variants.itemsFill;
149
149
  page.variantsMaxColumns = variants.maxColumns;
150
150
  }
151
- const states = readSlotItems(findProp(pageObject, 'states'), consts);
151
+ const states = readSlotItems(findProp(pageObject, 'states'), consts, page.component, sourceFile);
152
152
  if (!states.checkable)
153
153
  page.checkable = false;
154
154
  else {
@@ -156,8 +156,42 @@ function readStaticPage(file) {
156
156
  page.statesItemsFill = states.itemsFill;
157
157
  page.statesMaxColumns = states.maxColumns;
158
158
  }
159
+ const composedOfProp = findProp(pageObject, 'composedOf');
160
+ page.hasComposedOf = composedOfProp !== undefined;
161
+ if (composedOfProp)
162
+ page.composedOf = readComposedOf(composedOfProp.initializer, consts);
163
+ const intentionalStaticPreviewProp = findProp(pageObject, 'intentionalStaticPreview');
164
+ if (intentionalStaticPreviewProp) {
165
+ const resolved = resolve(intentionalStaticPreviewProp.initializer, consts, new Set());
166
+ if (ts.isObjectLiteralExpression(resolved)) {
167
+ const reasonProp = findProp(resolved, 'reason');
168
+ if (reasonProp)
169
+ page.intentionalStaticPreviewReason = stringLiteral(resolve(reasonProp.initializer, consts, new Set()));
170
+ }
171
+ }
159
172
  return page;
160
173
  }
174
+ /** A literal array of `ComposedOfEntry`-shaped object literals, read down to just `component`
175
+ * (design §4: role/relationship are authored prose `doctor` never needs for its missing-
176
+ * composition comparison). Undefined — not an empty array — for anything that isn't itself a
177
+ * literal array, or whose element isn't an object literal with a literal `component`: `doctor`
178
+ * must never mistake "couldn't read this" for "author declared no composition here". */
179
+ function readComposedOf(node, consts) {
180
+ const resolved = resolve(node, consts, new Set());
181
+ if (!ts.isArrayLiteralExpression(resolved))
182
+ return undefined;
183
+ const entries = [];
184
+ for (const element of resolved.elements) {
185
+ const resolvedElement = resolve(element, consts, new Set());
186
+ if (!ts.isObjectLiteralExpression(resolvedElement))
187
+ return undefined;
188
+ const component = propertyStringLiteral(resolvedElement, 'component', consts);
189
+ if (component === undefined)
190
+ return undefined;
191
+ entries.push({ component });
192
+ }
193
+ return entries;
194
+ }
161
195
  /** Local name -> imported name, for every named import from `moduleSpecifier`. */
162
196
  function importedNames(sourceFile, moduleSpecifier) {
163
197
  const names = new Map();
@@ -219,6 +253,20 @@ function resolve(node, consts, seen) {
219
253
  function stringLiteral(node) {
220
254
  return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) ? node.text : undefined;
221
255
  }
256
+ /** `true`/`false` only from a known `true`/`false` literal; any other expression (a variable, a
257
+ * computed value) stays `undefined` rather than being misread as `false`. Unlike the page's own
258
+ * `hide.*`/`tokenGallery`/`itemsFill`/`wide` booleans — which intentionally treat "not literally
259
+ * `true`" as `false`, since those already default to `false` when omitted — `fill`'s own default
260
+ * is inherited (`SpecimenPresentation.fill` defaults to the slot's `itemsFill`, not to `false`),
261
+ * so a non-literal value here must stay honestly unknown instead of claiming "not filling". */
262
+ function literalBooleanOrUnknown(node, consts) {
263
+ const resolved = resolve(node, consts, new Set());
264
+ if (resolved.kind === ts.SyntaxKind.TrueKeyword)
265
+ return true;
266
+ if (resolved.kind === ts.SyntaxKind.FalseKeyword)
267
+ return false;
268
+ return undefined;
269
+ }
222
270
  function propertyName(name) {
223
271
  return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText();
224
272
  }
@@ -271,6 +319,34 @@ function readAxis(node, consts) {
271
319
  }
272
320
  return undefined;
273
321
  }
322
+ /** A literal array of `ComparisonCell`-shaped object literals: `rowKey`/`columnKey` required
323
+ * (string literals only), `surface`/`align`/`fill` read the same per-field-lenient way
324
+ * `readListItems` reads them. `node`/`unavailableReason` are never inspected — design §4 never
325
+ * evaluates a cell's specimen node. Undefined (no cell metadata at all, not "every field unset")
326
+ * for anything that isn't itself a literal array, or whose element isn't itself an object literal
327
+ * with literal `rowKey`/`columnKey` — a `.flatMap()`-built `cells` array (the starter kit's own
328
+ * convention for a `grid()`-free literal comparison) stays unrepresented rather than guessed at. */
329
+ function readComparisonCells(node, consts) {
330
+ const resolved = resolve(node, consts, new Set());
331
+ if (!ts.isArrayLiteralExpression(resolved))
332
+ return undefined;
333
+ const cells = [];
334
+ for (const element of resolved.elements) {
335
+ const resolvedElement = resolve(element, consts, new Set());
336
+ if (!ts.isObjectLiteralExpression(resolvedElement))
337
+ return undefined;
338
+ const rowKey = propertyStringLiteral(resolvedElement, 'rowKey', consts);
339
+ const columnKey = propertyStringLiteral(resolvedElement, 'columnKey', consts);
340
+ if (rowKey === undefined || columnKey === undefined)
341
+ return undefined;
342
+ const surface = propertyStringLiteral(resolvedElement, 'surface', consts);
343
+ const align = propertyStringLiteral(resolvedElement, 'align', consts);
344
+ const fillProp = findProp(resolvedElement, 'fill');
345
+ const fill = fillProp ? literalBooleanOrUnknown(fillProp.initializer, consts) : undefined;
346
+ cells.push({ rowKey, columnKey, surface, align, fill });
347
+ }
348
+ return cells;
349
+ }
274
350
  /** A page's `comparison` field: either a direct `ComparisonDef`-shaped object literal, or (the
275
351
  * starter kit's own convention since Task 1/2) a call to this package's own `grid()` helper — the
276
352
  * one call shape this reader specially recognizes, reading its `rows`/`columns` arguments and
@@ -305,12 +381,14 @@ function readComparison(node, consts, gridNames) {
305
381
  const rowLabelProp = findProp(resolved, 'rowLabel');
306
382
  const columnLabelProp = findProp(resolved, 'columnLabel');
307
383
  const sizeProp = findProp(resolved, 'size');
384
+ const cellsProp = findProp(resolved, 'cells');
308
385
  return {
309
386
  rowLabel: rowLabelProp ? stringLiteral(resolve(rowLabelProp.initializer, consts, new Set())) : undefined,
310
387
  columnLabel: columnLabelProp ? stringLiteral(resolve(columnLabelProp.initializer, consts, new Set())) : undefined,
311
388
  rows,
312
389
  columns,
313
390
  size: sizeProp ? stringLiteral(resolve(sizeProp.initializer, consts, new Set())) : undefined,
391
+ cells: cellsProp ? readComparisonCells(cellsProp.initializer, consts) : undefined,
314
392
  };
315
393
  }
316
394
  return undefined;
@@ -318,7 +396,7 @@ function readComparison(node, consts, gridNames) {
318
396
  /** A literal array of list-item object literals — `key`/`name` required; `props` (read as a flat
319
397
  * literal map, non-literal values simply omitted) and `group` optional. Each item's `node` field
320
398
  * (always present, always JSX) is never inspected — reading the sibling fields never depends on it. */
321
- function readListItems(node, consts) {
399
+ function readListItems(node, consts, componentName, sourceFile) {
322
400
  const resolved = resolve(node, consts, new Set());
323
401
  if (!ts.isArrayLiteralExpression(resolved))
324
402
  return undefined;
@@ -333,10 +411,154 @@ function readListItems(node, consts) {
333
411
  return undefined;
334
412
  const group = propertyStringLiteral(resolvedElement, 'group', consts);
335
413
  const propsProp = findProp(resolvedElement, 'props');
336
- items.push({ key, name, group, props: propsProp ? readLiteralProps(propsProp.initializer, consts) : undefined });
414
+ const fillProp = findProp(resolvedElement, 'fill');
415
+ const fill = fillProp ? literalBooleanOrUnknown(fillProp.initializer, consts) : undefined;
416
+ const surface = propertyStringLiteral(resolvedElement, 'surface', consts);
417
+ const align = propertyStringLiteral(resolvedElement, 'align', consts);
418
+ const nodeProp = findProp(resolvedElement, 'node');
419
+ const nodeExample = nodeProp ? readNodeExample(nodeProp.initializer, consts, componentName, sourceFile) : {};
420
+ items.push({
421
+ key, name, group, props: propsProp ? readLiteralProps(propsProp.initializer, consts) : undefined, fill, surface, align,
422
+ ...nodeExample,
423
+ });
337
424
  }
338
425
  return items;
339
426
  }
427
+ /** Generic, additive reading of one item's own `node:` JSX (design §3, plan Task 3) — used only by
428
+ * `cli/doctor.ts`'s interactive-preview advisory. Never evaluates `node`; only reads its literal
429
+ * AST shape. `node` matching `componentName` by plain tag-name text (not module resolution — this
430
+ * reader never resolves imports) means "a direct instance of the documented component": its own
431
+ * JSX attributes are read generically (`nodeAttributes`). Any other JSX tag is checked only for
432
+ * whether it's a confidently-identified, genuinely stateful same-file wrapper
433
+ * (`nodeStatefulWrapper`) — never asserted one way or the other when that can't be confirmed.
434
+ * Anything that isn't a direct JSX element at all (`null`, a conditional, a function call, …)
435
+ * leaves every field unset. */
436
+ function readNodeExample(node, consts, componentName, sourceFile) {
437
+ const resolved = resolve(node, consts, new Set());
438
+ let element;
439
+ if (ts.isJsxSelfClosingElement(resolved))
440
+ element = resolved;
441
+ else if (ts.isJsxElement(resolved))
442
+ element = resolved.openingElement;
443
+ if (!element || !ts.isIdentifier(element.tagName))
444
+ return {};
445
+ const tagText = element.tagName.text;
446
+ if (componentName !== undefined && tagText === componentName) {
447
+ const nodeAttributes = {};
448
+ for (const attribute of element.attributes.properties) {
449
+ // Skips a spread attribute ({...rest}) and a namespaced name (xml:lang) — unreadable,
450
+ // never a reason to reject the whole item.
451
+ if (!ts.isJsxAttribute(attribute) || !ts.isIdentifier(attribute.name))
452
+ continue;
453
+ nodeAttributes[attribute.name.text] = readJsxAttributeValue(attribute, consts);
454
+ }
455
+ return { nodeDirectComponent: tagText, nodeAttributes };
456
+ }
457
+ return { nodeStatefulWrapper: sameFileWrapperHasUseState(sourceFile, tagText) ? true : undefined };
458
+ }
459
+ /** One JSX attribute's value, as a `StaticAttributeValue`. A shorthand attribute (`disabled`, no
460
+ * `initializer` at all — equivalent to `disabled={true}`) is a literal `true`. */
461
+ function readJsxAttributeValue(attribute, consts) {
462
+ if (!attribute.initializer)
463
+ return { kind: 'literal', value: true };
464
+ if (ts.isStringLiteral(attribute.initializer))
465
+ return { kind: 'literal', value: attribute.initializer.text };
466
+ if (!ts.isJsxExpression(attribute.initializer) || !attribute.initializer.expression)
467
+ return { kind: 'unknown' };
468
+ const resolved = resolve(attribute.initializer.expression, consts, new Set());
469
+ if (ts.isStringLiteral(resolved) || ts.isNoSubstitutionTemplateLiteral(resolved))
470
+ return { kind: 'literal', value: resolved.text };
471
+ if (ts.isNumericLiteral(resolved))
472
+ return { kind: 'literal', value: Number(resolved.text) };
473
+ if (resolved.kind === ts.SyntaxKind.TrueKeyword)
474
+ return { kind: 'literal', value: true };
475
+ if (resolved.kind === ts.SyntaxKind.FalseKeyword)
476
+ return { kind: 'literal', value: false };
477
+ if (ts.isIdentifier(resolved) && resolved.text === 'undefined')
478
+ return { kind: 'no-op-callback' };
479
+ if ((ts.isArrowFunction(resolved) || ts.isFunctionExpression(resolved)) && isEmptyFunctionBody(resolved.body)) {
480
+ return { kind: 'no-op-callback' };
481
+ }
482
+ return { kind: 'unknown' };
483
+ }
484
+ function isEmptyFunctionBody(body) {
485
+ return ts.isBlock(body) && body.statements.length === 0;
486
+ }
487
+ /** True only when `sourceFile` declares a top-level function (declaration, or a `const`/`let`
488
+ * initialized with a function/arrow expression) named `tagName` whose own body contains a call to
489
+ * a *source-backed* React `useState`/`React.useState` — a confidently-identified, genuinely
490
+ * stateful wrapper. Walking only that one function's own body (never the whole file) keeps this
491
+ * scoped the same way `cli/props.ts`'s composedOfCandidate reader scopes its own JSX walk to one
492
+ * implementation. */
493
+ function sameFileWrapperHasUseState(sourceFile, tagName) {
494
+ const bindings = reactUseStateBindings(sourceFile);
495
+ for (const statement of sourceFile.statements) {
496
+ let body;
497
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === tagName)
498
+ body = statement.body;
499
+ else if (ts.isVariableStatement(statement)) {
500
+ for (const declaration of statement.declarationList.declarations) {
501
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === tagName && declaration.initializer &&
502
+ (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) {
503
+ body = declaration.initializer.body;
504
+ }
505
+ }
506
+ }
507
+ if (body && containsUseStateCall(body, bindings))
508
+ return true;
509
+ }
510
+ return false;
511
+ }
512
+ /** This file's own top-level `import ... from 'react'` bindings relevant to recognizing a genuine
513
+ * `useState` call — never a type-checker resolution, only the same-file import syntax, matching
514
+ * this reader's existing "never resolves imports across files" contract. `namedUseState` holds
515
+ * every local name bound to the real named `useState` export (an alias, e.g. `import { useState as
516
+ * useReactState }`, included); `reactNamespace` holds every local name bound to the whole `react`
517
+ * module (a default or `* as` import) that a `React.useState(...)` call could be written through.
518
+ * A locally-defined `useState` (no matching import) or an unrelated object's own `.useState` method
519
+ * binds to neither set and so is never counted — left uncheckable, never asserted stateful. */
520
+ function reactUseStateBindings(sourceFile) {
521
+ const namedUseState = new Set();
522
+ const reactNamespace = new Set();
523
+ for (const statement of sourceFile.statements) {
524
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== 'react')
525
+ continue;
526
+ const clause = statement.importClause;
527
+ if (!clause)
528
+ continue;
529
+ if (clause.name)
530
+ reactNamespace.add(clause.name.text);
531
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings))
532
+ reactNamespace.add(clause.namedBindings.name.text);
533
+ else if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
534
+ for (const element of clause.namedBindings.elements) {
535
+ if ((element.propertyName ?? element.name).text === 'useState')
536
+ namedUseState.add(element.name.text);
537
+ }
538
+ }
539
+ }
540
+ return { namedUseState, reactNamespace };
541
+ }
542
+ function containsUseStateCall(node, bindings) {
543
+ let found = false;
544
+ const visit = (n) => {
545
+ if (found)
546
+ return;
547
+ if (ts.isCallExpression(n)) {
548
+ const callee = n.expression;
549
+ const isNamedUseState = ts.isIdentifier(callee) && bindings.namedUseState.has(callee.text);
550
+ const isReactNamespaceUseState = ts.isPropertyAccessExpression(callee) && callee.name.text === 'useState' &&
551
+ ts.isIdentifier(callee.expression) && bindings.reactNamespace.has(callee.expression.text);
552
+ if (isNamedUseState || isReactNamespaceUseState) {
553
+ found = true;
554
+ return;
555
+ }
556
+ }
557
+ ts.forEachChild(n, visit);
558
+ };
559
+ visit(node);
560
+ return found;
561
+ }
340
562
  function readLiteralProps(node, consts) {
341
563
  const resolved = resolve(node, consts, new Set());
342
564
  if (!ts.isObjectLiteralExpression(resolved))
@@ -365,14 +587,14 @@ function readLiteralProps(node, consts) {
365
587
  * literal — like `previewWidths`/`fullWidthLabel`, it isn't in the original literal-checkable
366
588
  * list, so a non-literal `itemsFill` is simply left unset. `maxColumns` does affect the geometry
367
589
  * `explain` reports, so it must be a literal integer from 1–5 or the page is not checkable. */
368
- function readSlotItems(prop, consts) {
590
+ function readSlotItems(prop, consts, componentName, sourceFile) {
369
591
  if (!prop)
370
592
  return { checkable: true };
371
593
  const resolved = resolve(prop.initializer, consts, new Set());
372
594
  const itemsProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'items') : undefined;
373
595
  if (!itemsProp)
374
596
  return { checkable: false };
375
- const items = readListItems(itemsProp.initializer, consts);
597
+ const items = readListItems(itemsProp.initializer, consts, componentName, sourceFile);
376
598
  if (!items)
377
599
  return { checkable: false };
378
600
  const itemsFillProp = ts.isObjectLiteralExpression(resolved) ? findProp(resolved, 'itemsFill') : undefined;
@@ -1,5 +1,5 @@
1
1
  // cli/updateCheck.ts
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { compareVersions, isBreakingUpgrade, parseVersion } from "./semver.js";
5
5
  const CACHE_FILE_NAME = 'update-check.json';
@@ -79,59 +79,163 @@ export async function fetchWithTimeout(url, fetchImpl, timeoutMs) {
79
79
  clearTimeout(timer);
80
80
  }
81
81
  }
82
+ /** One update check, with whether npm actually answered — `checkForUpdate` alone can't tell "no
83
+ * newer version" from "couldn't reach npm", which the update page must say differently. Never
84
+ * throws. `cached` is the cache as it stands afterwards (the last successful check on this
85
+ * computer), when there is one. */
86
+ export async function runUpdateCheck(currentVersion, options = {}) {
87
+ const now = options.now ?? Date.now;
88
+ const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
89
+ const cached = readCache(cacheDir);
90
+ // `parseVersion` (inside `toResultOrUndefined`) throws on a non-semver `latest`, whether from a
91
+ // malformed cache file or an unexpected registry body; this function must never throw.
92
+ const safeResult = (entry) => {
93
+ try {
94
+ return toResultOrUndefined(entry, currentVersion);
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ };
100
+ if (cached && !options.force && isCacheFresh(cached, now()))
101
+ return { result: safeResult(cached), reached: true, cached };
102
+ const fetchImpl = options.fetchImpl ?? fetch;
103
+ const timeoutMs = options.timeoutMs ?? 900;
104
+ let latest;
105
+ try {
106
+ const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
107
+ latest = registryResponse.version;
108
+ parseVersion(latest);
109
+ }
110
+ catch {
111
+ // A stale cache is still better than nothing once offline — still run through the same
112
+ // not-actually-newer guard, never a raw passthrough.
113
+ return { result: cached ? safeResult(cached) : undefined, reached: false, cached };
114
+ }
115
+ let summary = [];
116
+ let releasedAt;
117
+ try {
118
+ const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
119
+ summary = release.body ? extractSummaryBullets(release.body) : [];
120
+ releasedAt = release.published_at;
121
+ }
122
+ catch {
123
+ // The version comparison is the half that matters for the footer/banner; a missing release
124
+ // summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
125
+ // empty "What's new" list, never a failed check.
126
+ }
127
+ const fresh = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
128
+ // A registry that (briefly, during its own propagation) reports an older "latest" than what is
129
+ // already cached never regresses the cache's version, but the check time still moves forward.
130
+ let stored = fresh;
131
+ try {
132
+ if (cached && compareVersions(parseVersion(fresh.latest), parseVersion(cached.latest)) < 0)
133
+ stored = { ...cached, checkedAt: fresh.checkedAt };
134
+ }
135
+ catch {
136
+ // An unreadable cached version is simply replaced.
137
+ }
138
+ try {
139
+ writeCache(cacheDir, stored);
140
+ }
141
+ catch {
142
+ // A read-only cache folder must not turn a successful check into a failure.
143
+ }
144
+ return { result: safeResult(fresh), reached: true, cached: stored };
145
+ }
82
146
  /** Design §5 "Update check" in full. Never throws — every failure (a stale/missing cache with no
83
147
  * reachable registry, a GitHub release that 404s, a request that times out) resolves to either a
84
148
  * usable result or `undefined`, so every caller can treat "no update known" as the one, uniform
85
149
  * outcome of being offline, blocked, or genuinely up to date with nothing cached yet. */
86
150
  export async function checkForUpdate(currentVersion, options = {}) {
87
- // M5 (Minor, Fable correction pass): this function is documented — and relied on by `dev()`,
88
- // which `await`s it before starting anything else — as never throwing. `parseVersion` (inside
89
- // `toResultOrUndefined`) throws on a non-semver `latest`, whether from a parseable-but-malformed
90
- // cache file or an unexpected 200 body with no real `version`; either would otherwise stop `dev`
91
- // from starting at all.
92
151
  try {
93
- const now = options.now ?? Date.now;
94
- const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
95
- const cached = readCache(cacheDir);
96
- if (cached && isCacheFresh(cached, now()))
97
- return toResultOrUndefined(cached, currentVersion);
98
- const fetchImpl = options.fetchImpl ?? fetch;
99
- const timeoutMs = options.timeoutMs ?? 900;
100
- let latest;
101
- try {
102
- const registryResponse = (await fetchWithTimeout(REGISTRY_URL, fetchImpl, timeoutMs));
103
- latest = registryResponse.version;
104
- }
105
- catch {
106
- // A stale cache is still better than nothing once offline — still run through the same
107
- // not-actually-newer guard, never a raw passthrough.
108
- return cached ? toResultOrUndefined(cached, currentVersion) : undefined;
109
- }
110
- let summary = [];
111
- let releasedAt;
112
- try {
113
- const release = (await fetchWithTimeout(`https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${latest}`, fetchImpl, timeoutMs));
114
- summary = release.body ? extractSummaryBullets(release.body) : [];
115
- releasedAt = release.published_at;
116
- }
117
- catch {
118
- // The version comparison is the half that matters for the footer/banner; a missing release
119
- // summary (private repo hiccup, a tag pushed slightly before its release note) degrades to an
120
- // empty "What's new" list, never a failed check.
121
- }
122
- const cachedResult = { latest, summary, releasedAt, checkedAt: new Date(now()).toISOString() };
123
- // compareVersions is used only to decide *whether* this is worth caching as "the latest" at all
124
- // — a registry that (briefly, during its own propagation) reports an older "latest" than what
125
- // is already cached never regresses the cache.
126
- if (!cached || compareVersions(parseVersion(cachedResult.latest), parseVersion(cached.latest)) >= 0) {
127
- writeCache(cacheDir, cachedResult);
128
- }
129
- return toResultOrUndefined(cachedResult, currentVersion);
152
+ return (await runUpdateCheck(currentVersion, options)).result;
130
153
  }
131
154
  catch {
132
155
  return undefined;
133
156
  }
134
157
  }
158
+ /** A settings folder, not the cache folder (which the OS may clear): the personal switch must
159
+ * survive. */
160
+ export function preferencesDirFor(platform, env, homedir) {
161
+ if (platform === 'darwin')
162
+ return path.posix.join(homedir, 'Library', 'Application Support', 'ds-viewer');
163
+ if (platform === 'win32')
164
+ return path.win32.join(env.APPDATA ?? path.win32.join(homedir, 'AppData', 'Roaming'), 'ds-viewer');
165
+ return path.posix.join(env.XDG_CONFIG_HOME ?? path.posix.join(homedir, '.config'), 'ds-viewer');
166
+ }
167
+ export function defaultPreferencesDir() {
168
+ return preferencesDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
169
+ }
170
+ const PREFERENCES_FILE_NAME = 'preferences.json';
171
+ function readPreferences(dir) {
172
+ try {
173
+ const parsed = JSON.parse(readFileSync(path.join(dir, PREFERENCES_FILE_NAME), 'utf8'));
174
+ return parsed && typeof parsed === 'object' ? parsed : {};
175
+ }
176
+ catch {
177
+ return {};
178
+ }
179
+ }
180
+ /** Your own "check automatically" switch for one project on this computer, or `undefined` when you
181
+ * haven't set it (the project config then decides). */
182
+ export function readPersonalAutoCheck(dir, projectRoot) {
183
+ const value = readPreferences(dir).projects?.[path.resolve(projectRoot)]?.autoCheck;
184
+ return typeof value === 'boolean' ? value : undefined;
185
+ }
186
+ export function writePersonalAutoCheck(dir, projectRoot, enabled) {
187
+ const prefs = readPreferences(dir);
188
+ const projects = { ...(prefs.projects ?? {}) };
189
+ projects[path.resolve(projectRoot)] = { ...projects[path.resolve(projectRoot)], autoCheck: enabled };
190
+ mkdirSync(dir, { recursive: true });
191
+ // Write then rename, so a crash mid-write never leaves a half file that would read as "no
192
+ // settings" and silently reset every project's switch.
193
+ const target = path.join(dir, PREFERENCES_FILE_NAME);
194
+ const temp = `${target}.${process.pid}.tmp`;
195
+ writeFileSync(temp, `${JSON.stringify({ ...prefs, projects }, null, 2)}\n`);
196
+ renameSync(temp, target);
197
+ }
198
+ /** Which setting decides automatic checks, strongest first: CI's environment variable, your own
199
+ * switch, the project config, then on by default. */
200
+ export function resolveAutoCheck(config, env, personal) {
201
+ if (env.DS_VIEWER_NO_UPDATE_CHECK === '1')
202
+ return { enabled: false, source: 'env' };
203
+ if (personal !== undefined)
204
+ return { enabled: personal, source: 'personal' };
205
+ if (config.updateCheck !== undefined)
206
+ return { enabled: config.updateCheck, source: 'project' };
207
+ return { enabled: true, source: 'default' };
208
+ }
209
+ function toNotice(result) {
210
+ if (!result)
211
+ return null;
212
+ return { current: result.current, latest: result.latest, breaking: result.breaking, summary: result.summary, releasedAt: result.releasedAt };
213
+ }
214
+ /** The version status `dev` starts with: runs the (cached) check only when automatic checks are on. */
215
+ export async function buildVersionStatus(currentVersion, autoCheck, options = {}) {
216
+ if (!autoCheck.enabled) {
217
+ const cacheDir = options.cacheDir ?? cacheDirFor(process.platform, process.env, process.env.HOME ?? process.env.USERPROFILE ?? '');
218
+ return { current: currentVersion, autoCheck, lastCheckedAt: readCache(cacheDir)?.checkedAt, lastOutcome: 'not-run', update: null };
219
+ }
220
+ return statusFromCheck(currentVersion, autoCheck, await runUpdateCheck(currentVersion, options));
221
+ }
222
+ /** Folds one check's outcome into a status (startup, or **Check now**). */
223
+ export function statusFromCheck(currentVersion, autoCheck, outcome) {
224
+ return {
225
+ current: currentVersion,
226
+ autoCheck,
227
+ lastCheckedAt: outcome.cached?.checkedAt,
228
+ lastOutcome: outcome.reached ? 'ok' : 'unreachable',
229
+ update: toNotice(outcome.result),
230
+ };
231
+ }
232
+ /** `dev` writes this next to `update.json`; the generated entry file imports it. */
233
+ export function writeVersionFile(projectRoot, status) {
234
+ const file = path.join(projectRoot, '.ds-viewer', 'version.json');
235
+ mkdirSync(path.dirname(file), { recursive: true });
236
+ writeFileSync(file, JSON.stringify(status));
237
+ return file;
238
+ }
135
239
  /** Design §5: "`dev` writes the result to `.ds-viewer/update.json`; the viewer reads it." Always
136
240
  * writes something — `null` when there is no result — so the generated entry file (Task 17) can
137
241
  * unconditionally `import updateNotice from './update.json'` without ever hitting a missing file. */
@@ -141,3 +245,30 @@ export function writeUpdateFile(projectRoot, result) {
141
245
  writeFileSync(file, JSON.stringify(result ?? null));
142
246
  return file;
143
247
  }
248
+ /** The live version status behind the local endpoint (`GET /version`, **Check now**, the switch),
249
+ * kept outside `dev()` so its ordering rules are testable. */
250
+ export function createVersionController(deps) {
251
+ let status = deps.initial;
252
+ const resolveNow = () => resolveAutoCheck(deps.getConfig(), deps.env, readPersonalAutoCheck(deps.prefsDir, deps.projectRoot));
253
+ return {
254
+ getVersionStatus: () => status,
255
+ /** **Check now**: asks npm ignoring the cache. Reads the switch *after* the check, so a change
256
+ * made while it ran is kept. */
257
+ checkNow: async () => {
258
+ const outcome = await deps.runCheck();
259
+ status = statusFromCheck(deps.ownVersion, status.autoCheck, outcome);
260
+ return status;
261
+ },
262
+ /** The switch: your own setting for this project on this computer. Takes effect for automatic
263
+ * checks from the next start; the current status keeps any update already found. */
264
+ setAutoCheck: (enabled) => {
265
+ writePersonalAutoCheck(deps.prefsDir, deps.projectRoot, enabled);
266
+ status = { ...status, autoCheck: resolveNow() };
267
+ return status;
268
+ },
269
+ /** After `ds-viewer.config.ts` changes while `dev` runs. */
270
+ refreshAutoCheck: () => {
271
+ status = { ...status, autoCheck: resolveNow() };
272
+ },
273
+ };
274
+ }
@@ -65,13 +65,14 @@ function entryTsx(name, hasSafeArea, groupOrder, logoRequirePath, updateEndpoint
65
65
  // (no reserved words in either key) — the same reasoning the existing `logoRequirePath` baking
66
66
  // already relies on, extended to a whole object instead of one string.
67
67
  const endpointConst = `const updateEndpoint = ${updateEndpoint ? JSON.stringify(updateEndpoint) : 'undefined'};\n`;
68
- const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} updateEndpoint={updateEndpoint} />`;
68
+ const viewer = `<CatalogShell appName={${JSON.stringify(name)}} title="Component Catalog" groups={groups} sections={sections}${logoProp} update={updateNotice} versionStatus={versionStatus} updateEndpoint={updateEndpoint} />`;
69
69
  const root = hasSafeArea ? `<SafeAreaProvider>${viewer}</SafeAreaProvider>` : viewer;
70
70
  return `import { registerRootComponent } from 'expo';
71
71
  import { CatalogShell, buildCatalogSections } from '@krapwoo/ds-viewer';
72
72
  ${safeAreaImport}${logoImport}import pages from './generated/pages';
73
73
  import components from './generated/components.json';
74
74
  import updateNotice from './update.json';
75
+ import versionStatus from './version.json';
75
76
  ${endpointConst}
76
77
  function App() {
77
78
  const { sections, groups } = buildCatalogSections(pages, components, ${JSON.stringify(groupOrder)});