@astryxdesign/cli 0.1.2-canary.3f9afb0 → 0.1.2-canary.41cdf33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/package.json +7 -7
  2. package/src/api/blog.mjs +192 -0
  3. package/src/api/blog.test.mjs +144 -0
  4. package/src/api/index.mjs +1 -0
  5. package/src/commands/blog.mjs +60 -0
  6. package/src/commands/build-theme.mjs +117 -0
  7. package/src/commands/build-theme.watch.test.mjs +157 -0
  8. package/src/index.mjs +1 -0
  9. package/src/lib/error-codes.mjs +8 -0
  10. package/src/lib/site.mjs +20 -0
  11. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.doc.mjs +14 -0
  12. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +28 -0
  13. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.doc.mjs +14 -0
  14. package/templates/blocks/components/BreadcrumbItem/BreadcrumbItemBasic.tsx +15 -0
  15. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.doc.mjs +14 -0
  16. package/templates/blocks/components/ButtonGroup/ButtonGroupBasic.tsx +16 -0
  17. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.doc.mjs +19 -0
  18. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonBasic.tsx +34 -0
  19. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.doc.mjs +14 -0
  20. package/templates/blocks/components/CheckboxListItem/CheckboxListItemBasic.tsx +30 -0
  21. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.doc.mjs +14 -0
  22. package/templates/blocks/components/CollapsibleGroup/CollapsibleGroupAccordion.tsx +31 -0
  23. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.doc.mjs +14 -0
  24. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyBasic.tsx +26 -0
  25. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.doc.mjs +14 -0
  26. package/templates/blocks/components/CommandPaletteFooter/CommandPaletteFooterBasic.tsx +32 -0
  27. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.doc.mjs +18 -0
  28. package/templates/blocks/components/CommandPaletteGroup/CommandPaletteGroupBasic.tsx +32 -0
  29. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.doc.mjs +14 -0
  30. package/templates/blocks/components/CommandPaletteItem/CommandPaletteItemBasic.tsx +27 -0
  31. package/templates/blocks/components/ContextMenu/ContextMenuBasic.doc.mjs +14 -0
  32. package/templates/blocks/components/ContextMenu/ContextMenuBasic.tsx +32 -0
  33. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.doc.mjs +14 -0
  34. package/templates/blocks/components/DialogHeader/DialogHeaderBasic.tsx +30 -0
  35. package/templates/blocks/components/DropdownMenu/DropdownMenuShowcase.tsx +0 -5
  36. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.doc.mjs +14 -0
  37. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemBasic.tsx +27 -0
  38. package/templates/blocks/components/DropdownMenuItem/DropdownMenuItemShowcase.tsx +1 -4
  39. package/templates/blocks/components/FieldLabel/FieldLabelBasic.doc.mjs +14 -0
  40. package/templates/blocks/components/FieldLabel/FieldLabelBasic.tsx +20 -0
  41. package/templates/blocks/components/FieldStatus/FieldStatusBasic.doc.mjs +14 -0
  42. package/templates/blocks/components/FieldStatus/FieldStatusBasic.tsx +23 -0
  43. package/templates/blocks/components/FileInput/FileInputBasic.doc.mjs +14 -0
  44. package/templates/blocks/components/FileInput/FileInputBasic.tsx +22 -0
  45. package/templates/blocks/components/GridSpan/GridSpanColumns.doc.mjs +14 -0
  46. package/templates/blocks/components/GridSpan/GridSpanColumns.tsx +38 -0
  47. package/templates/blocks/components/HStack/HStackBasic.doc.mjs +14 -0
  48. package/templates/blocks/components/HStack/HStackBasic.tsx +16 -0
  49. package/templates/blocks/components/InputGroup/InputGroupBasic.doc.mjs +14 -0
  50. package/templates/blocks/components/InputGroup/InputGroupBasic.tsx +27 -0
  51. package/templates/blocks/components/LayoutContent/LayoutContentBasic.doc.mjs +23 -0
  52. package/templates/blocks/components/LayoutContent/LayoutContentBasic.tsx +40 -0
  53. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.doc.mjs +22 -0
  54. package/templates/blocks/components/LayoutFooter/LayoutFooterActions.tsx +41 -0
  55. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.doc.mjs +23 -0
  56. package/templates/blocks/components/LayoutHeader/LayoutHeaderWithActions.tsx +40 -0
  57. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.doc.mjs +22 -0
  58. package/templates/blocks/components/LayoutPanel/LayoutPanelNavigation.tsx +37 -0
  59. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.doc.mjs +14 -0
  60. package/templates/blocks/components/MetadataListItem/MetadataListItemBasic.tsx +19 -0
  61. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.doc.mjs +23 -0
  62. package/templates/blocks/components/MobileNavToggle/MobileNavToggleBasic.tsx +42 -0
  63. package/templates/blocks/components/MoreMenu/MoreMenuShowcase.tsx +0 -5
  64. package/templates/blocks/components/NavIcon/NavIconBasic.doc.mjs +14 -0
  65. package/templates/blocks/components/NavIcon/NavIconBasic.tsx +16 -0
  66. package/templates/blocks/components/RadioListItem/RadioListItemBasic.doc.mjs +14 -0
  67. package/templates/blocks/components/RadioListItem/RadioListItemBasic.tsx +30 -0
  68. package/templates/blocks/components/Resizable/ResizableSidebar.doc.mjs +25 -0
  69. package/templates/blocks/components/Resizable/ResizableSidebar.tsx +67 -0
  70. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.doc.mjs +14 -0
  71. package/templates/blocks/components/SegmentedControlItem/SegmentedControlItemBasic.tsx +21 -0
  72. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.doc.mjs +14 -0
  73. package/templates/blocks/components/SelectorOption/SelectorOptionBasic.tsx +39 -0
  74. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.doc.mjs +14 -0
  75. package/templates/blocks/components/SideNavCollapseButton/SideNavCollapseButtonBasic.tsx +55 -0
  76. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.doc.mjs +14 -0
  77. package/templates/blocks/components/SideNavHeading/SideNavHeadingBasic.tsx +38 -0
  78. package/templates/blocks/components/SideNavItem/SideNavItemBasic.doc.mjs +14 -0
  79. package/templates/blocks/components/SideNavItem/SideNavItemBasic.tsx +49 -0
  80. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.doc.mjs +14 -0
  81. package/templates/blocks/components/SideNavSection/SideNavSectionBasic.tsx +71 -0
  82. package/templates/blocks/components/StackItem/StackItemFill.doc.mjs +14 -0
  83. package/templates/blocks/components/StackItem/StackItemFill.tsx +28 -0
  84. package/templates/blocks/components/TabMenu/TabMenuBasic.doc.mjs +14 -0
  85. package/templates/blocks/components/TabMenu/TabMenuBasic.tsx +23 -0
  86. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.doc.mjs +14 -0
  87. package/templates/blocks/components/ToggleButtonGroup/ToggleButtonGroupVertical.tsx +47 -0
  88. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.doc.mjs +14 -0
  89. package/templates/blocks/components/TopNavHeading/TopNavHeadingBasic.tsx +22 -0
  90. package/templates/blocks/components/TopNavItem/TopNavItemBasic.doc.mjs +14 -0
  91. package/templates/blocks/components/TopNavItem/TopNavItemBasic.tsx +21 -0
  92. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.doc.mjs +20 -0
  93. package/templates/blocks/components/TopNavMegaMenu/TopNavMegaMenuBasic.tsx +46 -0
  94. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.doc.mjs +14 -0
  95. package/templates/blocks/components/TopNavMegaMenuFeaturedCard/TopNavMegaMenuFeaturedCardBasic.tsx +16 -0
  96. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.doc.mjs +14 -0
  97. package/templates/blocks/components/TopNavMegaMenuItem/TopNavMegaMenuItemBasic.tsx +26 -0
  98. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.doc.mjs +14 -0
  99. package/templates/blocks/components/TopNavMenu/TopNavMenuBasic.tsx +43 -0
  100. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.doc.mjs +14 -0
  101. package/templates/blocks/components/TypeaheadItem/TypeaheadItemBasic.tsx +43 -0
  102. package/templates/blocks/components/VStack/VStackBasic.doc.mjs +14 -0
  103. package/templates/blocks/components/VStack/VStackBasic.tsx +20 -0
  104. package/templates/pages/kanban-board/page.tsx +729 -0
  105. package/templates/pages/kanban-board/template.doc.mjs +12 -0
  106. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.doc.mjs +0 -14
  107. package/templates/blocks/components/TreeListBranches/TreeListBranchesShowcase.tsx +0 -64
  108. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.doc.mjs +0 -14
  109. package/templates/blocks/components/TreeListItem/TreeListItemShowcase.tsx +0 -60
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.1.2-canary.3f9afb0",
3
+ "version": "0.1.2-canary.41cdf33",
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.3f9afb0",
79
- "@astryxdesign/lab": "0.1.2-canary.3f9afb0",
80
- "@astryxdesign/theme-neutral": "0.1.2-canary.3f9afb0",
78
+ "@astryxdesign/core": "0.1.2-canary.41cdf33",
79
+ "@astryxdesign/lab": "0.1.2-canary.41cdf33",
80
+ "@astryxdesign/theme-neutral": "0.1.2-canary.41cdf33",
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.3f9afb0",
96
- "@astryxdesign/lab": "0.1.2-canary.3f9afb0",
97
- "@astryxdesign/theme-neutral": "0.1.2-canary.3f9afb0",
95
+ "@astryxdesign/core": "0.1.2-canary.41cdf33",
96
+ "@astryxdesign/lab": "0.1.2-canary.41cdf33",
97
+ "@astryxdesign/theme-neutral": "0.1.2-canary.41cdf33",
98
98
  "gpt-tokenizer": "^2.0.0"
