@astryxdesign/cli 0.7.12-canary.c86484d → 0.7.12-canary.d84658f

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.
@@ -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
@@ -282,7 +282,7 @@ useOverlay useOverlay Overlay/useOverlay.doc.mjs Overlay/useOverlay.tsx
282
282
  useOwnScrollGesture useOwnScrollGesture DateInput/DateInput.doc.mjs DateInput/useOwnScrollGesture.ts
283
283
  usePager usePager hooks/useAnimatedLayout.doc.mjs hooks/usePager.ts
284
284
  usePointerDragScroll usePointerDragScroll DateInput/DateInput.doc.mjs DateInput/usePointerDragScroll.ts
285
- usePowerSearchConfig usePowerSearchConfig PowerSearch/PowerSearch.doc.mjs PowerSearch/usePowerSearchConfig.ts
285
+ usePowerSearchConfig usePowerSearchConfig PowerSearch/usePowerSearchConfig.doc.mjs PowerSearch/usePowerSearchConfig.ts
286
286
  usePowerSearchSource usePowerSearchSource PowerSearch/PowerSearch.doc.mjs PowerSearch/usePowerSearchSource.ts
287
287
  useRenamedProp useRenamedProp hooks/useAnimatedLayout.doc.mjs hooks/useRenamedProp.ts
288
288
  useResolvedRequired useResolvedRequired hooks/useAnimatedLayout.doc.mjs hooks/useResolvedRequired.ts
@@ -90,7 +90,7 @@ export const docs = {
90
90
  {
91
91
  type: 'code',
92
92
  lang: 'bash',
93
- label: 'Install (add to an existing Tailwind v4 app)',
93
+ label: 'Install',
94
94
  code: '{@pm install @astryxdesign/core @stylexjs/stylex @astryxdesign/theme-neutral}',
95
95
  },
96
96
  {
@@ -113,7 +113,7 @@ export const docs = {
113
113
  },
114
114
  {
115
115
  type: 'prose',
116
- text: 'For Tailwind v3, see the layer workaround in {@link generic:migration-setup}.',
116
+ text: 'A project with Tailwind already set up needs a smaller change, which {@link generic:tailwind} shows. For Tailwind v3, see the layer workaround in {@link generic:migration-setup}.',
117
117
  },
118
118
  ],
119
119
  },
@@ -162,7 +162,7 @@ export const docs = {
162
162
  },
163
163
  {
164
164
  type: 'prose',
165
- text: '`{@command swizzle <Component>}` writes an `astryx-swizzle.json` marker into an output folder it owns (one the copy creates, an empty one, or one it marked before); every module under that folder then compiles as Astryx source, so keep copies in a folder of their own, such as the default `components/astryx`. For a folder that already holds other files it writes no marker and says how to add one by hand. With `@astryxdesign/build`\'s Babel, PostCSS and Vite integrations, or `withAstryx()` under webpack, a copy under that folder compiles into `astryx-base`, below `astryx-theme`, so theme component overrides reach it as they reach the package component. Under Turbopack, and with the stock StyleX plugins above, a copy\'s rules land in your own styles, above `astryx-theme`, and theme component overrides do not reach it.',
165
+ text: "`{@command swizzle <Component>}` writes an `astryx-swizzle.json` marker into an output folder it owns (one the copy creates, an empty one, or one it marked before); every module under that folder then compiles as Astryx source, so keep copies in a folder of their own, such as the default `components/astryx`. For a folder that already holds other files it writes no marker and says how to add one by hand. With `@astryxdesign/build`'s Babel, PostCSS and Vite integrations, or `withAstryx()` under webpack, a copy under that folder compiles into `astryx-base`, below `astryx-theme`, so theme component overrides reach it as they reach the package component. Under Turbopack, and with the stock StyleX plugins above, a copy's rules land in your own styles, above `astryx-theme`, and theme component overrides do not reach it.",
166
166
  },
