@astryxdesign/cli 0.1.2-canary.043f3fd → 0.1.2-canary.04e71b2
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/CHANGELOG.md +0 -1
- package/README.md +49 -55
- package/package.json +7 -7
- package/src/api/blog.mjs +192 -0
- package/src/api/blog.test.mjs +144 -0
- package/src/api/doctor.mjs +3 -3
- package/src/api/index.mjs +1 -0
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-css-surfaces.test.mjs +67 -0
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-declare-module.test.mjs +61 -0
- package/src/codemods/transforms/v0.1.0/__tests__/v0.1.0-ordering.test.mjs +23 -0
- package/src/codemods/transforms/v0.1.0/index.mjs +28 -0
- package/src/codemods/transforms/v0.1.0/migrate-xds-css-surfaces.mjs +77 -0
- package/src/codemods/transforms/v0.1.0/migrate-xds-declare-module.mjs +78 -0
- package/src/commands/agent-docs.mjs +1 -1
- package/src/commands/blog.mjs +60 -0
- package/src/commands/build-theme.mjs +117 -0
- package/src/commands/build-theme.watch.test.mjs +157 -0
- package/src/index.mjs +1 -0
- package/src/lib/error-codes.mjs +8 -0
- package/src/lib/site.mjs +20 -0
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +14 -0
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +28 -0
- package/templates/blocks/components/Blockquote/BlockquoteTestimonials.doc.mjs +14 -0
- package/templates/blocks/components/Blockquote/BlockquoteTestimonials.tsx +34 -0
- package/templates/blocks/components/Blockquote/BlockquoteWithCite.doc.mjs +14 -0
- package/templates/blocks/components/Blockquote/BlockquoteWithCite.tsx +21 -0
- package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +15 -0
- package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +14 -0
- package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +16 -0
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +19 -0
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +34 -0
- package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +30 -0
- package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +14 -0
- package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +31 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +14 -0
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +26 -0
- package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +14 -0
- package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +32 -0
- package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +18 -0
- package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +32 -0
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +27 -0
- package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +14 -0
- package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +32 -0
- package/templates/blocks/components/DialogHeader/DialogHeaderBasic.doc.mjs +14 -0
- package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +30 -0
- package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -6
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +27 -0
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +1 -5
- package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +14 -0
- package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +20 -0
- package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +14 -0
- package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +23 -0
- package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +14 -0
- package/templates/blocks/components/FileInput/FileInputBasic.tsx +22 -0
- package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +14 -0
- package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +38 -0
- package/templates/blocks/components/HStack/HStackBasic.doc.mjs +14 -0
- package/templates/blocks/components/HStack/HStackBasic.tsx +16 -0
- package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.doc.mjs +14 -0
- package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx +57 -0
- package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +14 -0
- package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +27 -0
- package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +23 -0
- package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +40 -0
- package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +22 -0
- package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +41 -0
- package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +23 -0
- package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +40 -0
- package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +22 -0
- package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +37 -0
- package/templates/blocks/components/Lightbox/LightboxGallery.doc.mjs +14 -0
- package/templates/blocks/components/Lightbox/LightboxGallery.tsx +53 -0
- package/templates/blocks/components/Lightbox/LightboxShowcase.tsx +4 -3
- package/templates/blocks/components/Lightbox/LightboxVideo.doc.mjs +14 -0
- package/templates/blocks/components/Lightbox/LightboxVideo.tsx +27 -0
- package/templates/blocks/components/Lightbox/LightboxZoom.doc.mjs +14 -0
- package/templates/blocks/components/Lightbox/LightboxZoom.tsx +33 -0
- package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +19 -0
- package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +23 -0
- package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +42 -0
- package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -6
- package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +14 -0
- package/templates/blocks/components/NavIcon/NavIconBasic.tsx +16 -0
- package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +30 -0
- package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +25 -0
- package/templates/blocks/components/Resizable/ResizableSidebar.tsx +67 -0
- package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +21 -0
- package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +14 -0
- package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +39 -0
- package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +55 -0
- package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +38 -0
- package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +49 -0
- package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +14 -0
- package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +71 -0
- package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +14 -0
- package/templates/blocks/components/StackItem/StackItemFill.tsx +28 -0
- package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +14 -0
- package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +23 -0
- package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +14 -0
- package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +47 -0
- package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +22 -0
- package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +21 -0
- package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +20 -0
- package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +46 -0
- package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +16 -0
- package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +26 -0
- package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +14 -0
- package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +43 -0
- package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +14 -0
- package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +43 -0
- package/templates/blocks/components/VStack/VStackBasic.doc.mjs +14 -0
- package/templates/blocks/components/VStack/VStackBasic.tsx +20 -0
- package/templates/pages/shell-side-nav/page.tsx +0 -1
- package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +0 -14
- package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +0 -64
- package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +0 -14
- package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +0 -60
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -62,8 +62,7 @@ Options:
|
|
|
62
62
|
| `upgrade` | Run codemods to migrate between versions |
|
|
63
63
|
| `theme build` | Compile a defineTheme file to production CSS and JS |
|
|
64
64
|
| `discover` | Discover external packages and components |
|
|
65
|
-
| `
|
|
66
|
-
| `doctor` | Diagnose your XDS setup and report problems with fixes (CI-friendly via exit code) |
|
|
65
|
+
| `doctor` | Diagnose your Astryx setup and report problems with fixes (CI-friendly via exit code) |
|
|
67
66
|
|
|
68
67
|
### Global options
|
|
69
68
|
|
|
@@ -161,7 +160,6 @@ if (isError(result)) {
|
|
|
161
160
|
| `ERR_INVALID_VERSION` | A `--from`/`--to` value was not a valid semver string. |
|
|
162
161
|
| `ERR_DEP_MISSING` | A required external dependency (e.g. jscodeshift) is missing. |
|
|
163
162
|
| `ERR_GH_CLI` | GitHub CLI (`gh`) is not installed or not authenticated. |
|
|
164
|
-
| `ERR_GAP_REPORT_FAILED` | Filing a gap report failed (disabled, or the integration errored). |
|
|
165
163
|
|
|
166
164
|
## Capability manifest (agent discovery)
|
|
167
165
|
|
|
@@ -172,7 +170,7 @@ discriminators each command can emit. Think of it as an OpenAPI spec for the CLI
|
|
|
172
170
|
|
|
173
171
|
```bash
|
|
174
172
|
astryx manifest --json # dedicated surface — type: "manifest"
|
|
175
|
-
|
|
173
|
+
astryx --json # bare invocation — embeds the same payload under data.manifest
|
|
176
174
|
```
|
|
177
175
|
|
|
178
176
|
Shape:
|
|
@@ -182,7 +180,7 @@ Shape:
|
|
|
182
180
|
"apiVersion": 1,
|
|
183
181
|
"type": "manifest",
|
|
184
182
|
"data": {
|
|
185
|
-
"name": "
|
|
183
|
+
"name": "astryx",
|
|
186
184
|
"version": "0.0.14",
|
|
187
185
|
"description": "Design system CLI — components, themes, and tooling",
|
|
188
186
|
"globalOptions": [
|
|
@@ -249,14 +247,14 @@ the `JSON_SUPPORTED` allowlist and a small declarative `RESPONSE_TYPES` map in
|
|
|
249
247
|
`src/lib/manifest.mjs`, guarded by a drift test (`manifest.test.mjs`) so adding a
|
|
250
248
|
command without describing it fails CI.
|
|
251
249
|
|
|
252
|
-
**Backwards-compat:** the bare `
|
|
250
|
+
**Backwards-compat:** the bare `astryx --json` envelope keeps `type: "help"` and its
|
|
253
251
|
original shallow fields (`name`, `version`, `commands` as a `string[]` of names,
|
|
254
252
|
`jsonSupported`); the full structured manifest is additive under `data.manifest`.
|
|
255
253
|
For the standalone manifest envelope (`type: "manifest"`), use `astryx manifest --json`.
|
|
256
254
|
|
|
257
255
|
## Programmatic API
|
|
258
256
|
|
|
259
|
-
The same logic that powers `
|
|
257
|
+
The same logic that powers `astryx --json` is available as importable, type-safe functions:
|
|
260
258
|
|
|
261
259
|
```typescript
|
|
262
260
|
import {
|
|
@@ -269,20 +267,20 @@ import {
|
|
|
269
267
|
AstryxError,
|
|
270
268
|
} from '@astryxdesign/cli/api';
|
|
271
269
|
|
|
272
|
-
// Same result as:
|
|
270
|
+
// Same result as: astryx --json component Button
|
|
273
271
|
const btn = await component('Button');
|
|
274
272
|
btn.type; // 'component.detail'
|
|
275
273
|
btn.data.name; // 'Button' (typed as ComponentDoc)
|
|
276
274
|
|
|
277
|
-
// Same result as:
|
|
275
|
+
// Same result as: astryx --json component --list
|
|
278
276
|
const list = await component(undefined, {list: true});
|
|
279
277
|
list.data; // Record<string, string[]>
|
|
280
278
|
|
|
281
|
-
// Same result as:
|
|
279
|
+
// Same result as: astryx --json docs principles
|
|
282
280
|
const principles = await docs('principles');
|
|
283
|
-
principles.data.title; // '
|
|
281
|
+
principles.data.title; // 'Principles'
|
|
284
282
|
|
|
285
|
-
// Same result as:
|
|
283
|
+
// Same result as: astryx --json hook useMediaQuery
|
|
286
284
|
const useMediaQuery = await hook('useMediaQuery');
|
|
287
285
|
useMediaQuery.data.params; // typed as HookParamDoc[]
|
|
288
286
|
|
|
@@ -296,7 +294,7 @@ try {
|
|
|
296
294
|
}
|
|
297
295
|
```
|
|
298
296
|
|
|
299
|
-
The CLI command handlers are thin wrappers around these functions: they parse args, call the API, then format the output (JSON or text). This guarantees that `@astryxdesign/cli/api` and `
|
|
297
|
+
The CLI command handlers are thin wrappers around these functions: they parse args, call the API, then format the output (JSON or text). This guarantees that `@astryxdesign/cli/api` and `astryx --json` always return identical data.
|
|
300
298
|
|
|
301
299
|
### Consumer utilities
|
|
302
300
|
|
|
@@ -326,43 +324,41 @@ detail.data.name; // already narrowed
|
|
|
326
324
|
|
|
327
325
|
Every response has a `type` string that uniquely identifies it:
|
|
328
326
|
|
|
329
|
-
| Command
|
|
330
|
-
|
|
|
331
|
-
| `
|
|
332
|
-
| `
|
|
333
|
-
| `
|
|
334
|
-
| `
|
|
335
|
-
| `
|
|
336
|
-
| `
|
|
337
|
-
| `
|
|
338
|
-
| `
|
|
339
|
-
| `
|
|
340
|
-
| `
|
|
341
|
-
| `
|
|
342
|
-
| `
|
|
343
|
-
| `
|
|
344
|
-
| `
|
|
345
|
-
| `
|
|
346
|
-
| `
|
|
347
|
-
| `
|
|
348
|
-
| `
|
|
349
|
-
| `
|
|
350
|
-
| `
|
|
351
|
-
| `
|
|
352
|
-
| `
|
|
353
|
-
| `
|
|
354
|
-
| `
|
|
355
|
-
| `
|
|
356
|
-
| `
|
|
357
|
-
| `
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `
|
|
361
|
-
| `
|
|
362
|
-
|
|
|
363
|
-
|
|
|
364
|
-
| any error | — | `CLIError` |
|
|
365
|
-
| unsupported command | — | `CLIUnsupportedError` |
|
|
327
|
+
| Command | Type | Response |
|
|
328
|
+
| ------------------------------------------------- | --------------------------- | --------------------------------- |
|
|
329
|
+
| `astryx --json component [--list]` | `component.list` | `ComponentListResponse` |
|
|
330
|
+
| `astryx --json component --list --detail compact` | `component.brief` | `ComponentBriefResponse` |
|
|
331
|
+
| `astryx --json component --list --detail full` | `component.full` | `ComponentFullResponse` |
|
|
332
|
+
| `astryx --json component <name>` | `component.detail` | `ComponentDetailResponse` |
|
|
333
|
+
| `astryx --json component <name> --props` | `component.detail.props` | `ComponentDetailPropsResponse` |
|
|
334
|
+
| `astryx --json component <name> --source` | `component.detail.source` | `ComponentDetailSourceResponse` |
|
|
335
|
+
| `astryx --json component <name> --showcase` | `component.detail.showcase` | `ComponentDetailShowcaseResponse` |
|
|
336
|
+
| `astryx --json component <name> --blocks` | `component.detail.blocks` | `ComponentDetailBlocksResponse` |
|
|
337
|
+
| `astryx --json discover` | `discover.list` | `DiscoverListResponse` |
|
|
338
|
+
| `astryx --json discover @scope/name` | `discover.detail` | `DiscoverDetailResponse` |
|
|
339
|
+
| `astryx --json discover @scope/name/Comp` | `discover.detail.doc` | `DiscoverDetailDocResponse` |
|
|
340
|
+
| `astryx --json discover <search>` | `discover.search` | `DiscoverSearchResponse` |
|
|
341
|
+
| `astryx --json docs` | `docs.list` | `DocsListResponse` |
|
|
342
|
+
| `astryx --json docs <topic>` | `docs.detail` | `DocsDetailResponse` |
|
|
343
|
+
| `astryx --json docs <topic> <section>` | `docs.detail.section` | `DocsDetailSectionResponse` |
|
|
344
|
+
| `astryx --json template [--list]` | `template.list` | `TemplateListResponse` |
|
|
345
|
+
| `astryx --json template <name>` | `template.show` | `TemplateShowResponse` |
|
|
346
|
+
| `astryx --json template <name> --skeleton` | `template.skeleton` | `TemplateSkeletonResponse` |
|
|
347
|
+
| `astryx --json template <name> [path]` | `template.copy` | `TemplateCopyResponse` |
|
|
348
|
+
| `astryx --json hook [--list]` | `hook.list` | `HookListResponse` |
|
|
349
|
+
| `astryx --json hook --list --detail compact` | `hook.brief` | `HookBriefResponse` |
|
|
350
|
+
| `astryx --json hook --list --detail full` | `hook.full` | `HookFullResponse` |
|
|
351
|
+
| `astryx --json hook <name>` | `hook.detail` | `HookDetailResponse` |
|
|
352
|
+
| `astryx --json hook <name> --params` | `hook.detail.params` | `HookDetailParamsResponse` |
|
|
353
|
+
| `astryx --json search <query>` | `search` | `SearchResponse` |
|
|
354
|
+
| `astryx --json swizzle [--list]` | `swizzle.list` | `SwizzleListResponse` |
|
|
355
|
+
| `astryx --json swizzle <component>` | `swizzle.copy` | `SwizzleCopyResponse` |
|
|
356
|
+
| `astryx --json theme build <file>` | `theme.build` | `ThemeBuildResponse` |
|
|
357
|
+
| `astryx --json upgrade --list` | `upgrade.list` | `UpgradeListResponse` |
|
|
358
|
+
| `astryx --json upgrade [--apply]` | `upgrade.run` | `UpgradeRunResponse` |
|
|
359
|
+
| `astryx --json doctor` | `doctor` | `DoctorResponse` |
|
|
360
|
+
| any error | — | `CLIError` |
|
|
361
|
+
| unsupported command | — | `CLIUnsupportedError` |
|
|
366
362
|
|
|
367
363
|
## Doctor
|
|
368
364
|
|
|
@@ -383,7 +379,7 @@ astryx doctor — diagnosing your setup
|
|
|
383
379
|
@astryxdesign/core v0.0.14 is in step with @astryxdesign/cli v0.0.14.
|
|
384
380
|
⚠ Theme packages
|
|
385
381
|
No @astryxdesign/theme-* packages are installed.
|
|
386
|
-
→ fix: Install a theme, e.g. `npm install @astryxdesign/theme-neutral`, then import its CSS or set
|
|
382
|
+
→ fix: Install a theme, e.g. `npm install @astryxdesign/theme-neutral`, then import its CSS or set astryx.theme.
|
|
387
383
|
ℹ astryx.config.mjs
|
|
388
384
|
No astryx.config.mjs found — using defaults.
|
|
389
385
|
ℹ AI agent docs
|
|
@@ -408,7 +404,7 @@ No failures — but review the ⚠ warnings above when you can.
|
|
|
408
404
|
| Version alignment | pass / warn / info | Installed `@astryxdesign/core` is in step with `@astryxdesign/cli` |
|
|
409
405
|
| Theme packages | pass / warn | An `@astryxdesign/theme-*` package is installed and a theme is wired |
|
|
410
406
|
| astryx.config.mjs | pass / fail / info | Config (if present) loads cleanly with a valid shape |
|
|
411
|
-
| AI agent docs | pass / warn / info | Agent docs exist and contain the
|
|
407
|
+
| AI agent docs | pass / warn / info | Agent docs exist and contain the Astryx section markers |
|
|
412
408
|
| Peer dependencies | pass / warn / info | `@astryxdesign/core`'s peer deps (react, …) are installed |
|
|
413
409
|
| Package manager | info | Reports the detected package manager |
|
|
414
410
|
|
|
@@ -434,8 +430,6 @@ export default {
|
|
|
434
430
|
templates: {
|
|
435
431
|
get: async id => fetchTemplateFromAPI(id),
|
|
436
432
|
},
|
|
437
|
-
|
|
438
|
-
url: 'https://your-api.com/gaps',
|
|
439
|
-
},
|
|
433
|
+
issuesUrl: 'https://github.com/your-org/your-repo/issues',
|
|
440
434
|
};
|
|
441
435
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.1.2-canary.
|
|
3
|
+
"version": "0.1.2-canary.04e71b2",
|
|
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",
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
"zod": "^4.4.3"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@astryxdesign/core": "0.1.2-canary.
|
|
79
|
-
"@astryxdesign/lab": "0.1.2-canary.
|
|
80
|
-
"@astryxdesign/theme-neutral": "0.1.2-canary.
|
|
78
|
+
"@astryxdesign/core": "0.1.2-canary.04e71b2",
|
|
79
|
+
"@astryxdesign/lab": "0.1.2-canary.04e71b2",
|
|
80
|
+
"@astryxdesign/theme-neutral": "0.1.2-canary.04e71b2",
|
|
81
81
|
"gpt-tokenizer": "^2.0.0"
|
|
82
82
|
},
|
|
83
83
|
"peerDependenciesMeta": {
|
|
@@ -92,9 +92,9 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@astryxdesign/core": "0.1.2-canary.
|
|
96
|
-
"@astryxdesign/lab": "0.1.2-canary.
|
|
97
|
-
"@astryxdesign/theme-neutral": "0.1.2-canary.
|
|
95
|
+
"@astryxdesign/core": "0.1.2-canary.04e71b2",
|
|
96
|
+
"@astryxdesign/lab": "0.1.2-canary.04e71b2",
|
|
97
|
+
"@astryxdesign/theme-neutral": "0.1.2-canary.04e71b2",
|
|
98
98
|
"gpt-tokenizer": "^2.0.0"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
package/src/api/blog.mjs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Programmatic API for the blog command.
|
|
5
|
+
*
|
|
6
|
+
* The blog is read the same way any feed reader reads it: over the published
|
|
7
|
+
* RSS feed. Listing parses the feed; reading a post fetches the plaintext
|
|
8
|
+
* (.txt) alternate the feed advertises for each item. Nothing here touches the
|
|
9
|
+
* blog's source files — the CLI is just a consumer of the public feed, so the
|
|
10
|
+
* blog's structure can change freely without touching the CLI.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {AstryxError} from './error.mjs';
|
|
14
|
+
import {ERROR_CODES} from '../lib/error-codes.mjs';
|
|
15
|
+
import {SITE_URL, SITE_ORIGIN} from '../lib/site.mjs';
|
|
16
|
+
|
|
17
|
+
/** Abort a feed/post fetch that hangs, and cap how much we'll read. */
|
|
18
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
19
|
+
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB — a blog feed is never larger.
|
|
20
|
+
|
|
21
|
+
const FEED_URL = new URL('/rss.xml', SITE_URL).toString();
|
|
22
|
+
|
|
23
|
+
async function fetchText(url) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetch(url, {
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
redirect: 'follow',
|
|
31
|
+
});
|
|
32
|
+
} catch (e) {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
const reason = e.name === 'AbortError' ? 'timed out' : e.message;
|
|
35
|
+
throw new AstryxError(
|
|
36
|
+
`Could not reach ${url}: ${reason}`,
|
|
37
|
+
[],
|
|
38
|
+
ERROR_CODES.ERR_FETCH_FAILED,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
throw new AstryxError(
|
|
44
|
+
`Request to ${url} failed with ${res.status}`,
|
|
45
|
+
[],
|
|
46
|
+
ERROR_CODES.ERR_FETCH_FAILED,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const body = await res.text();
|
|
50
|
+
if (body.length > MAX_BYTES) {
|
|
51
|
+
throw new AstryxError(
|
|
52
|
+
`Response from ${url} exceeded ${MAX_BYTES} bytes`,
|
|
53
|
+
[],
|
|
54
|
+
ERROR_CODES.ERR_FETCH_FAILED,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return body;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Defense in depth: a post's plaintext URL comes from feed content. Require it
|
|
62
|
+
* to live on the canonical origin so even a tampered feed can't redirect the
|
|
63
|
+
* CLI to another host.
|
|
64
|
+
*/
|
|
65
|
+
function assertCanonicalOrigin(target) {
|
|
66
|
+
let targetOrigin;
|
|
67
|
+
try {
|
|
68
|
+
targetOrigin = new URL(target).origin;
|
|
69
|
+
} catch {
|
|
70
|
+
throw new AstryxError(
|
|
71
|
+
`Post has an invalid plaintext URL: "${target}"`,
|
|
72
|
+
[],
|
|
73
|
+
ERROR_CODES.ERR_FETCH_FAILED,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (targetOrigin !== SITE_ORIGIN) {
|
|
77
|
+
throw new AstryxError(
|
|
78
|
+
`Refusing to fetch post text from a non-canonical origin (${targetOrigin})`,
|
|
79
|
+
[],
|
|
80
|
+
ERROR_CODES.ERR_FETCH_FAILED,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function unescapeXml(value) {
|
|
86
|
+
return value
|
|
87
|
+
.replace(/</g, '<')
|
|
88
|
+
.replace(/>/g, '>')
|
|
89
|
+
.replace(/"/g, '"')
|
|
90
|
+
.replace(/'/g, "'")
|
|
91
|
+
.replace(/&/g, '&');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function tag(item, name) {
|
|
95
|
+
const m = item.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`));
|
|
96
|
+
return m ? unescapeXml(m[1].trim()) : '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function tagAll(item, name) {
|
|
100
|
+
const out = [];
|
|
101
|
+
const re = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, 'g');
|
|
102
|
+
let m;
|
|
103
|
+
while ((m = re.exec(item))) out.push(unescapeXml(m[1].trim()));
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Extract the plaintext alternate href from an <item>. */
|
|
108
|
+
function textHref(item) {
|
|
109
|
+
// Match the atom:link alternate regardless of attribute order/quoting; then
|
|
110
|
+
// confirm it's the text/plain alternate before trusting the href.
|
|
111
|
+
const re = /<atom:link\b[^>]*?\/?>/g;
|
|
112
|
+
let m;
|
|
113
|
+
while ((m = re.exec(item))) {
|
|
114
|
+
const el = m[0];
|
|
115
|
+
if (/rel\s*=\s*["']alternate["']/.test(el) &&
|
|
116
|
+
/type\s*=\s*["']text\/plain["']/.test(el)) {
|
|
117
|
+
const href = el.match(/href\s*=\s*["']([^"']+)["']/);
|
|
118
|
+
if (href) return unescapeXml(href[1]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Derive a slug from a post link (last path segment). */
|
|
125
|
+
function slugFromLink(link) {
|
|
126
|
+
try {
|
|
127
|
+
const path = new URL(link).pathname.replace(/\/$/, '');
|
|
128
|
+
return path.slice(path.lastIndexOf('/') + 1);
|
|
129
|
+
} catch {
|
|
130
|
+
return link;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseFeed(xml) {
|
|
135
|
+
const items = [];
|
|
136
|
+
const re = /<item>([\s\S]*?)<\/item>/g;
|
|
137
|
+
let m;
|
|
138
|
+
while ((m = re.exec(xml))) {
|
|
139
|
+
const raw = m[1];
|
|
140
|
+
const link = tag(raw, 'link');
|
|
141
|
+
items.push({
|
|
142
|
+
slug: slugFromLink(link),
|
|
143
|
+
title: tag(raw, 'title'),
|
|
144
|
+
description: tag(raw, 'description'),
|
|
145
|
+
date: tag(raw, 'pubDate'),
|
|
146
|
+
type: tag(raw, 'category'),
|
|
147
|
+
authors: tagAll(raw, 'author'),
|
|
148
|
+
link,
|
|
149
|
+
textUrl: textHref(raw),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return items;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* List posts (from the feed), or read one post (via its .txt alternate).
|
|
157
|
+
* Both envelopes carry `feedUrl` so a caller can hit the RSS feed directly.
|
|
158
|
+
* The feed is always the canonical site — there is no user-supplied URL.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} [slug]
|
|
161
|
+
* @returns {Promise<{type: string, data: unknown}>}
|
|
162
|
+
*/
|
|
163
|
+
export async function blog(slug) {
|
|
164
|
+
const xml = await fetchText(FEED_URL);
|
|
165
|
+
const posts = parseFeed(xml);
|
|
166
|
+
|
|
167
|
+
if (!slug) {
|
|
168
|
+
return {type: 'blog.list', data: {feedUrl: FEED_URL, posts}};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const normalized = slug.toLowerCase();
|
|
172
|
+
const post = posts.find(p => p.slug.toLowerCase() === normalized);
|
|
173
|
+
if (!post) {
|
|
174
|
+
throw new AstryxError(
|
|
175
|
+
`No blog post with slug "${slug}"`,
|
|
176
|
+
posts.map(p => ({name: p.slug, reason: 'available post'})),
|
|
177
|
+
ERROR_CODES.ERR_UNKNOWN_POST,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!post.textUrl) {
|
|
182
|
+
throw new AstryxError(
|
|
183
|
+
`Post "${slug}" has no plaintext alternate in the feed`,
|
|
184
|
+
[],
|
|
185
|
+
ERROR_CODES.ERR_FETCH_FAILED,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
assertCanonicalOrigin(post.textUrl);
|
|
190
|
+
const text = await fetchText(post.textUrl);
|
|
191
|
+
return {type: 'blog.detail', data: {...post, feedUrl: FEED_URL, text}};
|
|
192
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Tests for the blog API — reads the blog over the canonical RSS feed
|
|
5
|
+
* and fetches each post's plaintext (.txt) alternate. `fetch` is stubbed so
|
|
6
|
+
* the tests never hit the network. The feed origin is fixed (not
|
|
7
|
+
* user-configurable), so the tests assert the canonical URLs directly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
|
|
11
|
+
import {blog} from './blog.mjs';
|
|
12
|
+
import {AstryxError} from './error.mjs';
|
|
13
|
+
import {SITE_URL} from '../lib/site.mjs';
|
|
14
|
+
|
|
15
|
+
const FEED = `<?xml version="1.0" encoding="UTF-8"?>
|
|
16
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
17
|
+
<channel>
|
|
18
|
+
<title>Astryx Blog</title>
|
|
19
|
+
<link>${SITE_URL}/blog</link>
|
|
20
|
+
<item>
|
|
21
|
+
<title>How Astryx works</title>
|
|
22
|
+
<link>${SITE_URL}/blog/how-astryx-works</link>
|
|
23
|
+
<guid isPermaLink="true">${SITE_URL}/blog/how-astryx-works</guid>
|
|
24
|
+
<description>Under the hood & more</description>
|
|
25
|
+
<category>engineering</category>
|
|
26
|
+
<author>cvkxx</author>
|
|
27
|
+
<author>cixzhang</author>
|
|
28
|
+
<pubDate>Mon, 29 Jun 2026 00:00:00 GMT</pubDate>
|
|
29
|
+
<atom:link rel="alternate" type="text/plain" href="${SITE_URL}/blog/how-astryx-works.txt" />
|
|
30
|
+
</item>
|
|
31
|
+
<item>
|
|
32
|
+
<title>Introducing Astryx</title>
|
|
33
|
+
<link>${SITE_URL}/blog/introducing-astryx</link>
|
|
34
|
+
<guid isPermaLink="true">${SITE_URL}/blog/introducing-astryx</guid>
|
|
35
|
+
<description>The launch</description>
|
|
36
|
+
<category>update</category>
|
|
37
|
+
<author>cvkxx</author>
|
|
38
|
+
<pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate>
|
|
39
|
+
<atom:link rel="alternate" type="text/plain" href="${SITE_URL}/blog/introducing-astryx.txt" />
|
|
40
|
+
</item>
|
|
41
|
+
</channel>
|
|
42
|
+
</rss>`;
|
|
43
|
+
|
|
44
|
+
const POST_TEXT = '# How Astryx works\n\nThe body of the post.';
|
|
45
|
+
const FEED_URL = `${SITE_URL}/rss.xml`;
|
|
46
|
+
const TXT_URL = `${SITE_URL}/blog/how-astryx-works.txt`;
|
|
47
|
+
|
|
48
|
+
/** Build a fetch stub from a url→{status,body} map. */
|
|
49
|
+
function stubFetch(routes) {
|
|
50
|
+
return vi.fn(async (url, opts) => {
|
|
51
|
+
const u = String(url);
|
|
52
|
+
const r = routes[u];
|
|
53
|
+
if (!r) return {ok: false, status: 404, text: async () => 'not found'};
|
|
54
|
+
if (r.throw) throw r.throw;
|
|
55
|
+
return {ok: r.status < 400, status: r.status, text: async () => r.body};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.stubGlobal('fetch', stubFetch({
|
|
61
|
+
[FEED_URL]: {status: 200, body: FEED},
|
|
62
|
+
[TXT_URL]: {status: 200, body: POST_TEXT},
|
|
63
|
+
[`${SITE_URL}/blog/introducing-astryx.txt`]: {status: 200, body: 'launch body'},
|
|
64
|
+
}));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.unstubAllGlobals();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('blog API', () => {
|
|
72
|
+
it('lists posts parsed from the canonical feed', async () => {
|
|
73
|
+
const res = await blog();
|
|
74
|
+
expect(res.type).toBe('blog.list');
|
|
75
|
+
expect(res.data.feedUrl).toBe(FEED_URL);
|
|
76
|
+
expect(res.data.posts.map(p => p.slug)).toEqual([
|
|
77
|
+
'how-astryx-works',
|
|
78
|
+
'introducing-astryx',
|
|
79
|
+
]);
|
|
80
|
+
expect(res.data.posts[0].authors).toEqual(['cvkxx', 'cixzhang']);
|
|
81
|
+
expect(res.data.posts[0].textUrl).toBe(TXT_URL);
|
|
82
|
+
// Entity decoding works (& -> &).
|
|
83
|
+
expect(res.data.posts[0].description).toBe('Under the hood & more');
|
|
84
|
+
// The list never fetches post bodies.
|
|
85
|
+
expect(res.data.posts[0].text).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reads a post via its plaintext alternate', async () => {
|
|
89
|
+
const res = await blog('how-astryx-works');
|
|
90
|
+
expect(res.type).toBe('blog.detail');
|
|
91
|
+
expect(res.data.text).toBe(POST_TEXT);
|
|
92
|
+
expect(res.data.feedUrl).toBe(FEED_URL);
|
|
93
|
+
expect(fetch).toHaveBeenCalledWith(TXT_URL, expect.any(Object));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('is case-insensitive on the slug', async () => {
|
|
97
|
+
const res = await blog('How-Astryx-Works');
|
|
98
|
+
expect(res.data.slug).toBe('how-astryx-works');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('throws ERR_UNKNOWN_POST with suggestions for a bad slug', async () => {
|
|
102
|
+
await expect(blog('does-not-exist')).rejects.toMatchObject({
|
|
103
|
+
code: 'ERR_UNKNOWN_POST',
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
await blog('does-not-exist');
|
|
107
|
+
} catch (e) {
|
|
108
|
+
expect(e).toBeInstanceOf(AstryxError);
|
|
109
|
+
expect(Array.isArray(e.suggestions)).toBe(true);
|
|
110
|
+
expect(e.suggestions.length).toBeGreaterThan(0);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('throws ERR_FETCH_FAILED when the feed request fails', async () => {
|
|
115
|
+
vi.stubGlobal('fetch', stubFetch({
|
|
116
|
+
[FEED_URL]: {status: 500, body: 'boom'},
|
|
117
|
+
}));
|
|
118
|
+
await expect(blog()).rejects.toMatchObject({code: 'ERR_FETCH_FAILED'});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses a post whose plaintext URL is on a different origin (SSRF guard)', async () => {
|
|
122
|
+
const evilFeed = FEED.replace(
|
|
123
|
+
`${SITE_URL}/blog/how-astryx-works.txt`,
|
|
124
|
+
'http://169.254.169.254/latest/meta-data',
|
|
125
|
+
);
|
|
126
|
+
vi.stubGlobal('fetch', stubFetch({
|
|
127
|
+
[FEED_URL]: {status: 200, body: evilFeed},
|
|
128
|
+
'http://169.254.169.254/latest/meta-data': {status: 200, body: 'SECRETS'},
|
|
129
|
+
}));
|
|
130
|
+
await expect(blog('how-astryx-works')).rejects.toMatchObject({
|
|
131
|
+
code: 'ERR_FETCH_FAILED',
|
|
132
|
+
});
|
|
133
|
+
// The internal host must never be fetched.
|
|
134
|
+
expect(fetch).not.toHaveBeenCalledWith(
|
|
135
|
+
'http://169.254.169.254/latest/meta-data',
|
|
136
|
+
expect.anything(),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('always reads from the canonical feed URL', async () => {
|
|
141
|
+
await blog();
|
|
142
|
+
expect(fetch).toHaveBeenCalledWith(FEED_URL, expect.any(Object));
|
|
143
|
+
});
|
|
144
|
+
});
|
package/src/api/doctor.mjs
CHANGED
|
@@ -240,7 +240,7 @@ export function checkThemes(ctx) {
|
|
|
240
240
|
label: 'Theme packages',
|
|
241
241
|
status: 'warn',
|
|
242
242
|
message: 'No @astryxdesign/theme-* packages are installed.',
|
|
243
|
-
fix: 'Install a theme, e.g. `npm install @astryxdesign/theme-neutral`, then import its CSS or set
|
|
243
|
+
fix: 'Install a theme, e.g. `npm install @astryxdesign/theme-neutral`, then import its CSS or set astryx.theme.',
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
246
|
|
|
@@ -354,7 +354,7 @@ export function checkAgentDocs(ctx) {
|
|
|
354
354
|
label: 'AI agent docs',
|
|
355
355
|
status: 'warn',
|
|
356
356
|
message: `Agent docs present (${present.join(', ')}) but no Astryx section markers found.`,
|
|
357
|
-
fix: 'Add the
|
|
357
|
+
fix: 'Add the Astryx section to your agent docs with `astryx init --features agents`.',
|
|
358
358
|
};
|
|
359
359
|
}
|
|
360
360
|
|
|
@@ -362,7 +362,7 @@ export function checkAgentDocs(ctx) {
|
|
|
362
362
|
id: 'agent-docs',
|
|
363
363
|
label: 'AI agent docs',
|
|
364
364
|
status: 'pass',
|
|
365
|
-
message: `
|
|
365
|
+
message: `Astryx agent docs section present in ${withMarkers.join(', ')}.`,
|
|
366
366
|
};
|
|
367
367
|
}
|
|
368
368
|
|
package/src/api/index.mjs
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
export {component} from './component.mjs';
|
|
23
23
|
export {docs} from './docs.mjs';
|
|
24
|
+
export {blog} from './blog.mjs';
|
|
24
25
|
export {discover} from './discover.mjs';
|
|
25
26
|
export {template} from './template.mjs';
|
|
26
27
|
export {themeAdd, listThemes} from './theme-add.mjs';
|