@astryxdesign/cli 0.7.12-canary.c08de54 → 0.7.12-canary.c5cc8f4

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 (26) hide show
  1. package/api/component/__fixtures__/resolutions/modules.txt +2 -2
  2. package/api/template/template.mjs +1 -0
  3. package/assets/discover/official-packages.json +4 -0
  4. package/assets/docs/tree/type-scale.doc.mjs +19 -2
  5. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.doc.mjs +14 -0
  6. package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.tsx +56 -0
  7. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.doc.mjs +2 -2
  8. package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.tsx +66 -10
  9. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.doc.mjs +14 -0
  10. package/assets/templates/blocks/components/Link/useLinkifyHookUsage.tsx +39 -0
  11. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.doc.mjs +14 -0
  12. package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.tsx +35 -0
  13. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.doc.mjs +14 -0
  14. package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.tsx +97 -0
  15. package/assets/templates/themes/butter/butterTheme.ts +1 -2
  16. package/foundation/discovery/hook-discovery.mjs +31 -8
  17. package/foundation/doc-compiler/import.mjs +5 -4
  18. package/foundation/fs/import-url.d.mts +20 -0
  19. package/foundation/fs/import-url.mjs +22 -0
  20. package/foundation/fs/module-loader.d.mts +14 -3
  21. package/foundation/fs/module-loader.mjs +56 -47
  22. package/foundation/text/levenshtein.d.mts +6 -1
  23. package/foundation/text/levenshtein.mjs +25 -9
  24. package/foundation/text/suggest-order.d.mts +8 -1
  25. package/foundation/text/suggest-order.mjs +72 -6
  26. package/package.json +6 -6
@@ -271,7 +271,7 @@ useIsomorphicLayoutEffect useIsomorphicLayoutEffect hooks/useAnimatedLayout.doc.
271
271
  useLayerDismissal useLayerDismissal Layer/Layer.doc.mjs Layer/useLayerDismissal.ts
272
272
  useLightbox useLightbox Lightbox/useLightbox.doc.mjs Lightbox/useLightbox.tsx
273
273
  useLinkComponent useLinkComponent Link/useLinkComponent.doc.mjs Link/useLinkComponent.ts
274
- useLinkify useLinkify Link/Link.doc.mjs Link/useLinkify.tsx
274
+ useLinkify useLinkify Link/useLinkify.doc.mjs Link/useLinkify.tsx
275
275
  useListFormatter useListFormatter i18n/InternationalizationProvider.doc.mjs i18n/useListFormatter.ts
276
276
  useMenuDrillIn useMenuDrillIn DropdownMenu/DropdownMenu.doc.mjs DropdownMenu/useMenuDrillIn.tsx
277
277
  useMenuHover useMenuHover hooks/useAnimatedLayout.doc.mjs hooks/useMenuHover.ts
@@ -298,7 +298,7 @@ useSpeechRecognition useSpeechRecognition Chat/Chat.doc.mjs Chat/useSpeechRecogn
298
298
  useSwipeAction useSwipeAction Item/Item.doc.mjs Item/useSwipeAction.ts
299
299
  useSwitchTouch useSwitchTouch Switch/Switch.doc.mjs Switch/useSwitchTouch.ts
300
300
  useTableCellStyles useTableCellStyles Table/Table.doc.mjs Table/useTableCellStyles.ts
301
- useTableColumnSettingsState useTableColumnSettingsState Table/Table.doc.mjs Table/plugins/columnSettings/useTableColumnSettingsState.tsx
301
+ useTableColumnSettingsState useTableColumnSettingsState Table/plugins/columnSettings/useTableColumnSettingsState.doc.mjs Table/plugins/columnSettings/useTableColumnSettingsState.tsx
302
302
  useTableSortableState useTableSortableState Table/plugins/sortable/useTableSortableState.doc.mjs Table/plugins/sortable/useTableSortableState.tsx
303
303
  useToastGesture useToastGesture Toast/Toast.doc.mjs Toast/useToastGesture.ts
304
304
  useTouchTrigger useTouchTrigger Layer/Layer.doc.mjs Layer/useTouchTrigger.ts