167
167
  ],
168
168
  },
@@ -19,6 +19,8 @@ export const docs = {
19
19
  'tailwind-theme.css',
20
20
  'utility classes',
21
21
  'preflight',
22
+ 'existing Tailwind app',
23
+ 'cascade layer order',
22
24
  ],
23
25
 
24
26
  sections: [
@@ -69,7 +71,45 @@ import {themes, defaultThemeSlug} from './astryx-themes';`,
69
71
  },
70
72
  {
71
73
  type: 'prose',
72
- text: 'The Tailwind bridge is the concrete example of the general interop pattern: expose another library\'s semantic API, but point the values at system token variables.',
74
+ text: "The Tailwind bridge is the concrete example of the general interop pattern: expose another library's semantic API, but point the values at system token variables.",
75
+ },
76
+ ],
77
+ },
78
+ {
79
+ title: 'Add Astryx to an app that already uses Tailwind',
80
+ content: [
81
+ {
82
+ type: 'prose',
83
+ text: 'The recipe in the Tailwind section is a whole `globals.css` for a new app. An app that already uses Tailwind keeps its own file: its `@import "tailwindcss"`, its `@theme` mapping, and its `:root` variables stay as they are. Add one `@layer` line above the Tailwind import and the Astryx stylesheets below it.',
84
+ },
85
+ {
86
+ type: 'code',
87
+ lang: 'css',
88
+ label: 'globals.css (existing Tailwind app)',
89
+ code: `@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
90
+
91
+ @import "tailwindcss"; /* the app's own entry, unchanged */
92
+ @import "@astryxdesign/core/reset.css";
93
+ @import "@astryxdesign/core/astryx.css";
94
+ @import "@astryxdesign/theme-neutral/theme.css";
95
+
96
+ /* The app's own @theme and :root rules stay below, unchanged. */`,
97
+ },
98
+ {
99
+ type: 'prose',
100
+ text: "This recipe leaves out `tailwind-theme.css`. The bridge maps Tailwind theme names to system tokens, including Tailwind's own defaults such as `rounded-lg` and `shadow-md`, so in an existing app it changes what the app's own classes paint. Add it only when the app does not use the names it maps.",
101
+ },
102
+ {
103
+ type: 'prose',
104
+ text: "Astryx's theme defines its own CSS custom properties (color, spacing, radius, shadow, font, motion and more), and {@link namespace:tokens} has the full list. An app token with one of those names collides in one of two ways. An app `@theme` key takes Astryx's value, because `astryx.css` sets the tokens on `:root` above Tailwind's `theme` layer: with `@theme { --spacing-4: 1.5rem }`, the app's `gap-4` stays 16px. Marking the block `@theme inline` keeps the app's value in its utilities. An app `:root` variable replaces Astryx's value, so Astryx's own components change: `:root { --spacing-4: 1.5rem }` widens Astryx's spacing step 4 from 16px to 24px. Where the root Theme component sets a token again, such as `--color-warning`, the app's own rules inside it read Astryx's value instead. Rename any app token that shares a name.",
105
+ },
106
+ {
107
+ type: 'prose',
108
+ text: "Put the `@layer` line above the app's `@import \"tailwindcss\"`, never below it. A `@layer` statement orders only the layer names the page has not seen yet, and the Tailwind import names `theme`, `base`, `components`, and `utilities` as it loads. Below that import, the statement can only add `reset`, `astryx-base`, and `astryx-theme` after Tailwind's `utilities`. That inverts the cascade: the Astryx reset then outranks every utility class in the app, and, for example, removes the borders and padding the app's utilities give its controls. Nothing reports it: the build and the browser console stay clean, and the `@layer` line still looks right in the source, so only the rendered page shows the problem.",
109
+ },
110
+ {
111
+ type: 'prose',
112
+ text: 'The load-order rule from the Tailwind section still applies: `globals.css` must be the first stylesheet the page loads, so its `@layer` line names the layers first.',
73
113
  },
