beam-alpha 0.1.0
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/README.md +114 -0
- package/bin/blueprint.mjs +7 -0
- package/dist/beam.js +3272 -0
- package/dist/canvas/assets/AzeretMono-C5fM7CrN.woff2 +0 -0
- package/dist/canvas/assets/OpenRunde-Bold-DS-_1xH5.woff2 +0 -0
- package/dist/canvas/assets/OpenRunde-Medium-Krf-ZDqK.woff2 +0 -0
- package/dist/canvas/assets/OpenRunde-Regular-BZVnpUN1.woff2 +0 -0
- package/dist/canvas/assets/OpenRunde-Semibold-Nru5tuSm.woff2 +0 -0
- package/dist/canvas/assets/index-BIsbDeNY.css +1 -0
- package/dist/canvas/assets/index.embed-CE8oqNgH.js +230 -0
- package/dist/canvas/index.html +16 -0
- package/dist/cli.js +678 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +8663 -0
- package/dist/next/pin-loader.cjs +120 -0
- package/dist/next/source-loader.cjs +137 -0
- package/dist/next.d.ts +60 -0
- package/dist/next.js +8896 -0
- package/dist/overlay/overlay.css +1 -0
- package/dist/overlay/overlay.js +219 -0
- package/dist/probe.js +3084 -0
- package/dist/runtime/core.d.ts +43 -0
- package/dist/runtime/core.js +36 -0
- package/dist/runtime/core.prod.js +19 -0
- package/dist/runtime/react.d.ts +16 -0
- package/dist/runtime/react.js +48 -0
- package/dist/runtime/react.prod.js +17 -0
- package/package.json +67 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Blueprint</title>
|
|
6
|
+
<!-- No CSP meta here: this page is served by the user's own dev server
|
|
7
|
+
(`@blueprint/dev`), and Electron's policy would fight it. `main.tsx`'s
|
|
8
|
+
page keeps the locked-down one. -->
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
10
|
+
<script type="module" crossorigin src="/__blueprint/assets/index.embed-CE8oqNgH.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/__blueprint/assets/index-BIsbDeNY.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
3
|
+
import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
|
|
4
|
+
import { join as join3 } from "node:path";
|
|
5
|
+
|
|
6
|
+
// ../mcp/src/config.ts
|
|
7
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
var SERVER_NAME = "blueprint";
|
|
10
|
+
function entryFor(url) {
|
|
11
|
+
return { type: "http", url };
|
|
12
|
+
}
|
|
13
|
+
async function readJson(path) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function mergeConfig(config, url) {
|
|
21
|
+
const next = config ? { ...config } : {};
|
|
22
|
+
const servers = { ...next.mcpServers ?? {} };
|
|
23
|
+
const existing = servers[SERVER_NAME];
|
|
24
|
+
const desired = entryFor(url);
|
|
25
|
+
const changed = !existing || existing.url !== desired.url || existing.type !== desired.type;
|
|
26
|
+
servers[SERVER_NAME] = { ...existing, ...desired };
|
|
27
|
+
next.mcpServers = servers;
|
|
28
|
+
return { config: next, changed };
|
|
29
|
+
}
|
|
30
|
+
async function writeMerged(path, url) {
|
|
31
|
+
const current = await readJson(path);
|
|
32
|
+
const { config, changed } = mergeConfig(current, url);
|
|
33
|
+
if (!changed && current) return;
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
await writeFile(path, JSON.stringify(config, null, 2) + "\n");
|
|
36
|
+
}
|
|
37
|
+
async function writeClaudeCodeConfig(root, url) {
|
|
38
|
+
await writeMerged(join(root, ".mcp.json"), url);
|
|
39
|
+
}
|
|
40
|
+
function mergeClaudeSettings(settings) {
|
|
41
|
+
const next = settings ? { ...settings } : {};
|
|
42
|
+
let changed = false;
|
|
43
|
+
const disabled = next.disabledMcpjsonServers;
|
|
44
|
+
if (disabled?.includes(SERVER_NAME)) {
|
|
45
|
+
next.disabledMcpjsonServers = disabled.filter((n) => n !== SERVER_NAME);
|
|
46
|
+
changed = true;
|
|
47
|
+
}
|
|
48
|
+
const enabled = next.enabledMcpjsonServers ?? [];
|
|
49
|
+
if (!next.enableAllProjectMcpServers && !enabled.includes(SERVER_NAME)) {
|
|
50
|
+
next.enabledMcpjsonServers = [...enabled, SERVER_NAME];
|
|
51
|
+
changed = true;
|
|
52
|
+
}
|
|
53
|
+
return { settings: next, changed };
|
|
54
|
+
}
|
|
55
|
+
function claudeSettingsPath(root) {
|
|
56
|
+
return join(root, ".claude", "settings.local.json");
|
|
57
|
+
}
|
|
58
|
+
async function writeClaudeCodeTrust(root) {
|
|
59
|
+
const path = claudeSettingsPath(root);
|
|
60
|
+
const current = await readJson(path);
|
|
61
|
+
const { settings, changed } = mergeClaudeSettings(current);
|
|
62
|
+
if (!changed && current) return;
|
|
63
|
+
await mkdir(dirname(path), { recursive: true });
|
|
64
|
+
await writeFile(path, JSON.stringify(settings, null, 2) + "\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/generate.ts
|
|
68
|
+
import { parse } from "@babel/parser";
|
|
69
|
+
import MagicString from "magic-string";
|
|
70
|
+
import { createHash } from "node:crypto";
|
|
71
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
72
|
+
import { mkdir as mkdir2, readdir, readFile as readFile2, rename, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
73
|
+
import { dirname as dirname3, join as join2, posix, relative as relative2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
74
|
+
|
|
75
|
+
// ../shared/src/frames.ts
|
|
76
|
+
var UNGROUPED = "";
|
|
77
|
+
var VIEWPORT_PRESETS = [
|
|
78
|
+
{ id: "desktop", label: "Desktop", w: 1280, h: 800 },
|
|
79
|
+
{ id: "tablet", label: "Tablet", w: 834, h: 1194 },
|
|
80
|
+
{ id: "phone", label: "Phone", w: 390, h: 844 }
|
|
81
|
+
];
|
|
82
|
+
var DEFAULT_VIEWPORT = VIEWPORT_PRESETS[0];
|
|
83
|
+
|
|
84
|
+
// ../shared/src/sandbox.ts
|
|
85
|
+
var BLUEPRINT_DIR = ".blueprint";
|
|
86
|
+
var SANDBOX_DIR = `${BLUEPRINT_DIR}/sandbox`;
|
|
87
|
+
var SANDBOX_ENTRY = `${SANDBOX_DIR}/index.html`;
|
|
88
|
+
var SANDBOX_MARKER = "blueprint:sandbox-entry";
|
|
89
|
+
var BLUEPRINT_IGNORE = `# ${SANDBOX_MARKER}
|
|
90
|
+
# Blueprint's own folder: your canvas, your script choice, and any sandboxes
|
|
91
|
+
# your agent writes. All of it is local state rather than source, and deleting
|
|
92
|
+
# the folder leaves the project exactly as it was.
|
|
93
|
+
*
|
|
94
|
+
`;
|
|
95
|
+
var SANDBOX_SUFFIX = ".sandbox.tsx";
|
|
96
|
+
var SANDBOX_SOURCE_MARKER = "blueprint:source";
|
|
97
|
+
var SANDBOX_FORK_MARKER = "blueprint:fork";
|
|
98
|
+
function forkMarkerLine(lineage) {
|
|
99
|
+
return `// ${SANDBOX_FORK_MARKER} ${lineage.file}#${lineage.exportName} @${lineage.forkedAt}`;
|
|
100
|
+
}
|
|
101
|
+
var SANDBOX_LINEAGE_FILE = `${SANDBOX_DIR}/lineage.json`;
|
|
102
|
+
function sandboxFile(component) {
|
|
103
|
+
return `${SANDBOX_DIR}/${component}${SANDBOX_SUFFIX}`;
|
|
104
|
+
}
|
|
105
|
+
function selectionQuery(state) {
|
|
106
|
+
if (!state) return "";
|
|
107
|
+
if (typeof state === "string") return `&state=${encodeURIComponent(state)}`;
|
|
108
|
+
return Object.entries(state).map(
|
|
109
|
+
([group, option]) => group === UNGROUPED ? `&state=${encodeURIComponent(option)}` : `&v=${encodeURIComponent(`${group}:${option}`)}`
|
|
110
|
+
).join("");
|
|
111
|
+
}
|
|
112
|
+
var VIRTUAL_SANDBOX_BASE = "/__blueprint/sandbox";
|
|
113
|
+
function virtualSandboxRoute(component, state) {
|
|
114
|
+
const query = selectionQuery(state);
|
|
115
|
+
return `${VIRTUAL_SANDBOX_BASE}/${encodeURIComponent(component)}${query ? `?${query.slice(1)}` : ""}`;
|
|
116
|
+
}
|
|
117
|
+
var SANDBOX_PROBE = `${SANDBOX_DIR}/served.txt`;
|
|
118
|
+
var SANDBOX_CONTRACT = `// ${SANDBOX_DIR}/<Component>${SANDBOX_SUFFIX}
|
|
119
|
+
// ${SANDBOX_SOURCE_MARKER} <path to the real component, from the repo root>
|
|
120
|
+
|
|
121
|
+
// IMPORT the real component from its real path \u2014 never copy it. A copy is a
|
|
122
|
+
// fork, and "Move into the app" would become a merge instead of a delete.
|
|
123
|
+
// Node resolution walks up, so the project's own node_modules is shared.
|
|
124
|
+
import { Button } from '../../src/components/Button'
|
|
125
|
+
|
|
126
|
+
export default function Sandbox() { /* renders the component on its own */ }
|
|
127
|
+
|
|
128
|
+
// optional \u2014 each becomes an option in the card's variants picker.
|
|
129
|
+
// ALTERNATIVES (the card can only be in one) share an axis; leave the group off
|
|
130
|
+
// and they are the card's one unnamed axis:
|
|
131
|
+
export const states = [
|
|
132
|
+
{ name: 'Default', render: () => <Button>Save</Button> },
|
|
133
|
+
{ name: 'Loading', render: () => <Button loading>Save</Button> }
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
// ORTHOGONAL dimensions (every combination is a real thing to look at) are
|
|
137
|
+
// SEPARATE axes \u2014 one \`group\` each, one picker each, and the DEFAULT EXPORT
|
|
138
|
+
// renders the combination, because it is the only thing that sees all of it:
|
|
139
|
+
//
|
|
140
|
+
// export const states = [
|
|
141
|
+
// { group: 'Theme', name: 'Light' }, { group: 'Theme', name: 'Dark' },
|
|
142
|
+
// { group: 'Layout', name: 'Grid' }, { group: 'Layout', name: 'List' }
|
|
143
|
+
// ]
|
|
144
|
+
// export default function Sandbox({ variant }) {
|
|
145
|
+
// return <Button theme={variant.Theme} layout={variant.Layout} />
|
|
146
|
+
// }
|
|
147
|
+
//
|
|
148
|
+
// The test is mechanical: if the cross product is meaningful they are separate
|
|
149
|
+
// axes; if half of it is nonsense they were one axis all along. A union-typed
|
|
150
|
+
// prop IS an axis, and its default is the first option. At most 4 axes, at most
|
|
151
|
+
// 6 options each. An option carries its own \`render\` ONLY on a single-axis file.
|
|
152
|
+
|
|
153
|
+
// TWO RULES FOR THIS FOLDER
|
|
154
|
+
// 1. Write nothing outside ${SANDBOX_DIR}/. Blueprint checks with git after
|
|
155
|
+
// you report done, and a sandbox that touched the app says so on the card.
|
|
156
|
+
// 2. No NEW utility classes on your own wrappers. This folder is gitignored,
|
|
157
|
+
// and Tailwind skips gitignored paths \u2014 a className="p-6" here silently
|
|
158
|
+
// generates nothing. Use an inline style. The component's own classes come
|
|
159
|
+
// from src/ and work normally.`;
|
|
160
|
+
function sandboxReadme() {
|
|
161
|
+
return `<!-- ${SANDBOX_MARKER} -->
|
|
162
|
+
# ${SANDBOX_DIR}
|
|
163
|
+
|
|
164
|
+
Sandboxes \u2014 one file per component, each rendering that component **on its
|
|
165
|
+
own** so it can be looked at without the screen around it. Blueprint shows them
|
|
166
|
+
as cards. With \`@blueprint/dev\` installed they are served from a virtual
|
|
167
|
+
route (\`/__blueprint/sandbox/<Component>\`, no entry file anywhere) and
|
|
168
|
+
\`blueprint generate sandbox <Component>\` scaffolds one deterministically;
|
|
169
|
+
otherwise \`index.html\` beside this file is the page that serves them.
|
|
170
|
+
|
|
171
|
+
They are compiled by your own dev server with your own config: your aliases,
|
|
172
|
+
your tsconfig, your plugins, your providers and your component library all
|
|
173
|
+
apply here exactly as they do in the app. Each sandbox **imports** the real
|
|
174
|
+
component from its real path \u2014 never a copy, so tuning one here is tuning the
|
|
175
|
+
real thing and moving the work back is a move rather than a merge.
|
|
176
|
+
|
|
177
|
+
## The shape
|
|
178
|
+
|
|
179
|
+
\`\`\`tsx
|
|
180
|
+
// ${SANDBOX_DIR}/Button.sandbox.tsx
|
|
181
|
+
// ${SANDBOX_SOURCE_MARKER} src/components/Button.tsx
|
|
182
|
+
import { Button } from '../../src/components/Button'
|
|
183
|
+
|
|
184
|
+
export default function Sandbox() {
|
|
185
|
+
return <Button>Save</Button>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// optional \u2014 each becomes a state the card can switch to
|
|
189
|
+
export const states = [
|
|
190
|
+
{ name: 'Default', render: () => <Button>Save</Button> },
|
|
191
|
+
{ name: 'Loading', render: () => <Button disabled>Saving\u2026</Button> }
|
|
192
|
+
]
|
|
193
|
+
\`\`\`
|
|
194
|
+
|
|
195
|
+
## One thing to know about Tailwind
|
|
196
|
+
|
|
197
|
+
This folder is gitignored, and Tailwind v4 skips gitignored paths \u2014 so a
|
|
198
|
+
utility class used **only** in here generates nothing. The component's own
|
|
199
|
+
classes come from \`src/\` and work normally; it is only new classes on a
|
|
200
|
+
sandbox's own wrapper that don't, and an inline style is the answer. If you
|
|
201
|
+
would rather use utilities here, one line in your CSS opts the folder in:
|
|
202
|
+
|
|
203
|
+
\`\`\`css
|
|
204
|
+
@import 'tailwindcss';
|
|
205
|
+
@source '../${BLUEPRINT_DIR}';
|
|
206
|
+
\`\`\`
|
|
207
|
+
|
|
208
|
+
## Who writes what
|
|
209
|
+
|
|
210
|
+
Blueprint writes this README, the HTML entry and the ignore file, and nothing
|
|
211
|
+
else. The component code is written by your coding agent, so it follows your
|
|
212
|
+
conventions and you review it the way you review everything else. Blueprint
|
|
213
|
+
never edits a sandbox after it is written \u2014 tweaks stay as live overrides on
|
|
214
|
+
the card. Nothing either of them writes ever lands outside \`${BLUEPRINT_DIR}/\`,
|
|
215
|
+
and Blueprint checks with \`git status\` after every request rather than
|
|
216
|
+
trusting it.
|
|
217
|
+
|
|
218
|
+
## Deleting
|
|
219
|
+
|
|
220
|
+
Safe, always. Nothing in your app imports any of this. Remove a single file, or
|
|
221
|
+
the whole \`${BLUEPRINT_DIR}/\` folder, and the project is exactly as it was.
|
|
222
|
+
`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/sandboxShell.ts
|
|
226
|
+
var COMPONENT_RE = /^[A-Za-z0-9_-]+$/;
|
|
227
|
+
|
|
228
|
+
// src/scope.ts
|
|
229
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
230
|
+
import { dirname as dirname2, relative, resolve, sep } from "node:path";
|
|
231
|
+
function isWorkspaceRoot(dir) {
|
|
232
|
+
if (existsSync(resolve(dir, "pnpm-workspace.yaml")) || existsSync(resolve(dir, "lerna.json"))) return true;
|
|
233
|
+
try {
|
|
234
|
+
const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8"));
|
|
235
|
+
return Boolean(pkg.workspaces);
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function enclosingWorkspace(root) {
|
|
241
|
+
let outermost = root;
|
|
242
|
+
let current = root;
|
|
243
|
+
for (; ; ) {
|
|
244
|
+
if (isWorkspaceRoot(current) || existsSync(resolve(current, ".git"))) outermost = current;
|
|
245
|
+
const parent = dirname2(current);
|
|
246
|
+
if (parent === current) return outermost;
|
|
247
|
+
current = parent;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function projectScope(root) {
|
|
251
|
+
return { root, workspace: enclosingWorkspace(root) };
|
|
252
|
+
}
|
|
253
|
+
function within(dir, file) {
|
|
254
|
+
return file === dir || file.startsWith(dir + sep);
|
|
255
|
+
}
|
|
256
|
+
function inScope(scope, file) {
|
|
257
|
+
return within(scope.workspace, file) && !file.split(sep).includes("node_modules");
|
|
258
|
+
}
|
|
259
|
+
function resolveInScope(scope, address) {
|
|
260
|
+
const abs = resolve(scope.root, address);
|
|
261
|
+
return inScope(scope, abs) ? abs : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/generate.ts
|
|
265
|
+
var SCAN_FILE_CAP = 2e3;
|
|
266
|
+
var SCAN_DEPTH_CAP = 8;
|
|
267
|
+
var NEVER_SCAN = /* @__PURE__ */ new Set(["node_modules", "dist", "out", "build", "coverage", ".git", ".next", BLUEPRINT_DIR]);
|
|
268
|
+
async function generateSandbox(root, request) {
|
|
269
|
+
const component = request.component;
|
|
270
|
+
if (!COMPONENT_RE.test(component)) {
|
|
271
|
+
return { status: "error", component, detail: "Component names are [A-Za-z0-9_-]+." };
|
|
272
|
+
}
|
|
273
|
+
const target = join2(root, sandboxFile(component));
|
|
274
|
+
if (existsSync2(target)) {
|
|
275
|
+
return { status: "exists", component, file: sandboxFile(component), route: virtualSandboxRoute(component) };
|
|
276
|
+
}
|
|
277
|
+
const resolved = await resolveSource(root, component, request.source);
|
|
278
|
+
if (resolved.status !== "ok") return { component, ...resolved };
|
|
279
|
+
const { sourceRel, sourceAbs, text } = resolved;
|
|
280
|
+
const exportInfo = parseExport(text, component);
|
|
281
|
+
if (!exportInfo) {
|
|
282
|
+
return {
|
|
283
|
+
status: "not-exported",
|
|
284
|
+
component,
|
|
285
|
+
detail: `${sourceRel} doesn\u2019t export ${component}, so a sandbox can\u2019t import it. If it should be isolated anyway, ask the agent \u2014 copying an unexported component is a judgment call.`
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const body = request.fork ? forkBody(component, sourceRel, text, exportInfo) : importBody(component, sourceRel, request.props, exportInfo);
|
|
289
|
+
await mkdir2(dirname3(target), { recursive: true });
|
|
290
|
+
await ensureBoilerplate(root);
|
|
291
|
+
await writeAtomic(target, body);
|
|
292
|
+
if (request.fork) {
|
|
293
|
+
await recordLineage(root, component, { origin: hashOf(text), fork: hashOf(body) });
|
|
294
|
+
}
|
|
295
|
+
return { status: "created", component, file: sandboxFile(component), route: virtualSandboxRoute(component) };
|
|
296
|
+
}
|
|
297
|
+
async function resolveSource(root, component, source) {
|
|
298
|
+
if (source) {
|
|
299
|
+
const file = source.replace(/:\d+$/, "");
|
|
300
|
+
const scope = projectScope(root);
|
|
301
|
+
const abs = resolveInScope(scope, file);
|
|
302
|
+
if (abs === null) {
|
|
303
|
+
return {
|
|
304
|
+
status: "error",
|
|
305
|
+
detail: resolve2(root, file).split(sep2).includes("node_modules") ? "Sandboxes are for the project\u2019s own components, not node_modules." : `${file} is outside the project\u2019s workspace.`
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
let text;
|
|
309
|
+
try {
|
|
310
|
+
text = await readFile2(abs, "utf8");
|
|
311
|
+
} catch {
|
|
312
|
+
return { status: "not-found", detail: `${file} doesn\u2019t exist.` };
|
|
313
|
+
}
|
|
314
|
+
return { status: "ok", sourceRel: toPosix(relative2(root, abs)), sourceAbs: abs, text };
|
|
315
|
+
}
|
|
316
|
+
const shortlist = [];
|
|
317
|
+
let scanned = 0;
|
|
318
|
+
const walk = async (dir, depth) => {
|
|
319
|
+
if (depth > SCAN_DEPTH_CAP || scanned > SCAN_FILE_CAP) return;
|
|
320
|
+
let entries;
|
|
321
|
+
try {
|
|
322
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
323
|
+
} catch {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
if (scanned > SCAN_FILE_CAP) return;
|
|
328
|
+
if (entry.isDirectory()) {
|
|
329
|
+
if (!entry.name.startsWith(".") && !NEVER_SCAN.has(entry.name)) await walk(join2(dir, entry.name), depth + 1);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (!/\.(tsx|jsx)$/.test(entry.name)) continue;
|
|
333
|
+
scanned += 1;
|
|
334
|
+
const abs = join2(dir, entry.name);
|
|
335
|
+
let text;
|
|
336
|
+
try {
|
|
337
|
+
text = await readFile2(abs, "utf8");
|
|
338
|
+
} catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (!text.includes(component) || !text.includes("export")) continue;
|
|
342
|
+
if (parseExport(text, component)) shortlist.push({ rel: toPosix(relative2(root, abs)), abs, text });
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
await walk(root, 0);
|
|
346
|
+
if (scanned > SCAN_FILE_CAP) {
|
|
347
|
+
return {
|
|
348
|
+
status: "error",
|
|
349
|
+
detail: `The project is too big to scan (${SCAN_FILE_CAP}+ files) \u2014 name the component\u2019s file.`
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (shortlist.length === 0) {
|
|
353
|
+
return { status: "not-found", detail: `No .tsx/.jsx file under the project exports ${component}.` };
|
|
354
|
+
}
|
|
355
|
+
if (shortlist.length > 1) {
|
|
356
|
+
return {
|
|
357
|
+
status: "ambiguous",
|
|
358
|
+
detail: `${shortlist.length} files export ${component} \u2014 name one.`,
|
|
359
|
+
candidates: shortlist.map((c) => c.rel)
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const only = shortlist[0];
|
|
363
|
+
return { status: "ok", sourceRel: only.rel, sourceAbs: only.abs, text: only.text };
|
|
364
|
+
}
|
|
365
|
+
function parseExport(text, component) {
|
|
366
|
+
let program;
|
|
367
|
+
try {
|
|
368
|
+
const ast = parse(text, { sourceType: "module", plugins: ["jsx", "typescript"] });
|
|
369
|
+
program = ast.program.body;
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
let found = null;
|
|
374
|
+
let hasDefault = false;
|
|
375
|
+
for (const node of program) {
|
|
376
|
+
if (node.type === "ExportDefaultDeclaration") {
|
|
377
|
+
hasDefault = true;
|
|
378
|
+
const decl2 = node["declaration"];
|
|
379
|
+
const name = decl2.type === "FunctionDeclaration" || decl2.type === "ClassDeclaration" ? decl2["id"]?.["name"] : decl2.type === "Identifier" ? decl2["name"] : void 0;
|
|
380
|
+
if (name === component) found ??= { importKind: "default", localName: component };
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (node.type !== "ExportNamedDeclaration") continue;
|
|
384
|
+
const decl = node["declaration"];
|
|
385
|
+
if (decl) {
|
|
386
|
+
if ((decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration") && decl["id"]?.["name"] === component) {
|
|
387
|
+
found ??= { importKind: "named", localName: component };
|
|
388
|
+
}
|
|
389
|
+
if (decl.type === "VariableDeclaration") {
|
|
390
|
+
for (const d of decl["declarations"]) {
|
|
391
|
+
const id = d["id"];
|
|
392
|
+
if (id.type === "Identifier" && id["name"] === component) found ??= { importKind: "named", localName: component };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const spec of node["specifiers"] ?? []) {
|
|
397
|
+
if (spec.type !== "ExportSpecifier") continue;
|
|
398
|
+
const exported = spec["exported"];
|
|
399
|
+
const exportedName = exported.type === "Identifier" ? exported["name"] : exported["value"];
|
|
400
|
+
if (exportedName !== component) continue;
|
|
401
|
+
const local = spec["local"];
|
|
402
|
+
const localName = local.type === "Identifier" ? local["name"] : null;
|
|
403
|
+
found ??= { importKind: "named", localName: node["source"] ? null : localName };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return found ? { ...found, hasDefault } : null;
|
|
407
|
+
}
|
|
408
|
+
function importSpecifier(sourceRel) {
|
|
409
|
+
const fromDir = posix.join(...SANDBOX_DIR.split("/"));
|
|
410
|
+
const spec = posix.relative(fromDir, sourceRel).replace(/\.(tsx|jsx|ts|js)$/, "");
|
|
411
|
+
return spec.startsWith(".") ? spec : `./${spec}`;
|
|
412
|
+
}
|
|
413
|
+
function propsBrief(props) {
|
|
414
|
+
const entries = Object.entries(props ?? {});
|
|
415
|
+
if (entries.length === 0) return [];
|
|
416
|
+
return [
|
|
417
|
+
"// Props it was rendered with when it was pointed at \u2014 legible strings,",
|
|
418
|
+
"// captured live, worth more than anything invented:",
|
|
419
|
+
...entries.map(([k, v]) => `// ${k}=${v}`)
|
|
420
|
+
];
|
|
421
|
+
}
|
|
422
|
+
function importBody(component, sourceRel, props, exportInfo) {
|
|
423
|
+
const spec = importSpecifier(sourceRel);
|
|
424
|
+
const importLine = exportInfo.importKind === "default" ? `import ${component} from '${spec}'` : `import { ${component} } from '${spec}'`;
|
|
425
|
+
return [
|
|
426
|
+
`// ${SANDBOX_DIR}/${component}${SANDBOX_SUFFIX}`,
|
|
427
|
+
`// ${SANDBOX_SOURCE_MARKER} ${sourceRel}`,
|
|
428
|
+
"//",
|
|
429
|
+
"// Generated by Blueprint \u2014 the mechanical half. The judgment half is the",
|
|
430
|
+
"// bp:fill slots below: realistic props, the axes this component varies",
|
|
431
|
+
"// along, and any tunables declared with useParam.",
|
|
432
|
+
importLine,
|
|
433
|
+
"",
|
|
434
|
+
...propsBrief(props),
|
|
435
|
+
`export default function Sandbox() {`,
|
|
436
|
+
props && Object.keys(props).length > 0 ? ` // bp:fill props \u2014 give ${component} what it really renders with (captured above).` : ` // bp:fill props \u2014 give ${component} realistic props, in its most representative state.`,
|
|
437
|
+
` return <${component} />`,
|
|
438
|
+
`}`,
|
|
439
|
+
"",
|
|
440
|
+
"// bp:fill states \u2014 the AXES this component varies along, not a flat list",
|
|
441
|
+
"// of combinations. Same `group` = alternatives (the card can only be in",
|
|
442
|
+
"// one: Default, Loading, Error). Different groups = orthogonal, one picker",
|
|
443
|
+
"// each \u2014 { group: 'Theme', name: 'Dark' } \u2014 and then the DEFAULT EXPORT",
|
|
444
|
+
"// renders the combination from its `variant` prop, because it is the only",
|
|
445
|
+
"// thing that sees all of it. A union-typed prop IS an axis and its default",
|
|
446
|
+
"// is the first option; at most 4 axes, at most 6 options each.",
|
|
447
|
+
`export const states = [{ name: 'Default', render: () => <${component} /> }]`,
|
|
448
|
+
""
|
|
449
|
+
].join("\n");
|
|
450
|
+
}
|
|
451
|
+
function forkBody(component, sourceRel, text, exportInfo) {
|
|
452
|
+
const header = [
|
|
453
|
+
`// ${SANDBOX_DIR}/${component}${SANDBOX_SUFFIX}`,
|
|
454
|
+
`// ${SANDBOX_SOURCE_MARKER} ${sourceRel}`,
|
|
455
|
+
forkMarkerLine({ file: sourceRel, exportName: component, forkedAt: (/* @__PURE__ */ new Date()).toISOString() }),
|
|
456
|
+
"//",
|
|
457
|
+
"// A FORK \u2014 this file is a copy, and the card tracks its drift from the",
|
|
458
|
+
"// origin. The way back is Merge (land the changes) or Propose (a PR).",
|
|
459
|
+
""
|
|
460
|
+
].join("\n");
|
|
461
|
+
const rewritten = rewriteRelativeImports(text, sourceRel);
|
|
462
|
+
const lines = [];
|
|
463
|
+
if (exportInfo.localName) {
|
|
464
|
+
if (!exportInfo.hasDefault) {
|
|
465
|
+
lines.push("", `export default function Sandbox() {`, ` return <${exportInfo.localName} />`, `}`);
|
|
466
|
+
}
|
|
467
|
+
lines.push(
|
|
468
|
+
"",
|
|
469
|
+
`export const states = [{ name: 'Default', render: () => <${exportInfo.localName} /> }]`,
|
|
470
|
+
""
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return header + rewritten + lines.join("\n");
|
|
474
|
+
}
|
|
475
|
+
function rewriteRelativeImports(text, sourceRel) {
|
|
476
|
+
let program;
|
|
477
|
+
try {
|
|
478
|
+
const ast = parse(text, { sourceType: "module", plugins: ["jsx", "typescript"] });
|
|
479
|
+
program = ast.program.body;
|
|
480
|
+
} catch {
|
|
481
|
+
return text;
|
|
482
|
+
}
|
|
483
|
+
const sourceDir = posix.dirname(sourceRel);
|
|
484
|
+
const sandboxDir = posix.join(...SANDBOX_DIR.split("/"));
|
|
485
|
+
const ms = new MagicString(text);
|
|
486
|
+
let touched = false;
|
|
487
|
+
for (const node of program) {
|
|
488
|
+
if (node.type !== "ImportDeclaration" && node.type !== "ExportNamedDeclaration" && node.type !== "ExportAllDeclaration")
|
|
489
|
+
continue;
|
|
490
|
+
const src = node["source"];
|
|
491
|
+
if (!src || typeof src.value !== "string") continue;
|
|
492
|
+
if (!src.value.startsWith("./") && !src.value.startsWith("../")) continue;
|
|
493
|
+
const absolute = posix.join(sourceDir, src.value);
|
|
494
|
+
let next = posix.relative(sandboxDir, absolute);
|
|
495
|
+
if (!next.startsWith(".")) next = `./${next}`;
|
|
496
|
+
ms.overwrite(src.start, src.end, JSON.stringify(next).replace(/"/g, "'"));
|
|
497
|
+
touched = true;
|
|
498
|
+
}
|
|
499
|
+
return touched ? ms.toString() : text;
|
|
500
|
+
}
|
|
501
|
+
function hashOf(text) {
|
|
502
|
+
return createHash("sha1").update(text).digest("hex");
|
|
503
|
+
}
|
|
504
|
+
async function readLineage(root) {
|
|
505
|
+
try {
|
|
506
|
+
const raw = JSON.parse(await readFile2(join2(root, SANDBOX_LINEAGE_FILE), "utf8"));
|
|
507
|
+
if (typeof raw !== "object" || raw === null) return {};
|
|
508
|
+
const out = {};
|
|
509
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
510
|
+
const rec = value;
|
|
511
|
+
if (typeof rec?.origin === "string" && typeof rec?.fork === "string")
|
|
512
|
+
out[key] = { origin: rec.origin, fork: rec.fork };
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
} catch {
|
|
516
|
+
return {};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async function recordLineage(root, component, record) {
|
|
520
|
+
const all = await readLineage(root);
|
|
521
|
+
all[component] = record;
|
|
522
|
+
await writeAtomic(join2(root, SANDBOX_LINEAGE_FILE), JSON.stringify(all, null, 2) + "\n");
|
|
523
|
+
}
|
|
524
|
+
async function ensureBoilerplate(root) {
|
|
525
|
+
await mkdir2(join2(root, SANDBOX_DIR), { recursive: true });
|
|
526
|
+
await writeOwned(join2(root, BLUEPRINT_DIR, ".gitignore"), BLUEPRINT_IGNORE);
|
|
527
|
+
await writeOwned(join2(root, SANDBOX_DIR, "README.md"), sandboxReadme());
|
|
528
|
+
}
|
|
529
|
+
async function writeOwned(path, content) {
|
|
530
|
+
let existing = null;
|
|
531
|
+
try {
|
|
532
|
+
existing = await readFile2(path, "utf8");
|
|
533
|
+
} catch {
|
|
534
|
+
existing = null;
|
|
535
|
+
}
|
|
536
|
+
if (existing === content) return;
|
|
537
|
+
if (existing !== null && !existing.includes(SANDBOX_MARKER)) return;
|
|
538
|
+
await writeFile2(path, content, "utf8");
|
|
539
|
+
}
|
|
540
|
+
async function writeAtomic(path, content) {
|
|
541
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
542
|
+
await writeFile2(tmp, content, "utf8");
|
|
543
|
+
await rename(tmp, path);
|
|
544
|
+
}
|
|
545
|
+
function toPosix(p) {
|
|
546
|
+
return p.split(sep2).join("/");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/cli.ts
|
|
550
|
+
var USAGE = [
|
|
551
|
+
"Usage:",
|
|
552
|
+
" blueprint init [--port <n>]",
|
|
553
|
+
" blueprint generate sandbox <Component> [--source <file>] [--fork]"
|
|
554
|
+
].join("\n");
|
|
555
|
+
async function runCli(argv) {
|
|
556
|
+
const [command] = argv;
|
|
557
|
+
if (command === "init") return runInit(argv.slice(1));
|
|
558
|
+
if (command === "generate") return runGenerate(argv.slice(1));
|
|
559
|
+
console.error(USAGE);
|
|
560
|
+
return 2;
|
|
561
|
+
}
|
|
562
|
+
function nextRouteFile(root) {
|
|
563
|
+
const app = existsSync3(join3(root, "src", "app")) && !existsSync3(join3(root, "app")) ? join3("src", "app") : "app";
|
|
564
|
+
return join3(app, "api", "blueprint", "[[...path]]", "route.ts");
|
|
565
|
+
}
|
|
566
|
+
var NEXT_ROUTE_SOURCE = `export { GET, POST, PUT, DELETE } from '@blueprint/dev/next'
|
|
567
|
+
export const dynamic = 'force-dynamic'
|
|
568
|
+
`;
|
|
569
|
+
async function runInit(rest) {
|
|
570
|
+
const root = process.cwd();
|
|
571
|
+
const isNext = ["next.config.ts", "next.config.mts", "next.config.js", "next.config.mjs", "next.config.cjs"].some(
|
|
572
|
+
(name) => existsSync3(join3(root, name))
|
|
573
|
+
);
|
|
574
|
+
let port = isNext ? 3e3 : 5173;
|
|
575
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
576
|
+
const arg = rest[i];
|
|
577
|
+
if (arg === "--port" && rest[i + 1]) {
|
|
578
|
+
const parsed = Number.parseInt(rest[i + 1], 10);
|
|
579
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
580
|
+
console.error(`--port needs a port number, got ${JSON.stringify(rest[i + 1])}.`);
|
|
581
|
+
return 2;
|
|
582
|
+
}
|
|
583
|
+
port = parsed;
|
|
584
|
+
i += 1;
|
|
585
|
+
} else {
|
|
586
|
+
console.error(`Unknown argument: ${arg}`);
|
|
587
|
+
console.error(USAGE);
|
|
588
|
+
return 2;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const url = `http://localhost:${port}/__blueprint/mcp`;
|
|
592
|
+
await writeClaudeCodeConfig(root, url);
|
|
593
|
+
await writeClaudeCodeTrust(root);
|
|
594
|
+
await ensureBoilerplate(root);
|
|
595
|
+
console.log(`Wrote .mcp.json \u2014 your agent connects to ${url}`);
|
|
596
|
+
console.log("Wrote .claude/settings.local.json \u2014 Claude Code starts without a trust prompt.");
|
|
597
|
+
if (isNext) {
|
|
598
|
+
const route = nextRouteFile(root);
|
|
599
|
+
const file = join3(root, route);
|
|
600
|
+
if (existsSync3(file)) console.log(`${route} is already there \u2014 left as it is.`);
|
|
601
|
+
else {
|
|
602
|
+
await mkdir3(join3(file, ".."), { recursive: true });
|
|
603
|
+
await writeFile3(file, NEXT_ROUTE_SOURCE, "utf8");
|
|
604
|
+
console.log(`Wrote ${route} \u2014 the Blueprint route handler.`);
|
|
605
|
+
}
|
|
606
|
+
console.log("");
|
|
607
|
+
console.log("Wrap your Next config:");
|
|
608
|
+
console.log("");
|
|
609
|
+
console.log(" import { withBlueprint } from '@blueprint/dev/next'");
|
|
610
|
+
console.log("");
|
|
611
|
+
console.log(" export default withBlueprint({ /* your config */ })");
|
|
612
|
+
console.log("");
|
|
613
|
+
console.log("And put the probe in the root layout\u2019s <head>:");
|
|
614
|
+
console.log("");
|
|
615
|
+
console.log(" import { Beam } from '@blueprint/dev/next'");
|
|
616
|
+
console.log("");
|
|
617
|
+
console.log(" <head><Beam /></head>");
|
|
618
|
+
console.log("");
|
|
619
|
+
console.log(
|
|
620
|
+
`Then run your dev server on this machine, open your app and press \u2325\u2318B \u2014 or open http://localhost:${port}/__blueprint for the canvas. If the server binds a different port, the host corrects .mcp.json on first request.`
|
|
621
|
+
);
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
console.log("");
|
|
625
|
+
console.log("Add the plugin (first in the array) to your vite config:");
|
|
626
|
+
console.log("");
|
|
627
|
+
console.log(" import { blueprint } from '@blueprint/dev'");
|
|
628
|
+
console.log("");
|
|
629
|
+
console.log(" export default defineConfig({");
|
|
630
|
+
console.log(" plugins: [blueprint() /* , react(), tailwindcss(), \u2026 */]");
|
|
631
|
+
console.log(" })");
|
|
632
|
+
console.log("");
|
|
633
|
+
console.log(
|
|
634
|
+
`Then run your dev server on this machine, open your app and press \u2325\u2318B \u2014 or open http://localhost:${port}/__blueprint for the canvas. If the server binds a different port, the plugin corrects .mcp.json on boot.`
|
|
635
|
+
);
|
|
636
|
+
return 0;
|
|
637
|
+
}
|
|
638
|
+
async function runGenerate(rest) {
|
|
639
|
+
const [kind, component, ...args] = rest;
|
|
640
|
+
if (kind !== "sandbox" || !component || component.startsWith("-")) {
|
|
641
|
+
console.error(USAGE);
|
|
642
|
+
return 2;
|
|
643
|
+
}
|
|
644
|
+
const request = { component };
|
|
645
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
646
|
+
const arg = args[i];
|
|
647
|
+
if (arg === "--fork") request.fork = true;
|
|
648
|
+
else if (arg === "--source" && args[i + 1]) {
|
|
649
|
+
request.source = args[i + 1];
|
|
650
|
+
i += 1;
|
|
651
|
+
} else {
|
|
652
|
+
console.error(`Unknown argument: ${arg}`);
|
|
653
|
+
return 2;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const result = await generateSandbox(process.cwd(), request);
|
|
657
|
+
switch (result.status) {
|
|
658
|
+
case "created":
|
|
659
|
+
console.log(`Created ${result.file}${request.fork ? " (fork, lineage recorded)" : ""}`);
|
|
660
|
+
console.log(`Serving at ${result.route} once the dev server runs with the blueprint() plugin.`);
|
|
661
|
+
return 0;
|
|
662
|
+
case "exists":
|
|
663
|
+
console.log(`${result.file} already exists \u2014 the generator never overwrites.`);
|
|
664
|
+
console.log(`Serving at ${result.route}.`);
|
|
665
|
+
return 0;
|
|
666
|
+
case "ambiguous":
|
|
667
|
+
console.error(result.detail ?? "Several files export that name.");
|
|
668
|
+
for (const candidate of result.candidates ?? []) console.error(` ${candidate}`);
|
|
669
|
+
console.error(`Run again with --source <file>.`);
|
|
670
|
+
return 1;
|
|
671
|
+
default:
|
|
672
|
+
console.error(result.detail ?? `Refused: ${result.status}.`);
|
|
673
|
+
return 1;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
export {
|
|
677
|
+
runCli
|
|
678
|
+
};
|