@@ -193,6 +193,7 @@ export async function template(name, options = {}) {
193
193
  `Unknown template "${name}"`,
194
194
  orderByAffinity(name, templates, t => [t.dirName, t.name], {
195
195
  closest: true,
196
+ typos: true,
196
197
  }).map(t => ({
197
198
  name: t.dirName,
198
199
  reason: `${t.type} template`,
@@ -260,6 +260,10 @@
260
260
  "kind": "template",
261
261
  "name": "CommentThreadShowcase"
262
262
  },
263
+ {
264
+ "kind": "template",
265
+ "name": "DialogHeroHeaderShowcase"
266
+ },
263
267
  {
264
268
  "kind": "template",
265
269
  "name": "DiffViewerShowcase"
@@ -2,7 +2,8 @@
2
2
 
3
3
  /**
4
4
  * @file `astryx docs typography/type-scale`: line height, semantic type scale,
5
- * display text, headings and text components, and custom type scales.
5
+ * display text, headings and text components, custom type scales, and the
6
+ * reader's text size on iOS.
6
7
  */
7
8
 
8
9
  /** @type {import('@astryxdesign/cli/authoring').ReferenceDoc} */
@@ -14,13 +15,15 @@ export const docs = {
14
15
  placement: {parent: 'namespace:typography', slot: 'guides', order: 20},
15
16
  category: 'foundations',
16
17
  description:
17
- 'Line height, semantic type scale tokens, display text, Heading and Text components, and custom type scales via defineTheme.',
18
+ "Line height, semantic type scale tokens, display text, Heading and Text components, custom type scales via defineTheme, and following the reader's text size on iOS.",
18
19
  keywords: [
19
20
  'line height',
20
21
  'leading',
21
22
  'defineTheme',
22
23
  'base',
23
24
  'ratio',
25
+ 'dynamic type',
26
+ 'text size',
24
27
  ],
25
28
 
26
29
  sections: [
@@ -143,5 +146,19 @@ const denseTheme = defineTheme({
143
146
  },
144
147
  ],
145
148
  },
149
+ {
150
+ id: 'dynamic-type',
151
+ title: "Following the reader's text size on iOS",
152
+ content: [
153
+ {
154
+ type: 'prose',
155
+ text: "On iPhone, readers pick a text size in Settings (Display & Text Size, then Larger Text), and native apps grow or shrink their text with it. Every theme does the same in Safari on iPhone, which exposes that choice through `font: -apple-system-body`: each font size follows the reader's choice on the curve of the iOS text style it plays, so body text scales like Body, `heading-1` like Title 1 and display text like Large Title, and every role keeps its order. A size the theme fixes at a length, such as a 12px supporting size, scales on the curve of the step it reads by default; a size in `em` or `%` follows its parent, and one that reads a variable follows that variable. At the default size every font size is the theme's own. The family, spacing, radius and the root font size never change, line heights keep their ratios, text inputs keep their 16px floor on iOS, and on Android the system font scale grows every font size through `env(preferred-text-scale)` where the browser reports it; browsers with neither see no change. There is nothing to turn on: no theme option, prop or document attribute.",
156
+ },
157
+ {
158
+ type: 'prose',
159
+ text: 'Fixed-height controls such as the text inputs keep their height, so their text can overflow at the accessibility sizes. A labelled `Button` grows with its label.',
160
+ },
161
+ ],
162
+ },
146
163
  ],
147
164
  };
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useInteractiveRole',
7
+ name: 'useInteractiveRole — One Tile, Four Roles',
8
+ displayName: 'useInteractiveRole — One Tile, Four Roles',
9
+ description:
10
+ 'A polymorphic Tile used four ways: with an href it renders as a link, with an onClick as a button, with neither as plain content, and placed as a Popover trigger it becomes the trigger button through context — the resolution order in one place.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['Popover', 'Card', 'Text', 'Layout', 'Link'],
14
+ };
@@ -0,0 +1,56 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useInteractiveRole} from '@astryxdesign/core/hooks';
6
+ import {Link} from '@astryxdesign/core/Link';
7
+ import {Popover} from '@astryxdesign/core/Popover';
8
+ import {Card} from '@astryxdesign/core/Card';
9
+ import {Text} from '@astryxdesign/core/Text';
10
+ import {VStack} from '@astryxdesign/core/Layout';
11
+
12
+ // A polymorphic Tile: the hook alone decides what it renders as — href wins,
13
+ // then onClick, then a trigger context (Popover), then inert.
14
+ function Tile({
15
+ href,
16
+ onClick,
17
+ children,
18
+ }: {
19
+ href?: string;
20
+ onClick?: () => void;
21
+ children: React.ReactNode;
22
+ }) {
23
+ const role = useInteractiveRole({href, onClick});
24
+ const body = (
25
+ <Card padding={3} width={200}>
26
+ <Text type="body">{children}</Text>
27
+ </Card>
28
+ );
29
+ switch (role) {
30
+ case 'link':
31
+ return <Link href={href}>{body}</Link>;
32
+ case 'button':
33
+ return <button onClick={onClick}>{body}</button>;
34
+ default:
35
+ return body;
36
+ }
37
+ }
38
+
39
+ export default function UseInteractiveRoleHookUsage() {
40
+ return (
41
+ <VStack gap={3} hAlign="center" style={{width: '100%'}}>
42
+ <Tile href="https://example.com/docs">Renders as a link</Tile>
43
+ <Tile onClick={() => {}}>Renders as a button</Tile>
44
+ <Popover
45
+ label="Tile details"
46
+ content={
47
+ <Card padding={3}>
48
+ <Text type="body">Opened through the tile's context role</Text>
49
+ </Card>
50
+ }>
51
+ <Tile>Trigger via context</Tile>
52
+ </Popover>
53
+ <Tile>Plain content</Tile>
54
+ </VStack>
55
+ );
56
+ }
@@ -7,8 +7,8 @@ export const doc = {
7
7
  name: 'useStreamingText — Streaming Response',
8
8
  displayName: 'useStreamingText — Streaming Response',
9
9
  description:
10
- 'Smooth bursty generated text into a steady reveal with useStreamingText.',
10
+ 'A chat-style reply card: while the response streams in bursts, useStreamingText reveals it steadily character by character; when streaming ends the full text snaps into place. Regenerate replays the stream.',
11
11
  isReady: true,
12
12
  aspectRatio: 4 / 3,
13
- componentsUsed: ['Card', 'Text', 'Layout'],
13
+ componentsUsed: ['Button', 'Card', 'Text', 'Layout'],
14
14
  };
@@ -2,29 +2,85 @@
2
2
 
3
3
  'use client';
4
4
 
5
+ import {useCallback, useEffect, useRef, useState} from 'react';
5
6
  import {useStreamingText} from '@astryxdesign/core/hooks';
7
+ import {Button} from '@astryxdesign/core/Button';
6
8
  import {Card} from '@astryxdesign/core/Card';
7
- import {VStack} from '@astryxdesign/core/Layout';
8
9
  import {Text} from '@astryxdesign/core/Text';
10
+ import {VStack} from '@astryxdesign/core/Layout';
11
+
12
+ const CHUNKS = [
13
+ 'Astryx hooks keep behavior reusable ',
14
+ 'while components keep visuals consistent. ',
15
+ 'Streaming text arrives in bursts; ',
16
+ 'the hook smooths it into a steady reveal.',
17
+ ];
9
18
 
10
- const response =
11
- 'Astryx hooks keep behavior reusable while components keep visuals consistent.';
19
+ // A fake stream: chunks land in bursts, the way a model streams tokens.
20
+ // Starting again stops the stream in progress, and the last chunk ends it.
21
+ function useFakeStream(onChunk: (chunk: string) => void, onEnd: () => void) {
22
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+ const start = useCallback(() => {
24
+ if (timer.current) {
25
+ clearTimeout(timer.current);
26
+ }
27
+ let index = 0;
28
+ const tick = () => {
29
+ onChunk(CHUNKS[index]);
30
+ index += 1;
31
+ if (index < CHUNKS.length) {
32
+ timer.current = setTimeout(tick, 350);
33
+ } else {
34
+ timer.current = null;
35
+ onEnd();
36
+ }
37
+ };
38
+ timer.current = setTimeout(tick, 350);
39
+ }, [onChunk, onEnd]);
40
+ useEffect(
41
+ () => () => {
42
+ if (timer.current) {
43
+ clearTimeout(timer.current);
44
+ }
45
+ },
46
+ [],
47
+ );
48
+ return start;
49
+ }
12
50
 
13
51
  export default function UseStreamingTextHookUsage() {
14
- const displayedText = useStreamingText(response, false, {
15
- speed: 'fast',
16
- });
52
+ const [target, setTarget] = useState('');
53
+ const [isStreaming, setIsStreaming] = useState(false);
54
+
55
+ const appendChunk = useCallback((chunk: string) => {
56
+ setTarget(previous => previous + chunk);
57
+ }, []);
58
+ // When the last chunk has landed, end the stream so the hook snaps to the
59
+ // full text.
60
+ const endStream = useCallback(() => setIsStreaming(false), []);
61
+ const startStream = useFakeStream(appendChunk, endStream);
62
+
63
+ const regenerate = useCallback(() => {
64
+ setTarget('');
65
+ setIsStreaming(true);
66
+ startStream();
67
+ }, [startStream]);
68
+
69
+ const displayedText = useStreamingText(target, isStreaming, {speed: 'fast'});
17
70
 
18
71
  return (
19
72
  <Card width={420} padding={4}>
20
- <VStack gap={2}>
73
+ <VStack gap={3}>
21
74
  <Text type="body" weight="bold">
22
75
  Assistant response
23
76
  </Text>
24
77
  <Text type="body">{displayedText}</Text>
25
- <Text type="supporting" color="secondary">
26
- Complete
27
- </Text>
78
+ <Button
79
+ label={isStreaming ? 'Streaming…' : 'Regenerate'}
80
+ variant="secondary"
81
+ size="sm"
82
+ onClick={regenerate}
83
+ />
28
84
  </VStack>
29
85
  </Card>
30
86
  );
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useLinkify',
7
+ name: 'useLinkify — Links in a Comment Thread',
8
+ displayName: 'useLinkify — Links in a Comment Thread',
9
+ description:
10
+ 'Plain-text comments where useLinkify turns the URL and the email address into Links, and a custom pattern turns issue references like #123 into tracker links — the patterns option in action. The comment without matches stays plain text.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['Link', 'Layout', 'Text'],
14
+ };
@@ -0,0 +1,39 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useLinkify, type LinkifyPattern} from '@astryxdesign/core/Link';
6
+ import {VStack} from '@astryxdesign/core/Layout';
7
+ import {Text} from '@astryxdesign/core/Text';
8
+
9
+ const COMMENTS = [
10
+ 'See https://example.com/spec or email support@example.com.',
11
+ 'Fixed by #123, tracking the regression in #456.',
12
+ 'No links here, just a plain note.',
13
+ ];
14
+
15
+ // Issue references like #123 link into the tracker; the capture group maps
16
+ // onto its URL scheme, showing the patterns option.
17
+ const ISSUE_PATTERN: LinkifyPattern = {
18
+ pattern: /#(\d+)/g,
19
+ href: match => `https://example.com/issues/${match[1]}`,
20
+ };
21
+
22
+ function Comment({children}: {children: string}) {
23
+ const nodes = useLinkify(children, {patterns: [ISSUE_PATTERN]});
24
+ return (
25
+ <Text type="body" color="secondary">
26
+ {nodes}
27
+ </Text>
28
+ );
29
+ }
30
+
31
+ export default function UseLinkifyHookUsage() {
32
+ return (
33
+ <VStack gap={3} style={{width: '100%', maxWidth: 360}}>
34
+ {COMMENTS.map(comment => (
35
+ <Comment key={comment}>{comment}</Comment>
36
+ ))}
37
+ </VStack>
38
+ );
39
+ }
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useTableRowsVirtualization',
7
+ name: 'useTableRowsVirtualization - Fixed Height Rows',
8
+ displayName: 'useTableRowsVirtualization - Fixed Height Rows',
9
+ description:
10
+ 'A Table with 1,000 product rows in a fixed-height viewport. Scroll vertically to mount the visible rows. The caller keeps all data and columns; this hook does not virtualize columns.',
11
+ isReady: true,
12
+ aspectRatio: 16 / 9,
13
+ componentsUsed: ['Table'],
14
+ };
@@ -0,0 +1,35 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {
6
+ Table,
7
+ useTableRowsVirtualization,
8
+ type TableColumn,
9
+ } from '@astryxdesign/core/Table';
10
+
11
+ interface Product extends Record<string, unknown> {
12
+ id: string;
13
+ name: string;
14
+ }
15
+
16
+ const products: Product[] = Array.from({length: 1000}, (_, index) => ({
17
+ id: `product-${index}`,
18
+ name: `Product ${index + 1}`,
19
+ }));
20
+ const columns: TableColumn<Product>[] = [{key: 'name', header: 'Product'}];
21
+
22
+ export default function RowsVirtualizationHookUsage() {
23
+ const virtualization = useTableRowsVirtualization({
24
+ rowHeight: 56,
25
+ viewportHeight: 320,
26
+ });
27
+ return (
28
+ <Table
29
+ data={products}
30
+ columns={columns}
31
+ idKey="id"
32
+ virtualization={virtualization}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,14 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'block',
6
+ exampleFor: 'useTableColumnSettingsState',
7
+ name: 'useTableColumnSettingsState — Column Visibility Picker',
8
+ displayName: 'useTableColumnSettingsState — Column Visibility Picker',
9
+ description:
10
+ 'A Table toolbar with a Columns menu of checkboxes: toggling Email updates active columns, Reset restores defaults, and the config is passed directly to useTableColumnSettings.',
11
+ isReady: true,
12
+ aspectRatio: 16 / 9,
13
+ componentsUsed: ['Button', 'CheckboxInput', 'Popover', 'Table', 'Layout'],
14
+ };
@@ -0,0 +1,97 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useState} from 'react';
6
+ import {Button} from '@astryxdesign/core/Button';
7
+ import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
8
+ import {HStack, VStack} from '@astryxdesign/core/Layout';
9
+ import {Popover} from '@astryxdesign/core/Popover';
10
+ import {
11
+ Table,
12
+ useTableColumnSettings,
13
+ useTableColumnSettingsState,
14
+ } from '@astryxdesign/core/Table';
15
+ import type {TableColumn} from '@astryxdesign/core/Table';
16
+
17
+ interface Member extends Record<string, unknown> {
18
+ id: string;
19
+ name: string;
20
+ email: string;
21
+ role: string;
22
+ }
23
+
24
+ const members: Member[] = [
25
+ {id: '1', name: 'Alice', email: 'alice@example.com', role: 'Engineer'},
26
+ {id: '2', name: 'Bob', email: 'bob@example.com', role: 'Designer'},
27
+ {id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'Manager'},
28
+ ];
29
+
30
+ const allColumns: TableColumn<Member>[] = [
31
+ {key: 'name', header: 'Name'},
32
+ {key: 'email', header: 'Email'},
33
+ {key: 'role', header: 'Role'},
34
+ ];
35
+
36
+ const columnOptions = [
37
+ {key: 'name' as const, label: 'Name', isAlwaysVisible: true},
38
+ {key: 'email' as const, label: 'Email'},
39
+ {key: 'role' as const, label: 'Role'},
40
+ ];
41
+
42
+ const defaultKeys = ['name', 'email', 'role'];
43
+
44
+ export default function UseTableColumnSettingsStateHookUsage() {
45
+ const [activeKeys, setActiveKeys] =
46
+ useState<ReadonlyArray<string>>(defaultKeys);
47
+
48
+ const state = useTableColumnSettingsState({
49
+ columns: columnOptions,
50
+ activeColumnKeys: activeKeys,
51
+ onChangeActiveColumnKeys: setActiveKeys,
52
+ defaultColumnKeys: defaultKeys,
53
+ });
54
+
55
+ const plugin = useTableColumnSettings<Member>(state.columnSettingsConfig);
56
+
57
+ return (
58
+ <VStack gap={3}>
59
+ <HStack hAlign="start">
60
+ <Popover
61
+ placement="below"
62
+ label="Column settings"
63
+ width={200}
64
+ content={
65
+ <VStack gap={2}>
66
+ {columnOptions.map(col => (
67
+ <CheckboxInput
68
+ key={col.key}
69
+ label={col.label}
70
+ value={state.isColumnActive(col.key)}
71
+ isDisabled={!state.isColumnToggleable(col.key)}
72
+ onChange={() => state.toggleColumn(col.key)}
73
+ />
74
+ ))}
75
+ <Button
76
+ label="Reset"
77
+ size="sm"
78
+ variant="secondary"
79
+ onClick={state.resetToDefault}>
80
+ Reset
81
+ </Button>
82
+ </VStack>
83
+ }>
84
+ <Button label="Columns" size="sm">
85
+ Columns
86
+ </Button>
87
+ </Popover>
88
+ </HStack>
89
+ <Table
90
+ data={members}
91
+ columns={allColumns}
92
+ idKey="id"
93
+ plugins={{columnSettings: plugin}}
94
+ />
95
+ </VStack>
96
+ );
97
+ }
@@ -394,8 +394,7 @@ export const butterTheme = defineTheme({
394
394
  // Radius intentionally not pinned, so buttons keep core's
395
395
  // `var(--_button-radius, --radius-element)`: standalone buttons stay 8px,
396
396
  // while the chat composer (which sets --_button-radius) rounds them fully.
397
- // Inline padding only: a labelled button's size is its minimum height,
398
- // and block padding would add to it.
397
+ // Inline padding only. Button sets its own block padding and height.
399
398
  base: {
400
399
  paddingInline: 'var(--spacing-4)',
401
400
  },
@@ -12,6 +12,13 @@ import {levenshteinDistance} from '../text/string-utils.mjs';
12
12
 
13
13
  const SKIP_DIRS = new Set(['utils', '__tests__', 'node_modules']);
14
14
 
15
+ // A hook's doc outside src/hooks/: a `use…` hook, or a `create…` factory
16
+ // documented as one (its doc typed `HookDoc`, such as createReorderable).
17
+ const HOOK_DOC_FILE_RE = /^(?:use|create)[A-Z]\w*\.doc\.mjs$/;
18
+
19
+ // Matches the top-level `relatedHooks: [...]` field in a hook .doc.mjs file.
20
+ const RELATED_HOOKS_RE = /(?:^|\n) {0,4}relatedHooks:\s*\[([^\]]*)\]/;
21
+
15
22
  // Matches the top-level `category: 'categoryName'` field in a hook .doc.mjs file.
16
23
  const CATEGORY_RE = /(?:^|\n) {0,4}category:\s*['"]([^'"]+)['"]/;
17
24
 
@@ -32,6 +39,25 @@ function readHookMeta(docPath) {
32
39
  }
33
40
  }
