@xh/hoist 83.0.1 → 83.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/build/types/cmp/error/ErrorBoundary.d.ts +2 -0
  3. package/build/types/cmp/error/ErrorBoundaryModel.d.ts +12 -0
  4. package/build/types/cmp/filter/FilterChooserModel.d.ts +18 -0
  5. package/build/types/cmp/form/Form.d.ts +4 -4
  6. package/build/types/cmp/form/formfieldset/FormFieldSetModel.d.ts +9 -0
  7. package/build/types/cmp/grouping/GroupingChooserModel.d.ts +16 -0
  8. package/build/types/cmp/pinpad/PinPadModel.d.ts +8 -0
  9. package/build/types/cmp/treemap/SplitTreeMapModel.d.ts +10 -10
  10. package/build/types/cmp/treemap/TreeMapModel.d.ts +19 -20
  11. package/build/types/core/HoistComponent.d.ts +20 -22
  12. package/build/types/core/load/LoadSpec.d.ts +19 -19
  13. package/build/types/data/impl/RecordSet.d.ts +2 -2
  14. package/build/types/desktop/cmp/input/DateInput.d.ts +1 -1
  15. package/build/types/desktop/cmp/leftrightchooser/LeftRightChooserModel.d.ts +1 -1
  16. package/build/types/svc/AutoRefreshService.d.ts +1 -1
  17. package/build/types/svc/ChangelogService.d.ts +2 -3
  18. package/build/types/svc/FetchService.d.ts +19 -17
  19. package/cmp/error/ErrorBoundary.ts +2 -0
  20. package/cmp/error/ErrorBoundaryModel.ts +12 -0
  21. package/cmp/filter/FilterChooserModel.ts +18 -0
  22. package/cmp/form/Form.ts +4 -4
  23. package/cmp/form/formfieldset/FormFieldSetModel.ts +9 -0
  24. package/cmp/grouping/GroupingChooserModel.ts +16 -0
  25. package/cmp/pinpad/PinPadModel.ts +8 -0
  26. package/cmp/treemap/SplitTreeMapModel.ts +10 -10
  27. package/cmp/treemap/TreeMapModel.ts +19 -20
  28. package/core/HoistComponent.ts +20 -22
  29. package/core/load/LoadSpec.ts +19 -20
  30. package/data/impl/RecordSet.ts +3 -3
  31. package/desktop/cmp/input/DateInput.ts +1 -1
  32. package/desktop/cmp/leftrightchooser/LeftRightChooserModel.ts +1 -1
  33. package/mcp/cli/ts.ts +9 -2
  34. package/mcp/data/ts-registry.ts +116 -12
  35. package/mcp/formatters/typescript.ts +50 -4
  36. package/mcp/tools/typescript.ts +9 -2
  37. package/package.json +2 -2
  38. package/svc/AutoRefreshService.ts +1 -1
  39. package/svc/ChangelogService.ts +2 -3
  40. package/svc/EnvironmentService.ts +7 -5
  41. package/svc/FetchService.ts +20 -18
  42. package/svc/WebSocketService.ts +1 -1
@@ -14,26 +14,6 @@ import {throwIf, withDefault} from '@xh/hoist/utils/js';
14
14
  import {ReactNode} from 'react';
15
15
  import {cloneDeep, get, isEmpty, isFinite, max, set, sortBy, sumBy, unset} from 'lodash';
16
16
 
