@astryxdesign/cli 0.1.2-canary.4d9726d → 0.1.2-canary.4eb9849
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/docs/principles.doc.dense.mjs +2 -2
- package/docs/principles.doc.mjs +0 -4
- package/docs/principles.doc.zh.mjs +2 -2
- package/package.json +7 -7
- package/src/api/index.mjs +0 -1
- package/src/commands/agent-docs.mjs +0 -3
- package/src/commands/build-theme.mjs +0 -117
- package/src/index.mjs +0 -1
- package/src/lib/error-codes.mjs +0 -8
- package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +5 -0
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +4 -1
- package/templates/blocks/components/Lightbox/LightboxShowcase.tsx +3 -4
- package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +5 -0
- package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +14 -0
- package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +64 -0
- package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +14 -0
- package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +60 -0
- package/docs/layout.doc.dense.mjs +0 -90
- package/docs/layout.doc.mjs +0 -160
- package/src/api/blog.mjs +0 -192
- package/src/api/blog.test.mjs +0 -144
- package/src/commands/blog.mjs +0 -60
- package/src/commands/build-theme.watch.test.mjs +0 -157
- package/src/lib/site.mjs +0 -20
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +0 -14
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +0 -28
- package/templates/blocks/components/Blockquote/BlockquoteTestimonials.doc.mjs +0 -14
- package/templates/blocks/components/Blockquote/BlockquoteTestimonials.tsx +0 -34
- package/templates/blocks/components/Blockquote/BlockquoteWithCite.doc.mjs +0 -14
- package/templates/blocks/components/Blockquote/BlockquoteWithCite.tsx +0 -21
- package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +0 -15
- package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +0 -14
- package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +0 -16
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +0 -19
- package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +0 -34
- package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +0 -30
- package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +0 -14
- package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +0 -31
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +0 -14
- package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +0 -26
- package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +0 -14
- package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +0 -32
- package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +0 -18
- package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +0 -32
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +0 -27
- package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +0 -14
- package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +0 -32
- package/templates/blocks/components/DialogHeader/DialogHeaderBasic.doc.mjs +0 -14
- package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +0 -30
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +0 -27
- package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +0 -14
- package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +0 -20
- package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +0 -14
- package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +0 -23
- package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +0 -14
- package/templates/blocks/components/FileInput/FileInputBasic.tsx +0 -22
- package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +0 -14
- package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +0 -38
- package/templates/blocks/components/HStack/HStackBasic.doc.mjs +0 -14
- package/templates/blocks/components/HStack/HStackBasic.tsx +0 -16
- package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.doc.mjs +0 -14
- package/templates/blocks/components/Hooks/useKeyboardHintHookUsage.tsx +0 -57
- package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +0 -14
- package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +0 -27
- package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +0 -23
- package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +0 -40
- package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +0 -22
- package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +0 -41
- package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +0 -23
- package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +0 -40
- package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +0 -22
- package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +0 -37
- package/templates/blocks/components/Lightbox/LightboxGallery.doc.mjs +0 -14
- package/templates/blocks/components/Lightbox/LightboxGallery.tsx +0 -53
- package/templates/blocks/components/Lightbox/LightboxVideo.doc.mjs +0 -14
- package/templates/blocks/components/Lightbox/LightboxVideo.tsx +0 -27
- package/templates/blocks/components/Lightbox/LightboxZoom.doc.mjs +0 -14
- package/templates/blocks/components/Lightbox/LightboxZoom.tsx +0 -33
- package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +0 -19
- package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +0 -23
- package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +0 -42
- package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +0 -14
- package/templates/blocks/components/NavIcon/NavIconBasic.tsx +0 -16
- package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +0 -30
- package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +0 -25
- package/templates/blocks/components/Resizable/ResizableSidebar.tsx +0 -67
- package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +0 -21
- package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +0 -14
- package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +0 -39
- package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +0 -14
- package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +0 -55
- package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +0 -14
- package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +0 -38
- package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +0 -49
- package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +0 -14
- package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +0 -71
- package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +0 -14
- package/templates/blocks/components/StackItem/StackItemFill.tsx +0 -28
- package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +0 -14
- package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +0 -23
- package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +0 -14
- package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +0 -47
- package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +0 -14
- package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +0 -22
- package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +0 -21
- package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +0 -20
- package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +0 -46
- package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +0 -14
- package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +0 -16
- package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +0 -26
- package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +0 -14
- package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +0 -43
- package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +0 -14
- package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +0 -43
- package/templates/blocks/components/VStack/VStackBasic.doc.mjs +0 -14
- package/templates/blocks/components/VStack/VStackBasic.tsx +0 -20
- package/templates/pages/kanban-board/page.tsx +0 -729
- package/templates/pages/kanban-board/template.doc.mjs +0 -12
package/docs/layout.doc.mjs
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/** @type {import('../../core/src/docs-types').ReferenceDoc} */
|
|
4
|
-
|
|
5
|
-
export const docs = {
|
|
6
|
-
name: 'layout',
|
|
7
|
-
title: 'Layout',
|
|
8
|
-
category: 'guide',
|
|
9
|
-
description:
|
|
10
|
-
'Frame-first app layout: choosing a shell, budgeting regions, and when to use cards vs rows.',
|
|
11
|
-
|
|
12
|
-
sections: [
|
|
13
|
-
{
|
|
14
|
-
title: 'Frame First',
|
|
15
|
-
content: [
|
|
16
|
-
{
|
|
17
|
-
type: 'prose',
|
|
18
|
-
text: 'Decide the frame before writing any content. Real applications are built top-down: pick the shell, name its regions, give each region an explicit size budget, then fill regions with content. Content-first layout (writing sections and wrapping each one in a Card) produces a padded scroll column that reads as a prototype, not a product.',
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
type: 'list',
|
|
22
|
-
style: 'ordered',
|
|
23
|
-
items: [
|
|
24
|
-
'Pick the frame: AppShell (top nav and/or side nav apps), Layout + LayoutPanel + LayoutContent (multi-pane tools like explorers and consoles), or a plain content column (documents, marketing, forms)',
|
|
25
|
-
'Budget regions in px before filling them: side nav 240–280, icon rail 64–72, detail/inspector panel 340–420, filter/facet rail 220–260',
|
|
26
|
-
'Decide the container policy per region: dense data renders as rows; widget dashboards and galleries render as card grids (see Cards vs Rows)',
|
|
27
|
-
'Write the responsive contract up front: which regions collapse, overlay, or drop at which breakpoints (see Responsive Contract)',
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
type: 'code',
|
|
32
|
-
lang: 'tsx',
|
|
33
|
-
label: 'A three-region tool frame',
|
|
34
|
-
code: `// Frame: nav 256 | content flex | inspector 380 (resizable)
|
|
35
|
-
<AppShell sideNav={<SideNav>{/* nav items */}</SideNav>} contentPadding={0}>
|
|
36
|
-
<Layout>
|
|
37
|
-
<LayoutContent>{/* dense list or table, edge-to-edge */}</LayoutContent>
|
|
38
|
-
<LayoutPanel width={380} resizable={{minSizePx: 320, maxSizePx: 480}} hasDivider>
|
|
39
|
-
{/* inspector for the selected row */}
|
|
40
|
-
</LayoutPanel>
|
|
41
|
-
</Layout>
|
|
42
|
-
</AppShell>`,
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
title: 'App Archetypes',
|
|
48
|
-
content: [
|
|
49
|
-
{
|
|
50
|
-
type: 'prose',
|
|
51
|
-
text: 'Match the frame and container policy to the kind of app you are building. These recipes are distilled from product-scale apps built with the design system; container choice tracks the archetype, not personal preference.',
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
type: 'table',
|
|
55
|
-
headers: ['Archetype', 'Frame', 'Container policy'],
|
|
56
|
-
rows: [
|
|
57
|
-
[
|
|
58
|
-
'Tracker / work tool (issues, tickets, CRM)',
|
|
59
|
-
'AppShell + SideNav; inspector LayoutPanel on select',
|
|
60
|
-
'Rows only. Grouped edge-to-edge lists, zero cards',
|
|
61
|
-
],
|
|
62
|
-
[
|
|
63
|
-
'Console / observability (metrics, logs, deploys)',
|
|
64
|
-
'AppShell + SideNav or TopNav + TabList',
|
|
65
|
-
'Card grid for dashboard widgets; Table for everything else',
|
|
66
|
-
],
|
|
67
|
-
[
|
|
68
|
-
'Messaging / feed',
|
|
69
|
-
'Column frame: rail + sidebar + stream + panel',
|
|
70
|
-
'Rows and bubbles. No cards in the stream',
|
|
71
|
-
],
|
|
72
|
-
[
|
|
73
|
-
'Media library / gallery',
|
|
74
|
-
'AppShell + TopNav; grid content',
|
|
75
|
-
'Card grid (ClickableCard) with dense metadata rows in detail views',
|
|
76
|
-
],
|
|
77
|
-
[
|
|
78
|
-
'Settings / forms',
|
|
79
|
-
'AppShell + SideNav or settings template',
|
|
80
|
-
'Sections with FormLayout; Card only to group dangerous or billing actions',
|
|
81
|
-
],
|
|
82
|
-
],
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
type: 'prose',
|
|
86
|
-
text: 'Start from a template that matches the archetype (`npx astryx template --list`), then study its structure with `--skeleton` before customizing.',
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
title: 'Cards vs Rows',
|
|
92
|
-
content: [
|
|
93
|
-
{
|
|
94
|
-
type: 'prose',
|
|
95
|
-
text: 'Card is a widget container, not a list-item wrapper. The fastest way to make an app look like a generic AI prototype is to wrap every record in a Card with a Badge. Dense data — anything the user scans, filters, or selects — belongs in rows: Table for columnar data, List/Item for single-line records, edge-to-edge with dividers and 32–40px row height.',
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
type: 'list',
|
|
99
|
-
style: 'do',
|
|
100
|
-
items: [
|
|
101
|
-
'Table (with selection/sorting plugins) for columnar records: hosts, deployments, monitors, users',
|
|
102
|
-
'List/Item rows for scannable single-line records: issues, files, conversations',
|
|
103
|
-
'Card for self-contained widgets: KPI tiles, chart panels, gallery entries, settings groups',
|
|
104
|
-
'EmptyState inside the region when a filter matches nothing',
|
|
105
|
-
],
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
type: 'list',
|
|
109
|
-
style: 'dont',
|
|
110
|
-
items: [
|
|
111
|
-
'Wrapping each list item in a Card (card soup)',
|
|
112
|
-
'Stacking full-width Cards as a substitute for page structure',
|
|
113
|
-
'Nesting Cards inside Cards',
|
|
114
|
-
'Using Badge as decoration — reserve it for counts and enumerated states; use StatusDot or Token for status and metadata',
|
|
115
|
-
],
|
|
116
|
-
},
|
|
117
|
-
],
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
title: 'Panels and Inspectors',
|
|
121
|
-
content: [
|
|
122
|
-
{
|
|
123
|
-
type: 'prose',
|
|
124
|
-
text: 'Master-detail is the backbone of tool UIs: selecting a row opens a fixed-width inspector panel rather than navigating away. Use LayoutPanel in the end slot with an explicit width budget; add resizable (useResizable) for user control, and let the panel overlay the content region below ~1024px instead of compressing it.',
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
type: 'code',
|
|
128
|
-
lang: 'tsx',
|
|
129
|
-
label: 'Inspector that overlays at narrow widths',
|
|
130
|
-
code: `<LayoutPanel
|
|
131
|
-
width={380}
|
|
132
|
-
hasDivider
|
|
133
|
-
isScrollable
|
|
134
|
-
label="Details"
|
|
135
|
-
resizable={{minSizePx: 320, maxSizePx: 480, autoSaveId: 'inspector'}}>
|
|
136
|
-
{selected ? <DetailFields item={selected} /> : <EmptyState title="Nothing selected" />}
|
|
137
|
-
</LayoutPanel>`,
|
|
138
|
-
},
|
|
139
|
-
],
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
title: 'Responsive Contract',
|
|
143
|
-
content: [
|
|
144
|
-
{
|
|
145
|
-
type: 'prose',
|
|
146
|
-
text: 'Declare breakpoint behavior as a contract before building, and keep it in a comment at the frame root. A typical contract: full frame above 1024px; inspector panels overlay the content column at 1024px and below; the side nav collapses into MobileNav at 768px and below. Deciding this up front keeps every region change intentional instead of emergent.',
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
type: 'code',
|
|
150
|
-
lang: 'tsx',
|
|
151
|
-
label: 'Contract comment at the frame root',
|
|
152
|
-
code: `// Responsive contract:
|
|
153
|
-
// > 1024px nav 256 | content | inspector 380
|
|
154
|
-
// <= 1024px inspector overlays content (position: absolute, end-aligned)
|
|
155
|
-
// <= 768px nav collapses into MobileNav drawer; toolbar actions wrap`,
|
|
156
|
-
},
|
|
157
|
-
],
|
|
158
|
-
},
|
|
159
|
-
],
|
|
160
|
-
};
|
package/src/api/blog.mjs
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
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
|
-
}
|
package/src/api/blog.test.mjs
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
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/commands/blog.mjs
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @file blog command (hidden) — read the Astryx blog from the published feed.
|
|
5
|
-
*
|
|
6
|
-
* Hidden on purpose: it's not part of the documented command surface and does
|
|
7
|
-
* not appear in --help or the manifest. It reads the blog the same way any
|
|
8
|
-
* feed reader would — over the public RSS feed — and prints a post's plaintext
|
|
9
|
-
* (.txt) variant. Nothing about the blog's structure has to change for this to
|
|
10
|
-
* work; the CLI is just a consumer of the feed.
|
|
11
|
-
*
|
|
12
|
-
* astryx blog List posts from the feed
|
|
13
|
-
* astryx blog <slug> Print a post as plaintext
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import {getRunPrefix} from '../utils/package-manager.mjs';
|
|
17
|
-
import {humanLog} from '../lib/json.mjs';
|
|
18
|
-
import {cliError} from '../lib/cli-error.mjs';
|
|
19
|
-
import {blog as blogApi} from '../api/blog.mjs';
|
|
20
|
-
|
|
21
|
-
function formatList({feedUrl, posts}, run) {
|
|
22
|
-
const lines = [`\nAstryx blog · feed: ${feedUrl}\n`];
|
|
23
|
-
if (posts.length === 0) {
|
|
24
|
-
lines.push('No posts found in the feed.');
|
|
25
|
-
return lines.join('\n');
|
|
26
|
-
}
|
|
27
|
-
for (const p of posts) {
|
|
28
|
-
lines.push(` ${p.slug}`);
|
|
29
|
-
lines.push(` ${p.title}`);
|
|
30
|
-
if (p.type) lines.push(` ${p.type}`);
|
|
31
|
-
if (p.textUrl) lines.push(` ${p.textUrl}`);
|
|
32
|
-
lines.push('');
|
|
33
|
-
}
|
|
34
|
-
lines.push(`Read one: ${run} astryx blog <slug>`);
|
|
35
|
-
return lines.join('\n');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function registerBlog(program) {
|
|
39
|
-
program
|
|
40
|
-
.command('blog [slug]', {hidden: true})
|
|
41
|
-
.description('Read the Astryx blog from the published feed')
|
|
42
|
-
.action(async slug => {
|
|
43
|
-
const run = getRunPrefix();
|
|
44
|
-
let result;
|
|
45
|
-
try {
|
|
46
|
-
result = await blogApi(slug);
|
|
47
|
-
} catch (e) {
|
|
48
|
-
cliError(e.message, {suggestions: e.suggestions || [], code: e.code});
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (result.type === 'blog.list') {
|
|
53
|
-
humanLog(formatList(result.data, run));
|
|
54
|
-
} else {
|
|
55
|
-
// blog.detail — print the feed URL, then the plaintext body.
|
|
56
|
-
humanLog(`Feed: ${result.data.feedUrl}\n`);
|
|
57
|
-
humanLog(result.data.text);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
}
|