99
99
  },
100
100
  "scripts": {
@@ -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(/&lt;/g, '<')
88
+ .replace(/&gt;/g, '>')
89
+ .replace(/&quot;/g, '"')
90
+ .replace(/&apos;/g, "'")
91
+ .replace(/&amp;/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 &amp; 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 (&amp; -> &).
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/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';
@@ -0,0 +1,60 @@
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
+ }
@@ -15,6 +15,7 @@
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import {pathToFileURL, fileURLToPath} from 'node:url';
18
+ import {spawn} from 'node:child_process';
18
19
  import {createJiti} from 'jiti';
19
20
  import {getRunPrefix} from '../utils/package-manager.mjs';
20
21
  import {
@@ -597,6 +598,104 @@ function validatePrivateVars(themeDef) {
597
598
  return errors;
598
599
  }
599
600
 
601
+ /**
602
+ * Path to this CLI's real entry (bin/astryx.mjs), resolved from this module's
603
+ * location (src/commands/build-theme.mjs → ../../bin/astryx.mjs). Used to
604
+ * re-invoke `theme build` as a child process in watch mode.
605
+ */
606
+ function resolveCliBin() {
607
+ const commandsDir = path.dirname(fileURLToPath(import.meta.url));
608
+ return path.resolve(commandsDir, '../../bin/astryx.mjs');
609
+ }
610
+
611
+ /**
612
+ * Run a single `theme build` as a child process, reusing the exact
613
+ * single-build code path (and its error handling) rather than duplicating it.
614
+ * Resolves with the child's exit code; never rejects.
615
+ *
616
+ * @param {string} file - The theme file argument, as the user passed it.
617
+ * @param {object} options - Parsed command options (only `out` is forwarded).
618
+ * @returns {Promise<number>}
619
+ */
620
+ function runThemeBuildOnceChild(file, options) {
621
+ const cliBin = resolveCliBin();
622
+ const args = [cliBin, 'theme', 'build', file];
623
+ if (options.out) args.push('--out', options.out);
624
+ return new Promise(resolve => {
625
+ const child = spawn(process.execPath, args, {
626
+ stdio: 'inherit',
627
+ env: process.env,
628
+ });
629
+ child.on('close', code => resolve(code ?? 0));
630
+ child.on('error', () => resolve(1));
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Watch a theme file and rebuild on change. Runs an initial build, then
636
+ * rebuilds (debounced) whenever the file changes, until interrupted with
637
+ * Ctrl-C. Each rebuild runs in a child process so a build error (which the
638
+ * single-build path reports via a hard exit) is contained and the watcher
639
+ * keeps running.
640
+ *
641
+ * @param {string} file - The theme file argument, as the user passed it.
642
+ * @param {string} filePath - Absolute path to the theme file.
643
+ * @param {object} options - Parsed command options.
644
+ * @returns {Promise<void>} Resolves when the watcher is stopped (Ctrl-C).
645
+ */
646
+ async function runThemeBuildWatch(file, filePath, options) {
647
+ const rel = path.relative(process.cwd(), filePath);
648
+
649
+ // Initial build.
650
+ await runThemeBuildOnceChild(file, options);
651
+
652
+ humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`);
653
+
654
+ let building = false;
655
+ let queued = false;
656
+ let debounce = null;
657
+
658
+ const rebuild = async () => {
659
+ if (building) {
660
+ // Coalesce changes that land mid-build into a single follow-up run.
661
+ queued = true;
662
+ return;
663
+ }
664
+ building = true;
665
+ humanLog(`\n♻️ Change detected — rebuilding ${rel}...`);
666
+ await runThemeBuildOnceChild(file, options);
667
+ building = false;
668
+ humanLog(`\n👀 Watching ${rel} for changes — press Ctrl-C to stop.`);
669
+ if (queued) {
670
+ queued = false;
671
+ rebuild();
672
+ }
673
+ };
674
+
675
+ // Some editors replace the file (rename) rather than writing in place, which
676
+ // can drop the watch. Watch the containing directory and filter to our file
677
+ // so edits survive atomic-save/rename.
678
+ const watchDir = path.dirname(filePath);
679
+ const baseName = path.basename(filePath);
680
+ const watcher = fs.watch(watchDir, (_eventType, changed) => {
681
+ if (changed && changed !== baseName) return;
682
+ clearTimeout(debounce);
683
+ // Debounce: editors often emit several events per save.
684
+ debounce = setTimeout(rebuild, 100);
685
+ });
686
+
687
+ await new Promise(resolve => {
688
+ const stop = () => {
689
+ clearTimeout(debounce);
690
+ watcher.close();
691
+ humanLog('\nStopped watching.');
692
+ resolve();
693
+ };
694
+ process.once('SIGINT', stop);
695
+ process.once('SIGTERM', stop);
696
+ });
697
+ }
698
+
600
699
  export function registerTheme(program) {
601
700
  const theme = program
602
701
  .command('theme')
@@ -621,6 +720,10 @@ export function registerTheme(program) {
621
720
  .command('build <file>')
622
721
  .description('Compile a defineTheme file to CSS + JS')
623
722
  .option('-o, --out <path>', 'Output CSS file path')
723
+ .option(
724
+ '-w, --watch',
725
+ 'Rebuild automatically when the theme file changes (Ctrl-C to stop)',
726
+ )
624
727
  .action(async (file, options) => {
625
728
  const filePath = path.resolve(process.cwd(), file);
626
729
  const json = program.opts().json || false;
@@ -630,6 +733,20 @@ export function registerTheme(program) {
630
733
  return;
631
734
  }
632
735
 
736
+ // Watch mode: run an initial build, then rebuild on every change to the
737
+ // theme file. Watch is a human-interactive, long-running mode — it is not
738
+ // supported in --json (machine) mode, which expects a single envelope.
739
+ if (options.watch) {
740
+ if (json) {
741
+ cliError('--watch is not supported with --json', {
742
+ code: ERROR_CODES.ERR_THEME_INVALID,
743
+ });
744
+ return;
745
+ }
746
+ await runThemeBuildWatch(file, filePath, options);
747
+ return;
748
+ }
749
+
633
750
  if (!json) humanLog(`\nBuilding theme from ${path.relative(process.cwd(), filePath)}...`);
634
751
 
635
752
  // Extract theme definition