17
- /**
18
- * Core Model for a TreeMap.
19
- *
20
- * You should specify the TreeMap's data store, in addition to which StoreRecord fields should be
21
- * mapped to label (a node's display name), value (a node's size), and heat (a node's color).
22
- *
23
- * Can also (optionally) be bound to a GridModel. This will enable selection syncing and
24
- * expand / collapse syncing for GridModels in `treeMode`.
25
- *
26
- * Supports any Highcharts TreeMap algorithm ('squarified', 'sliceAndDice', 'stripes' or 'strip').
27
- *
28
- * Node colors are normalized to a 0-1 range and mapped to a colorAxis via the following colorModes:
29
- * 'linear' distributes normalized color values across the colorAxis according to the heatField.
30
- * 'wash' ignores the intensity of the heat value, applying a single positive and negative color.
31
- * 'none' will ignore the colorAxis, and instead use the neutral color.
32
- *
33
- * Color customization can be managed by setting colorAxis stops via the `highchartsConfig`.
34
- *
35
- * @see https://www.highcharts.com/docs/chart-and-series-types/treemap for Highcharts config options
36
- */
37
17
  export interface TreeMapConfig {
38
18
  /** A store containing records to be displayed. */
39
19
  store?: Store;
@@ -117,6 +97,25 @@ export interface TreeMapModelDefaults {
117
97
  maxNodes?: number;
118
98
  }
119
99
 
100
+ /**
101
+ * Core Model for a TreeMap, backed by a data Store with fields mapped to each node's label
102
+ * (display name), value (size), and heat (color).
103
+ *
104
+ * Can optionally bind to a GridModel to enable selection and expand/collapse syncing for
105
+ * GridModels in `treeMode`.
106
+ *
107
+ * Supports Highcharts TreeMap algorithms: 'squarified', 'sliceAndDice', 'stripes', and 'strip'.
108
+ *
109
+ * Node colors are normalized to a 0-1 range and mapped to a colorAxis. The `colorMode` config
110
+ * controls how:
111
+ * - 'linear' — distributes values across the colorAxis according to the heatField.
112
+ * - 'wash' — ignores heat intensity, applying a single positive/negative color.
113
+ * - 'none' — ignores the colorAxis entirely, using the neutral color.
114
+ *
115
+ * Color customization can be managed by setting colorAxis stops via `highchartsConfig`.
116
+ * See {@link https://www.highcharts.com/docs/chart-and-series-types/treemap} for Highcharts
117
+ * TreeMap config options.
118
+ */
120
119
  export class TreeMapModel extends HoistModel {
121
120
  /** App-level defaults for TreeMapModel. Instance config takes precedence. */
122
121
  static defaults: TreeMapModelDefaults = {
@@ -108,34 +108,32 @@ export type ComponentConfig<P extends HoistProps> =
108
108
  let cmpIndex = 0; // index for anonymous component dispay names
109
109
 
110
110
  /**
111
- * Hoist utility for defining functional components. This is the primary method for creating
112
- * components for use in Hoist applications. Accepts either a render function (directly) or a
113
- * configuration object to specify that function and additional options, as described below.
111
+ * The primary entry point for defining Hoist components functional React components enhanced
112
+ * with MobX reactivity and integrated model support.
114
113
  *
115
- * The primary additional config option is `model`. It specifies how a backing HoistModel will be
116
- * provided to / created by this component and if the component should publish its model to any
117
- * subcomponents via context.
114
+ * Accepts a configuration object (or a bare render function) and returns a React functional
115
+ * component. The `model` config is the key option: use {@link creates} to have the component
116
+ * create and own its backing model, {@link uses} to source a model from props or context, or
117
+ * `false` for simple components with no model. Defaults to `uses('*')` if not specified.
118
118
  *
119
- * By default, this utility wraps the returned component in the MobX 'observer' HOC, enabling
120
- * MobX-powered reactivity and auto-re-rendering of observable properties read from models and
121
- * any other sources of observable state.
119
+ * Components are wrapped in the MobX `observer` HOC by default, enabling automatic re-rendering
120
+ * when observable state read during render changes.
122
121
  *
123
- * Forward refs {@link https://reactjs.org/docs/forwarding-refs.html} are supported by specifying a
124
- * render function that accepts two arguments. In that case, the second arg will be considered a
125
- * ref, and this utility will apply `React.forwardRef` as required.
122
+ * Forward refs ({@link https://reactjs.org/docs/forwarding-refs.html}) are supported by
123
+ * specifying a render function with two arguments the second is treated as a ref, and
124
+ * `React.forwardRef` is applied automatically.
126
125
  *
127
- * @param config - specification object, or a render function defining the component.
128
- * @returns a functional React Component for use within Hoist apps.
129
- *
130
- * @see hoistCmp - a shorthand alias to this function.
126
+ * Most components should be defined via one of two convenience methods rather than calling
127
+ * this function directly:
128
+ * - `hoistCmp.factory()` — returns an element factory (the standard pattern for app components).
129
+ * - `hoistCmp.withFactory()` returns a `[Component, factory]` pair (the standard pattern for
130
+ * library components that need to export both).
131
131
  *
132
- * This function also has several related functions
132
+ * See `core/README.md` for full documentation on component configuration, model specs, and
133
+ * context lookup behavior.
133
134
  *
134
- * - `hoistCmp.factory` - return an elementFactory for a newly defined Component.
135
- * instead of the Component itself.
136
- *
137
- * - `hoistCmp.withFactory` - return a 2-element list containing both the newly
138
- * defined Component and an elementFactory for it.
135
+ * @param config - specification object, or a render function defining the component.
136
+ * @returns a functional React Component for use within Hoist apps.
139
137
  */
140
138
  export function hoistCmp<M extends HoistModel>(
141
139
  config: ComponentConfig<DefaultHoistProps<M>>
@@ -8,26 +8,6 @@
8
8
  import {PlainObject} from '../types/Types';
9
9
  import {LoadSupport} from './';
10
10
 
11
- /**
12
- * Object describing a load/refresh request in Hoist.
13
- *
14
- * Instances of this class are created within the public APIs provided by {@link LoadSupport}
15
- * and are passed to subclass (i.e. app-level) implementations of `doLoadAsync()`.
16
- *
17
- * Application implementations of `doLoadAsync()` can consult this object's flags. Of particular
18
- * interest are {@link isStale} and {@link isObsolete}, which implementations can read after any
19
- * async calls return to determine if a newer, subsequent load has already been requested.
20
- *
21
- * In addition, `doLoadAsync()` implementations should typically pass along this object to any
22
- * calls they make to `loadAsync()` on other objects + all calls to {@link FetchService} APIs.
23
- *
24
- * Note that Hoist's exception handling and activity tracking will consult the {@link isAutoRefresh}
25
- * flag on specs passed to their calls to automatically adjust their behavior (e.g. not showing an
26
- * exception dialog on error, not tracking background refresh activity).
27
- *
28
- * @see LoadSupport
29
- */
30
-
31
11
  export type LoadSpecConfig = {
32
12
  /** True if triggered by a refresh request (automatic or user-driven). */
33
13
  isRefresh?: boolean;
@@ -37,6 +17,25 @@ export type LoadSpecConfig = {
37
17
  meta?: PlainObject;
38
18
  };
39
19
 
20
+ /**
21
+ * Immutable descriptor for a load/refresh request, passed to `doLoadAsync()` implementations.
22
+ *
23
+ * Instances are created automatically by {@link LoadSupport} when `loadAsync()`,
24
+ * `refreshAsync()`, or `autoRefreshAsync()` are called. Application code should not construct
25
+ * LoadSpec instances directly.
26
+ *
27
+ * Within `doLoadAsync()`, check {@link isStale} after any async call to determine if a newer
28
+ * load has already been requested — if so, return early to avoid applying outdated results.
29
+ * Check {@link isAutoRefresh} to adjust behavior for background refreshes (e.g. skip expensive
30
+ * operations, avoid user-facing error dialogs).
31
+ *
32
+ * Pass this object along to any nested `loadAsync()` calls and to all {@link FetchService}
33
+ * requests. Hoist's exception handling and activity tracking consult the LoadSpec to
34
+ * automatically suppress error dialogs and skip tracking for auto-refresh operations.
35
+ *
36
+ * @see LoadSupport
37
+ * @see HoistModel.doLoadAsync
38
+ */
40
39
  export class LoadSpec {
41
40
  /** True if triggered by a refresh request (automatic or user-driven). */
42
41
  isRefresh: boolean;
@@ -12,15 +12,15 @@ import {StoreRecord, StoreRecordId} from '../StoreRecord';
12
12
  import {Store} from '../Store';
13
13
  import {Filter} from '../filter/Filter';
14
14
 
15
+ type StoreRecordMap = Map<StoreRecordId, StoreRecord>;
16
+ type ChildRecordMap = Map<StoreRecordId, StoreRecord[]>;
17
+
15
18
  /**
16
19
  * Internal container for StoreRecord management within a Store.
17
20
  * Note this is an immutable object; its update and filtering APIs return new instances as required.
18
21
  *
19
22
  * @internal
20
23
  */
21
- type StoreRecordMap = Map<StoreRecordId, StoreRecord>;
22
- type ChildRecordMap = Map<StoreRecordId, StoreRecord[]>;
23
-
24
24
  export class RecordSet {
25
25
  store: Store;
26
26
  recordMap: StoreRecordMap; // Map of all Records by id
@@ -146,7 +146,7 @@ export interface DateInputProps extends HoistProps, LayoutProps, HoistInputProps
146
146
  /**
147
147
  * Type of value to publish. Defaults to 'date'. The use of 'localDate' is often a good
148
148
  * choice for use cases where there is no time component.
149
- * @see LocalDate - the class that will be published when localDate mode.
149
+ * @see LocalDate
150
150
  */
151
151
  valueType?: 'date' | 'localDate';
152
152
  }
@@ -100,7 +100,7 @@ export class LeftRightChooserModel extends HoistModel {
100
100
  * Note that this will *not* affect the actual 'value' property, which will continue
101
101
  * to include unfiltered records.
102
102
  *
103
- * @see LeftRightChooserFilter - a component to easily control this field.
103
+ * @see LeftRightChooserFilter
104
104
  * @param fn - predicate function for filtering.
105
105
  */
106
106
  setDisplayFilter(fn: FilterTestFn) {
package/mcp/cli/ts.ts CHANGED
@@ -6,7 +6,13 @@
6
6
  */
7
7
  import {Command} from 'commander';
8
8
 
9
- import {searchSymbols, searchMembers, getSymbolDetail, getMembers} from '../data/ts-registry.js';
9
+ import {
10
+ searchSymbols,
11
+ searchMembers,
12
+ getSymbolDetail,
13
+ getMembers,
14
+ getCompanionSymbols
15
+ } from '../data/ts-registry.js';
10
16
  import {formatSymbolSearch, formatSymbolDetail, formatMembers} from '../formatters/typescript.js';
11
17
 
12
18
  const VALID_KINDS = ['class', 'interface', 'type', 'function', 'const', 'enum'] as const;
@@ -101,7 +107,8 @@ program
101
107
  process.exit(1);
102
108
  }
103
109
 
104
- let text = formatSymbolDetail(detail, name);
110
+ const companions = await getCompanionSymbols(detail);
111
+ let text = formatSymbolDetail(detail, name, companions);
105
112
  if (detail.kind === 'class' || detail.kind === 'interface') {
106
113
  text +=
107
114
  '\n\nTip: Use `hoist-ts members ' + name + '` to see all properties and methods.';
@@ -15,7 +15,7 @@
15
15
  * Detailed symbol info is extracted on-demand.
16
16
  */
17
17
  import {Project, Node, Scope, SyntaxKind} from 'ts-morph';
18
- import type {ClassDeclaration, SourceFile} from 'ts-morph';
18
+ import type {ClassDeclaration, FunctionDeclaration, SourceFile} from 'ts-morph';
19
19
  import {resolve} from 'node:path';
20
20
 
21
21
  import {log} from '../util/logger.js';
@@ -35,6 +35,8 @@ export interface SymbolEntry {
35
35
  filePath: string;
36
36
  isExported: boolean;
37
37
  sourcePackage: string;
38
+ /** JSDoc, if available. Populated at index time; displayed in search results. */
39
+ jsDoc: string;
38
40
  }
39
41
 
40
42
  /** Detailed symbol information extracted on-demand. */
@@ -285,7 +287,8 @@ function buildSymbolIndex(proj: Project): {
285
287
  kind: 'class',
286
288
  filePath,
287
289
  isExported: cls.isExported(),
288
- sourcePackage: pkg
290
+ sourcePackage: pkg,
291
+ jsDoc: extractJsDoc(cls)
289
292
  };
290
293
  addToIndex(index, entry);
291
294
  counts.total++;
@@ -308,7 +311,7 @@ function buildSymbolIndex(proj: Project): {
308
311
  sourcePackage: pkg,
309
312
  isStatic: m.isStatic,
310
313
  type: m.kind === 'method' ? formatMethodType(m) : m.type,
311
- jsDoc: m.jsDoc.split('\n')[0],
314
+ jsDoc: m.jsDoc,
312
315
  decorators: m.decorators
313
316
  };
314
317
  addToMemberIndex(mIndex, mEntry);
@@ -324,12 +327,15 @@ function buildSymbolIndex(proj: Project): {
324
327
  for (const iface of sourceFile.getInterfaces()) {
325
328
  const name = iface.getName();
326
329
  if (!name) continue;
330
+ let jsDoc = extractJsDoc(iface);
331
+ if (!jsDoc) jsDoc = extractCompanionJsDoc(sourceFile, name);
327
332
  const entry: SymbolEntry = {
328
333
  name,
329
334
  kind: 'interface',
330
335
  filePath,
331
336
  isExported: iface.isExported(),
332
- sourcePackage: pkg
337
+ sourcePackage: pkg,
338
+ jsDoc
333
339
  };
334
340
  addToIndex(index, entry);
335
341
  counts.total++;
@@ -346,7 +352,8 @@ function buildSymbolIndex(proj: Project): {
346
352
  kind: 'type',
347
353
  filePath,
348
354
  isExported: typeAlias.isExported(),
349
- sourcePackage: pkg
355
+ sourcePackage: pkg,
356
+ jsDoc: extractJsDoc(typeAlias)
350
357
  };
351
358
  addToIndex(index, entry);
352
359
  counts.total++;
@@ -363,7 +370,8 @@ function buildSymbolIndex(proj: Project): {
363
370
  kind: 'function',
364
371
  filePath,
365
372
  isExported: func.isExported(),
366
- sourcePackage: pkg
373
+ sourcePackage: pkg,
374
+ jsDoc: extractFunctionJsDoc(func)
367
375
  };
368
376
  addToIndex(index, entry);
369
377
  counts.total++;
@@ -380,7 +388,8 @@ function buildSymbolIndex(proj: Project): {
380
388
  kind: 'enum',
381
389
  filePath,
382
390
  isExported: enumDecl.isExported(),
383
- sourcePackage: pkg
391
+ sourcePackage: pkg,
392
+ jsDoc: extractJsDoc(enumDecl)
384
393
  };
385
394
  addToIndex(index, entry);
386
395
  counts.total++;
@@ -391,6 +400,7 @@ function buildSymbolIndex(proj: Project): {
391
400
  // Exported const variables
392
401
  for (const stmt of sourceFile.getVariableStatements()) {
393
402
  if (!stmt.isExported()) continue;
403
+ const stmtJsDoc = extractJsDoc(stmt);
394
404
  for (const decl of stmt.getDeclarations()) {
395
405
  // For destructured exports (e.g. `export const [Foo, foo] = ...`),
396
406
  // index each binding element as a separate symbol so both the
@@ -420,7 +430,8 @@ function buildSymbolIndex(proj: Project): {
420
430
  kind: 'const',
421
431
  filePath,
422
432
  isExported: true,
423
- sourcePackage: pkg
433
+ sourcePackage: pkg,
434
+ jsDoc: stmtJsDoc
424
435
  };
425
436
  addToIndex(index, entry);
426
437
  counts.total++;
@@ -508,7 +519,7 @@ function indexPromiseExtensions(
508
519
  sourcePackage: pkg,
509
520
  isStatic: false,
510
521
  type: `(${paramStr}) => ${returnType}`,
511
- jsDoc: jsDoc.split('\n')[0],
522
+ jsDoc,
512
523
  decorators: []
513
524
  });
514
525
 
@@ -518,7 +529,8 @@ function indexPromiseExtensions(
518
529
  kind: 'function',
519
530
  filePath,
520
531
  isExported: true,
521
- sourcePackage: pkg
532
+ sourcePackage: pkg,
533
+ jsDoc
522
534
  });
523
535
 
524
536
  // Pre-compute detail for `getSymbolDetail()` since these can't be
@@ -703,6 +715,55 @@ export async function getSymbolDetail(
703
715
  }
704
716
  }
705
717
 
718
+ /**
719
+ * Find companion symbols for cross-referencing in detail output.
720
+ *
721
+ * For a Props interface (e.g. `PanelProps`), returns the companion component
722
+ * consts (`Panel`, `panel`) from the same file. For a component const, returns
723
+ * the companion Props interface. Returns an empty array if no companions found.
724
+ */
725
+ export async function getCompanionSymbols(entry: SymbolEntry): Promise<SymbolEntry[]> {
726
+ await ensureInitialized();
727
+
728
+ if (entry.kind === 'interface' && entry.name.endsWith('Props')) {
729
+ // Props interface → look for companion component consts
730
+ const baseName = entry.name.slice(0, -5);
731
+ if (!baseName) return [];
732
+ return findCompanionEntries(baseName, entry.filePath);
733
+ }
734
+
735
+ if (entry.kind === 'const') {
736
+ // Component const → look for companion Props interface
737
+ const pascalName = entry.name[0].toUpperCase() + entry.name.slice(1) + 'Props';
738
+ const key = pascalName.toLowerCase();
739
+ const entries = symbolIndex!.get(key);
740
+ if (!entries) return [];
741
+ return entries.filter(
742
+ e => e.name === pascalName && e.kind === 'interface' && e.filePath === entry.filePath
743
+ );
744
+ }
745
+
746
+ return [];
747
+ }
748
+
749
+ /** Find companion const entries (PascalCase and camelCase) in the same file. */
750
+ function findCompanionEntries(baseName: string, filePath: string): SymbolEntry[] {
751
+ const results: SymbolEntry[] = [];
752
+ const camelName = baseName[0].toLowerCase() + baseName.slice(1);
753
+
754
+ for (const name of [baseName, camelName]) {
755
+ const key = name.toLowerCase();
756
+ const entries = symbolIndex!.get(key);
757
+ if (!entries) continue;
758
+ for (const e of entries) {
759
+ if (e.name === name && e.kind === 'const' && e.filePath === filePath) {
760
+ results.push(e);
761
+ }
762
+ }
763
+ }
764
+ return results;
765
+ }
766
+
706
767
  /**
707
768
  * Get members (properties, methods, accessors) of a class or interface.
708
769
  *
@@ -929,6 +990,45 @@ function extractJsDoc(node: {getJsDocs?: () => Array<{getDescription: () => stri
929
990
  }
930
991
  }
931
992
 
993
+ /**
994
+ * For a Props interface (e.g. `PanelProps`), look up the companion component's
995
+ * JSDoc from the same file. Uses the reliable `FooProps → Foo/foo` naming
996
+ * convention: strips the `Props` suffix and checks for a matching exported const.
997
+ *
998
+ * Returns the companion JSDoc string, or empty string if no match is found.
999
+ */
1000
+ function extractCompanionJsDoc(sourceFile: SourceFile, propsName: string): string {
1001
+ if (!propsName.endsWith('Props')) return '';
1002
+ const companionName = propsName.slice(0, -5);
1003
+ if (!companionName) return '';
1004
+
1005
+ // Look for `const [Foo, foo] = ...` or `const foo = ...` in the same file
1006
+ const varDecl =
1007
+ sourceFile.getVariableDeclaration(companionName) ??
1008
+ sourceFile.getVariableDeclaration(companionName[0].toLowerCase() + companionName.slice(1));
1009
+ if (!varDecl) return '';
1010
+
1011
+ const varStmt = varDecl.getFirstAncestorByKind(SyntaxKind.VariableStatement);
1012
+ return varStmt ? extractJsDoc(varStmt) : '';
1013
+ }
1014
+
1015
+ /**
1016
+ * Extract JSDoc from a function, falling back to the first overload signature if the
1017
+ * implementation has no JSDoc. TypeScript best practice places JSDoc on overload signatures
1018
+ * (which are visible to consumers) rather than the implementation (which is hidden).
1019
+ */
1020
+ function extractFunctionJsDoc(func: FunctionDeclaration): string {
1021
+ const implDoc = extractJsDoc(func);
1022
+ if (implDoc) return implDoc;
1023
+
1024
+ const overloads = func.getOverloads();
1025
+ for (const overload of overloads) {
1026
+ const doc = extractJsDoc(overload);
1027
+ if (doc) return doc;
1028
+ }
1029
+ return '';
1030
+ }
1031
+
932
1032
  /** Safely extract a type's text representation. */
933
1033
  function safeGetTypeText(node: Node, enclosing?: Node): string {
934
1034
  try {
@@ -996,10 +1096,14 @@ function extractInterfaceDetail(
996
1096
  const braceIdx = text.indexOf('{');
997
1097
  const signature = braceIdx === -1 ? text.trim() : text.slice(0, braceIdx).trim();
998
1098
 
1099
+ // For Props interfaces without their own JSDoc, inherit from the companion component
1100
+ let jsDoc = extractJsDoc(iface);
1101
+ if (!jsDoc) jsDoc = extractCompanionJsDoc(sourceFile, name);
1102
+
999
1103
  return {
1000
1104
  ...base,
1001
1105
  signature,
1002
- jsDoc: extractJsDoc(iface),
1106
+ jsDoc,
1003
1107
  ...(extendsClauses.length > 0 ? {extends: extendsClauses.join(', ')} : {})
1004
1108
  };
1005
1109
  }
@@ -1056,7 +1160,7 @@ function extractFunctionDetail(
1056
1160
  return {
1057
1161
  ...base,
1058
1162
  signature,
1059
- jsDoc: extractJsDoc(func)
1163
+ jsDoc: extractFunctionJsDoc(func)
1060
1164
  };
1061
1165
  }
1062
1166
 
@@ -7,6 +7,14 @@
7
7
  import type {MemberInfo, MemberIndexEntry, SymbolEntry, SymbolDetail} from '../data/ts-registry.js';
8
8
  import {resolveRepoRoot} from '../util/paths.js';
9
9
 
10
+ /** Remove blank lines from a JSDoc string to produce more compact output. */
11
+ function collapseJsDoc(jsDoc: string): string {
12
+ return jsDoc
13
+ .split('\n')
14
+ .filter(l => l.trim().length > 0)
15
+ .join('\n');
16
+ }
17
+
10
18
  /** Maximum length for type strings before truncation. */
11
19
  const MAX_TYPE_LENGTH = 200;
12
20
 
@@ -45,7 +53,11 @@ export function formatMember(member: MemberInfo): string {
45
53
  }
46
54
 
47
55
  if (member.jsDoc) {
48
- lines.push(` ${member.jsDoc.split('\n')[0]}`);
56
+ const indented = collapseJsDoc(member.jsDoc)
57
+ .split('\n')
58
+ .map(l => ` ${l}`)
59
+ .join('\n');
60
+ lines.push(indented);
49
61
  }
50
62
 
51
63
  return lines.join('\n');
@@ -62,7 +74,11 @@ export function formatMemberIndexEntry(entry: MemberIndexEntry, index: number):
62
74
  `${index}. [${entry.memberKind}] ${staticPrefix}${entry.name}: ${typeStr} (on ${entry.ownerName} \u2014 ${entry.ownerDescription})`
63
75
  );
64
76
  if (entry.jsDoc) {
65
- lines.push(` ${entry.jsDoc}`);
77
+ const indented = collapseJsDoc(entry.jsDoc)
78
+ .split('\n')
79
+ .map(l => ` ${l}`)
80
+ .join('\n');
81
+ lines.push(indented);
66
82
  }
67
83
  return lines.join('\n');
68
84
  }
@@ -81,6 +97,13 @@ export function formatSymbolSearch(
81
97
  lines.push(
82
98
  `${i + 1}. [${result.kind}] ${result.name} (package: ${result.sourcePackage}, file: ${toRelativePath(result.filePath)}, exported: ${result.isExported ? 'yes' : 'no'})`
83
99
  );
100
+ if (result.jsDoc) {
101
+ const indented = collapseJsDoc(result.jsDoc)
102
+ .split('\n')
103
+ .map(l => ` ${l}`)
104
+ .join('\n');
105
+ lines.push(indented);
106
+ }
84
107
  });
85
108
  }
86
109
 
@@ -100,7 +123,11 @@ export function formatSymbolSearch(
100
123
  }
101
124
 
102
125
  /** Format detailed symbol information as a readable text block. */
103
- export function formatSymbolDetail(detail: SymbolDetail | null, name: string): string {
126
+ export function formatSymbolDetail(
127
+ detail: SymbolDetail | null,
128
+ name: string,
129
+ companionSymbols?: SymbolEntry[]
130
+ ): string {
104
131
  if (!detail) {
105
132
  return `Symbol '${name}' not found. Use search to find available symbols.`;
106
133
  }
@@ -132,7 +159,26 @@ export function formatSymbolDetail(detail: SymbolDetail | null, name: string): s
132
159
  if (detail.jsDoc) {
133
160
  lines.push('');
134
161
  lines.push('## Documentation');
135
- lines.push(detail.jsDoc);
162
+ lines.push(collapseJsDoc(detail.jsDoc));
163
+ }
164
+
165
+ // Cross-reference: link Props interfaces to their companion component and vice versa
166
+ if (companionSymbols && companionSymbols.length > 0) {
167
+ lines.push('');
168
+ const companionNames = companionSymbols.map(s => `\`${s.name}\``).join(', ');
169
+ if (detail.kind === 'interface' && detail.name.endsWith('Props')) {
170
+ lines.push(`## Component`);
171
+ lines.push(
172
+ `This is the Props interface for ${companionNames}. ` +
173
+ `Use hoist-get-members on ${detail.name} to see all available props.`
174
+ );
175
+ } else {
176
+ const propsName = companionSymbols[0].name;
177
+ lines.push(`## Props`);
178
+ lines.push(
179
+ `Accepts \`${propsName}\` — use hoist-get-members on ${propsName} to see all available props.`
180
+ );
181
+ }
136
182
  }
137
183
 
138
184
  return lines.join('\n');
@@ -8,7 +8,13 @@
8
8
  import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  import {z} from 'zod';
10
10
 
11
- import {searchSymbols, searchMembers, getSymbolDetail, getMembers} from '../data/ts-registry.js';
11
+ import {
12
+ searchSymbols,
13
+ searchMembers,
14
+ getSymbolDetail,
15
+ getMembers,
16
+ getCompanionSymbols
17
+ } from '../data/ts-registry.js';
12
18
  import {formatSymbolSearch, formatSymbolDetail, formatMembers} from '../formatters/typescript.js';
13
19
 
14
20
  /**
@@ -100,7 +106,8 @@ export function registerTsTools(server: McpServer): void {
100
106
  },
101
107
  async ({name, filePath}) => {
102
108
  const detail = await getSymbolDetail(name, filePath);
103
- let text = formatSymbolDetail(detail, name);
109
+ const companions = detail ? await getCompanionSymbols(detail) : [];
110
+ let text = formatSymbolDetail(detail, name, companions);
104
111
 
105
112
  if (detail && (detail.kind === 'class' || detail.kind === 'interface')) {
106
113
  text += '\n\nUse hoist-get-members to see all properties and methods.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "83.0.1",
3
+ "version": "83.1.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -108,7 +108,7 @@
108
108
  "devDependencies": {
109
109
  "@types/react": "18.x",
110
110
  "@types/react-dom": "18.x",
111
- "@xh/hoist-dev-utils": "11.x",
111
+ "@xh/hoist-dev-utils": "12.x",
112
112
  "ag-grid-community": "34.x",
113
113
  "ag-grid-react": "34.x",
114
114
  "csstype": "3.x",
@@ -24,7 +24,7 @@ import {withDefault} from '@xh/hoist/utils/js';
24
24
  * to customize via the global options dialog, or set a default pref value if per-user
25
25
  * customization is not desirable.
26
26
  *
27
- * @see RefreshContextModel - the underlying mechanism used to implement the refresh.
27
+ * @see RefreshContextModel
28
28
  */
29
29
  export class AutoRefreshService extends HoistService {
30
30
  override xhImpl = true;
@@ -26,9 +26,8 @@ import {isEmpty, forOwn, includes} from 'lodash';
26
26
  *
27
27
  * Several additional options can be controlled via soft-config - see below.
28
28
  *
29
- * @see XH.showChangelog - public API for displaying the changelog, if enabled and populated.
30
- * @see whatsNewButton - utility button that conditionally renders when an unread entry exists for
31
- * the currently deployed app version. Installed by default in desktop appBar.
29
+ * @see XH.showChangelog
30
+ * @see whatsNewButton
32
31
  */
33
32
  export class ChangelogService extends HoistService {
34
33
  override xhImpl = true;
@@ -162,16 +162,18 @@ export class EnvironmentService extends HoistService {
162
162
 
163
163
  private ensureVersionRunnable() {
164
164
  const hcVersion = this.get('hoistCoreVersion'),
165
- clientVersion = this.get('appVersion'),
166
- serverVersion = this.serverVersion;
165
+ // This app version value is sourced by the network call to 'xh/environment'.
166
+ serverAppVersion = this.get('appVersion'),
167
+ // This app version value is packaged from configureWebpack -> appVersion.
168
+ clientAppVersion = this.get('clientVersion');
167
169
 
168
170
  // Check for client/server mismatch version. It's an ok transitory state *during* the
169
171
  // client app lifetime, but app should *never* start this way, would indicate caching issue.
170
172
  throwIf(
171
- clientVersion != serverVersion,
173
+ clientAppVersion != serverAppVersion,
172
174
  XH.exception(
173
- `The version of this client (${clientVersion}) is out of sync with the
174
- available server (${serverVersion}). Please reload to continue.`
175
+ `The version of this client (${clientAppVersion}) is out of sync with the
176
+ available server (${serverAppVersion}). Please reload to continue.`
175
177
  )
176
178
  );
177
179