34
41
 
42
+ /**
43
+ * Whether a `create…` factory's doc names its own `use…` hook in
44
+ * `relatedHooks`, as createAnimator's names useAnimator. Such a factory is
45
+ * listed beside that hook, not on its own.
46
+ * @param {string} docPath
47
+ * @param {string} hookName
48
+ * @returns {boolean}
49
+ */
50
+ function isBesideOwnHook(docPath, hookName) {
51
+ if (!hookName.startsWith('create')) return false;
52
+ const ownHook = `use${hookName.slice('create'.length)}`;
53
+ try {
54
+ const related = RELATED_HOOKS_RE.exec(fs.readFileSync(docPath, 'utf-8'));
55
+ return related != null && related[1].includes(`'${ownHook}'`);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
35
61
  /**
36
62
  * Extract the hook name from a doc filename.
37
63
  * e.g. 'useFocusTrap.doc.mjs' → 'useFocusTrap'
@@ -70,7 +96,8 @@ export function discoverHooks(coreDir) {
70
96
  }
71
97
  }
72
98
 
73
- // 2. Scan ALL component directories for files matching use*.doc.mjs
99
+ // 2. Scan ALL component directories for hook docs: use*.doc.mjs, and
100
+ // create*.doc.mjs for a factory documented as a hook (createReorderable)
74
101
  if (pathExists(srcDir)) {
75
102
  const topEntries = listDirEntries(srcDir);
76
103
  for (const entry of topEntries) {
@@ -120,14 +147,10 @@ function scanDirForHookDocs(dirPath, hookCategories) {
120
147
  for (const entry of entries) {
121
148
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
122
149
  scanDirForHookDocs(path.join(dirPath, entry.name), hookCategories);
123
- } else if (
124
- entry.isFile() &&
125
- entry.name.startsWith('use') &&
126
- entry.name.endsWith('.doc.mjs')
127
- ) {
150
+ } else if (entry.isFile() && HOOK_DOC_FILE_RE.test(entry.name)) {
128
151
  const hookName = hookNameFromFile(entry.name);
129
- if (!hookCategories.has(hookName)) {
130
- const docPath = path.join(dirPath, entry.name);
152
+ const docPath = path.join(dirPath, entry.name);
153
+ if (!hookCategories.has(hookName) && !isBesideOwnHook(docPath, hookName)) {
131
154
  const {category} = readHookMeta(docPath);
132
155
  hookCategories.set(hookName, category || 'Other');
133
156
  }
@@ -24,6 +24,7 @@
24
24
 
25
25
  import {pathToFileURL} from 'node:url';
26
26
  import {createJiti} from 'jiti';
27
+ import {importUrl} from '../fs/import-url.mjs';
27
28
  import {importUserModule} from '../fs/module-loader.mjs';
28
29
  import {fileStamp, recordContentRead} from '../fs/read-record.mjs';
29
30
 
@@ -105,14 +106,14 @@ export function importTemplateModule(file) {
105
106
  jsxJiti ??= createJiti(import.meta.url, {jsx: true});
106
107
  return jsxJiti.import(first);
107
108
  }
108
- return import(`file://${first}`);
109
+ return importUrl(`file://${first}`);
109
110
  },
110
111
  changed =>
111
112
  changed.endsWith('.ts')
112
113
  ? createJiti(import.meta.url, {jsx: true, moduleCache: false}).import(
113
114
  changed,
114
115
  )
115
- : import(freshUrl(changed)),
116
+ : importUrl(freshUrl(changed)),
116
117
  );
117
118
  }
118
119
 
@@ -126,7 +127,7 @@ export function importNativeModule(file) {
126
127
  return importCurrent(
127
128
  file,
128
129
  'native',
129
- first => import(pathToFileURL(first).href),
130
- changed => import(freshUrl(changed)),
130
+ first => importUrl(pathToFileURL(first).href),
131
+ changed => importUrl(freshUrl(changed)),
131
132
  );
132
133
  }
@@ -0,0 +1,20 @@
1
+ // @generated by scripts/sync-api-types.mjs from the JSDoc in foundation/**/*.mjs.
2
+ // DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
3
+
4
+ /**
5
+ * @file The dynamic import of an authored module by its file URL.
6
+ *
7
+ * @input An absolute `file:` URL, with any query its caller adds to name a
8
+ * fresh module.
9
+ * @output The module's namespace object, as a plain `import()` gives it.
10
+ * @position Called by the doc importers (../doc-compiler/import.mjs). The
11
+ * node test project loads this file natively, as the CLI does
12
+ * (vitest.config.ts). The doc files that a catalog command imports then load
13
+ * natively as well, without a request to the test runner for each of them.
14
+ */
15
+ /**
16
+ * Import the module at `url`.
17
+ * @param {string} url an absolute `file:` URL
18
+ * @returns {Promise<any>}
19
+ */
20
+ export function importUrl(url: string): Promise<any>;
@@ -0,0 +1,22 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file The dynamic import of an authored module by its file URL.
5
+ *
6
+ * @input An absolute `file:` URL, with any query its caller adds to name a
7
+ * fresh module.
8
+ * @output The module's namespace object, as a plain `import()` gives it.
9
+ * @position Called by the doc importers (../doc-compiler/import.mjs). The
10
+ * node test project loads this file natively, as the CLI does
11
+ * (vitest.config.ts). The doc files that a catalog command imports then load
12
+ * natively as well, without a request to the test runner for each of them.
13
+ */
14
+
15
+ /**
16
+ * Import the module at `url`.
17
+ * @param {string} url an absolute `file:` URL
18
+ * @returns {Promise<any>}
19
+ */
20
+ export function importUrl(url) {
21
+ return import(url);
22
+ }
@@ -4,9 +4,9 @@
4
4
  /**
5
5
  * Import a user-authored module. `.ts` is loaded via jiti; `.mjs`/`.js` use
6
6
  * native dynamic import for ordinary cached reads. A fresh `.ts` read uses a
7
- * no-cache jiti instance. A fresh CommonJS `.js` read evicts and reloads through
8
- * `require`; an ESM `.js` or `.mjs` read uses a cache-busted file URL. The
9
- * nearest package.json `type` decides `.js`, matching Node's package scopes.
7
+ * no-cache jiti instance. Any other fresh read drops the module from the
8
+ * require cache and imports a cache-busted file URL. Node then chooses its
9
+ * format as for the first read, so a fresh read gives what a first read gives.
10
10
  * Normal loads retain module caching. `fresh` rereads a file that changed
11
11
  * during this process: one a codemod rewrote, or an edited doc file (the doc
12
12
  * importers in ../doc-compiler/import.mjs read each change once).
@@ -29,6 +29,17 @@ export function importUserModule(file: string, { fresh }?: {
29
29
  * @returns {Promise<Record<string, unknown>>}
30
30
  */
31
31
  export function importThemeArtifact(specifier: string, cwd: string): Promise<Record<string, unknown>>;
32
+ /**
33
+ * Run `read`, an import of `file`, once the imports of `file` started before
34
+ * it have ended. Imports of other files do not wait. A fresh read removes the
35
+ * file from Node's CommonJS cache. A CommonJS import still in progress needs
36
+ * that entry until it ends.
37
+ * @template T
38
+ * @param {string} file absolute path
39
+ * @param {() => Promise<T>} read
40
+ * @returns {Promise<T>}
41
+ */
42
+ export function readInTurn<T>(file: string, read: () => Promise<T>): Promise<T>;
32
43
  /**
33
44
  * Import a user-authored module, take its default export, and validate it
34
45
  * through an authoring parser. This is the single load/validation boundary for
@@ -40,30 +40,6 @@ function getJiti() {
40
40
  return jitiInstance;
41
41
  }
42
42
 
43
- /**
44
- * Node treats `.js` as ESM only inside the nearest package scope whose
45
- * package.json declares `"type": "module"`; no package.json defaults to
46
- * CommonJS.
47
- * @param {string} file
48
- * @returns {boolean}
49
- */
50
- function isCommonJsFile(file) {
51
- let dir = path.dirname(file);
52
- for (;;) {
53
- const packageJson = path.join(dir, 'package.json');
54
- if (fs.existsSync(packageJson)) {
55
- try {
56
- return JSON.parse(fs.readFileSync(packageJson, 'utf-8')).type !== 'module';
57
- } catch {
58
- return true;
59
- }
60
- }
61
- const parent = path.dirname(dir);
62
- if (parent === dir) return true;
63
- dir = parent;
64
- }
65
- }
66
-
67
43
  let loadsInFlight = 0;
68
44
  /** @type {typeof process.stdout.write | undefined} */
69
45
  let realStdoutWrite;
@@ -104,9 +80,9 @@ async function withStdoutOnStderr(load) {
104
80
  /**
105
81
  * Import a user-authored module. `.ts` is loaded via jiti; `.mjs`/`.js` use
106
82
  * native dynamic import for ordinary cached reads. A fresh `.ts` read uses a
107
- * no-cache jiti instance. A fresh CommonJS `.js` read evicts and reloads through
108
- * `require`; an ESM `.js` or `.mjs` read uses a cache-busted file URL. The
109
- * nearest package.json `type` decides `.js`, matching Node's package scopes.
83
+ * no-cache jiti instance. Any other fresh read drops the module from the
84
+ * require cache and imports a cache-busted file URL. Node then chooses its
85
+ * format as for the first read, so a fresh read gives what a first read gives.
110
86
  * Normal loads retain module caching. `fresh` rereads a file that changed
111
87
  * during this process: one a codemod rewrote, or an edited doc file (the doc
112
88
  * importers in ../doc-compiler/import.mjs read each change once).
@@ -251,31 +227,64 @@ async function importModule(file, fresh) {
251
227
  if (fresh && file.endsWith('.ts')) {
252
228
  return await createJiti(import.meta.url, {moduleCache: false}).import(file);
253
229
  }
254
- if (fresh && file.endsWith('.js') && isCommonJsFile(file)) {
255
- const resolved = requireFromHere.resolve(file);
256
- delete requireFromHere.cache[resolved];
257
- const value = requireFromHere(file);
258
- if (
259
- value != null &&
260
- typeof value === 'object' &&
261
- value.__esModule === true &&
262
- Object.prototype.hasOwnProperty.call(value, 'default')
263
- ) {
264
- return value;
265
- }
266
- return {
267
- ...(value != null && typeof value === 'object' ? value : {}),
268
- default: value,
269
- };
270
- }
271
230
  if (file.endsWith('.ts')) {
272
231
  return await getJiti().import(file);
273
232
  }
274
233
  const url = pathToFileURL(file);
275
- if (fresh) {
276
- url.searchParams.set('astryx-fresh', String(++freshImportNonce));
234
+ if (!fresh) return await readInTurn(file, () => import(url.href));
235
+ // Node chooses the format again, as for the first import: a package's
236
+ // "type", the extension, or the source's syntax. The query names a new ES
237
+ // module. A CommonJS module also needs its cache entry gone.
238
+ url.searchParams.set('astryx-fresh', String(++freshImportNonce));
239
+ return await readInTurn(file, () => {
240
+ delete requireFromHere.cache[cacheKey(file)];
241
+ return import(url.href);
242
+ });
243
+ }
244
+
245
+ /**
246
+ * The name Node's CommonJS cache gives `file`: its real path, so a link and
247
+ * its target name one entry. A file that does not resolve keeps its path.
248
+ * @param {string} file absolute path
249
+ * @returns {string}
250
+ */
251
+ function cacheKey(file) {
252
+ try {
253
+ return requireFromHere.resolve(file);
254
+ } catch {
255
+ return file;
277
256
  }
278
- return await import(url.href);
257
+ }
258
+
259
+ /**
260
+ * Each file's latest read, by its cache name, settled once that read ends.
261
+ * @type {Map<string, Promise<void>>}
262
+ */
263
+ const readsInProgress = new Map();
264
+
265
+ /**
266
+ * Run `read`, an import of `file`, once the imports of `file` started before
267
+ * it have ended. Imports of other files do not wait. A fresh read removes the
268
+ * file from Node's CommonJS cache. A CommonJS import still in progress needs
269
+ * that entry until it ends.
270
+ * @template T
271
+ * @param {string} file absolute path
272
+ * @param {() => Promise<T>} read
273
+ * @returns {Promise<T>}
274
+ */
275
+ export function readInTurn(file, read) {
276
+ const key = cacheKey(file);
277
+ const before = readsInProgress.get(key) ?? Promise.resolve();
278
+ const result = before.then(read);
279
+ const ended = result.then(
280
+ () => {},
281
+ () => {},
282
+ );
283
+ readsInProgress.set(key, ended);
284
+ void ended.then(() => {
285
+ if (readsInProgress.get(key) === ended) readsInProgress.delete(key);
286
+ });
287
+ return result;
279
288
  }
280
289
 
281
290
 
@@ -19,6 +19,11 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a: string, b: string, max?: number): number;
27
+ export function levenshteinDistance(a: string, b: string, max?: number, { swaps }?: {
28
+ swaps?: boolean;
29
+ }): number;
@@ -19,11 +19,15 @@
19
19
  * @param {number} [max] - The largest distance the caller can use. When
20
20
  * given, a larger distance answers `max + 1`, and only the cells within
21
21
  * `max` of the diagonal are computed.
22
+ * @param {{swaps?: boolean}} [options] - With `swaps` and a `max`, two
23
+ * adjacent letters swapped cost one edit, not two (the optimal string
24
+ * alignment distance).
22
25
  * @returns {number}
23
26
  */
24
- export function levenshteinDistance(a, b, max) {
25
- if (max !== undefined) return distanceWithin(a, b, max);
26
- const m = a.length, n = b.length;
27
+ export function levenshteinDistance(a, b, max, {swaps = false} = {}) {
28
+ if (max !== undefined) return distanceWithin(a, b, max, swaps);
29
+ const m = a.length,
30
+ n = b.length;
27
31
  // Early exit: the distance is always ≥ |m − n|, so once the length gap
28
32
  // exceeds the loosest fuzzy-match threshold any caller uses (the hook
29
33
  // suggester keeps distance ≤ 5) the pair can never match — skip the O(m·n)
@@ -36,9 +40,10 @@ export function levenshteinDistance(a, b, max) {
36
40
  for (let j = 0; j <= n; j++) dp[0][j] = j;
37
41
  for (let i = 1; i <= m; i++) {
38
42
  for (let j = 1; j <= n; j++) {
39
- dp[i][j] = a[i-1] === b[j-1]
40
- ? dp[i-1][j-1]
41
- : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
43
+ dp[i][j] =
44
+ a[i - 1] === b[j - 1]
45
+ ? dp[i - 1][j - 1]
46
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
42
47
  }
43
48
  }
44
49
  return dp[m][n];
@@ -51,11 +56,13 @@ export function levenshteinDistance(a, b, max) {
51
56
  * @param {string} a
52
57
  * @param {string} b
53
58
  * @param {number} max
59
+ * @param {boolean} swaps - two adjacent letters swapped cost one edit
54
60
  * @returns {number}
55
61
  */
56
- function distanceWithin(a, b, max) {
62
+ function distanceWithin(a, b, max, swaps) {
57
63
  const over = max + 1;
58
64
  if (Math.abs(a.length - b.length) > max) return over;
65
+ let before = new Array(b.length + 1).fill(over);
59
66
  let previous = new Array(b.length + 1).fill(over);
60
67
  let current = new Array(b.length + 1).fill(over);
61
68
  for (let j = 0; j <= Math.min(b.length, max); j++) previous[j] = j;
@@ -67,18 +74,27 @@ function distanceWithin(a, b, max) {
67
74
  let rowMin = current[0];
68
75
  for (let j = from; j <= to; j++) {
69
76
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
70
- const value = Math.min(
77
+ let value = Math.min(
71
78
  previous[j - 1] + cost,
72
79
  previous[j] + 1,
73
80
  current[j - 1] + 1,
74
81
  over,
75
82
  );
83
+ if (
84
+ swaps &&
85
+ i > 1 &&
86
+ j > 1 &&
87
+ a[i - 1] === b[j - 2] &&
88
+ a[i - 2] === b[j - 1]
89
+ ) {
90
+ value = Math.min(value, before[j - 2] + 1);
91
+ }
76
92
  current[j] = value;
77
93
  if (value < rowMin) rowMin = value;
78
94
  }
79
95
  // Every later cell costs at least this row's least.
80
96
  if (rowMin >= over) return over;
81
- [previous, current] = [current, previous];
97
+ [before, previous, current] = [previous, current, before];
82
98
  }
83
99
  return Math.min(previous[b.length], over);
84
100
  }
@@ -18,15 +18,22 @@ export function nameWords(text: string): string[];
18
18
  * when it carries some of them, the items that carry none are left out. When
19
19
  * no item carries one, nothing is left out. The list only ever gets shorter,
20
20
  * and its order does not change.
21
+ *
22
+ * With `typos`, a near name (one or two slips from the typed name, as
23
+ * `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
24
+ * that carry every typed word and above the rest. With `closest` too, near
25
+ * names stay in the list whatever the cut leaves out, and the list is never
26
+ * longer than it is without `typos`.
21
27
  * @template T
22
28
  * @param {string} typedName
23
29
  * @param {T[]} items
24
30
  * @param {(item: T) => Array<string | undefined>} namesOf
25
- * @param {{closest?: boolean}} [options]
31
+ * @param {{closest?: boolean, typos?: boolean}} [options]
26
32
  * @returns {T[]}
27
33
  */
28
34
  export function orderByAffinity<T>(typedName: string, items: T[], namesOf: (item: T) => Array<string | undefined>, options?: {
29
35
  closest?: boolean;
36
+ typos?: boolean;
30
37
  }): T[];
31
38
  /**
32
39
  * Docs route suggestions, closest to the typed route first. When every
@@ -19,6 +19,12 @@
19
19
 
20
20
  import {levenshteinDistance} from './levenshtein.mjs';
21
21
 
22
+ /**
23
+ * The score of a near name (nearNameSlips), less a little per slip: above any
24
+ * name that carries only some of the typed words, below one that carries all
25
+ * of them.
26
+ */
27
+ const NEAR_NAME_SCORE = 19.6;
22
28
  /** The score a synonym match gets: an exact keyword's. */
23
29
  const SYNONYM_SCORE = 90;
24
30
  /** The score a name with exactly the typed words gets: an exact name's. */
@@ -59,17 +65,22 @@ export function nameWords(text) {
59
65
  * closer. Tiers, best first: the same words; every typed word present; some
60
66
  * typed word present; none. Within a tier, more of the typed words and more of
61
67
  * the candidate's own words matched rank higher, and a name that starts with
62
- * the typed name (or the reverse) gets a small lift.
68
+ * the typed name (or the reverse) gets a small lift. With `typos`, a near name
69
+ * (one or two slips from the typed name) ranks just below the names that
70
+ * carry every typed word.
63
71
  * @param {string[]} typed - {@link nameWords} of the typed name
64
72
  * @param {string} name
73
+ * @param {{typos?: boolean}} [options]
65
74
  * @returns {number}
66
75
  */
67
- function affinityOf(typed, name) {
76
+ function affinityOf(typed, name, {typos = false} = {}) {
68
77
  const words = nameWords(name);
69
78
  if (typed.length === 0 || words.length === 0) return 0;
70
79
  const typedJoined = typed.join('');
71
80
  const joined = words.join('');
72
81
  if (joined === typedJoined) return 40;
82
+ const slips = typos ? nearNameSlips(typedJoined, joined) : 0;
83
+ const near = slips > 0 ? NEAR_NAME_SCORE - slips * 0.2 : 0;
73
84
  const have = new Set(words);
74
85
  const want = new Set(typed);
75
86
  const covered = typed.filter(w => have.has(w)).length / typed.length;
@@ -77,7 +88,29 @@ function affinityOf(typed, name) {
77
88
  const prefix =
78
89
  joined.startsWith(typedJoined) || typedJoined.startsWith(joined) ? 1 : 0;
79
90
  const tier = covered === 1 ? 2 : covered > 0 ? 1 : 0;
80
- return tier * 10 + covered * 4 + precise * 2 + prefix + precise * 0.01;
91
+ // A near name keeps its word score when that is higher: a name that also
92
+ // carries every typed word ranks among the other full matches.
93
+ return Math.max(
94
+ near,
95
+ tier * 10 + covered * 4 + precise * 2 + prefix + precise * 0.01,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * How many slips separate a typed name from a candidate, both with their
101
+ * words joined, when it is a near name: an edit or two adjacent letters
102
+ * swapped count as one slip, one slip is allowed from five letters and two from
103
+ * eight. Zero when it is not a near name.
104
+ * @param {string} typed
105
+ * @param {string} name
106
+ * @returns {number}
107
+ */
108
+ function nearNameSlips(typed, name) {
109
+ const shorter = Math.min(typed.length, name.length);
110
+ const cap = shorter >= 8 ? 2 : shorter >= 5 ? 1 : 0;
111
+ if (cap === 0 || Math.abs(typed.length - name.length) > cap) return 0;
112
+ const slips = levenshteinDistance(typed, name, cap, {swaps: true});
113
+ return slips <= cap ? slips : 0;
81
114
  }
82
115
 
83
116
  /**
@@ -87,14 +120,15 @@ function affinityOf(typed, name) {
87
120
  * @param {string} typedName
88
121
  * @param {T[]} items
89
122
  * @param {(item: T) => Array<string | undefined>} namesOf
123
+ * @param {{typos?: boolean}} [options]
90
124
  * @returns {Array<{item: T, best: number}>}
91
125
  */
92
- function scoreByAffinity(typedName, items, namesOf) {
126
+ function scoreByAffinity(typedName, items, namesOf, options = {}) {
93
127
  const typed = nameWords(typedName);
94
128
  const scored = items.map((item, index) => {
95
129
  let best = 0;
96
130
  for (const name of namesOf(item)) {
97
- if (name) best = Math.max(best, affinityOf(typed, name));
131
+ if (name) best = Math.max(best, affinityOf(typed, name, options));
98
132
  }
99
133
  return {item, index, best};
100
134
  });
@@ -112,14 +146,46 @@ function scoreByAffinity(typedName, items, namesOf) {
112
146
  * when it carries some of them, the items that carry none are left out. When
113
147
  * no item carries one, nothing is left out. The list only ever gets shorter,
114
148
  * and its order does not change.
149
+ *
150
+ * With `typos`, a near name (one or two slips from the typed name, as
151
+ * `ButonGroup` or `ButtonGorup` for ButtonGroup) ranks just below the names
152
+ * that carry every typed word and above the rest. With `closest` too, near
153
+ * names stay in the list whatever the cut leaves out, and the list is never
154
+ * longer than it is without `typos`.
115
155
  * @template T
116
156
  * @param {string} typedName
117
157
  * @param {T[]} items
118
158
  * @param {(item: T) => Array<string | undefined>} namesOf
119
- * @param {{closest?: boolean}} [options]
159
+ * @param {{closest?: boolean, typos?: boolean}} [options]
120
160
  * @returns {T[]}
121
161
  */
122
162
  export function orderByAffinity(typedName, items, namesOf, options = {}) {
163
+ if (!options.typos) return orderByWords(typedName, items, namesOf, options);
164
+ const scored = scoreByAffinity(typedName, items, namesOf, {typos: true});
165
+ if (!options.closest) return scored.map(s => s.item);
166
+ const kept = new Set(orderByWords(typedName, items, namesOf, options));
167
+ return scored
168
+ .filter(s => kept.has(s.item) || isNearScore(s.best))
169
+ .slice(0, kept.size)
170
+ .map(s => s.item);
171
+ }
172
+
173
+ /**
174
+ * Whether a score is a near name's.
175
+ * @param {number} score
176
+ */
177
+ const isNearScore = score => score < 20 && score > NEAR_NAME_SCORE - 1;
178
+
179
+ /**
180
+ * {@link orderByAffinity} without `typos`.
181
+ * @template T
182
+ * @param {string} typedName
183
+ * @param {T[]} items
184
+ * @param {(item: T) => Array<string | undefined>} namesOf
185
+ * @param {{closest?: boolean}} options
186
+ * @returns {T[]}
187
+ */
188
+ function orderByWords(typedName, items, namesOf, options) {
123
189
  const scored = scoreByAffinity(typedName, items, namesOf);
124
190
  if (!options.closest) return scored.map(s => s.item);
125
191
  // One-letter words (`a` in `a11y`, `use-a-theme`) say nothing about which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.7.12-canary.c08de54",
3
+ "version": "0.7.12-canary.c5cc8f4",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -125,7 +125,7 @@
125
125
  "zod": "^4.4.3"
126
126
  },
127
127
  "peerDependencies": {
128
- "@astryxdesign/core": "0.7.12-canary.c08de54"
128
+ "@astryxdesign/core": "0.7.12-canary.c5cc8f4"
129
129
  },
130
130
  "peerDependenciesMeta": {
131
131
  "@astryxdesign/core": {
@@ -133,10 +133,10 @@
133
133
  }
134
134
  },
135
135
  "devDependencies": {
136
- "@astryxdesign/charts": "0.7.12-canary.c08de54",
137
- "@astryxdesign/core": "0.7.12-canary.c08de54",
138
- "@astryxdesign/lab": "0.7.12-canary.c08de54",
139
- "@astryxdesign/theme-neutral": "0.7.12-canary.c08de54",
136
+ "@astryxdesign/charts": "0.7.12-canary.c5cc8f4",
137
+ "@astryxdesign/core": "0.7.12-canary.c5cc8f4",
138
+ "@astryxdesign/lab": "0.7.12-canary.c5cc8f4",
139
+ "@astryxdesign/theme-neutral": "0.7.12-canary.c5cc8f4",
140
140
  "@heroicons/react": "^2.2.0",
141
141
  "@stylexjs/stylex": "^0.19.0",
142
142
  "@types/babel__core": "^7.20.5",