74
114
  ],
75
115
  },
@@ -152,7 +152,7 @@ const denseTheme = defineTheme({
152
152
  content: [
153
153
  {
154
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 browsers without the keyword see no change. There is nothing to turn on: no theme option, prop or document attribute.",
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
156
  },
157
157
  {
158
158
  type: 'prose',
@@ -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: 'usePowerSearchConfig',
7
+ name: 'usePowerSearchConfig — Filtered Directory Table',
8
+ displayName: 'usePowerSearchConfig — Filtered Directory Table',
9
+ description:
10
+ 'A people directory with a PowerSearch bar over a Table: typed field definitions feed usePowerSearchConfig, returning the search config and an applyFilters helper that updates Table rows.',
11
+ isReady: true,
12
+ aspectRatio: 4 / 3,
13
+ componentsUsed: ['PowerSearch', 'Table', 'Layout'],
14
+ };
@@ -0,0 +1,83 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {useState} from 'react';
6
+ import {VStack} from '@astryxdesign/core/Layout';
7
+ import {
8
+ PowerSearch,
9
+ usePowerSearchConfig,
10
+ } from '@astryxdesign/core/PowerSearch';
11
+ import type {PowerSearchFilter} from '@astryxdesign/core/PowerSearch';
12
+ import {Table} from '@astryxdesign/core/Table';
13
+ import type {TableColumn} from '@astryxdesign/core/Table';
14
+
15
+ const statusValues = [
16
+ {value: 'active', label: 'Active'},
17
+ {value: 'on-leave', label: 'On Leave'},
18
+ {value: 'contractor', label: 'Contractor'},
19
+ ];
20
+
21
+ const fieldDefs = [
22
+ {key: 'name', type: 'string', label: 'Name'},
23
+ {key: 'status', type: 'enum', label: 'Status', enumValues: statusValues},
24
+ {key: 'startDate', type: 'date', label: 'Start date'},
25
+ ] as const;
26
+
27
+ interface Person extends Record<string, unknown> {
28
+ id: string;
29
+ name: string;
30
+ status: string;
31
+ startDate: number;
32
+ }
33
+
34
+ const people: Person[] = [
35
+ {id: '1', name: 'Alice Chen', status: 'active', startDate: 1672531200},
36
+ {id: '2', name: 'Bob Dylan', status: 'on-leave', startDate: 1680307200},
37
+ {id: '3', name: 'Charlie Kim', status: 'active', startDate: 1688169600},
38
+ {id: '4', name: 'Dana Scully', status: 'contractor', startDate: 1696118400},
39
+ {id: '5', name: 'Evan Wright', status: 'active', startDate: 1704067200},
40
+ ];
41
+
42
+ const columns: TableColumn<Person>[] = [
43
+ {key: 'name', header: 'Name'},
44
+ {
45
+ key: 'status',
46
+ header: 'Status',
47
+ renderCell: (person: Person) =>
48
+ statusValues.find(s => s.value === person.status)?.label ?? person.status,
49
+ },
50
+ {
51
+ key: 'startDate',
52
+ header: 'Start Date',
53
+ renderCell: (person: Person) =>
54
+ new Date(person.startDate * 1000).toLocaleDateString('en-US', {
55
+ timeZone: 'UTC',
56
+ year: 'numeric',
57
+ month: 'short',
58
+ day: 'numeric',
59
+ }),
60
+ },
61
+ ];
62
+
63
+ export default function UsePowerSearchConfigHookUsage() {
64
+ const [filters, setFilters] = useState<PowerSearchFilter[]>([]);
65
+ const {config, applyFilters} = usePowerSearchConfig(
66
+ fieldDefs,
67
+ 'PeopleDirectory',
68
+ );
69
+ const filteredPeople = applyFilters(filters, people);
70
+
71
+ return (
72
+ <VStack gap={4}>
73
+ <PowerSearch
74
+ config={config}
75
+ filters={filters}
76
+ onChange={newFilters => setFilters([...newFilters])}
77
+ placeholder="Filter directory by name, status, start date..."
78
+ resultCount={filteredPeople.length}
79
+ />
80
+ <Table data={filteredPeople} columns={columns} idKey="id" hasHover />
81
+ </VStack>
82
+ );
83
+ }
@@ -73,11 +73,22 @@ function extractTheme(mod) {
73
73
  return null;
74
74
  }
75
75
 
76
+ /**
77
+ * Print a theme warning as one inert line. The configured theme value and the
78
+ * generated record come from the checkout, so a newline, an escape sequence or
79
+ * a bidi override in them must not rewrite the terminal. Control and format
80
+ * characters print as U+FFFD.
81
+ * @param {string} message
82
+ */
83
+ function warnTheme(message) {
84
+ console.warn(message.replace(/[\p{Cc}\p{Cf}]/gu, '\uFFFD'));
85
+ }
86
+
76
87
  /** @param {unknown} mod @param {string} specifier */
77
88
  function resolvedThemeData(mod, specifier) {
78
89
  const theme = extractTheme(mod);
79
90
  if (!theme) {
80
- console.warn(
91
+ warnTheme(
81
92
  `⚠ theme: loaded "${specifier}" but could not find a theme object`,
82
93
  );
83
94
  return null;
@@ -100,7 +111,7 @@ export async function resolveTheme(cwd = process.cwd()) {
100
111
  const loaded = await resolveRecordedTheme(cwd);
101
112
  if (loaded.configured) {
102
113
  if (!loaded.module) {
103
- console.warn(
114
+ warnTheme(
104
115
  `⚠ theme: ${loaded.problem ?? `could not resolve the generated default theme (${loaded.specifier})`}`,
105
116
  );
106
117
  return null;
@@ -130,15 +141,13 @@ export async function resolveTheme(cwd = process.cwd()) {
130
141
  if (specifier.startsWith('.') || specifier.startsWith('/')) {
131
142
  mod = tryLoadLegacyModule(specifier, cwd);
132
143
  if (!mod) {
133
- console.warn(
134
- `⚠ theme: could not resolve file "${specifier}" from ${cwd}`,
135
- );
144
+ warnTheme(`⚠ theme: could not resolve file "${specifier}" from ${cwd}`);
136
145
  return null;
137
146
  }
138
147
  } else if (specifier.startsWith('@')) {
139
148
  mod = tryLoadLegacyModule(specifier, cwd);
140
149
  if (!mod) {
141
- console.warn(`⚠ theme: could not resolve package "${specifier}"`);
150
+ warnTheme(`⚠ theme: could not resolve package "${specifier}"`);
142
151
  return null;
143
152
  }
144
153
  } else {
@@ -147,7 +156,7 @@ export async function resolveTheme(cwd = process.cwd()) {
147
156
  tryLoadLegacyModule(conventional, cwd) ??
148
157
  tryLoadLegacyModule(specifier, cwd);
149
158
  if (!mod) {
150
- console.warn(
159
+ warnTheme(
151
160
  `⚠ theme: could not resolve "${specifier}" (tried ${conventional} and ${specifier})`,
152
161
  );
153
162
  return null;
@@ -17,7 +17,11 @@ export function importDocModule(file: string): Promise<any>;
17
17
  export function importTemplateModule(file: string): Promise<any>;
18
18
  /**
19
19
  * Import a module natively, by file URL: how the docs API, search, and the
20
- * unchecked component and hook readers have always imported docs.
20
+ * unchecked component and hook readers have always imported docs. A `.ts`
21
+ * file goes through jiti, as {@link importDocModule} imports it: Node cannot
22
+ * strip types from a file under node_modules, and below 22.18 it does not
23
+ * strip types at all, so a plain import of an installed or linked
24
+ * integration's `.doc.ts` fails.
21
25
  * @param {string} file
22
26
  * @returns {Promise<any>}
23
27
  */
@@ -24,7 +24,12 @@
24
24
 
25
25
  import {pathToFileURL} from 'node:url';
26
26
  import {createJiti} from 'jiti';
27
- import {importUserModule} from '../fs/module-loader.mjs';
27
+ import {importUrl} from '../fs/import-url.mjs';
28
+ import {
29
+ importUserModule,
30
+ readFreshInTurn,
31
+ readInTurn,
32
+ } from '../fs/module-loader.mjs';
28
33
  import {fileStamp, recordContentRead} from '../fs/read-record.mjs';
29
34
 
30
35
  /** @type {ReturnType<typeof createJiti> | undefined} */
@@ -78,6 +83,28 @@ function freshUrl(file) {
78
83
  return url.href;
79
84
  }
80
85
 
86
+ /**
87
+ * Import `file` from `url`, in its turn with every other read of the file in
88
+ * this process (`readInTurn`, ../fs/module-loader.mjs).
89
+ * @param {string} file
90
+ * @param {string} url
91
+ * @returns {Promise<any>}
92
+ */
93
+ function importInTurn(file, url) {
94
+ return readInTurn(file, () => importUrl(url));
95
+ }
96
+
97
+ /**
98
+ * Import `file` again, for a change, as a new module. Node chooses its format
99
+ * as for the first import. The read takes its turn without the file's
100
+ * CommonJS cache entry (`readFreshInTurn`, ../fs/module-loader.mjs).
101
+ * @param {string} file
102
+ * @returns {Promise<any>}
103
+ */
104
+ function importFresh(file) {
105
+ return readFreshInTurn(file, () => importUrl(freshUrl(file)));
106
+ }
107
+
81
108
  /**
82
109
  * Import a component, hook, topic, or self-doc module: `.ts` through jiti,
83
110
  * anything else natively.
@@ -105,28 +132,35 @@ export function importTemplateModule(file) {
105
132
  jsxJiti ??= createJiti(import.meta.url, {jsx: true});
106
133
  return jsxJiti.import(first);
107
134
  }
108
- return import(`file://${first}`);
135
+ return importInTurn(first, `file://${first}`);
109
136
  },
110
137
  changed =>
111
138
  changed.endsWith('.ts')
112
139
  ? createJiti(import.meta.url, {jsx: true, moduleCache: false}).import(
113
140
  changed,
114
141
  )
115
- : import(freshUrl(changed)),
142
+ : importFresh(changed),
116
143
  );
117
144
  }
118
145
 
119
146
  /**
120
147
  * Import a module natively, by file URL: how the docs API, search, and the
121
- * unchecked component and hook readers have always imported docs.
148
+ * unchecked component and hook readers have always imported docs. A `.ts`
149
+ * file goes through jiti, as {@link importDocModule} imports it: Node cannot
150
+ * strip types from a file under node_modules, and below 22.18 it does not
151
+ * strip types at all, so a plain import of an installed or linked
152
+ * integration's `.doc.ts` fails.
122
153
  * @param {string} file
123
154
  * @returns {Promise<any>}
124
155
  */
125
156
  export function importNativeModule(file) {
157
+ if (file.endsWith('.ts')) {
158
+ return importDocModule(file);
159
+ }
126
160
  return importCurrent(
127
161
  file,
128
162
  'native',
129
- first => import(pathToFileURL(first).href),
130
- changed => import(freshUrl(changed)),
163
+ first => importInTurn(first, pathToFileURL(first).href),
164
+ changed => importFresh(changed),
131
165
  );
132
166
  }
@@ -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,27 @@ 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>;
43
+ /**
44
+ * Run `read`, a fresh import of `file`, in the file's turn (`readInTurn`),
45
+ * once the file is gone from Node's CommonJS cache. A new URL names a new ES
46
+ * module only, so a CommonJS module also needs its cache entry gone.
47
+ * @template T
48
+ * @param {string} file absolute path
49
+ * @param {() => Promise<T>} read
50
+ * @returns {Promise<T>}
51
+ */
52
+ export function readFreshInTurn<T>(file: string, read: () => Promise<T>): Promise<T>;
32
53
  /**
33
54
  * Import a user-authored module, take its default export, and validate it
34
55
  * 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,77 @@ 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 readFreshInTurn(file, () => import(url.href));
240
+ }
241
+
242
+ /**
243
+ * The name Node's CommonJS cache gives `file`: its real path, so a link and
244
+ * its target name one entry. A file that does not resolve keeps its path.
245
+ * @param {string} file absolute path
246
+ * @returns {string}
247
+ */
248
+ function cacheKey(file) {
249
+ try {
250
+ return requireFromHere.resolve(file);
251
+ } catch {
252
+ return file;
277
253
  }
278
- return await import(url.href);
254
+ }
255
+
256
+ /**
257
+ * Each file's latest read, by its cache name, settled once that read ends.
258
+ * @type {Map<string, Promise<void>>}
259
+ */
260
+ const readsInProgress = new Map();
261
+
262
+ /**
263
+ * Run `read`, an import of `file`, once the imports of `file` started before
264
+ * it have ended. Imports of other files do not wait. A fresh read removes the
265
+ * file from Node's CommonJS cache. A CommonJS import still in progress needs
266
+ * that entry until it ends.
267
+ * @template T
268
+ * @param {string} file absolute path
269
+ * @param {() => Promise<T>} read
270
+ * @returns {Promise<T>}
271
+ */
272
+ export function readInTurn(file, read) {
273
+ const key = cacheKey(file);
274
+ const before = readsInProgress.get(key) ?? Promise.resolve();
275
+ const result = before.then(read);
276
+ const ended = result.then(
277
+ () => {},
278
+ () => {},
279
+ );
280
+ readsInProgress.set(key, ended);
281
+ void ended.then(() => {
282
+ if (readsInProgress.get(key) === ended) readsInProgress.delete(key);
283
+ });
284
+ return result;
285
+ }
286
+
287
+ /**
288
+ * Run `read`, a fresh import of `file`, in the file's turn (`readInTurn`),
289
+ * once the file is gone from Node's CommonJS cache. A new URL names a new ES
290
+ * module only, so a CommonJS module also needs its cache entry gone.
291
+ * @template T
292
+ * @param {string} file absolute path
293
+ * @param {() => Promise<T>} read
294
+ * @returns {Promise<T>}
295
+ */
296
+ export function readFreshInTurn(file, read) {
297
+ return readInTurn(file, () => {
298
+ delete requireFromHere.cache[cacheKey(file)];
299
+ return read();
300
+ });
279
301
  }
280
302
 
281
303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.7.12-canary.c86484d",
3
+ "version": "0.7.12-canary.d84658f",
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.c86484d"
128
+ "@astryxdesign/core": "0.7.12-canary.d84658f"
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.c86484d",
137
- "@astryxdesign/core": "0.7.12-canary.c86484d",
138
- "@astryxdesign/lab": "0.7.12-canary.c86484d",
139
- "@astryxdesign/theme-neutral": "0.7.12-canary.c86484d",
136
+ "@astryxdesign/charts": "0.7.12-canary.d84658f",
137
+ "@astryxdesign/core": "0.7.12-canary.d84658f",
138
+ "@astryxdesign/lab": "0.7.12-canary.d84658f",
139
+ "@astryxdesign/theme-neutral": "0.7.12-canary.d84658f",
140
140
  "@heroicons/react": "^2.2.0",
141
141
  "@stylexjs/stylex": "^0.19.0",
142
142
  "@types/babel__core": "^7.20.5",