@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.
- package/api/component/__fixtures__/resolutions/modules.txt +2 -2
- package/api/template/template.mjs +1 -0
- package/assets/discover/official-packages.json +4 -0
- package/assets/docs/tree/type-scale.doc.mjs +19 -2
- package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Hooks/useInteractiveRoleHookUsage.tsx +56 -0
- package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.doc.mjs +2 -2
- package/assets/templates/blocks/components/Hooks/useStreamingTextHookUsage.tsx +66 -10
- package/assets/templates/blocks/components/Link/useLinkifyHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Link/useLinkifyHookUsage.tsx +39 -0
- package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Table/RowsVirtualizationHookUsage.tsx +35 -0
- package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.doc.mjs +14 -0
- package/assets/templates/blocks/components/Table/useTableColumnSettingsStateHookUsage.tsx +97 -0
- package/assets/templates/themes/butter/butterTheme.ts +1 -2
- package/foundation/discovery/hook-discovery.mjs +31 -8
- package/foundation/doc-compiler/import.mjs +5 -4
- package/foundation/fs/import-url.d.mts +20 -0
- package/foundation/fs/import-url.mjs +22 -0
- package/foundation/fs/module-loader.d.mts +14 -3
- package/foundation/fs/module-loader.mjs +56 -47
- package/foundation/text/levenshtein.d.mts +6 -1
- package/foundation/text/levenshtein.mjs +25 -9
- package/foundation/text/suggest-order.d.mts +8 -1
- package/foundation/text/suggest-order.mjs +72 -6
- 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/
|
|
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/
|
|
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`,
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
15
|
-
|
|
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={
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
:
|
|
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 =>
|
|
130
|
-
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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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] =
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
137
|
-
"@astryxdesign/core": "0.7.12-canary.
|
|
138
|
-
"@astryxdesign/lab": "0.7.12-canary.
|
|
139
|
-
"@astryxdesign/theme-neutral": "0.7.12-canary.
|
|
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",
|