truecanvas 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/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/catalog-worker.js +7 -0
- package/dist/chunk-753N4QRO.js +317 -0
- package/dist/chunk-J6CJBZO6.js +23 -0
- package/dist/chunk-WB35FXGP.js +3366 -0
- package/dist/cli.js +2931 -0
- package/dist/core/index.d.ts +820 -0
- package/dist/core/index.js +71 -0
- package/dist/editor/assets/controls-BQYSgX53.css +1 -0
- package/dist/editor/assets/controls-BYFjuGCq.js +9 -0
- package/dist/editor/assets/hub-C4_700Hv.js +2 -0
- package/dist/editor/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/editor/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/editor/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/editor/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/editor/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/editor/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/editor/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/editor/assets/main-CfAJbDf1.js +25 -0
- package/dist/editor/assets/react-DB-4Zxce.js +1 -0
- package/dist/editor/favicon.svg +3 -0
- package/dist/editor/hub.html +16 -0
- package/dist/editor/index.html +16 -0
- package/dist/next/index.d.ts +11 -0
- package/dist/next/index.js +83 -0
- package/dist/next/loader.cjs +16210 -0
- package/dist/runtime/host.d.ts +23 -0
- package/dist/runtime/host.js +412 -0
- package/dist/runtime/index.d.ts +42 -0
- package/dist/runtime/index.js +11 -0
- package/package.json +108 -0
|
@@ -0,0 +1,3366 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Catalog,
|
|
3
|
+
importSpecifier
|
|
4
|
+
} from "./chunk-753N4QRO.js";
|
|
5
|
+
|
|
6
|
+
// src/core/config.ts
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
function loadConfig(root, overrides = {}) {
|
|
10
|
+
let file = {};
|
|
11
|
+
const configPath = path.join(root, "truecanvas.config.json");
|
|
12
|
+
if (fs.existsSync(configPath)) file = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
13
|
+
const appDir = file.appDir ?? (fs.existsSync(path.join(root, "src/app")) ? "src/app" : "app");
|
|
14
|
+
const srcPrefix = appDir.startsWith("src/") ? "src/" : "";
|
|
15
|
+
let deps = {};
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
18
|
+
deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
19
|
+
} catch {
|
|
20
|
+
deps = {};
|
|
21
|
+
}
|
|
22
|
+
const knownLibraries = ["@paper-design/shaders-react"].filter((l) => l in deps);
|
|
23
|
+
return {
|
|
24
|
+
root,
|
|
25
|
+
appUrl: "http://localhost:3000",
|
|
26
|
+
port: 4800,
|
|
27
|
+
canvasDir: `${srcPrefix}canvas`,
|
|
28
|
+
appDir,
|
|
29
|
+
components: [`${srcPrefix}components/**/*.tsx`],
|
|
30
|
+
darkMode: { strategy: "class", value: "dark" },
|
|
31
|
+
...file,
|
|
32
|
+
libraries: [.../* @__PURE__ */ new Set([...knownLibraries, ...file.libraries ?? []])],
|
|
33
|
+
...stripUndefined(overrides)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function stripUndefined(o) {
|
|
37
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/core/ast.ts
|
|
41
|
+
import { parse } from "@babel/parser";
|
|
42
|
+
function parseModule(source) {
|
|
43
|
+
return parse(source, {
|
|
44
|
+
sourceType: "module",
|
|
45
|
+
plugins: ["typescript", "jsx"],
|
|
46
|
+
errorRecovery: false
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function idOf(node) {
|
|
50
|
+
const loc = node.loc.start;
|
|
51
|
+
return `${loc.line}:${loc.column}`;
|
|
52
|
+
}
|
|
53
|
+
function jsxName(name) {
|
|
54
|
+
if (name.type === "JSXIdentifier") return name.name;
|
|
55
|
+
if (name.type === "JSXNamespacedName") return `${name.namespace.name}:${name.name.name}`;
|
|
56
|
+
return `${jsxName(name.object)}.${name.property.name}`;
|
|
57
|
+
}
|
|
58
|
+
function walk(node, visit, parent = null) {
|
|
59
|
+
if (!node || typeof node !== "object") return;
|
|
60
|
+
if (Array.isArray(node)) {
|
|
61
|
+
for (const child of node) walk(child, visit, parent);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const n = node;
|
|
65
|
+
if (typeof n.type !== "string") return;
|
|
66
|
+
if (visit(n, parent) === false) return;
|
|
67
|
+
for (const key of Object.keys(n)) {
|
|
68
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "extra") continue;
|
|
69
|
+
const value = n[key];
|
|
70
|
+
if (value && typeof value === "object") walk(value, visit, n);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function isComponentName(name) {
|
|
74
|
+
return /^[A-Z]/.test(name) || name.includes(".");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/core/parse.ts
|
|
78
|
+
function parseCanvas(name, file, source) {
|
|
79
|
+
let ast;
|
|
80
|
+
try {
|
|
81
|
+
ast = parseModule(source);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { name, file, frames: [], imports: [], canvasNode: null, prologueEnd: 0, declared: [], error: err.message };
|
|
84
|
+
}
|
|
85
|
+
const imports = readImports(ast);
|
|
86
|
+
const directives = ast.program.directives;
|
|
87
|
+
const prologueEnd = directives.length ? directives[directives.length - 1].end : 0;
|
|
88
|
+
const declared = topLevelNames(ast);
|
|
89
|
+
let canvasEl = null;
|
|
90
|
+
walk(ast, (node) => {
|
|
91
|
+
if (canvasEl) return false;
|
|
92
|
+
if (node.type === "JSXElement" && jsxName(node.openingElement.name) === "Canvas") {
|
|
93
|
+
canvasEl = node;
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (!canvasEl) {
|
|
98
|
+
return { name, file, frames: [], imports, canvasNode: null, prologueEnd, declared, error: "No <Canvas> element found in the default export." };
|
|
99
|
+
}
|
|
100
|
+
const canvasNode = buildNode(canvasEl, source, []);
|
|
101
|
+
const frames = [];
|
|
102
|
+
let frameIndex = 0;
|
|
103
|
+
for (const child of canvasNode.children) {
|
|
104
|
+
if (child.kind !== "component" || child.name !== "Frame") continue;
|
|
105
|
+
const path12 = [frameIndex++];
|
|
106
|
+
reindex(child, path12);
|
|
107
|
+
frames.push({
|
|
108
|
+
...child,
|
|
109
|
+
frameName: str(child.props.name) ?? `Frame ${frameIndex}`,
|
|
110
|
+
x: num(child.props.x) ?? 0,
|
|
111
|
+
y: num(child.props.y) ?? 0,
|
|
112
|
+
width: num(child.props.width) ?? 1024,
|
|
113
|
+
height: num(child.props.height) ?? null,
|
|
114
|
+
theme: str(child.props.theme) ?? null,
|
|
115
|
+
device: str(child.props.device) ?? null,
|
|
116
|
+
page: str(child.props.page) ?? null,
|
|
117
|
+
from: str(child.props.from) ?? null,
|
|
118
|
+
component: str(child.props.component) ?? null,
|
|
119
|
+
link: null
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { name, file, frames, imports, canvasNode, prologueEnd, declared };
|
|
123
|
+
}
|
|
124
|
+
function readImports(ast) {
|
|
125
|
+
const imports = [];
|
|
126
|
+
for (const stmt of ast.program.body) {
|
|
127
|
+
if (stmt.type !== "ImportDeclaration") continue;
|
|
128
|
+
const typeOnly = stmt.importKind === "type";
|
|
129
|
+
const named = stmt.specifiers.filter((s) => s.type === "ImportSpecifier");
|
|
130
|
+
const def = stmt.specifiers.find((s) => s.type === "ImportDefaultSpecifier");
|
|
131
|
+
const ns = stmt.specifiers.find((s) => s.type === "ImportNamespaceSpecifier");
|
|
132
|
+
imports.push({
|
|
133
|
+
source: stmt.source.value,
|
|
134
|
+
typeOnly,
|
|
135
|
+
// value bindings only: `import type` and `{ type X }` don't exist at runtime
|
|
136
|
+
names: typeOnly ? [] : [...named.filter((s) => s.importKind !== "type").map((s) => s.local.name), ...ns ? [ns.local.name] : []],
|
|
137
|
+
defaultName: def ? def.local.name : null,
|
|
138
|
+
start: stmt.start,
|
|
139
|
+
end: stmt.end,
|
|
140
|
+
lastSpecifierEnd: named.length && !typeOnly ? named[named.length - 1].end : null
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return imports;
|
|
144
|
+
}
|
|
145
|
+
function topLevelNames(ast) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let stmt of ast.program.body) {
|
|
148
|
+
if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportDefaultDeclaration") && stmt.declaration) stmt = stmt.declaration;
|
|
149
|
+
if ((stmt.type === "FunctionDeclaration" || stmt.type === "ClassDeclaration") && stmt.id) out.push(stmt.id.name);
|
|
150
|
+
if (stmt.type === "VariableDeclaration") {
|
|
151
|
+
for (const d of stmt.declarations) if (d.id.type === "Identifier") out.push(d.id.name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function reindex(node, path12) {
|
|
157
|
+
node.path = path12;
|
|
158
|
+
node.children.forEach((c, i) => reindex(c, [...path12, i]));
|
|
159
|
+
}
|
|
160
|
+
function str(v) {
|
|
161
|
+
return v?.kind === "string" ? v.value : void 0;
|
|
162
|
+
}
|
|
163
|
+
function num(v) {
|
|
164
|
+
return v?.kind === "number" ? v.value : void 0;
|
|
165
|
+
}
|
|
166
|
+
function buildNode(el, source, path12) {
|
|
167
|
+
const children = [];
|
|
168
|
+
for (const child of el.children) {
|
|
169
|
+
const built = buildChild(child, source, [...path12, children.length]);
|
|
170
|
+
if (built) children.push(built);
|
|
171
|
+
}
|
|
172
|
+
if (el.type === "JSXFragment") {
|
|
173
|
+
return {
|
|
174
|
+
id: idOf(el),
|
|
175
|
+
kind: "fragment",
|
|
176
|
+
name: "Fragment",
|
|
177
|
+
props: {},
|
|
178
|
+
spreads: 0,
|
|
179
|
+
children,
|
|
180
|
+
start: el.start,
|
|
181
|
+
end: el.end,
|
|
182
|
+
line: el.loc.start.line,
|
|
183
|
+
col: el.loc.start.column,
|
|
184
|
+
path: path12,
|
|
185
|
+
attrs: [],
|
|
186
|
+
nameEnd: el.openingFragment.end - 1,
|
|
187
|
+
openEnd: el.openingFragment.end,
|
|
188
|
+
selfClosing: false,
|
|
189
|
+
closeStart: el.closingFragment.start
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const open = el.openingElement;
|
|
193
|
+
const name = jsxName(open.name);
|
|
194
|
+
const props = {};
|
|
195
|
+
const attrs = [];
|
|
196
|
+
let spreads = 0;
|
|
197
|
+
for (const attr of open.attributes) {
|
|
198
|
+
if (attr.type === "JSXSpreadAttribute") {
|
|
199
|
+
spreads++;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const attrName = attr.name.type === "JSXIdentifier" ? attr.name.name : `${attr.name.namespace.name}:${attr.name.name.name}`;
|
|
203
|
+
if (attrName === "data-tc") continue;
|
|
204
|
+
const literal = classLiteral(attr.value);
|
|
205
|
+
attrs.push({ name: attrName, start: attr.start, end: attr.end, ...literal ? { literal: { start: literal.start, end: literal.end } } : {} });
|
|
206
|
+
props[attrName] = literal ? { kind: "string", value: literal.type === "StringLiteral" ? literal.value : literal.quasis.map((q) => q.value.cooked ?? "").join("") } : readValue(attr.value, source);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
id: idOf(open),
|
|
210
|
+
kind: isComponentName(name) ? "component" : "element",
|
|
211
|
+
name,
|
|
212
|
+
props,
|
|
213
|
+
spreads,
|
|
214
|
+
children,
|
|
215
|
+
start: el.start,
|
|
216
|
+
end: el.end,
|
|
217
|
+
line: open.loc.start.line,
|
|
218
|
+
col: open.loc.start.column,
|
|
219
|
+
path: path12,
|
|
220
|
+
attrs,
|
|
221
|
+
// after type arguments: `<Select<Opt> …>`
|
|
222
|
+
nameEnd: (open.typeParameters ?? open.name).end,
|
|
223
|
+
openEnd: open.end,
|
|
224
|
+
selfClosing: open.selfClosing,
|
|
225
|
+
closeStart: el.closingElement ? el.closingElement.start : open.end
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function buildChild(child, source, path12) {
|
|
229
|
+
if (child.type === "JSXElement" || child.type === "JSXFragment") return buildNode(child, source, path12);
|
|
230
|
+
if (child.type === "JSXText") {
|
|
231
|
+
const text = child.value.replace(/\s+/g, " ").trim();
|
|
232
|
+
if (!text) return null;
|
|
233
|
+
return leaf("text", text, child, path12, { text });
|
|
234
|
+
}
|
|
235
|
+
if (child.type === "JSXExpressionContainer") {
|
|
236
|
+
const expr = child.expression;
|
|
237
|
+
if (expr.type === "JSXEmptyExpression") return null;
|
|
238
|
+
if (expr.type === "StringLiteral") return leaf("text", expr.value, child, path12, { text: expr.value });
|
|
239
|
+
if (expr.type === "TemplateLiteral" && expr.expressions.length === 0) {
|
|
240
|
+
const text = expr.quasis.map((q) => q.value.cooked ?? "").join("");
|
|
241
|
+
return leaf("text", text, child, path12, { text });
|
|
242
|
+
}
|
|
243
|
+
return leaf("expression", source.slice(expr.start, expr.end), child, path12, {});
|
|
244
|
+
}
|
|
245
|
+
if (child.type === "JSXSpreadChild") return leaf("expression", source.slice(child.start, child.end), child, path12, {});
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
function leaf(kind, name, node, path12, extra) {
|
|
249
|
+
return {
|
|
250
|
+
id: `${kind[0]}${idOf(node)}`,
|
|
251
|
+
kind,
|
|
252
|
+
name,
|
|
253
|
+
props: {},
|
|
254
|
+
spreads: 0,
|
|
255
|
+
children: [],
|
|
256
|
+
...extra,
|
|
257
|
+
start: node.start,
|
|
258
|
+
end: node.end,
|
|
259
|
+
line: node.loc.start.line,
|
|
260
|
+
col: node.loc.start.column,
|
|
261
|
+
path: path12,
|
|
262
|
+
attrs: [],
|
|
263
|
+
nameEnd: node.end,
|
|
264
|
+
openEnd: node.end,
|
|
265
|
+
selfClosing: true,
|
|
266
|
+
closeStart: node.end
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
var CLASS_HELPERS = /* @__PURE__ */ new Set(["cn", "clsx", "cx", "twMerge", "classNames", "classnames"]);
|
|
270
|
+
function classLiteral(value) {
|
|
271
|
+
if (value?.type !== "JSXExpressionContainer") return null;
|
|
272
|
+
const e = value.expression;
|
|
273
|
+
if (e.type !== "CallExpression" || e.callee.type !== "Identifier" || !CLASS_HELPERS.has(e.callee.name)) return null;
|
|
274
|
+
const first = e.arguments[0];
|
|
275
|
+
if (first?.type === "StringLiteral") return first;
|
|
276
|
+
if (first?.type === "TemplateLiteral" && first.expressions.length === 0) return first;
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
function readValue(value, source) {
|
|
280
|
+
if (value == null) return { kind: "boolean", value: true };
|
|
281
|
+
if (value.type === "StringLiteral") return { kind: "string", value: value.value };
|
|
282
|
+
if (value.type === "JSXExpressionContainer") {
|
|
283
|
+
const e = value.expression;
|
|
284
|
+
if (e.type === "StringLiteral") return { kind: "string", value: e.value };
|
|
285
|
+
if (e.type === "NumericLiteral") return { kind: "number", value: e.value };
|
|
286
|
+
if (e.type === "BooleanLiteral") return { kind: "boolean", value: e.value };
|
|
287
|
+
if (e.type === "UnaryExpression" && e.operator === "-" && e.argument.type === "NumericLiteral") {
|
|
288
|
+
return { kind: "number", value: -e.argument.value };
|
|
289
|
+
}
|
|
290
|
+
if (e.type === "TemplateLiteral" && e.expressions.length === 0) {
|
|
291
|
+
return { kind: "string", value: e.quasis.map((q) => q.value.cooked ?? "").join("") };
|
|
292
|
+
}
|
|
293
|
+
if (e.type === "ArrayExpression") {
|
|
294
|
+
const items = [];
|
|
295
|
+
for (const el of e.elements) {
|
|
296
|
+
if (el?.type === "StringLiteral" || el?.type === "NumericLiteral" || el?.type === "BooleanLiteral") items.push(el.value);
|
|
297
|
+
else if (el?.type === "UnaryExpression" && el.operator === "-" && el.argument.type === "NumericLiteral") items.push(-el.argument.value);
|
|
298
|
+
else return { kind: "expression", code: source.slice(e.start, e.end) };
|
|
299
|
+
}
|
|
300
|
+
return { kind: "array", value: items };
|
|
301
|
+
}
|
|
302
|
+
if (e.type === "JSXEmptyExpression") return { kind: "expression", code: "" };
|
|
303
|
+
return { kind: "expression", code: source.slice(e.start, e.end) };
|
|
304
|
+
}
|
|
305
|
+
return { kind: "expression", code: source.slice(value.start, value.end) };
|
|
306
|
+
}
|
|
307
|
+
function* allNodes(doc) {
|
|
308
|
+
for (const frame of doc.frames) {
|
|
309
|
+
yield { node: frame, parent: null, frame };
|
|
310
|
+
const stack = frame.children.map((c) => ({ node: c, parent: frame })).reverse();
|
|
311
|
+
while (stack.length) {
|
|
312
|
+
const item = stack.pop();
|
|
313
|
+
yield { ...item, frame };
|
|
314
|
+
for (let i = item.node.children.length - 1; i >= 0; i--) stack.push({ node: item.node.children[i], parent: item.node });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function findNode(doc, id) {
|
|
319
|
+
for (const entry of allNodes(doc)) if (entry.node.id === id) return entry;
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
function findFrame(doc, ref) {
|
|
323
|
+
return doc.frames.find((f) => f.id === ref || f.frameName === ref) ?? null;
|
|
324
|
+
}
|
|
325
|
+
function findByPath(doc, path12) {
|
|
326
|
+
if (!path12.length) return null;
|
|
327
|
+
let node = doc.frames[path12[0]];
|
|
328
|
+
for (const i of path12.slice(1)) node = node?.children[i];
|
|
329
|
+
return node ?? null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/core/edit.ts
|
|
333
|
+
import MagicString from "magic-string";
|
|
334
|
+
var EditError = class extends Error {
|
|
335
|
+
};
|
|
336
|
+
function attrCode(name, value) {
|
|
337
|
+
if (value === true) return name;
|
|
338
|
+
if (typeof value === "string") {
|
|
339
|
+
return /["\n\\{}&]/.test(value) ? `${name}={${JSON.stringify(value)}}` : `${name}="${value}"`;
|
|
340
|
+
}
|
|
341
|
+
if (typeof value === "number" && !Number.isFinite(value)) throw new EditError(`Invalid number for ${name}.`);
|
|
342
|
+
if (Array.isArray(value)) return `${name}={[${value.map((v) => JSON.stringify(v)).join(", ")}]}`;
|
|
343
|
+
return `${name}={${JSON.stringify(value)}}`;
|
|
344
|
+
}
|
|
345
|
+
var PROP_NAME = /^[A-Za-z_$][\w$-]*(:[\w$-]+)?$/;
|
|
346
|
+
function textCode(text) {
|
|
347
|
+
if (text === "") return "";
|
|
348
|
+
if (/[{}<>&\n]/.test(text) || /^\s|\s$/.test(text)) return `{${JSON.stringify(text)}}`;
|
|
349
|
+
return text;
|
|
350
|
+
}
|
|
351
|
+
var CanvasEditor = class {
|
|
352
|
+
constructor(source, doc) {
|
|
353
|
+
this.source = source;
|
|
354
|
+
this.doc = doc;
|
|
355
|
+
this.s = new MagicString(source);
|
|
356
|
+
}
|
|
357
|
+
source;
|
|
358
|
+
doc;
|
|
359
|
+
s;
|
|
360
|
+
/** Path of the node that should be selected after the edit (best effort, before formatting). */
|
|
361
|
+
selectPaths = [];
|
|
362
|
+
usedFrameNames = null;
|
|
363
|
+
placed = null;
|
|
364
|
+
node(id) {
|
|
365
|
+
const found = findNode(this.doc, id);
|
|
366
|
+
if (!found) throw new EditError(`Node ${id} not found. Re-read the canvas, ids change after every edit.`);
|
|
367
|
+
return found;
|
|
368
|
+
}
|
|
369
|
+
frame(ref) {
|
|
370
|
+
const f = findFrame(this.doc, ref);
|
|
371
|
+
if (!f) throw new EditError(`Frame "${ref}" not found. Available: ${this.doc.frames.map((x) => `"${x.frameName}"`).join(", ")}`);
|
|
372
|
+
return f;
|
|
373
|
+
}
|
|
374
|
+
/** A frame id/name or a node id: whatever an agent passes as "parent". */
|
|
375
|
+
container(ref) {
|
|
376
|
+
const f = findFrame(this.doc, ref);
|
|
377
|
+
if (f) return f;
|
|
378
|
+
const n = this.node(ref).node;
|
|
379
|
+
if (n.kind === "text" || n.kind === "expression") throw new EditError(`${ref} is a ${n.kind} node and cannot have children.`);
|
|
380
|
+
return n;
|
|
381
|
+
}
|
|
382
|
+
setProp(node, name, value, opts = {}) {
|
|
383
|
+
if (node.kind !== "component" && node.kind !== "element") throw new EditError(`Cannot set props on a ${node.kind} node.`);
|
|
384
|
+
if (!PROP_NAME.test(name) || name === "data-tc") throw new EditError(`Invalid prop name "${name}".`);
|
|
385
|
+
const existing = node.attrs.find((a) => a.name === name);
|
|
386
|
+
if (existing?.literal && opts.raw === void 0 && (typeof value === "string" || value === null)) {
|
|
387
|
+
this.s.overwrite(existing.literal.start, existing.literal.end, JSON.stringify(value ?? ""));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const code = opts.raw !== void 0 ? `${name}={${opts.raw}}` : value === null || value === void 0 ? null : attrCode(name, value);
|
|
391
|
+
if (existing) {
|
|
392
|
+
if (code === null) {
|
|
393
|
+
let start = existing.start;
|
|
394
|
+
while (start > 0 && /\s/.test(this.source[start - 1])) start--;
|
|
395
|
+
this.s.remove(start, existing.end);
|
|
396
|
+
} else {
|
|
397
|
+
this.s.overwrite(existing.start, existing.end, code, { contentOnly: true });
|
|
398
|
+
}
|
|
399
|
+
} else if (code !== null) {
|
|
400
|
+
const at = node.attrs.length ? node.attrs[node.attrs.length - 1].end : node.nameEnd;
|
|
401
|
+
this.s.appendRight(at, ` ${code}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
setText(node, text) {
|
|
405
|
+
if (node.kind === "text") {
|
|
406
|
+
this.s.overwrite(this.contentStart(node), this.contentEnd(node), textCode(text) || '{""}');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (node.kind !== "component" && node.kind !== "element") throw new EditError(`Cannot set text on a ${node.kind} node.`);
|
|
410
|
+
if (node.children.some((c) => c.kind !== "text")) {
|
|
411
|
+
throw new EditError(`Node ${node.id} has element children; edit a text child instead.`);
|
|
412
|
+
}
|
|
413
|
+
const code = textCode(text);
|
|
414
|
+
if (node.selfClosing) {
|
|
415
|
+
let end = node.openEnd - 2;
|
|
416
|
+
while (/\s/.test(this.source[end - 1])) end--;
|
|
417
|
+
this.s.overwrite(end, node.openEnd, `>${code}</${node.name}>`);
|
|
418
|
+
} else if (node.openEnd === node.closeStart) {
|
|
419
|
+
this.s.appendLeft(node.openEnd, code);
|
|
420
|
+
} else {
|
|
421
|
+
this.s.overwrite(node.openEnd, node.closeStart, code);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// ---------- indentation helpers: edits match the file's existing style ----------
|
|
425
|
+
unitCache = null;
|
|
426
|
+
/** The file's indent unit: tabs, or the smallest non-zero space indent. */
|
|
427
|
+
unit() {
|
|
428
|
+
if (this.unitCache) return this.unitCache;
|
|
429
|
+
if (/^\t/m.test(this.source)) return this.unitCache = " ";
|
|
430
|
+
let min = Infinity;
|
|
431
|
+
for (const m of this.source.matchAll(/^( +)(?!\*)\S/gm)) min = Math.min(min, m[1].length);
|
|
432
|
+
return this.unitCache = " ".repeat(Number.isFinite(min) && min <= 8 ? min : 2);
|
|
433
|
+
}
|
|
434
|
+
lineIndent(pos) {
|
|
435
|
+
const lineStart = this.source.lastIndexOf("\n", pos - 1) + 1;
|
|
436
|
+
return /^[ \t]*/.exec(this.source.slice(lineStart))[0];
|
|
437
|
+
}
|
|
438
|
+
/** Where a node's real content starts/ends (JSX text includes surrounding whitespace). */
|
|
439
|
+
contentStart(n) {
|
|
440
|
+
if (n.kind !== "text") return n.start;
|
|
441
|
+
const raw = this.source.slice(n.start, n.end);
|
|
442
|
+
return n.start + (raw.length - raw.trimStart().length);
|
|
443
|
+
}
|
|
444
|
+
contentEnd(n) {
|
|
445
|
+
if (n.kind !== "text") return n.end;
|
|
446
|
+
return n.start + this.source.slice(n.start, n.end).trimEnd().length;
|
|
447
|
+
}
|
|
448
|
+
childIndent(parent) {
|
|
449
|
+
const kid = parent.children.find((c) => c.kind !== "text") ?? parent.children[0];
|
|
450
|
+
if (kid) {
|
|
451
|
+
const pos = this.contentStart(kid);
|
|
452
|
+
const lineStart = this.source.lastIndexOf("\n", pos - 1) + 1;
|
|
453
|
+
if (/^[ \t]*$/.test(this.source.slice(lineStart, pos))) return this.lineIndent(pos);
|
|
454
|
+
}
|
|
455
|
+
return this.lineIndent(parent.start) + this.unit();
|
|
456
|
+
}
|
|
457
|
+
/** Re-indents lines 2..n of a snippet so it sits at `indent`. */
|
|
458
|
+
shift(code, indent) {
|
|
459
|
+
const lines = code.trim().split("\n");
|
|
460
|
+
const rest = lines.slice(1).filter((l) => l.trim());
|
|
461
|
+
const base = rest.length ? Math.min(...rest.map((l) => /^[ \t]*/.exec(l)[0].length)) : 0;
|
|
462
|
+
const relative = lines.slice(1).map((l) => l.trim() ? l.slice(base) : "");
|
|
463
|
+
const steps = relative.map((l) => /^ */.exec(l)[0].length).filter((n) => n > 0);
|
|
464
|
+
const from = steps.length ? Math.min(...steps) : 0;
|
|
465
|
+
const unit = this.unit();
|
|
466
|
+
const convert = (l) => {
|
|
467
|
+
if (!from || unit === " ".repeat(from)) return l;
|
|
468
|
+
const n = /^ */.exec(l)[0].length;
|
|
469
|
+
return unit.repeat(Math.floor(n / from)) + " ".repeat(n % from) + l.slice(n);
|
|
470
|
+
};
|
|
471
|
+
return [lines[0], ...relative.map((l) => l ? indent + convert(l) : "")].join("\n");
|
|
472
|
+
}
|
|
473
|
+
openToClose(node, inner) {
|
|
474
|
+
let end = node.openEnd - 2;
|
|
475
|
+
while (/\s/.test(this.source[end - 1])) end--;
|
|
476
|
+
this.s.overwrite(end, node.openEnd, `>${inner}</${node.name}>`);
|
|
477
|
+
}
|
|
478
|
+
insertAt(parent, kids, i, code) {
|
|
479
|
+
const ci = this.childIndent(parent);
|
|
480
|
+
const pi = this.lineIndent(parent.start);
|
|
481
|
+
const body = this.shift(code, ci);
|
|
482
|
+
if (parent.selfClosing) {
|
|
483
|
+
this.openToClose(parent, `
|
|
484
|
+
${ci}${body}
|
|
485
|
+
${pi}`);
|
|
486
|
+
} else if (i < kids.length) {
|
|
487
|
+
this.s.appendRight(this.contentStart(kids[i]), `${body}
|
|
488
|
+
${ci}`);
|
|
489
|
+
} else if (kids.length) {
|
|
490
|
+
this.s.appendLeft(this.contentEnd(kids[kids.length - 1]), `
|
|
491
|
+
${ci}${body}`);
|
|
492
|
+
} else if (parent.openEnd === parent.closeStart) {
|
|
493
|
+
this.s.appendLeft(parent.openEnd, `
|
|
494
|
+
${ci}${body}
|
|
495
|
+
${pi}`);
|
|
496
|
+
} else if (/^\s*$/.test(this.source.slice(parent.openEnd, parent.closeStart))) {
|
|
497
|
+
this.s.overwrite(parent.openEnd, parent.closeStart, `
|
|
498
|
+
${ci}${body}
|
|
499
|
+
${pi}`);
|
|
500
|
+
} else {
|
|
501
|
+
this.s.appendRight(parent.closeStart, `${body}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
insertChild(parent, index, jsx) {
|
|
505
|
+
const kids = parent.children;
|
|
506
|
+
const i = index === void 0 || index < 0 || index > kids.length ? kids.length : index;
|
|
507
|
+
this.insertAt(parent, kids, i, jsx);
|
|
508
|
+
this.selectPaths.push([...parent.path, i]);
|
|
509
|
+
}
|
|
510
|
+
/** Removes a node together with its line when it sits alone on it. */
|
|
511
|
+
remove(node) {
|
|
512
|
+
const start = this.contentStart(node);
|
|
513
|
+
const end = this.contentEnd(node);
|
|
514
|
+
let a = start;
|
|
515
|
+
while (a > 0 && (this.source[a - 1] === " " || this.source[a - 1] === " ")) a--;
|
|
516
|
+
const restOfLine = /^[ \t]*(\n|$)/.test(this.source.slice(end));
|
|
517
|
+
if (a > 0 && this.source[a - 1] === "\n" && restOfLine) this.s.remove(a - 1, end);
|
|
518
|
+
else this.s.remove(node.start, node.end);
|
|
519
|
+
}
|
|
520
|
+
duplicate(node) {
|
|
521
|
+
let code = this.source.slice(node.start, node.end);
|
|
522
|
+
if ("frameName" in node) {
|
|
523
|
+
const f = node;
|
|
524
|
+
this.usedFrameNames ??= new Set(this.doc.frames.map((x) => x.frameName));
|
|
525
|
+
this.placed ??= this.doc.frames.map((o) => ({ x: o.x, y: o.y, width: o.width, height: o.height ?? 600 }));
|
|
526
|
+
let name = `${f.frameName} copy`;
|
|
527
|
+
for (let n = 2; this.usedFrameNames.has(name); n++) name = `${f.frameName} copy ${n}`;
|
|
528
|
+
this.usedFrameNames.add(name);
|
|
529
|
+
const h = f.height ?? 600;
|
|
530
|
+
const row = this.placed.filter((o) => o.y < f.y + Math.max(h, 1) && o.y + Math.max(o.height, 1) > f.y);
|
|
531
|
+
const right = row.length ? Math.max(...row.map((o) => o.x + o.width)) : f.x + f.width;
|
|
532
|
+
this.placed.push({ x: right + 80, y: f.y, width: f.width, height: h });
|
|
533
|
+
code = rewriteFrameProps(code, { name, x: right + 80 });
|
|
534
|
+
}
|
|
535
|
+
this.s.appendLeft(this.contentEnd(node), `
|
|
536
|
+
${this.lineIndent(node.start)}${code.trim()}`);
|
|
537
|
+
const path12 = [...node.path];
|
|
538
|
+
path12[path12.length - 1] += 1;
|
|
539
|
+
this.selectPaths.push(path12);
|
|
540
|
+
}
|
|
541
|
+
move(node, target, index) {
|
|
542
|
+
if (isInside(target, node)) throw new EditError("Cannot move a node into itself.");
|
|
543
|
+
const raw = this.source.slice(this.contentStart(node), this.contentEnd(node));
|
|
544
|
+
const code = node.kind === "component" || node.kind === "element" ? raw.replace(/^<([\w.:-]+)/, "<$1 __tcsel") : raw;
|
|
545
|
+
const kids = target.children.filter((c) => c.id !== node.id);
|
|
546
|
+
const i = index === void 0 || index < 0 || index > kids.length ? kids.length : index;
|
|
547
|
+
this.remove(node);
|
|
548
|
+
this.insertAt(target, kids, i, code);
|
|
549
|
+
}
|
|
550
|
+
wrap(nodes, open, close) {
|
|
551
|
+
const sorted = [...nodes].sort((a, b) => a.start - b.start);
|
|
552
|
+
const first = sorted[0];
|
|
553
|
+
const last = sorted[sorted.length - 1];
|
|
554
|
+
if (sorted.some((n) => "frameName" in n)) throw new EditError("Frames can't be wrapped; wrap the layers inside a frame.");
|
|
555
|
+
const parentPath = first.path.slice(0, -1).join(",");
|
|
556
|
+
if (sorted.some((n) => n.path.slice(0, -1).join(",") !== parentPath)) throw new EditError("Nodes to wrap must share the same parent.");
|
|
557
|
+
const indices = sorted.map((n) => n.path[n.path.length - 1]);
|
|
558
|
+
const parent = findByPath(this.doc, first.path.slice(0, -1));
|
|
559
|
+
const between = parent?.children.slice(indices[0], indices[indices.length - 1] + 1) ?? [];
|
|
560
|
+
if (between.some((c) => c.kind !== "text" && !sorted.includes(c))) throw new EditError("Nodes to wrap must be adjacent siblings.");
|
|
561
|
+
const ind = this.lineIndent(this.contentStart(first));
|
|
562
|
+
const inner = ind + this.unit();
|
|
563
|
+
const slice = this.source.slice(this.contentStart(first), this.contentEnd(last));
|
|
564
|
+
this.s.overwrite(this.contentStart(first), this.contentEnd(last), `${open}
|
|
565
|
+
${inner}${this.shift(slice, inner)}
|
|
566
|
+
${ind}${close}`);
|
|
567
|
+
this.selectPaths.push(first.path);
|
|
568
|
+
}
|
|
569
|
+
/** Like wrap, but keeps content that sits on one line on one line: `<p><X>Hi</X></p>`. */
|
|
570
|
+
wrapInline(nodes, open, close) {
|
|
571
|
+
const sorted = [...nodes].sort((a, b) => a.start - b.start);
|
|
572
|
+
const start = this.contentStart(sorted[0]);
|
|
573
|
+
const end = this.contentEnd(sorted[sorted.length - 1]);
|
|
574
|
+
const slice = this.source.slice(start, end);
|
|
575
|
+
if (slice.includes("\n")) return this.wrap(nodes, open, close);
|
|
576
|
+
this.s.overwrite(start, end, `${open}${slice}${close}`);
|
|
577
|
+
this.selectPaths.push(sorted[0].path);
|
|
578
|
+
}
|
|
579
|
+
/** Replaces a wrapper element with its children (re-indented to its place). */
|
|
580
|
+
unwrap(node) {
|
|
581
|
+
const kids = node.children;
|
|
582
|
+
if (!kids.length) {
|
|
583
|
+
this.remove(node);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const inner = this.source.slice(this.contentStart(kids[0]), this.contentEnd(kids[kids.length - 1]));
|
|
587
|
+
this.s.overwrite(this.contentStart(node), this.contentEnd(node), this.shift(inner, this.lineIndent(this.contentStart(node))));
|
|
588
|
+
this.selectPaths.push(node.path);
|
|
589
|
+
}
|
|
590
|
+
replace(node, jsx) {
|
|
591
|
+
this.s.overwrite(this.contentStart(node), this.contentEnd(node), this.shift(jsx, this.lineIndent(this.contentStart(node))));
|
|
592
|
+
this.selectPaths.push(node.path);
|
|
593
|
+
}
|
|
594
|
+
added = /* @__PURE__ */ new Set();
|
|
595
|
+
ensureImport(name, from) {
|
|
596
|
+
if (this.doc.imports.some((i) => i.names.includes(name) || i.defaultName === name)) return;
|
|
597
|
+
if (this.added.has(name)) return;
|
|
598
|
+
this.added.add(name);
|
|
599
|
+
const same = this.doc.imports.find((i) => i.source === from && !i.typeOnly && i.lastSpecifierEnd !== null);
|
|
600
|
+
if (same) {
|
|
601
|
+
this.s.appendLeft(same.lastSpecifierEnd, `, ${name}`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const last = this.doc.imports[this.doc.imports.length - 1];
|
|
605
|
+
const line = `import { ${name} } from ${JSON.stringify(from)};`;
|
|
606
|
+
if (last) this.s.appendLeft(last.end, `
|
|
607
|
+
${line}`);
|
|
608
|
+
else if (this.doc.prologueEnd) this.s.appendLeft(this.doc.prologueEnd, `
|
|
609
|
+
${line}`);
|
|
610
|
+
else this.prependImport(line);
|
|
611
|
+
}
|
|
612
|
+
/** Adds a whole import statement unless every name it binds is already imported. */
|
|
613
|
+
addImportStatement(statement) {
|
|
614
|
+
let names = [];
|
|
615
|
+
try {
|
|
616
|
+
const ast = parseModule(statement);
|
|
617
|
+
const decl = ast.program.body[0];
|
|
618
|
+
if (decl?.type === "ImportDeclaration") names = decl.specifiers.map((sp) => sp.local.name);
|
|
619
|
+
} catch {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const known = /* @__PURE__ */ new Set([...this.doc.imports.flatMap((i) => [...i.names, ...i.defaultName ? [i.defaultName] : []]), ...this.added]);
|
|
623
|
+
if (names.length && names.every((n) => known.has(n))) return;
|
|
624
|
+
for (const n of names) this.added.add(n);
|
|
625
|
+
const last = this.doc.imports[this.doc.imports.length - 1];
|
|
626
|
+
if (last) this.s.appendLeft(last.end, `
|
|
627
|
+
${statement}`);
|
|
628
|
+
else if (this.doc.prologueEnd) this.s.appendLeft(this.doc.prologueEnd, `
|
|
629
|
+
${statement}`);
|
|
630
|
+
else this.prependImport(statement);
|
|
631
|
+
}
|
|
632
|
+
importsPrepended = false;
|
|
633
|
+
/** First imports of a file: in order, with a blank line before the code. */
|
|
634
|
+
prependImport(line) {
|
|
635
|
+
if (!this.importsPrepended) {
|
|
636
|
+
this.importsPrepended = true;
|
|
637
|
+
this.s.prependRight(0, "\n");
|
|
638
|
+
}
|
|
639
|
+
this.s.appendLeft(0, `${line}
|
|
640
|
+
`);
|
|
641
|
+
}
|
|
642
|
+
insertFrame(code) {
|
|
643
|
+
const canvas = this.doc.canvasNode;
|
|
644
|
+
if (!canvas) throw new EditError("No <Canvas> element to add a frame to.");
|
|
645
|
+
this.insertAt(canvas, canvas.children, canvas.children.length, code);
|
|
646
|
+
this.selectPaths.push([this.doc.frames.length]);
|
|
647
|
+
}
|
|
648
|
+
result() {
|
|
649
|
+
return this.s.toString();
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
function isInside(target, node) {
|
|
653
|
+
return target.start >= node.start && target.end <= node.end;
|
|
654
|
+
}
|
|
655
|
+
function rewriteFrameProps(code, props) {
|
|
656
|
+
const wrapped = `(${code})`;
|
|
657
|
+
const ast = parseModule(wrapped);
|
|
658
|
+
let open = null;
|
|
659
|
+
walk(ast, (n) => {
|
|
660
|
+
if (open) return false;
|
|
661
|
+
if (n.type === "JSXOpeningElement") open = n;
|
|
662
|
+
});
|
|
663
|
+
if (!open) return code;
|
|
664
|
+
const o = open;
|
|
665
|
+
const s = new MagicString(wrapped);
|
|
666
|
+
for (const [name, value] of Object.entries(props)) {
|
|
667
|
+
const attr = o.attributes.find((a) => a.type === "JSXAttribute" && a.name.name === name);
|
|
668
|
+
if (attr) {
|
|
669
|
+
if (value === null) s.remove(attr.start - 1, attr.end);
|
|
670
|
+
else s.overwrite(attr.start, attr.end, attrCode(name, value));
|
|
671
|
+
} else if (value !== null) s.appendLeft(o.name.end, ` ${attrCode(name, value)}`);
|
|
672
|
+
}
|
|
673
|
+
return s.toString().slice(1, -1);
|
|
674
|
+
}
|
|
675
|
+
function componentNamesIn(jsx) {
|
|
676
|
+
const names = /* @__PURE__ */ new Set();
|
|
677
|
+
const ast = parseJsx(jsx);
|
|
678
|
+
walk(ast, (n) => {
|
|
679
|
+
if (n.type === "JSXOpeningElement") {
|
|
680
|
+
const name = jsxName(n.name);
|
|
681
|
+
if (/^[A-Z]/.test(name) || name.includes(".")) names.add(name.split(".")[0]);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
return [...names];
|
|
685
|
+
}
|
|
686
|
+
function parseJsx(jsx) {
|
|
687
|
+
try {
|
|
688
|
+
return parseModule(`<>${jsx}</>`);
|
|
689
|
+
} catch (err) {
|
|
690
|
+
throw new EditError(`Invalid JSX: ${err.message.replace(/\(\d+:\d+\)$/, "")}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function frameCode(opts) {
|
|
694
|
+
const attrs = [attrCode("name", opts.name), attrCode("x", Math.round(opts.x)), attrCode("y", Math.round(opts.y)), attrCode("width", Math.round(opts.width))];
|
|
695
|
+
if (opts.height) attrs.push(attrCode("height", Math.round(opts.height)));
|
|
696
|
+
if (opts.theme) attrs.push(attrCode("theme", opts.theme));
|
|
697
|
+
if (opts.device) attrs.push(attrCode("device", opts.device));
|
|
698
|
+
if (opts.page) attrs.push(attrCode("page", opts.page));
|
|
699
|
+
if (opts.from) attrs.push(attrCode("from", opts.from));
|
|
700
|
+
if (opts.component) attrs.push(attrCode("component", opts.component));
|
|
701
|
+
const body = opts.body ?? `<div className="flex flex-col gap-3 p-6"></div>`;
|
|
702
|
+
return `<Frame ${attrs.join(" ")}>
|
|
703
|
+
${indentBlock(body)}
|
|
704
|
+
</Frame>`;
|
|
705
|
+
}
|
|
706
|
+
function indentBlock(code, unit = " ") {
|
|
707
|
+
return code.split("\n").map((l) => l.trim() ? unit + l : l).join("\n");
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/core/devices.ts
|
|
711
|
+
var DEVICES = [
|
|
712
|
+
{ id: "iphone-16", name: "iPhone 16", kind: "phone", width: 393, height: 852, dpr: 3, statusBar: 54, homeIndicator: 34, radius: 55, island: true },
|
|
713
|
+
{ id: "iphone-16-pro-max", name: "iPhone 16 Pro Max", kind: "phone", width: 440, height: 956, dpr: 3, statusBar: 54, homeIndicator: 34, radius: 62, island: true },
|
|
714
|
+
{ id: "iphone-se", name: "iPhone SE", kind: "phone", width: 375, height: 667, dpr: 2, statusBar: 20, homeIndicator: 0, radius: 0 },
|
|
715
|
+
{ id: "pixel-9", name: "Pixel 9", kind: "phone", width: 412, height: 923, dpr: 2.625, statusBar: 36, homeIndicator: 24, radius: 48 },
|
|
716
|
+
{ id: "galaxy-s24", name: "Galaxy S24", kind: "phone", width: 360, height: 780, dpr: 3, statusBar: 32, homeIndicator: 24, radius: 44 },
|
|
717
|
+
{ id: "ipad-mini", name: "iPad mini", kind: "tablet", width: 744, height: 1133, dpr: 2, statusBar: 24, homeIndicator: 20, radius: 22 },
|
|
718
|
+
{ id: "ipad-pro-11", name: "iPad Pro 11\u2033", kind: "tablet", width: 834, height: 1210, dpr: 2, statusBar: 24, homeIndicator: 20, radius: 18 },
|
|
719
|
+
{ id: "laptop", name: "Laptop", kind: "desktop", width: 1280, height: 800, dpr: 2, statusBar: 0, homeIndicator: 0, radius: 0 },
|
|
720
|
+
{ id: "desktop", name: "Desktop", kind: "desktop", width: 1440, height: 900, dpr: 1, statusBar: 0, homeIndicator: 0, radius: 0 },
|
|
721
|
+
{ id: "desktop-hd", name: "Desktop HD", kind: "desktop", width: 1920, height: 1080, dpr: 1, statusBar: 0, homeIndicator: 0, radius: 0 }
|
|
722
|
+
];
|
|
723
|
+
function findDevice(id) {
|
|
724
|
+
return id ? DEVICES.find((d) => d.id === id) : void 0;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/core/comments.ts
|
|
728
|
+
import fs2 from "fs";
|
|
729
|
+
import path2 from "path";
|
|
730
|
+
import { randomUUID } from "crypto";
|
|
731
|
+
var Comments = class {
|
|
732
|
+
constructor(config) {
|
|
733
|
+
this.config = config;
|
|
734
|
+
}
|
|
735
|
+
config;
|
|
736
|
+
file(canvas) {
|
|
737
|
+
return path2.join(this.config.root, this.config.canvasDir, `${canvas}.comments.json`);
|
|
738
|
+
}
|
|
739
|
+
list(canvas) {
|
|
740
|
+
try {
|
|
741
|
+
return JSON.parse(fs2.readFileSync(this.file(canvas), "utf8")).threads ?? [];
|
|
742
|
+
} catch {
|
|
743
|
+
return [];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
save(canvas, threads) {
|
|
747
|
+
const file = this.file(canvas);
|
|
748
|
+
if (!threads.length) {
|
|
749
|
+
if (fs2.existsSync(file)) fs2.rmSync(file);
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
fs2.mkdirSync(path2.dirname(file), { recursive: true });
|
|
753
|
+
fs2.writeFileSync(file, `${JSON.stringify({ version: 1, threads }, null, 2)}
|
|
754
|
+
`);
|
|
755
|
+
}
|
|
756
|
+
update(canvas, id, fn) {
|
|
757
|
+
const threads = this.list(canvas);
|
|
758
|
+
const t = threads.find((x) => x.id === id || x.id.startsWith(id));
|
|
759
|
+
if (!t) throw new Error(`No comment thread "${id}".`);
|
|
760
|
+
fn(t);
|
|
761
|
+
this.save(canvas, threads);
|
|
762
|
+
return t;
|
|
763
|
+
}
|
|
764
|
+
add(canvas, input) {
|
|
765
|
+
const threads = this.list(canvas);
|
|
766
|
+
const now = Date.now();
|
|
767
|
+
const thread = {
|
|
768
|
+
id: randomUUID().slice(0, 8),
|
|
769
|
+
frame: input.frame,
|
|
770
|
+
x: Math.round(input.x),
|
|
771
|
+
y: Math.round(input.y),
|
|
772
|
+
node: input.node ?? null,
|
|
773
|
+
resolved: false,
|
|
774
|
+
messages: [{ id: randomUUID().slice(0, 8), author: input.author, text: input.text.trim(), at: now }],
|
|
775
|
+
createdAt: now
|
|
776
|
+
};
|
|
777
|
+
threads.push(thread);
|
|
778
|
+
this.save(canvas, threads);
|
|
779
|
+
return thread;
|
|
780
|
+
}
|
|
781
|
+
reply(canvas, id, text, author) {
|
|
782
|
+
return this.update(canvas, id, (t) => {
|
|
783
|
+
t.messages.push({ id: randomUUID().slice(0, 8), author, text: text.trim(), at: Date.now() });
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
resolve(canvas, id, resolved, author) {
|
|
787
|
+
return this.update(canvas, id, (t) => {
|
|
788
|
+
t.resolved = resolved;
|
|
789
|
+
t.resolvedBy = resolved ? author : void 0;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
remove(canvas, id) {
|
|
793
|
+
this.save(
|
|
794
|
+
canvas,
|
|
795
|
+
this.list(canvas).filter((t) => t.id !== id)
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
/** Frames keep their comments when renamed. */
|
|
799
|
+
renameFrame(canvas, from, to) {
|
|
800
|
+
const threads = this.list(canvas);
|
|
801
|
+
let changed = false;
|
|
802
|
+
for (const t of threads) if (t.frame === from) t.frame = to, changed = true;
|
|
803
|
+
if (changed) this.save(canvas, threads);
|
|
804
|
+
}
|
|
805
|
+
renameCanvas(from, to) {
|
|
806
|
+
const a = this.file(from);
|
|
807
|
+
if (fs2.existsSync(a)) fs2.renameSync(a, this.file(to));
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
// src/core/git.ts
|
|
812
|
+
import { execFile } from "child_process";
|
|
813
|
+
import path3 from "path";
|
|
814
|
+
var GitError = class extends EditError {
|
|
815
|
+
};
|
|
816
|
+
function git(cwd, args, timeout = 3e4, opts = {}) {
|
|
817
|
+
return run("git", cwd, args, timeout, opts);
|
|
818
|
+
}
|
|
819
|
+
function run(cmd, cwd, args, timeout, opts = {}) {
|
|
820
|
+
return new Promise((resolve, reject) => {
|
|
821
|
+
const child = execFile(
|
|
822
|
+
cmd,
|
|
823
|
+
args,
|
|
824
|
+
{ cwd, timeout, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1", LC_ALL: "C", ...opts.env } },
|
|
825
|
+
(err, stdout, stderr) => {
|
|
826
|
+
if (err && opts.exit1 && err.code === 1) resolve(stdout);
|
|
827
|
+
else if (err) {
|
|
828
|
+
const missing = err.code === "ENOENT";
|
|
829
|
+
reject(new GitError(missing ? `${cmd} isn't installed.` : (stderr || err.message).trim().split("\n").slice(-3).join(" ")));
|
|
830
|
+
} else resolve(stdout);
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
if (opts.input !== void 0) child.stdin?.end(opts.input);
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
var Git = class {
|
|
837
|
+
constructor(cwd) {
|
|
838
|
+
this.cwd = cwd;
|
|
839
|
+
}
|
|
840
|
+
cwd;
|
|
841
|
+
async status() {
|
|
842
|
+
let root;
|
|
843
|
+
try {
|
|
844
|
+
root = (await git(this.cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
845
|
+
} catch {
|
|
846
|
+
return { repo: false, root: null, branch: null, unborn: false, upstream: null, ahead: 0, behind: 0, files: [], user: null, defaultBranch: null, remote: null };
|
|
847
|
+
}
|
|
848
|
+
const out = await git(this.cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=all", "--", "."]);
|
|
849
|
+
const s = { repo: true, root, branch: null, unborn: false, upstream: null, ahead: 0, behind: 0, files: [], user: null, defaultBranch: null, remote: null };
|
|
850
|
+
for (const line of out.split("\n")) {
|
|
851
|
+
if (line.startsWith("# branch.head ")) s.branch = line.slice(14) === "(detached)" ? null : line.slice(14);
|
|
852
|
+
else if (line.startsWith("# branch.oid ")) s.unborn = line.slice(13) === "(initial)";
|
|
853
|
+
else if (line.startsWith("# branch.upstream ")) s.upstream = line.slice(18);
|
|
854
|
+
else if (line.startsWith("# branch.ab ")) {
|
|
855
|
+
const m = /\+(\d+) -(\d+)/.exec(line);
|
|
856
|
+
if (m) [s.ahead, s.behind] = [Number(m[1]), Number(m[2])];
|
|
857
|
+
} else if (line.startsWith("1 ") || line.startsWith("2 ")) {
|
|
858
|
+
const parts = line.split(" ");
|
|
859
|
+
const xy = parts[1];
|
|
860
|
+
const file = line.startsWith("2 ") ? line.split(" ")[0].split(" ").slice(9).join(" ") : parts.slice(8).join(" ");
|
|
861
|
+
const code = xy.replace(".", "")[0] ?? "M";
|
|
862
|
+
s.files.push({ path: this.rel(root, file), status: code === "A" ? "added" : code === "D" ? "deleted" : code === "R" ? "renamed" : "modified", staged: xy[0] !== "." });
|
|
863
|
+
} else if (line.startsWith("? ")) s.files.push({ path: this.rel(root, line.slice(2)), status: "untracked", staged: false });
|
|
864
|
+
}
|
|
865
|
+
s.user = (await git(this.cwd, ["config", "user.name"]).catch(() => "")).trim() || null;
|
|
866
|
+
s.remote = (await git(this.cwd, ["remote", "get-url", "origin"]).catch(() => "")).trim() || null;
|
|
867
|
+
s.defaultBranch = await this.defaultBranch();
|
|
868
|
+
return s;
|
|
869
|
+
}
|
|
870
|
+
/** The branch PRs target: origin's HEAD, else a local main/master. */
|
|
871
|
+
async defaultBranch() {
|
|
872
|
+
const head = (await git(this.cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).catch(() => "")).trim();
|
|
873
|
+
if (head) return head.replace(/^origin\//, "");
|
|
874
|
+
const { local } = await this.branches();
|
|
875
|
+
return ["main", "master", "trunk", "develop"].find((b) => local.includes(b)) ?? null;
|
|
876
|
+
}
|
|
877
|
+
/** When the last commit was made (ms), or 0 with no commits. */
|
|
878
|
+
async lastCommitAt() {
|
|
879
|
+
const out = await git(this.cwd, ["log", "-1", "--format=%ct"]).catch(() => "");
|
|
880
|
+
return Number(out.trim() || 0) * 1e3;
|
|
881
|
+
}
|
|
882
|
+
/** When a commit was made (ms). */
|
|
883
|
+
async commitAt(ref) {
|
|
884
|
+
const out = await git(this.cwd, ["log", "-1", "--format=%ct", ref]).catch(() => "");
|
|
885
|
+
return Number(out.trim() || 0) * 1e3;
|
|
886
|
+
}
|
|
887
|
+
/** Commit where this branch left `base`. */
|
|
888
|
+
async mergeBase(base) {
|
|
889
|
+
for (const ref of [`origin/${base}`, base]) {
|
|
890
|
+
const out = await git(this.cwd, ["merge-base", ref, "HEAD"]).catch(() => "");
|
|
891
|
+
if (out.trim()) return out.trim();
|
|
892
|
+
}
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
/** Files changed between a commit and HEAD, from the project root. */
|
|
896
|
+
async changedSince(ref) {
|
|
897
|
+
const out = await git(this.cwd, ["diff", "--name-only", "--relative", `${ref}...HEAD`]).catch(() => "");
|
|
898
|
+
return out.split("\n").filter(Boolean);
|
|
899
|
+
}
|
|
900
|
+
/** Unified diff of these files against HEAD, including new files. */
|
|
901
|
+
async diff(files) {
|
|
902
|
+
if (!files.length) return "";
|
|
903
|
+
const tracked = await git(this.cwd, ["diff", "--relative", "HEAD", "--", ...files]).catch(() => "");
|
|
904
|
+
const untracked = (await git(this.cwd, ["ls-files", "--others", "--exclude-standard", "--", ...files]).catch(() => "")).split("\n").filter(Boolean);
|
|
905
|
+
let out = tracked;
|
|
906
|
+
for (const f of untracked) out += await git(this.cwd, ["diff", "--no-index", "--", "/dev/null", f], 3e4, { exit1: true }).catch(() => "");
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Adds files to a side branch without touching the working tree or index
|
|
911
|
+
* (used for PR screenshots), and pushes it. Returns the branch tip.
|
|
912
|
+
*/
|
|
913
|
+
async commitToSideBranch(branch, files, message) {
|
|
914
|
+
const tip = (await git(this.cwd, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).catch(() => "")).trim() || (await git(this.cwd, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`]).catch(() => "")).trim();
|
|
915
|
+
const os = await import("os");
|
|
916
|
+
const fs11 = await import("fs");
|
|
917
|
+
const index = path3.join(os.tmpdir(), `truecanvas-index-${process.pid}-${Date.now()}`);
|
|
918
|
+
const env = { GIT_INDEX_FILE: index };
|
|
919
|
+
try {
|
|
920
|
+
if (tip) await git(this.cwd, ["read-tree", tip], 3e4, { env });
|
|
921
|
+
else await git(this.cwd, ["read-tree", "--empty"], 3e4, { env });
|
|
922
|
+
for (const f of files) {
|
|
923
|
+
const blob = (await git(this.cwd, ["hash-object", "-w", "--stdin"], 3e4, { input: f.data })).trim();
|
|
924
|
+
await git(this.cwd, ["update-index", "--add", "--cacheinfo", `100644,${blob},${f.path}`], 3e4, { env });
|
|
925
|
+
}
|
|
926
|
+
const tree = (await git(this.cwd, ["write-tree"], 3e4, { env })).trim();
|
|
927
|
+
const commit = (await git(this.cwd, ["commit-tree", tree, ...tip ? ["-p", tip] : [], "-m", message])).trim();
|
|
928
|
+
await git(this.cwd, ["update-ref", `refs/heads/${branch}`, commit]);
|
|
929
|
+
await git(this.cwd, ["push", "origin", `refs/heads/${branch}:refs/heads/${branch}`], 12e4);
|
|
930
|
+
} finally {
|
|
931
|
+
fs11.rmSync(index, { force: true });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// ---------- GitHub (gh CLI) ----------
|
|
935
|
+
/** owner/name of the GitHub repo, if origin is on GitHub and gh is set up. */
|
|
936
|
+
async githubRepo() {
|
|
937
|
+
const out = await run("gh", this.cwd, ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], 2e4).catch(() => "");
|
|
938
|
+
return out.trim() || null;
|
|
939
|
+
}
|
|
940
|
+
/** The open (or latest) pull request of the current branch. */
|
|
941
|
+
async pullRequest() {
|
|
942
|
+
const out = await run("gh", this.cwd, ["pr", "view", "--json", "number,url,title,state,isDraft,reviewDecision,statusCheckRollup"], 2e4).catch(() => "");
|
|
943
|
+
if (!out.trim()) return null;
|
|
944
|
+
const pr = JSON.parse(out);
|
|
945
|
+
const checks = { passed: 0, failed: 0, pending: 0 };
|
|
946
|
+
for (const c of pr.statusCheckRollup ?? []) {
|
|
947
|
+
const v = (c.conclusion || c.state || c.status || "").toUpperCase();
|
|
948
|
+
if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v)) checks.passed++;
|
|
949
|
+
else if (["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"].includes(v)) checks.failed++;
|
|
950
|
+
else checks.pending++;
|
|
951
|
+
}
|
|
952
|
+
return { number: pr.number, url: pr.url, title: pr.title, state: pr.state, draft: pr.isDraft, review: pr.reviewDecision || null, checks };
|
|
953
|
+
}
|
|
954
|
+
async createPullRequest(opts) {
|
|
955
|
+
const out = await run("gh", this.cwd, ["pr", "create", "--title", opts.title, "--body-file", "-", "--base", opts.base, ...opts.draft ? ["--draft"] : []], 12e4, { input: opts.body });
|
|
956
|
+
return out.trim().split("\n").pop() ?? "";
|
|
957
|
+
}
|
|
958
|
+
/** Creates a GitHub repo for this project and pushes it (gh repo create). */
|
|
959
|
+
async publish(name, isPrivate) {
|
|
960
|
+
if (!/^[\w.-]+$/.test(name)) throw new GitError("Invalid repository name.");
|
|
961
|
+
await run("gh", this.cwd, ["repo", "create", name, isPrivate ? "--private" : "--public", "--source", ".", "--remote", "origin", "--push"], 18e4);
|
|
962
|
+
return (await git(this.cwd, ["remote", "get-url", "origin"])).trim();
|
|
963
|
+
}
|
|
964
|
+
/** git reports paths from the repo root; we show them from the project root. */
|
|
965
|
+
rel(root, file) {
|
|
966
|
+
return path3.relative(this.cwd, path3.join(root, file));
|
|
967
|
+
}
|
|
968
|
+
async branches() {
|
|
969
|
+
const out = await git(this.cwd, ["branch", "-a", "--format=%(refname)%09%(HEAD)"]).catch(() => "");
|
|
970
|
+
const local = [];
|
|
971
|
+
const remote = [];
|
|
972
|
+
let current = null;
|
|
973
|
+
for (const line of out.split("\n").filter(Boolean)) {
|
|
974
|
+
const [ref, head] = line.split(" ");
|
|
975
|
+
if (ref.endsWith("/HEAD") || ref.endsWith("/truecanvas-previews")) continue;
|
|
976
|
+
if (ref.startsWith("refs/heads/")) {
|
|
977
|
+
const name = ref.slice("refs/heads/".length);
|
|
978
|
+
if (head === "*") current = name;
|
|
979
|
+
local.push(name);
|
|
980
|
+
} else if (ref.startsWith("refs/remotes/")) remote.push(ref.slice("refs/remotes/".length));
|
|
981
|
+
}
|
|
982
|
+
return { current, local, remote };
|
|
983
|
+
}
|
|
984
|
+
/** Commits that touched these paths (newest first). */
|
|
985
|
+
async log(paths, limit = 30) {
|
|
986
|
+
const out = await git(this.cwd, ["log", `-n${limit}`, "--format=%H%x09%h%x09%s%x09%an%x09%ct", "--", ...paths]).catch(() => "");
|
|
987
|
+
return out.split("\n").filter(Boolean).map((l) => {
|
|
988
|
+
const [hash, short2, subject, author, at] = l.split(" ");
|
|
989
|
+
return { hash, short: short2, subject, author, at: Number(at) * 1e3 };
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
/** A file's content at a ref, or null if it doesn't exist there. */
|
|
993
|
+
async show(ref, file) {
|
|
994
|
+
const root = (await git(this.cwd, ["rev-parse", "--show-toplevel"])).trim();
|
|
995
|
+
const inRepo = path3.relative(root, path3.join(this.cwd, file)).split(path3.sep).join("/");
|
|
996
|
+
return git(this.cwd, ["show", `${ref}:${inRepo}`]).catch(() => null);
|
|
997
|
+
}
|
|
998
|
+
async commit(message, files) {
|
|
999
|
+
if (!message.trim()) throw new GitError("Write a commit message.");
|
|
1000
|
+
if (!files.length) throw new GitError("Nothing to commit.");
|
|
1001
|
+
await git(this.cwd, ["add", "--all", "--", ...files]);
|
|
1002
|
+
await git(this.cwd, ["commit", "-m", message.trim(), "--", ...files]);
|
|
1003
|
+
return (await git(this.cwd, ["rev-parse", "--short", "HEAD"])).trim();
|
|
1004
|
+
}
|
|
1005
|
+
async push() {
|
|
1006
|
+
const s = await this.status();
|
|
1007
|
+
if (s.upstream) await git(this.cwd, ["push"], 12e4);
|
|
1008
|
+
else await git(this.cwd, ["push", "-u", "origin", "HEAD"], 12e4);
|
|
1009
|
+
}
|
|
1010
|
+
async pull() {
|
|
1011
|
+
await git(this.cwd, ["pull", "--ff-only"], 12e4);
|
|
1012
|
+
}
|
|
1013
|
+
async fetch() {
|
|
1014
|
+
await git(this.cwd, ["fetch", "--quiet"], 6e4).catch(() => {
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
async switch(branch, create = false) {
|
|
1018
|
+
if (!/^[\w./-]+$/.test(branch) || branch.startsWith("-")) throw new GitError("Invalid branch name.");
|
|
1019
|
+
await git(this.cwd, create ? ["switch", "-c", branch] : ["switch", branch]);
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
// src/core/workspace.ts
|
|
1024
|
+
import fs8 from "fs";
|
|
1025
|
+
import path9 from "path";
|
|
1026
|
+
import { createRequire } from "module";
|
|
1027
|
+
|
|
1028
|
+
// src/core/link.ts
|
|
1029
|
+
import fs4 from "fs";
|
|
1030
|
+
import path5 from "path";
|
|
1031
|
+
|
|
1032
|
+
// src/core/routes.ts
|
|
1033
|
+
import fs3 from "fs";
|
|
1034
|
+
import path4 from "path";
|
|
1035
|
+
|
|
1036
|
+
// src/core/source.ts
|
|
1037
|
+
function defaultReturn(source, layout) {
|
|
1038
|
+
let ast;
|
|
1039
|
+
try {
|
|
1040
|
+
ast = parseModule(source);
|
|
1041
|
+
} catch {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
let fn = null;
|
|
1045
|
+
const decls = /* @__PURE__ */ new Map();
|
|
1046
|
+
for (const stmt of ast.program.body) {
|
|
1047
|
+
if (stmt.type === "FunctionDeclaration" && stmt.id) decls.set(stmt.id.name, stmt);
|
|
1048
|
+
if (stmt.type === "VariableDeclaration") {
|
|
1049
|
+
for (const d of stmt.declarations) if (d.id.type === "Identifier" && d.init) decls.set(d.id.name, d.init);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
for (const stmt of ast.program.body) {
|
|
1053
|
+
if (stmt.type !== "ExportDefaultDeclaration") continue;
|
|
1054
|
+
const d = stmt.declaration;
|
|
1055
|
+
const target = d.type === "Identifier" ? decls.get(d.name) : d;
|
|
1056
|
+
if (target && (target.type === "FunctionDeclaration" || target.type === "ArrowFunctionExpression" || target.type === "FunctionExpression")) fn = target;
|
|
1057
|
+
}
|
|
1058
|
+
if (!fn) return null;
|
|
1059
|
+
let ret = null;
|
|
1060
|
+
let simple = !fn.async;
|
|
1061
|
+
if (fn.body.type !== "BlockStatement") ret = fn.body;
|
|
1062
|
+
else {
|
|
1063
|
+
const body = fn.body.body.filter((s) => s.type !== "EmptyStatement");
|
|
1064
|
+
const last = body[body.length - 1];
|
|
1065
|
+
if (last?.type !== "ReturnStatement") return null;
|
|
1066
|
+
if (body.length !== 1) simple = false;
|
|
1067
|
+
ret = last.argument ?? null;
|
|
1068
|
+
}
|
|
1069
|
+
while (ret?.type === "ParenthesizedExpression") ret = ret.expression;
|
|
1070
|
+
if (!ret || ret.type !== "JSXElement" && ret.type !== "JSXFragment") return null;
|
|
1071
|
+
const imported = /* @__PURE__ */ new Set();
|
|
1072
|
+
for (const stmt of ast.program.body) {
|
|
1073
|
+
if (stmt.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
|
|
1074
|
+
for (const s of stmt.specifiers) if (!(s.type === "ImportSpecifier" && s.importKind === "type")) imported.add(s.local.name);
|
|
1075
|
+
}
|
|
1076
|
+
let pure = simple;
|
|
1077
|
+
walk(ret, (n) => {
|
|
1078
|
+
if (n.type === "JSXOpeningElement") {
|
|
1079
|
+
const name = jsxName(n.name);
|
|
1080
|
+
const root = name.split(".")[0];
|
|
1081
|
+
if ((/^[A-Z]/.test(name) || name.includes(".")) && !imported.has(root)) pure = false;
|
|
1082
|
+
}
|
|
1083
|
+
if (n.type === "JSXExpressionContainer") {
|
|
1084
|
+
const e = n.expression;
|
|
1085
|
+
const ok = e.type === "JSXEmptyExpression" || e.type === "StringLiteral" || e.type === "NumericLiteral" || e.type === "BooleanLiteral" || e.type === "TemplateLiteral" && e.expressions.length === 0 || layout && e.type === "Identifier" && e.name === "children" || e.type === "JSXElement" || e.type === "JSXFragment";
|
|
1086
|
+
if (!ok) pure = false;
|
|
1087
|
+
}
|
|
1088
|
+
if (n.type === "JSXSpreadAttribute") pure = false;
|
|
1089
|
+
});
|
|
1090
|
+
return { ast, node: ret, pure, imported };
|
|
1091
|
+
}
|
|
1092
|
+
function qualify(node, file) {
|
|
1093
|
+
node.id = `${file}#${node.id}`;
|
|
1094
|
+
for (const c of node.children) qualify(c, file);
|
|
1095
|
+
}
|
|
1096
|
+
function parseSourceDoc(file, source, layout) {
|
|
1097
|
+
const info = defaultReturn(source, layout);
|
|
1098
|
+
if (!info || !info.pure) return null;
|
|
1099
|
+
const root = buildNode(info.node, source, [0, 0]);
|
|
1100
|
+
qualify(root, file);
|
|
1101
|
+
const frame = {
|
|
1102
|
+
...root,
|
|
1103
|
+
id: `${file}#file`,
|
|
1104
|
+
kind: "component",
|
|
1105
|
+
name: "File",
|
|
1106
|
+
props: {},
|
|
1107
|
+
attrs: [],
|
|
1108
|
+
children: [root],
|
|
1109
|
+
path: [0],
|
|
1110
|
+
frameName: file,
|
|
1111
|
+
x: 0,
|
|
1112
|
+
y: 0,
|
|
1113
|
+
width: 0,
|
|
1114
|
+
height: null,
|
|
1115
|
+
theme: null,
|
|
1116
|
+
device: null,
|
|
1117
|
+
page: null,
|
|
1118
|
+
from: null,
|
|
1119
|
+
component: null,
|
|
1120
|
+
link: null
|
|
1121
|
+
};
|
|
1122
|
+
const directives = info.ast.program.directives;
|
|
1123
|
+
return {
|
|
1124
|
+
name: file,
|
|
1125
|
+
file,
|
|
1126
|
+
frames: [frame],
|
|
1127
|
+
imports: readImports(info.ast),
|
|
1128
|
+
canvasNode: null,
|
|
1129
|
+
prologueEnd: directives.length ? directives[directives.length - 1].end : 0,
|
|
1130
|
+
declared: topLevelNames(info.ast)
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function fileOfId(id) {
|
|
1134
|
+
const i = id.lastIndexOf("#");
|
|
1135
|
+
if (i <= 0) return null;
|
|
1136
|
+
const file = id.slice(0, i);
|
|
1137
|
+
return /\.[jt]sx?$/.test(file) ? file : null;
|
|
1138
|
+
}
|
|
1139
|
+
function unwrapFn(n) {
|
|
1140
|
+
if (!n) return null;
|
|
1141
|
+
if (n.type === "FunctionDeclaration" || n.type === "ArrowFunctionExpression" || n.type === "FunctionExpression") return n;
|
|
1142
|
+
if (n.type === "CallExpression") {
|
|
1143
|
+
const callee = n.callee.type === "MemberExpression" && n.callee.property.type === "Identifier" ? n.callee.property.name : n.callee.type === "Identifier" ? n.callee.name : "";
|
|
1144
|
+
if (/^(forwardRef|memo)$/.test(callee)) return unwrapFn(n.arguments[0]);
|
|
1145
|
+
}
|
|
1146
|
+
if (n.type === "TSAsExpression" || n.type === "TSSatisfiesExpression") return unwrapFn(n.expression);
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
function lastReturn(fn) {
|
|
1150
|
+
let ret = null;
|
|
1151
|
+
if (fn.body.type !== "BlockStatement") ret = fn.body;
|
|
1152
|
+
else for (const stmt of fn.body.body) if (stmt.type === "ReturnStatement") ret = stmt.argument;
|
|
1153
|
+
while (ret?.type === "ParenthesizedExpression") ret = ret.expression;
|
|
1154
|
+
return ret && (ret.type === "JSXElement" || ret.type === "JSXFragment") ? ret : null;
|
|
1155
|
+
}
|
|
1156
|
+
function exportedComponents(ast) {
|
|
1157
|
+
const local = /* @__PURE__ */ new Map();
|
|
1158
|
+
for (const stmt of ast.program.body) {
|
|
1159
|
+
const decl = stmt.type === "ExportNamedDeclaration" && stmt.declaration ? stmt.declaration : stmt;
|
|
1160
|
+
if (decl.type === "FunctionDeclaration" && decl.id) local.set(decl.id.name, decl);
|
|
1161
|
+
if (decl.type === "VariableDeclaration") {
|
|
1162
|
+
for (const d of decl.declarations) if (d.id.type === "Identifier" && d.init) local.set(d.id.name, d.init);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const names = [];
|
|
1166
|
+
for (const stmt of ast.program.body) {
|
|
1167
|
+
if (stmt.type === "ExportNamedDeclaration") {
|
|
1168
|
+
const d = stmt.declaration;
|
|
1169
|
+
if (d?.type === "FunctionDeclaration" && d.id) names.push(d.id.name);
|
|
1170
|
+
if (d?.type === "VariableDeclaration") {
|
|
1171
|
+
for (const v of d.declarations) if (v.id.type === "Identifier") names.push(v.id.name);
|
|
1172
|
+
}
|
|
1173
|
+
for (const sp of stmt.specifiers) if (sp.type === "ExportSpecifier") names.push(sp.local.name);
|
|
1174
|
+
}
|
|
1175
|
+
if (stmt.type === "ExportDefaultDeclaration") {
|
|
1176
|
+
const d = stmt.declaration;
|
|
1177
|
+
if (d.type === "Identifier") names.push(d.name);
|
|
1178
|
+
else if (d.type === "FunctionDeclaration" && d.id) names.push(d.id.name);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
const out = [];
|
|
1182
|
+
for (const name of [...new Set(names)]) {
|
|
1183
|
+
if (!/^[A-Z]/.test(name)) continue;
|
|
1184
|
+
const fn = unwrapFn(local.get(name));
|
|
1185
|
+
const jsx = fn && lastReturn(fn);
|
|
1186
|
+
if (jsx) out.push({ name, jsx });
|
|
1187
|
+
}
|
|
1188
|
+
return out;
|
|
1189
|
+
}
|
|
1190
|
+
function parseModuleDoc(file, source) {
|
|
1191
|
+
let ast;
|
|
1192
|
+
try {
|
|
1193
|
+
ast = parseModule(source);
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
const comps = exportedComponents(ast);
|
|
1198
|
+
if (!comps.length) return null;
|
|
1199
|
+
const frames = comps.map((c, i) => {
|
|
1200
|
+
const root = buildNode(c.jsx, source, [i, 0]);
|
|
1201
|
+
qualify(root, file);
|
|
1202
|
+
return {
|
|
1203
|
+
...root,
|
|
1204
|
+
id: `${file}#${c.name}`,
|
|
1205
|
+
kind: "component",
|
|
1206
|
+
name: "File",
|
|
1207
|
+
props: {},
|
|
1208
|
+
attrs: [],
|
|
1209
|
+
children: [root],
|
|
1210
|
+
path: [i],
|
|
1211
|
+
frameName: c.name,
|
|
1212
|
+
x: 0,
|
|
1213
|
+
y: 0,
|
|
1214
|
+
width: 0,
|
|
1215
|
+
height: null,
|
|
1216
|
+
theme: null,
|
|
1217
|
+
device: null,
|
|
1218
|
+
page: null,
|
|
1219
|
+
from: null,
|
|
1220
|
+
component: null,
|
|
1221
|
+
link: null
|
|
1222
|
+
};
|
|
1223
|
+
});
|
|
1224
|
+
const directives = ast.program.directives;
|
|
1225
|
+
return {
|
|
1226
|
+
name: file,
|
|
1227
|
+
file,
|
|
1228
|
+
frames,
|
|
1229
|
+
imports: readImports(ast),
|
|
1230
|
+
canvasNode: null,
|
|
1231
|
+
prologueEnd: directives.length ? directives[directives.length - 1].end : 0,
|
|
1232
|
+
declared: topLevelNames(ast)
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function parseTargetDoc(file, source) {
|
|
1236
|
+
const base = file.split("/").pop() ?? "";
|
|
1237
|
+
if (/^page\.[jt]sx?$/.test(base)) return parseSourceDoc(file, source, false);
|
|
1238
|
+
if (/^layout\.[jt]sx?$/.test(base)) return parseSourceDoc(file, source, true);
|
|
1239
|
+
return parseModuleDoc(file, source);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// src/core/routes.ts
|
|
1243
|
+
var PAGE = /^page\.(tsx|jsx|ts|js)$/;
|
|
1244
|
+
var LAYOUT = ["layout.tsx", "layout.jsx", "layout.js", "layout.ts"];
|
|
1245
|
+
function listRoutes(config) {
|
|
1246
|
+
const appDir = path4.join(config.root, config.appDir);
|
|
1247
|
+
const out = [];
|
|
1248
|
+
const walkDir = (dir, segments) => {
|
|
1249
|
+
let entries;
|
|
1250
|
+
try {
|
|
1251
|
+
entries = fs3.readdirSync(dir, { withFileTypes: true });
|
|
1252
|
+
} catch {
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
for (const e of entries) {
|
|
1256
|
+
if (e.isFile() && PAGE.test(e.name)) {
|
|
1257
|
+
const visible = segments.filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith("@"));
|
|
1258
|
+
out.push({ route: `/${visible.join("/")}`, file: path4.relative(config.root, path4.join(dir, e.name)), dynamic: segments.some((s) => s.startsWith("[")) });
|
|
1259
|
+
}
|
|
1260
|
+
if (e.isDirectory() && !e.name.startsWith("_") && e.name !== "truecanvas" && e.name !== "api" && e.name !== "node_modules") walkDir(path4.join(dir, e.name), [...segments, e.name]);
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
walkDir(appDir, []);
|
|
1264
|
+
return out.sort((a, b) => a.route.localeCompare(b.route));
|
|
1265
|
+
}
|
|
1266
|
+
function extract(file, canvasFile, config, layout) {
|
|
1267
|
+
const source = fs3.readFileSync(file, "utf8");
|
|
1268
|
+
const info = defaultReturn(source, layout);
|
|
1269
|
+
if (!info?.pure) return null;
|
|
1270
|
+
const imports = [];
|
|
1271
|
+
for (const stmt of info.ast.program.body) {
|
|
1272
|
+
if (stmt.type !== "ImportDeclaration" || stmt.importKind === "type") continue;
|
|
1273
|
+
const src = stmt.source.value;
|
|
1274
|
+
if (/\.(css|scss|sass|less)$/.test(src)) continue;
|
|
1275
|
+
const from = repoint(config, src, file, canvasFile);
|
|
1276
|
+
const text = source.slice(stmt.start, stmt.end);
|
|
1277
|
+
imports.push(from === src ? text : text.replace(JSON.stringify(src), JSON.stringify(from)).replace(`'${src}'`, `'${from}'`));
|
|
1278
|
+
}
|
|
1279
|
+
return { jsx: source.slice(info.node.start, info.node.end), imports };
|
|
1280
|
+
}
|
|
1281
|
+
function repoint(config, spec, fromFile, toFile) {
|
|
1282
|
+
if (!spec.startsWith(".")) return spec;
|
|
1283
|
+
return importSpecifier(config.root, path4.relative(config.root, toFile), path4.relative(config.root, path4.resolve(path4.dirname(fromFile), spec)));
|
|
1284
|
+
}
|
|
1285
|
+
function layoutFiles(config, pageFile) {
|
|
1286
|
+
const appDir = path4.join(config.root, config.appDir);
|
|
1287
|
+
const out = [];
|
|
1288
|
+
for (let dir = path4.dirname(path4.resolve(config.root, pageFile)); dir.startsWith(appDir) && dir !== appDir; dir = path4.dirname(dir)) {
|
|
1289
|
+
const file = LAYOUT.map((f) => path4.join(dir, f)).find((f) => fs3.existsSync(f));
|
|
1290
|
+
if (file) out.push(path4.relative(config.root, file));
|
|
1291
|
+
}
|
|
1292
|
+
return out;
|
|
1293
|
+
}
|
|
1294
|
+
function routeOf(config, pageFile) {
|
|
1295
|
+
const rel = path4.relative(path4.join(config.root, config.appDir), path4.dirname(path4.resolve(config.root, pageFile)));
|
|
1296
|
+
const visible = rel.split(path4.sep).filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("@"));
|
|
1297
|
+
return `/${visible.join("/")}`;
|
|
1298
|
+
}
|
|
1299
|
+
function routeFrame(config, pageFile, canvasFile) {
|
|
1300
|
+
const appDir = path4.join(config.root, config.appDir);
|
|
1301
|
+
const pageAbs = path4.resolve(config.root, pageFile);
|
|
1302
|
+
const canvasAbs = path4.resolve(config.root, canvasFile);
|
|
1303
|
+
const imports = [];
|
|
1304
|
+
let mode = "inline";
|
|
1305
|
+
const page = extract(pageAbs, canvasAbs, config, false);
|
|
1306
|
+
let jsx;
|
|
1307
|
+
if (page) {
|
|
1308
|
+
jsx = page.jsx;
|
|
1309
|
+
imports.push(...page.imports);
|
|
1310
|
+
} else {
|
|
1311
|
+
mode = "component";
|
|
1312
|
+
imports.push(`import RoutePage from ${JSON.stringify(importSpecifier(config.root, canvasFile, pageFile))};`);
|
|
1313
|
+
jsx = "<RoutePage />";
|
|
1314
|
+
}
|
|
1315
|
+
let i = 0;
|
|
1316
|
+
for (let dir = path4.dirname(pageAbs); dir.startsWith(appDir) && dir !== appDir; dir = path4.dirname(dir)) {
|
|
1317
|
+
const file = LAYOUT.map((f) => path4.join(dir, f)).find((f) => fs3.existsSync(f));
|
|
1318
|
+
if (!file) continue;
|
|
1319
|
+
const layout = extract(file, canvasAbs, config, true);
|
|
1320
|
+
const holes = layout ? (layout.jsx.match(/\{\s*children\s*\}/g) ?? []).length : 0;
|
|
1321
|
+
if (layout && holes === 1) {
|
|
1322
|
+
jsx = layout.jsx.replace(/\{\s*children\s*\}/, jsx.startsWith("<>") ? jsx.slice(2, -3) : jsx);
|
|
1323
|
+
imports.push(...layout.imports);
|
|
1324
|
+
} else {
|
|
1325
|
+
const local = `RouteLayout${i++ || ""}`;
|
|
1326
|
+
imports.push(`import ${local} from ${JSON.stringify(importSpecifier(config.root, canvasFile, path4.relative(config.root, file)))};`);
|
|
1327
|
+
jsx = `<${local}>${jsx}</${local}>`;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (jsx.trimStart().startsWith("<>")) jsx = `<div>${jsx.trim().slice(2, -3)}</div>`;
|
|
1331
|
+
return { jsx, imports: [...new Set(imports)], mode };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/core/link.ts
|
|
1335
|
+
var flatten = (n) => n.kind === "fragment" ? n.children : [n];
|
|
1336
|
+
function wrapperChain(frame) {
|
|
1337
|
+
const chain = [];
|
|
1338
|
+
for (let n = frame; ; ) {
|
|
1339
|
+
const kids = n.children.filter((c) => c.kind !== "text");
|
|
1340
|
+
if (kids.length !== 1 || kids[0].kind !== "component") return chain;
|
|
1341
|
+
chain.push(kids[0]);
|
|
1342
|
+
n = kids[0];
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
function readSource(config, rel) {
|
|
1346
|
+
try {
|
|
1347
|
+
return fs4.readFileSync(path5.join(config.root, rel), "utf8");
|
|
1348
|
+
} catch {
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
function resolveLinks(config, doc) {
|
|
1353
|
+
if (!doc.frames.some((f) => f.page || f.component)) return doc;
|
|
1354
|
+
return { ...doc, frames: doc.frames.map((f) => f.component ? linkComponent(config, f) : f.page ? linkFrame(config, f) : f) };
|
|
1355
|
+
}
|
|
1356
|
+
function splitComponentRef(ref) {
|
|
1357
|
+
const i = ref.lastIndexOf("#");
|
|
1358
|
+
return i > 0 ? { file: ref.slice(0, i), name: ref.slice(i + 1) } : { file: ref, name: "default" };
|
|
1359
|
+
}
|
|
1360
|
+
function linkComponent(config, original) {
|
|
1361
|
+
const frame = { ...original };
|
|
1362
|
+
const { file, name } = splitComponentRef(frame.component);
|
|
1363
|
+
const source = readSource(config, file);
|
|
1364
|
+
const doc = source === null ? null : parseModuleDoc(file, source);
|
|
1365
|
+
const own = doc?.frames.find((f) => f.frameName === name);
|
|
1366
|
+
if (!own) {
|
|
1367
|
+
frame.link = { kind: "component", page: file, route: name, files: [], editable: false, reason: source === null ? `${file} doesn't exist anymore.` : `${file} has no exported component ${name} returning JSX.` };
|
|
1368
|
+
return frame;
|
|
1369
|
+
}
|
|
1370
|
+
frame.children = flatten(own.children[0]);
|
|
1371
|
+
reindex(frame, frame.path);
|
|
1372
|
+
frame.link = { kind: "component", page: file, route: name, files: [file], editable: true };
|
|
1373
|
+
return frame;
|
|
1374
|
+
}
|
|
1375
|
+
function linkFrame(config, original) {
|
|
1376
|
+
const frame = { ...original };
|
|
1377
|
+
const page = frame.page;
|
|
1378
|
+
const route = routeOf(config, page);
|
|
1379
|
+
const pageSource = readSource(config, page);
|
|
1380
|
+
if (pageSource === null) {
|
|
1381
|
+
frame.link = { page, route, files: [], editable: false, reason: `${page} doesn't exist anymore.` };
|
|
1382
|
+
return frame;
|
|
1383
|
+
}
|
|
1384
|
+
const chain = wrapperChain(original);
|
|
1385
|
+
const files = [];
|
|
1386
|
+
let current;
|
|
1387
|
+
const pageDoc = parseSourceDoc(page, pageSource, false);
|
|
1388
|
+
if (pageDoc) {
|
|
1389
|
+
current = flatten(pageDoc.frames[0].children[0]);
|
|
1390
|
+
files.push(page);
|
|
1391
|
+
} else {
|
|
1392
|
+
current = chain.length ? [chain[chain.length - 1]] : original.children;
|
|
1393
|
+
}
|
|
1394
|
+
const layouts = layoutFiles(config, page);
|
|
1395
|
+
layouts.forEach((rel, i) => {
|
|
1396
|
+
const src = readSource(config, rel);
|
|
1397
|
+
const ldoc = src === null ? null : parseSourceDoc(rel, src, true);
|
|
1398
|
+
const root = ldoc?.frames[0].children[0];
|
|
1399
|
+
const slot = root && findSlot(root);
|
|
1400
|
+
if (root && slot) {
|
|
1401
|
+
slot.parent.children.splice(slot.index, 1, ...current);
|
|
1402
|
+
current = flatten(root);
|
|
1403
|
+
files.push(rel);
|
|
1404
|
+
} else {
|
|
1405
|
+
const wrapper = chain[layouts.length - 1 - i];
|
|
1406
|
+
if (wrapper) current = [{ ...wrapper, children: current }];
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
frame.children = current;
|
|
1410
|
+
reindex(frame, frame.path);
|
|
1411
|
+
frame.link = pageDoc ? { page, route, files, editable: true } : { page, route, files, editable: false, reason: "This page fetches data or has logic before its return, so it's shown as one layer. Explore a copy to design on it." };
|
|
1412
|
+
return frame;
|
|
1413
|
+
}
|
|
1414
|
+
function findSlot(root) {
|
|
1415
|
+
for (const [i, c] of root.children.entries()) {
|
|
1416
|
+
if (c.kind === "expression" && c.name.trim() === "children") return { parent: root, index: i };
|
|
1417
|
+
const deeper = findSlot(c);
|
|
1418
|
+
if (deeper) return deeper;
|
|
1419
|
+
}
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
var pascal = (s) => s.replace(/[^A-Za-z0-9]+/g, " ").trim().split(" ").filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
1423
|
+
function uniqueName(doc, taken, base) {
|
|
1424
|
+
const used = /* @__PURE__ */ new Set([...doc.declared, ...doc.imports.flatMap((i) => [...i.names, ...i.defaultName ? [i.defaultName] : []]), ...taken, "Canvas", "Frame"]);
|
|
1425
|
+
let name = /^[A-Z]/.test(base) ? base : `Page${base}`;
|
|
1426
|
+
for (let n = 2; used.has(name); n++) name = `${base}${n}`;
|
|
1427
|
+
taken.add(name);
|
|
1428
|
+
return name;
|
|
1429
|
+
}
|
|
1430
|
+
function importName(config, doc, canvasRel, file, base, taken, imports) {
|
|
1431
|
+
const spec = importSpecifier(config.root, canvasRel, file);
|
|
1432
|
+
const existing = doc.imports.find((i) => i.source === spec && i.defaultName);
|
|
1433
|
+
if (existing) return existing.defaultName;
|
|
1434
|
+
const name = uniqueName(doc, taken, base);
|
|
1435
|
+
imports.push(`import ${name} from ${JSON.stringify(spec)};`);
|
|
1436
|
+
return name;
|
|
1437
|
+
}
|
|
1438
|
+
function linkedFrameBody(config, doc, canvasRel, page) {
|
|
1439
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1440
|
+
const imports = [];
|
|
1441
|
+
const route = routeOf(config, page);
|
|
1442
|
+
const segments = route.split("/").filter(Boolean);
|
|
1443
|
+
const pageName = importName(config, doc, canvasRel, page, `${segments.length ? pascal(segments[segments.length - 1]) : "Home"}Page`, taken, imports);
|
|
1444
|
+
let jsx = `<${pageName} />`;
|
|
1445
|
+
for (const rel of layoutFiles(config, page)) {
|
|
1446
|
+
const dir = path5.basename(path5.dirname(rel));
|
|
1447
|
+
const name = importName(config, doc, canvasRel, rel, `${pascal(dir) || "Route"}Layout`, taken, imports);
|
|
1448
|
+
jsx = `<${name}>
|
|
1449
|
+
${indentBlock(jsx)}
|
|
1450
|
+
</${name}>`;
|
|
1451
|
+
}
|
|
1452
|
+
return { imports, jsx };
|
|
1453
|
+
}
|
|
1454
|
+
function unwrapFragment(jsx) {
|
|
1455
|
+
let code = jsx.trim();
|
|
1456
|
+
if (code.startsWith("<>") && code.endsWith("</>")) code = code.slice(2, -3).trim();
|
|
1457
|
+
return dedent(code);
|
|
1458
|
+
}
|
|
1459
|
+
function dedent(code) {
|
|
1460
|
+
const lines = code.split("\n");
|
|
1461
|
+
const rest = lines.slice(1).filter((l) => l.trim());
|
|
1462
|
+
const base = rest.length ? Math.min(...rest.map((l) => /^[ \t]*/.exec(l)[0].length)) : 0;
|
|
1463
|
+
return [lines[0], ...lines.slice(1).map((l) => l.trim() ? l.slice(base) : "")].join("\n");
|
|
1464
|
+
}
|
|
1465
|
+
function explorationBody(config, frame, canvasRel) {
|
|
1466
|
+
if (frame.component) throw new EditError(`"${frame.frameName}" is a main component: edit it directly, every instance follows.`);
|
|
1467
|
+
if (!frame.page) throw new EditError(`"${frame.frameName}" isn't linked to a page.`);
|
|
1468
|
+
const pageAbs = path5.join(config.root, frame.page);
|
|
1469
|
+
const source = readSource(config, frame.page);
|
|
1470
|
+
if (source === null) throw new EditError(`${frame.page} doesn't exist anymore.`);
|
|
1471
|
+
const chain = wrapperChain(frame);
|
|
1472
|
+
const wrappers = chain.slice(0, -1).map((n) => n.name);
|
|
1473
|
+
const info = defaultReturn(source, false);
|
|
1474
|
+
const imports = [];
|
|
1475
|
+
let jsx;
|
|
1476
|
+
if (info?.pure) {
|
|
1477
|
+
for (const stmt of info.ast.program.body) {
|
|
1478
|
+
if (stmt.type !== "ImportDeclaration" || stmt.importKind === "type" || /\.(css|scss|sass|less)$/.test(stmt.source.value)) continue;
|
|
1479
|
+
const src = stmt.source.value;
|
|
1480
|
+
const from = repoint(config, src, pageAbs, path5.join(config.root, canvasRel));
|
|
1481
|
+
const text = source.slice(stmt.start, stmt.end);
|
|
1482
|
+
imports.push(from === src ? text : text.replace(JSON.stringify(src), JSON.stringify(from)).replace(`'${src}'`, `'${from}'`));
|
|
1483
|
+
}
|
|
1484
|
+
jsx = unwrapFragment(source.slice(info.node.start, info.node.end));
|
|
1485
|
+
} else {
|
|
1486
|
+
jsx = chain.length ? `<${chain[chain.length - 1].name} />` : "";
|
|
1487
|
+
}
|
|
1488
|
+
for (const w of [...wrappers].reverse()) jsx = `<${w}>
|
|
1489
|
+
${indentBlock(jsx)}
|
|
1490
|
+
</${w}>`;
|
|
1491
|
+
return { imports, jsx, inline: !!info?.pure };
|
|
1492
|
+
}
|
|
1493
|
+
function applyPlan(config, canvasSource, doc, frame, canvasRel) {
|
|
1494
|
+
const page = frame.from;
|
|
1495
|
+
if (!page) throw new EditError(`"${frame.frameName}" isn't an exploration of a page. Explore a copy of a linked frame first.`);
|
|
1496
|
+
if (!fs4.existsSync(path5.join(config.root, page))) throw new EditError(`${page} doesn't exist anymore.`);
|
|
1497
|
+
const layoutSpecs = new Set(doc.imports.filter((i) => /(^|\/)layout(\.\w+)?$/.test(i.source)).flatMap((i) => [...i.names, ...i.defaultName ? [i.defaultName] : []]));
|
|
1498
|
+
let content = frame.children;
|
|
1499
|
+
for (; ; ) {
|
|
1500
|
+
const kids = content.filter((c) => c.kind !== "text" || c.text?.trim());
|
|
1501
|
+
if (kids.length === 1 && kids[0].kind === "component" && layoutSpecs.has(kids[0].name)) content = kids[0].children;
|
|
1502
|
+
else break;
|
|
1503
|
+
}
|
|
1504
|
+
content = content.filter((c) => !(c.kind === "text" && !c.text?.trim()));
|
|
1505
|
+
if (!content.length) throw new EditError(`"${frame.frameName}" is empty: there's nothing to apply.`);
|
|
1506
|
+
const body = dedent(canvasSource.slice(content[0].start, content[content.length - 1].end));
|
|
1507
|
+
const single = content.length === 1 && content[0].kind !== "text" && content[0].kind !== "expression";
|
|
1508
|
+
const jsx = single ? body : `<>
|
|
1509
|
+
${indentBlock(body)}
|
|
1510
|
+
</>`;
|
|
1511
|
+
const ast = parseModule(canvasSource);
|
|
1512
|
+
const imports = [];
|
|
1513
|
+
for (const name of componentNamesIn(jsx)) {
|
|
1514
|
+
const decl = ast.program.body.find(
|
|
1515
|
+
(s) => s.type === "ImportDeclaration" && s.importKind !== "type" && s.specifiers.some((sp2) => sp2.local.name === name)
|
|
1516
|
+
);
|
|
1517
|
+
if (!decl) {
|
|
1518
|
+
if (doc.declared.includes(name)) throw new EditError(`<${name}> is defined in the canvas file itself. Move it to a component file before applying.`);
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
const sp = decl.specifiers.find((x) => x.local.name === name);
|
|
1522
|
+
const from = repoint(config, decl.source.value, path5.join(config.root, canvasRel), path5.join(config.root, page));
|
|
1523
|
+
const what = sp.type === "ImportDefaultSpecifier" ? name : sp.type === "ImportNamespaceSpecifier" ? `* as ${name}` : `{ ${sp.imported.type === "Identifier" && sp.imported.name !== name ? `${sp.imported.name} as ${name}` : name} }`;
|
|
1524
|
+
imports.push(`import ${what} from ${JSON.stringify(from)};`);
|
|
1525
|
+
}
|
|
1526
|
+
return { page, jsx, imports };
|
|
1527
|
+
}
|
|
1528
|
+
function pruneImports(source, candidates) {
|
|
1529
|
+
let ast;
|
|
1530
|
+
try {
|
|
1531
|
+
ast = parseModule(source);
|
|
1532
|
+
} catch {
|
|
1533
|
+
return source;
|
|
1534
|
+
}
|
|
1535
|
+
const used = /* @__PURE__ */ new Set();
|
|
1536
|
+
for (const stmt of ast.program.body) {
|
|
1537
|
+
if (stmt.type === "ImportDeclaration") continue;
|
|
1538
|
+
walk(stmt, (n) => {
|
|
1539
|
+
if (n.type === "Identifier" || n.type === "JSXIdentifier") used.add(n.name);
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
let out = source;
|
|
1543
|
+
const decls = ast.program.body.filter((s) => s.type === "ImportDeclaration" && s.specifiers.length > 0);
|
|
1544
|
+
for (const decl of [...decls].reverse()) {
|
|
1545
|
+
const names = decl.specifiers.map((s) => s.local.name);
|
|
1546
|
+
if (!names.every((n) => candidates.has(n) && !used.has(n))) continue;
|
|
1547
|
+
let end = decl.end;
|
|
1548
|
+
if (out[end] === "\n") end++;
|
|
1549
|
+
out = out.slice(0, decl.start) + out.slice(end);
|
|
1550
|
+
}
|
|
1551
|
+
return out;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// src/core/motion.ts
|
|
1555
|
+
import fs5 from "fs";
|
|
1556
|
+
import path6 from "path";
|
|
1557
|
+
var REVEAL = `"use client";
|
|
1558
|
+
|
|
1559
|
+
import { Children, cloneElement, isValidElement, useEffect, useRef, useState, type CSSProperties, type ReactElement, type ReactNode } from "react";
|
|
1560
|
+
|
|
1561
|
+
export type RevealEffect = "fade-up" | "fade-down" | "fade-in" | "blur-in" | "scale-in" | "slide-left" | "slide-right";
|
|
1562
|
+
|
|
1563
|
+
export interface RevealProps {
|
|
1564
|
+
/** How it enters */
|
|
1565
|
+
effect?: RevealEffect;
|
|
1566
|
+
/** Wait before it starts, in seconds (0 to 2) */
|
|
1567
|
+
delay?: number;
|
|
1568
|
+
/** Length of the animation, in seconds (0.1 to 2) */
|
|
1569
|
+
duration?: number;
|
|
1570
|
+
/** Animate each child one after another, this many seconds apart (0 to 0.3) */
|
|
1571
|
+
stagger?: number;
|
|
1572
|
+
/** Play only the first time it scrolls into view */
|
|
1573
|
+
once?: boolean;
|
|
1574
|
+
className?: string;
|
|
1575
|
+
children?: ReactNode;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Short distances and a light blur: enough to read as motion, never a jump.
|
|
1579
|
+
const HIDDEN: Record<RevealEffect, CSSProperties> = {
|
|
1580
|
+
"fade-up": { opacity: 0, transform: "translateY(16px)", filter: "blur(4px)" },
|
|
1581
|
+
"fade-down": { opacity: 0, transform: "translateY(-16px)", filter: "blur(4px)" },
|
|
1582
|
+
"fade-in": { opacity: 0 },
|
|
1583
|
+
"blur-in": { opacity: 0, filter: "blur(10px)" },
|
|
1584
|
+
"scale-in": { opacity: 0, transform: "scale(0.96)", filter: "blur(4px)" },
|
|
1585
|
+
"slide-left": { opacity: 0, transform: "translateX(24px)", filter: "blur(4px)" },
|
|
1586
|
+
"slide-right": { opacity: 0, transform: "translateX(-24px)", filter: "blur(4px)" },
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
const EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
1590
|
+
|
|
1591
|
+
/** Animates its content in when it scrolls into view. Added by Truecanvas; it's your code now. */
|
|
1592
|
+
export function Reveal({ effect = "fade-up", delay = 0, duration = 0.6, stagger = 0, once = true, className, children }: RevealProps) {
|
|
1593
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
1594
|
+
const [shown, setShown] = useState(false);
|
|
1595
|
+
const [reduced, setReduced] = useState(false);
|
|
1596
|
+
useEffect(() => {
|
|
1597
|
+
const el = ref.current;
|
|
1598
|
+
if (!el) return;
|
|
1599
|
+
setReduced(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
1600
|
+
const io = new IntersectionObserver(
|
|
1601
|
+
([entry]) => {
|
|
1602
|
+
if (entry.isIntersecting) {
|
|
1603
|
+
setShown(true);
|
|
1604
|
+
if (once) io.disconnect();
|
|
1605
|
+
} else if (!once) setShown(false);
|
|
1606
|
+
},
|
|
1607
|
+
{ root: document, rootMargin: "0px 0px -48px 0px", threshold: 0 },
|
|
1608
|
+
);
|
|
1609
|
+
io.observe(el);
|
|
1610
|
+
return () => io.disconnect();
|
|
1611
|
+
}, [once]);
|
|
1612
|
+
|
|
1613
|
+
const styleAt = (d: number): CSSProperties => {
|
|
1614
|
+
// Reduced motion keeps a short fade (it helps reading) and drops the movement.
|
|
1615
|
+
if (reduced) return { opacity: shown ? 1 : 0, transition: \`opacity 0.2s ease \${d}s\` };
|
|
1616
|
+
return {
|
|
1617
|
+
...(shown ? { opacity: 1, transform: "none", filter: "none" } : HIDDEN[effect]),
|
|
1618
|
+
transition: \`opacity \${duration}s \${EASE_OUT} \${d}s, transform \${duration}s \${EASE_OUT} \${d}s, filter \${duration}s \${EASE_OUT} \${d}s\`,
|
|
1619
|
+
};
|
|
1620
|
+
};
|
|
1621
|
+
|
|
1622
|
+
if (stagger > 0) {
|
|
1623
|
+
let i = 0;
|
|
1624
|
+
return (
|
|
1625
|
+
<div ref={ref} className={className}>
|
|
1626
|
+
{Children.map(children, (child) => {
|
|
1627
|
+
if (!isValidElement(child)) return child;
|
|
1628
|
+
const el = child as ReactElement<{ style?: CSSProperties }>;
|
|
1629
|
+
return cloneElement(el, { style: { ...el.props.style, ...styleAt(delay + i++ * stagger) } });
|
|
1630
|
+
})}
|
|
1631
|
+
</div>
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
return (
|
|
1635
|
+
<div ref={ref} className={className} style={styleAt(delay)}>
|
|
1636
|
+
{children}
|
|
1637
|
+
</div>
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
`;
|
|
1641
|
+
var TEXT_ANIMATE = `"use client";
|
|
1642
|
+
|
|
1643
|
+
import { Children, cloneElement, isValidElement, use, useEffect, useRef, useState, type CSSProperties, type ReactElement, type ReactNode } from "react";
|
|
1644
|
+
|
|
1645
|
+
export type TextEffect = "words" | "letters" | "blur-in" | "slide-up" | "typewriter";
|
|
1646
|
+
|
|
1647
|
+
export interface TextAnimateProps {
|
|
1648
|
+
/** How the text appears */
|
|
1649
|
+
effect?: TextEffect;
|
|
1650
|
+
/** Time between words or letters, in seconds (0 to 0.3) */
|
|
1651
|
+
stagger?: number;
|
|
1652
|
+
/** Wait before it starts, in seconds (0 to 2) */
|
|
1653
|
+
delay?: number;
|
|
1654
|
+
/** Length of each word's or letter's animation, in seconds (0.1 to 1.5) */
|
|
1655
|
+
duration?: number;
|
|
1656
|
+
/** Play only the first time it scrolls into view */
|
|
1657
|
+
once?: boolean;
|
|
1658
|
+
className?: string;
|
|
1659
|
+
children?: ReactNode;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const FROM: Record<TextEffect, CSSProperties> = {
|
|
1663
|
+
words: { opacity: 0, transform: "translateY(0.25em)", filter: "blur(6px)" },
|
|
1664
|
+
letters: { opacity: 0, transform: "translateY(0.25em)", filter: "blur(4px)" },
|
|
1665
|
+
"blur-in": { opacity: 0, filter: "blur(8px)" },
|
|
1666
|
+
"slide-up": { transform: "translateY(110%)" },
|
|
1667
|
+
typewriter: { opacity: 0 },
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
const SR_ONLY: CSSProperties = { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)", whiteSpace: "nowrap" };
|
|
1671
|
+
|
|
1672
|
+
// Elements a Server Component passes down can reach the client as lazy
|
|
1673
|
+
// references (React 19). Resolve them so server and client split the same text.
|
|
1674
|
+
const LAZY = Symbol.for("react.lazy");
|
|
1675
|
+
function resolve(node: ReactNode): ReactNode {
|
|
1676
|
+
const lazy = node as { $$typeof?: symbol; _payload?: PromiseLike<ReactNode> } | null;
|
|
1677
|
+
return lazy && typeof lazy === "object" && lazy.$$typeof === LAZY && lazy._payload ? resolve(use(lazy._payload as Promise<ReactNode>)) : node;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function textOf(node: ReactNode): string {
|
|
1681
|
+
node = resolve(node);
|
|
1682
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
1683
|
+
if (Array.isArray(node)) return node.map(textOf).join("");
|
|
1684
|
+
if (isValidElement(node)) {
|
|
1685
|
+
const props = node.props as { children?: ReactNode; "aria-hidden"?: unknown };
|
|
1686
|
+
return props["aria-hidden"] ? "" : textOf(props.children);
|
|
1687
|
+
}
|
|
1688
|
+
return "";
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/**
|
|
1692
|
+
* Reveals text word by word or letter by letter when it scrolls into view.
|
|
1693
|
+
* Inline elements inside (like <em>) keep their styling. Added by Truecanvas;
|
|
1694
|
+
* it's your code now.
|
|
1695
|
+
*/
|
|
1696
|
+
export function TextAnimate({ effect = "words", stagger = 0.06, delay = 0, duration = 0.5, once = true, className, children }: TextAnimateProps) {
|
|
1697
|
+
const ref = useRef<HTMLSpanElement>(null);
|
|
1698
|
+
const [shown, setShown] = useState(false);
|
|
1699
|
+
useEffect(() => {
|
|
1700
|
+
const el = ref.current;
|
|
1701
|
+
if (!el) return;
|
|
1702
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
1703
|
+
setShown(true);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
const io = new IntersectionObserver(
|
|
1707
|
+
([entry]) => {
|
|
1708
|
+
if (entry.isIntersecting) {
|
|
1709
|
+
setShown(true);
|
|
1710
|
+
if (once) io.disconnect();
|
|
1711
|
+
} else if (!once) setShown(false);
|
|
1712
|
+
},
|
|
1713
|
+
{ root: document, rootMargin: "0px 0px -48px 0px", threshold: 0 },
|
|
1714
|
+
);
|
|
1715
|
+
io.observe(el);
|
|
1716
|
+
return () => io.disconnect();
|
|
1717
|
+
}, [once]);
|
|
1718
|
+
|
|
1719
|
+
const byLetter = effect === "letters" || effect === "typewriter";
|
|
1720
|
+
const ease = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
1721
|
+
let i = 0;
|
|
1722
|
+
const piece = (content: ReactNode, key: string) => {
|
|
1723
|
+
const d = delay + i++ * stagger;
|
|
1724
|
+
const transition =
|
|
1725
|
+
effect === "typewriter" ? \`opacity 0.01s linear \${d}s\` : \`opacity \${duration}s \${ease} \${d}s, transform \${duration}s \${ease} \${d}s, filter \${duration}s \${ease} \${d}s\`;
|
|
1726
|
+
const style: CSSProperties = { display: "inline-block", whiteSpace: "pre", ...(shown ? { opacity: 1, transform: "none", filter: "none" } : FROM[effect]), transition };
|
|
1727
|
+
const span = (
|
|
1728
|
+
<span key={key} style={style}>
|
|
1729
|
+
{content}
|
|
1730
|
+
</span>
|
|
1731
|
+
);
|
|
1732
|
+
// slide-up: each word rises from behind its own line
|
|
1733
|
+
return effect === "slide-up" ? (
|
|
1734
|
+
<span key={key} style={{ display: "inline-block", overflow: "hidden", verticalAlign: "bottom" }}>
|
|
1735
|
+
{span}
|
|
1736
|
+
</span>
|
|
1737
|
+
) : (
|
|
1738
|
+
span
|
|
1739
|
+
);
|
|
1740
|
+
};
|
|
1741
|
+
const split = (nodes: ReactNode, prefix: string): ReactNode[] => {
|
|
1742
|
+
const out: ReactNode[] = [];
|
|
1743
|
+
Children.toArray(nodes).forEach((raw, ci) => {
|
|
1744
|
+
const child = resolve(raw);
|
|
1745
|
+
const key = \`\${prefix}\${ci}\`;
|
|
1746
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
1747
|
+
String(child)
|
|
1748
|
+
.split(/(\\s+)/)
|
|
1749
|
+
.forEach((part, pi) => {
|
|
1750
|
+
if (!part) return;
|
|
1751
|
+
if (/^\\s+$/.test(part)) return void out.push(" ");
|
|
1752
|
+
const k = \`\${key}-\${pi}\`;
|
|
1753
|
+
if (byLetter)
|
|
1754
|
+
out.push(
|
|
1755
|
+
<span key={k} style={{ display: "inline-block", whiteSpace: "nowrap" }}>
|
|
1756
|
+
{Array.from(part).map((ch, li) => piece(ch, \`\${k}-\${li}\`))}
|
|
1757
|
+
</span>,
|
|
1758
|
+
);
|
|
1759
|
+
else out.push(piece(part, k));
|
|
1760
|
+
});
|
|
1761
|
+
} else if (isValidElement(child) && (child.props as { children?: ReactNode }).children != null && !(child.props as { "aria-hidden"?: unknown })["aria-hidden"]) {
|
|
1762
|
+
// <em>, <strong>, links\u2026: keep the element, animate the text inside it
|
|
1763
|
+
const el = child as ReactElement<{ children?: ReactNode }>;
|
|
1764
|
+
out.push(cloneElement(el, { key }, split(el.props.children, \`\${key}.\`)));
|
|
1765
|
+
} else out.push(child);
|
|
1766
|
+
});
|
|
1767
|
+
return out;
|
|
1768
|
+
};
|
|
1769
|
+
|
|
1770
|
+
return (
|
|
1771
|
+
<span ref={ref} className={className}>
|
|
1772
|
+
<span style={SR_ONLY}>{textOf(children)}</span>
|
|
1773
|
+
<span aria-hidden>{split(children, "")}</span>
|
|
1774
|
+
</span>
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
`;
|
|
1778
|
+
function motionFiles(config) {
|
|
1779
|
+
const glob = config.components[0] ?? "components/**/*.tsx";
|
|
1780
|
+
const base = glob.split("/").filter((p) => !p.includes("*")).join("/") || "components";
|
|
1781
|
+
const dir = path6.join(base, "motion");
|
|
1782
|
+
return { reveal: path6.join(dir, "reveal.tsx"), text: path6.join(dir, "text-animate.tsx") };
|
|
1783
|
+
}
|
|
1784
|
+
function ensureMotionComponents(config) {
|
|
1785
|
+
const files = motionFiles(config);
|
|
1786
|
+
let created = false;
|
|
1787
|
+
for (const [rel, content] of [
|
|
1788
|
+
[files.reveal, REVEAL],
|
|
1789
|
+
[files.text, TEXT_ANIMATE]
|
|
1790
|
+
]) {
|
|
1791
|
+
const abs = path6.join(config.root, rel);
|
|
1792
|
+
if (fs5.existsSync(abs)) continue;
|
|
1793
|
+
fs5.mkdirSync(path6.dirname(abs), { recursive: true });
|
|
1794
|
+
fs5.writeFileSync(abs, content);
|
|
1795
|
+
created = true;
|
|
1796
|
+
}
|
|
1797
|
+
return { ...files, created };
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// src/core/components.ts
|
|
1801
|
+
import fs6 from "fs";
|
|
1802
|
+
import path7 from "path";
|
|
1803
|
+
function componentsDir(config) {
|
|
1804
|
+
const glob = config.components[0] ?? "components/**/*.tsx";
|
|
1805
|
+
return glob.split("/").filter((p) => !p.includes("*")).join("/") || "components";
|
|
1806
|
+
}
|
|
1807
|
+
var kebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^A-Za-z0-9]+/g, "-").toLowerCase().replace(/^-|-$/g, "");
|
|
1808
|
+
function componentName(input) {
|
|
1809
|
+
const name = input.trim().split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
1810
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(name) ? name : null;
|
|
1811
|
+
}
|
|
1812
|
+
var GLOBALS = /* @__PURE__ */ new Set(["undefined", "null", "true", "false", "Math", "JSON", "Number", "String", "Boolean", "Date", "Array", "Object", "Intl", "console", "window", "document"]);
|
|
1813
|
+
function freeNames(jsx) {
|
|
1814
|
+
const ast = parseModule(`<>${jsx}</>`);
|
|
1815
|
+
const out = /* @__PURE__ */ new Set();
|
|
1816
|
+
const visit = (n, locals) => {
|
|
1817
|
+
if (!n || typeof n !== "object") return;
|
|
1818
|
+
switch (n.type) {
|
|
1819
|
+
case "Identifier":
|
|
1820
|
+
if (!locals.has(n.name)) out.add(n.name);
|
|
1821
|
+
return;
|
|
1822
|
+
case "MemberExpression":
|
|
1823
|
+
visit(n.object, locals);
|
|
1824
|
+
if (n.computed) visit(n.property, locals);
|
|
1825
|
+
return;
|
|
1826
|
+
case "ObjectProperty":
|
|
1827
|
+
if (n.computed) visit(n.key, locals);
|
|
1828
|
+
visit(n.value, locals);
|
|
1829
|
+
return;
|
|
1830
|
+
case "ArrowFunctionExpression":
|
|
1831
|
+
case "FunctionExpression": {
|
|
1832
|
+
const inner = new Set(locals);
|
|
1833
|
+
for (const p of n.params) walk(p, (x) => void (x.type === "Identifier" && inner.add(x.name)));
|
|
1834
|
+
visit(n.body, inner);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
case "JSXAttribute":
|
|
1838
|
+
visit(n.value, locals);
|
|
1839
|
+
return;
|
|
1840
|
+
case "JSXOpeningElement":
|
|
1841
|
+
for (const a of n.attributes) visit(a, locals);
|
|
1842
|
+
return;
|
|
1843
|
+
case "JSXClosingElement":
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
for (const key of Object.keys(n)) {
|
|
1847
|
+
if (key === "loc" || key === "start" || key === "end" || key.endsWith("Comments")) continue;
|
|
1848
|
+
const v = n[key];
|
|
1849
|
+
if (Array.isArray(v)) for (const x of v) visit(x, locals);
|
|
1850
|
+
else if (v && typeof v === "object" && "type" in v) visit(v, locals);
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
visit(ast.program, /* @__PURE__ */ new Set());
|
|
1854
|
+
return out;
|
|
1855
|
+
}
|
|
1856
|
+
function importsFor(config, jsx, source, doc, fromRel, toRel) {
|
|
1857
|
+
const ast = parseModule(source);
|
|
1858
|
+
const needed = /* @__PURE__ */ new Set([...componentNamesIn(jsx), ...freeNames(jsx)]);
|
|
1859
|
+
const out = [];
|
|
1860
|
+
for (const name of needed) {
|
|
1861
|
+
if (GLOBALS.has(name)) continue;
|
|
1862
|
+
const decl = ast.program.body.find(
|
|
1863
|
+
(s) => s.type === "ImportDeclaration" && s.importKind !== "type" && s.specifiers.some((sp2) => sp2.local.name === name)
|
|
1864
|
+
);
|
|
1865
|
+
if (!decl) {
|
|
1866
|
+
if (doc.declared.includes(name)) throw new EditError(`These layers use ${name}, defined in ${path7.basename(fromRel)}. Move it to its own file first, or ask your agent to extract the component.`);
|
|
1867
|
+
throw new EditError(`These layers use \`${name}\` from the surrounding code, so they can't become a component automatically. Ask your agent to extract it with props.`);
|
|
1868
|
+
}
|
|
1869
|
+
const sp = decl.specifiers.find((x) => x.local.name === name);
|
|
1870
|
+
const from = repoint(config, decl.source.value, path7.join(config.root, fromRel), path7.join(config.root, toRel));
|
|
1871
|
+
const what = sp.type === "ImportDefaultSpecifier" ? name : sp.type === "ImportNamespaceSpecifier" ? `* as ${name}` : `{ ${sp.imported.type === "Identifier" && sp.imported.name !== name ? `${sp.imported.name} as ${name}` : name} }`;
|
|
1872
|
+
out.push(`import ${what} from ${JSON.stringify(from)};`);
|
|
1873
|
+
}
|
|
1874
|
+
return out;
|
|
1875
|
+
}
|
|
1876
|
+
function starterJsx(name) {
|
|
1877
|
+
const words = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
|
1878
|
+
return `<div className="flex flex-col gap-2 rounded-xl border border-neutral-200 p-6">
|
|
1879
|
+
<h3 className="text-lg font-semibold">${words}</h3>
|
|
1880
|
+
<p className="text-sm text-neutral-500">Describe it here.</p>
|
|
1881
|
+
</div>`;
|
|
1882
|
+
}
|
|
1883
|
+
function componentFile(name, jsx, imports) {
|
|
1884
|
+
return `${imports.length ? `${imports.join("\n")}
|
|
1885
|
+
|
|
1886
|
+
` : ""}export function ${name}() {
|
|
1887
|
+
return (
|
|
1888
|
+
${indentBlock(indentBlock(jsx.trim()))}
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
`;
|
|
1892
|
+
}
|
|
1893
|
+
function newComponentPath(config, name) {
|
|
1894
|
+
const rel = path7.join(componentsDir(config), `${kebab(name)}.tsx`);
|
|
1895
|
+
if (fs6.existsSync(path7.join(config.root, rel))) throw new EditError(`${rel} already exists. Pick another name.`);
|
|
1896
|
+
return rel;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// src/core/scaffold.ts
|
|
1900
|
+
import fs7 from "fs";
|
|
1901
|
+
import path8 from "path";
|
|
1902
|
+
var CANVAS_SUFFIX = ".canvas.tsx";
|
|
1903
|
+
var ROUTE = "truecanvas";
|
|
1904
|
+
function canvasFiles(config) {
|
|
1905
|
+
const dir = path8.join(config.root, config.canvasDir);
|
|
1906
|
+
if (!fs7.existsSync(dir)) return [];
|
|
1907
|
+
return fs7.readdirSync(dir).filter((f) => f.endsWith(CANVAS_SUFFIX)).sort((a, b) => canvasNameOf(a).localeCompare(canvasNameOf(b)));
|
|
1908
|
+
}
|
|
1909
|
+
function canvasNameOf(file) {
|
|
1910
|
+
return path8.basename(file).slice(0, -CANVAS_SUFFIX.length);
|
|
1911
|
+
}
|
|
1912
|
+
function canvasPath(config, name) {
|
|
1913
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/i.test(name)) throw new Error(`Invalid canvas name "${name}". Use letters, digits, - and _.`);
|
|
1914
|
+
return path8.join(config.root, config.canvasDir, `${name}${CANVAS_SUFFIX}`);
|
|
1915
|
+
}
|
|
1916
|
+
function writeIfChanged(file, content) {
|
|
1917
|
+
if (fs7.existsSync(file) && fs7.readFileSync(file, "utf8") === content) return false;
|
|
1918
|
+
fs7.mkdirSync(path8.dirname(file), { recursive: true });
|
|
1919
|
+
fs7.writeFileSync(file, content);
|
|
1920
|
+
return true;
|
|
1921
|
+
}
|
|
1922
|
+
function syncRoute(config) {
|
|
1923
|
+
const routeDir = path8.join(config.root, config.appDir, ROUTE);
|
|
1924
|
+
const pageFile = path8.join(routeDir, "[canvas]", "page.tsx");
|
|
1925
|
+
const registryFile = path8.join(routeDir, "canvases.tsx");
|
|
1926
|
+
const canvasDirAbs = path8.join(config.root, config.canvasDir);
|
|
1927
|
+
let rel = path8.relative(routeDir, canvasDirAbs);
|
|
1928
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
1929
|
+
const compareDir = path8.join(routeDir, "compare");
|
|
1930
|
+
const compares = fs7.existsSync(compareDir) ? fs7.readdirSync(compareDir).filter((f) => f.endsWith(CANVAS_SUFFIX)) : [];
|
|
1931
|
+
const entries = [
|
|
1932
|
+
...canvasFiles(config).map((f) => ` ${JSON.stringify(canvasNameOf(f))}: lazy(() => import(${JSON.stringify(`${rel}/${f.replace(/\.tsx$/, "")}`)})),`),
|
|
1933
|
+
// older versions rendered for Compare
|
|
1934
|
+
...compares.map((f) => ` ${JSON.stringify(`__compare__${canvasNameOf(f)}`)}: lazy(() => import(${JSON.stringify(`./compare/${f.replace(/\.tsx$/, "")}`)})),`)
|
|
1935
|
+
].join("\n");
|
|
1936
|
+
const registry = `"use client";
|
|
1937
|
+
/* eslint-disable */
|
|
1938
|
+
// Generated by Truecanvas. Do not edit: it is regenerated when canvases are added or removed.
|
|
1939
|
+
import { lazy } from "react";
|
|
1940
|
+
import { TruecanvasHost, type HostProps } from "truecanvas/host";
|
|
1941
|
+
import { components } from "./components";
|
|
1942
|
+
|
|
1943
|
+
const canvases = {
|
|
1944
|
+
${entries}
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
export default function Canvases(props: Omit<HostProps, "canvases" | "components">) {
|
|
1948
|
+
return <TruecanvasHost {...props} canvases={canvases} components={components} />;
|
|
1949
|
+
}
|
|
1950
|
+
`;
|
|
1951
|
+
const dm = config.darkMode;
|
|
1952
|
+
const page = `/* eslint-disable */
|
|
1953
|
+
// Generated by Truecanvas. Dev-only route that renders canvas frames.
|
|
1954
|
+
import { notFound } from "next/navigation";
|
|
1955
|
+
import Canvases from "../canvases";
|
|
1956
|
+
|
|
1957
|
+
export const dynamic = "force-dynamic";
|
|
1958
|
+
|
|
1959
|
+
export default async function TruecanvasFrame({
|
|
1960
|
+
params,
|
|
1961
|
+
searchParams,
|
|
1962
|
+
}: {
|
|
1963
|
+
params: Promise<{ canvas: string }>;
|
|
1964
|
+
searchParams: Promise<Record<string, string | undefined>>;
|
|
1965
|
+
}) {
|
|
1966
|
+
if (process.env.NODE_ENV !== "development") notFound();
|
|
1967
|
+
const { canvas } = await params;
|
|
1968
|
+
const query = await searchParams;
|
|
1969
|
+
return (
|
|
1970
|
+
<Canvases
|
|
1971
|
+
canvas={canvas}
|
|
1972
|
+
frame={query.frame ?? null}
|
|
1973
|
+
theme={(query.theme as "light" | "dark" | "system" | undefined) ?? "system"}
|
|
1974
|
+
darkMode={${JSON.stringify(dm)}}
|
|
1975
|
+
/>
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
`;
|
|
1979
|
+
const componentsFile = path8.join(routeDir, "components.tsx");
|
|
1980
|
+
if (!fs7.existsSync(componentsFile)) writeIfChanged(componentsFile, componentRegistry(config, []));
|
|
1981
|
+
const a = writeIfChanged(registryFile, registry);
|
|
1982
|
+
const b = writeIfChanged(pageFile, page);
|
|
1983
|
+
return a || b;
|
|
1984
|
+
}
|
|
1985
|
+
var STARTER_CANVAS = `"use client";
|
|
1986
|
+
import { Canvas, Frame } from "truecanvas";
|
|
1987
|
+
|
|
1988
|
+
/*
|
|
1989
|
+
* A placeholder page. Replace the Welcome frame with your own components:
|
|
1990
|
+
* drag them from Assets, draw frames with F, or ask your agent over MCP.
|
|
1991
|
+
* Inline styles keep it readable in any app, with or without Tailwind.
|
|
1992
|
+
*/
|
|
1993
|
+
export default function StarterCanvas() {
|
|
1994
|
+
return (
|
|
1995
|
+
<Canvas>
|
|
1996
|
+
<Frame name="Welcome" x={0} y={0} width={1200} height={760}>
|
|
1997
|
+
<div
|
|
1998
|
+
style={{
|
|
1999
|
+
height: "100%",
|
|
2000
|
+
display: "flex",
|
|
2001
|
+
alignItems: "center",
|
|
2002
|
+
justifyContent: "center",
|
|
2003
|
+
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
|
|
2004
|
+
color: "light-dark(#1d1d1b, #ededea)",
|
|
2005
|
+
backgroundColor: "light-dark(#f6f5f2, #121211)",
|
|
2006
|
+
backgroundImage: "radial-gradient(light-dark(rgba(0,0,0,.07), rgba(255,255,255,.06)) 1px, transparent 1px), radial-gradient(circle at 50% 38%, rgba(255,138,92,.16), transparent 55%)",
|
|
2007
|
+
backgroundSize: "22px 22px, 100% 100%",
|
|
2008
|
+
}}
|
|
2009
|
+
>
|
|
2010
|
+
<div
|
|
2011
|
+
style={{
|
|
2012
|
+
width: 520,
|
|
2013
|
+
padding: 40,
|
|
2014
|
+
borderRadius: 24,
|
|
2015
|
+
backgroundColor: "light-dark(#ffffff, #1b1b1a)",
|
|
2016
|
+
border: "1px solid light-dark(#e9e8e4, #2a2a28)",
|
|
2017
|
+
boxShadow: "0 1px 2px rgba(0,0,0,.04), 0 24px 60px -24px rgba(0,0,0,.25)",
|
|
2018
|
+
}}
|
|
2019
|
+
>
|
|
2020
|
+
<svg width="44" height="44" viewBox="0 0 64 64" aria-hidden>
|
|
2021
|
+
<defs>
|
|
2022
|
+
<linearGradient id="tc-starter" x1="0" y1="0" x2="0" y2="1">
|
|
2023
|
+
<stop offset="0" stopColor="#ff9466" />
|
|
2024
|
+
<stop offset="1" stopColor="#ea6a3c" />
|
|
2025
|
+
</linearGradient>
|
|
2026
|
+
</defs>
|
|
2027
|
+
<rect width="64" height="64" rx="15" fill="url(#tc-starter)" />
|
|
2028
|
+
<path fill="#f6f5f1" fillRule="evenodd" d="M10.5 10.5h43v13.1h-43z M25.5 16.8h13v36.7h-13z" />
|
|
2029
|
+
</svg>
|
|
2030
|
+
<h1 style={{ margin: "22px 0 6px", fontSize: 26, lineHeight: "32px", fontWeight: 650, letterSpacing: "-0.02em" }}>Your canvas is ready</h1>
|
|
2031
|
+
<p style={{ margin: 0, fontSize: 15, lineHeight: "23px", color: "light-dark(#6b6b66, #a3a39d)" }}>
|
|
2032
|
+
Everything here is real code from your app. Replace this frame with your own components.
|
|
2033
|
+
</p>
|
|
2034
|
+
<div style={{ marginTop: 28, display: "flex", flexDirection: "column", gap: 14 }}>
|
|
2035
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
|
|
2036
|
+
<span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, backgroundColor: "light-dark(#f3f2ee, #262624)", border: "1px solid light-dark(#e3e1dc, #34342f)" }}>Assets</span>
|
|
2037
|
+
<span>Drag a component from your design system onto a frame</span>
|
|
2038
|
+
</div>
|
|
2039
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
|
|
2040
|
+
<span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, backgroundColor: "light-dark(#f3f2ee, #262624)", border: "1px solid light-dark(#e3e1dc, #34342f)" }}>F</span>
|
|
2041
|
+
<span>Draw a frame, or pick an iPhone or desktop size</span>
|
|
2042
|
+
</div>
|
|
2043
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
|
|
2044
|
+
<span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, color: "#fff", backgroundColor: "#ee7445", border: "1px solid #e0652f" }}>Agent</span>
|
|
2045
|
+
<span>Ask: \u201CBuild a pricing page with our components\u201D</span>
|
|
2046
|
+
</div>
|
|
2047
|
+
</div>
|
|
2048
|
+
</div>
|
|
2049
|
+
</div>
|
|
2050
|
+
</Frame>
|
|
2051
|
+
</Canvas>
|
|
2052
|
+
);
|
|
2053
|
+
}
|
|
2054
|
+
`;
|
|
2055
|
+
function componentRegistry(config, specs) {
|
|
2056
|
+
const routeDir = path8.join(config.root, config.appDir, ROUTE);
|
|
2057
|
+
const lines = specs.map((c) => {
|
|
2058
|
+
let from = c.library;
|
|
2059
|
+
if (!from) {
|
|
2060
|
+
from = path8.relative(routeDir, path8.join(config.root, c.file)).replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
2061
|
+
if (!from.startsWith(".")) from = `./${from}`;
|
|
2062
|
+
}
|
|
2063
|
+
return ` ${JSON.stringify(c.name)}: lazy(() => import(${JSON.stringify(from)}).then((m) => ({ default: m[${JSON.stringify(c.name)}] }))),`;
|
|
2064
|
+
});
|
|
2065
|
+
return `"use client";
|
|
2066
|
+
/* eslint-disable */
|
|
2067
|
+
// Generated by Truecanvas. Do not edit: it is regenerated when components change.
|
|
2068
|
+
import { lazy, type ComponentType } from "react";
|
|
2069
|
+
|
|
2070
|
+
// any: components with required props are rendered with whatever the preview passes
|
|
2071
|
+
export const components: Record<string, ComponentType<any>> = {
|
|
2072
|
+
${lines.join("\n")}
|
|
2073
|
+
};
|
|
2074
|
+
`;
|
|
2075
|
+
}
|
|
2076
|
+
function syncComponentRegistry(config, specs) {
|
|
2077
|
+
const file = path8.join(config.root, config.appDir, ROUTE, "components.tsx");
|
|
2078
|
+
return writeIfChanged(file, componentRegistry(config, specs));
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
// src/core/workspace.ts
|
|
2082
|
+
var require2 = createRequire(import.meta.url);
|
|
2083
|
+
var MARK = "__tcsel";
|
|
2084
|
+
var ConflictError = class extends EditError {
|
|
2085
|
+
constructor(message, file) {
|
|
2086
|
+
super(message);
|
|
2087
|
+
this.file = file;
|
|
2088
|
+
}
|
|
2089
|
+
file;
|
|
2090
|
+
};
|
|
2091
|
+
var Workspace = class {
|
|
2092
|
+
constructor(config) {
|
|
2093
|
+
this.config = config;
|
|
2094
|
+
this.catalog = new Catalog(config.root, config.components, config.libraries);
|
|
2095
|
+
this.comments = new Comments(config);
|
|
2096
|
+
this.git = new Git(config.root);
|
|
2097
|
+
for (const c of this.canvases()) this.sources.set(c, fs8.readFileSync(this.file(c), "utf8"));
|
|
2098
|
+
}
|
|
2099
|
+
config;
|
|
2100
|
+
catalog;
|
|
2101
|
+
sources = /* @__PURE__ */ new Map();
|
|
2102
|
+
undoStacks = /* @__PURE__ */ new Map();
|
|
2103
|
+
redoStacks = /* @__PURE__ */ new Map();
|
|
2104
|
+
feed = [];
|
|
2105
|
+
seq = 0;
|
|
2106
|
+
queue = Promise.resolve();
|
|
2107
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2108
|
+
selection = { canvas: null, ids: [] };
|
|
2109
|
+
agents = /* @__PURE__ */ new Map();
|
|
2110
|
+
comments;
|
|
2111
|
+
git;
|
|
2112
|
+
on(fn) {
|
|
2113
|
+
this.listeners.add(fn);
|
|
2114
|
+
return () => this.listeners.delete(fn);
|
|
2115
|
+
}
|
|
2116
|
+
emit(e) {
|
|
2117
|
+
for (const fn of this.listeners) fn(e);
|
|
2118
|
+
}
|
|
2119
|
+
// ---------- canvases ----------
|
|
2120
|
+
canvases() {
|
|
2121
|
+
return canvasFiles(this.config).map(canvasNameOf);
|
|
2122
|
+
}
|
|
2123
|
+
file(canvas) {
|
|
2124
|
+
return canvasPath(this.config, canvas);
|
|
2125
|
+
}
|
|
2126
|
+
relFile(canvas) {
|
|
2127
|
+
return path9.relative(this.config.root, this.file(canvas));
|
|
2128
|
+
}
|
|
2129
|
+
read(canvas) {
|
|
2130
|
+
const file = this.file(canvas);
|
|
2131
|
+
if (!fs8.existsSync(file)) throw new EditError(`Canvas "${canvas}" does not exist. Canvases: ${this.canvases().join(", ") || "none"}`);
|
|
2132
|
+
const src = fs8.readFileSync(file, "utf8");
|
|
2133
|
+
if (!this.sources.has(canvas)) this.sources.set(canvas, src);
|
|
2134
|
+
return src;
|
|
2135
|
+
}
|
|
2136
|
+
doc(canvas) {
|
|
2137
|
+
return this.docFrom(canvas, this.read(canvas));
|
|
2138
|
+
}
|
|
2139
|
+
/** A canvas doc with linked frames resolved to their page's layers. */
|
|
2140
|
+
docFrom(canvas, source) {
|
|
2141
|
+
return resolveLinks(this.config, parseCanvas(canvas, this.relFile(canvas), source));
|
|
2142
|
+
}
|
|
2143
|
+
/** Where a node's code lives: the canvas, or a linked page/layout file. */
|
|
2144
|
+
nodeSource(canvas, id) {
|
|
2145
|
+
const hit = findNode(this.doc(canvas), id);
|
|
2146
|
+
if (!hit) return null;
|
|
2147
|
+
const rel = fileOfId(id) ?? this.relFile(canvas);
|
|
2148
|
+
const abs = path9.join(this.config.root, rel);
|
|
2149
|
+
return { node: hit.node, rel, abs, source: fs8.readFileSync(abs, "utf8") };
|
|
2150
|
+
}
|
|
2151
|
+
/** Page and layout files shown by linked frames, across all canvases (the Git panel counts them as design files). */
|
|
2152
|
+
linkedFiles() {
|
|
2153
|
+
const out = /* @__PURE__ */ new Set();
|
|
2154
|
+
for (const c of this.canvases()) {
|
|
2155
|
+
try {
|
|
2156
|
+
for (const f of this.doc(c).frames) for (const file of f.link?.files ?? []) out.add(file);
|
|
2157
|
+
} catch {
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
return [...out];
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Files that belong with design commits besides the canvas folder: linked
|
|
2164
|
+
* pages and layouts, and the motion components animations import.
|
|
2165
|
+
*/
|
|
2166
|
+
designFiles() {
|
|
2167
|
+
const m = motionFiles(this.config);
|
|
2168
|
+
return [...this.linkedFiles(), ...[m.reveal, m.text].filter((f) => fs8.existsSync(path9.join(this.config.root, f)))];
|
|
2169
|
+
}
|
|
2170
|
+
/** Canvases with a linked frame showing this page or layout file. */
|
|
2171
|
+
canvasesLinking(rel) {
|
|
2172
|
+
return this.canvases().filter((c) => {
|
|
2173
|
+
try {
|
|
2174
|
+
return this.doc(c).frames.some((f) => f.page === rel || f.link?.files.includes(rel));
|
|
2175
|
+
} catch {
|
|
2176
|
+
return false;
|
|
2177
|
+
}
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
/** Called by the app-dir watcher: a page or layout changed (in a code editor, or by git). */
|
|
2181
|
+
linkedFileChanged(rel) {
|
|
2182
|
+
let next;
|
|
2183
|
+
try {
|
|
2184
|
+
next = fs8.readFileSync(path9.join(this.config.root, rel), "utf8");
|
|
2185
|
+
} catch {
|
|
2186
|
+
next = "";
|
|
2187
|
+
}
|
|
2188
|
+
const key = `file:${rel}`;
|
|
2189
|
+
if (this.sources.get(key) === next) return;
|
|
2190
|
+
this.sources.set(key, next);
|
|
2191
|
+
for (const c of this.canvasesLinking(rel)) this.emit({ type: "doc", canvas: c, doc: this.doc(c) });
|
|
2192
|
+
}
|
|
2193
|
+
createCanvas(name, actor, content = STARTER_CANVAS) {
|
|
2194
|
+
const file = canvasPath(this.config, name);
|
|
2195
|
+
if (fs8.existsSync(file)) throw new EditError(`Canvas "${name}" already exists.`);
|
|
2196
|
+
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
2197
|
+
fs8.writeFileSync(file, content);
|
|
2198
|
+
this.sources.set(name, content);
|
|
2199
|
+
this.canvasesChanged();
|
|
2200
|
+
this.record(name, `Created canvas ${name}`, actor, "", content, []);
|
|
2201
|
+
return name;
|
|
2202
|
+
}
|
|
2203
|
+
// ---------- comments ----------
|
|
2204
|
+
async userAuthor() {
|
|
2205
|
+
const s = await this.git.status().catch(() => null);
|
|
2206
|
+
return { name: s?.user ?? (process.env.USER || "You"), kind: "user" };
|
|
2207
|
+
}
|
|
2208
|
+
commentsChanged(canvas) {
|
|
2209
|
+
this.emit({ type: "comments", canvas });
|
|
2210
|
+
}
|
|
2211
|
+
/** Pages panel: each canvas with its frame count. */
|
|
2212
|
+
canvasInfos() {
|
|
2213
|
+
return this.canvases().map((name) => {
|
|
2214
|
+
try {
|
|
2215
|
+
return { name, frames: this.doc(name).frames.length };
|
|
2216
|
+
} catch {
|
|
2217
|
+
return { name, frames: 0 };
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
uniqueCanvasName(base) {
|
|
2222
|
+
const taken = new Set(this.canvases());
|
|
2223
|
+
if (!taken.has(base)) return base;
|
|
2224
|
+
for (let i = 2; ; i++) if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
|
|
2225
|
+
}
|
|
2226
|
+
canvasesChanged() {
|
|
2227
|
+
syncRoute(this.config);
|
|
2228
|
+
this.emit({ type: "canvases", canvases: this.canvases() });
|
|
2229
|
+
}
|
|
2230
|
+
renameCanvas(from, to) {
|
|
2231
|
+
const src = this.file(from);
|
|
2232
|
+
if (!fs8.existsSync(src)) throw new EditError(`Canvas "${from}" does not exist.`);
|
|
2233
|
+
const dest = canvasPath(this.config, to);
|
|
2234
|
+
if (fs8.existsSync(dest)) throw new EditError(`A canvas named "${to}" already exists.`);
|
|
2235
|
+
fs8.renameSync(src, dest);
|
|
2236
|
+
this.comments.renameCanvas(from, to);
|
|
2237
|
+
for (const map of [this.sources, this.undoStacks, this.redoStacks]) {
|
|
2238
|
+
if (map.has(from)) {
|
|
2239
|
+
map.set(to, map.get(from));
|
|
2240
|
+
map.delete(from);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
for (const e of this.feed) if (e.canvas === from) e.canvas = to;
|
|
2244
|
+
if (this.selection.canvas === from) this.selection = { ...this.selection, canvas: to };
|
|
2245
|
+
this.canvasesChanged();
|
|
2246
|
+
this.emit({ type: "canvas-renamed", from, to });
|
|
2247
|
+
return to;
|
|
2248
|
+
}
|
|
2249
|
+
duplicateCanvas(name) {
|
|
2250
|
+
const source = this.read(name);
|
|
2251
|
+
const copy = this.uniqueCanvasName(`${name}-copy`);
|
|
2252
|
+
fs8.writeFileSync(canvasPath(this.config, copy), source);
|
|
2253
|
+
this.sources.set(copy, source);
|
|
2254
|
+
this.canvasesChanged();
|
|
2255
|
+
return copy;
|
|
2256
|
+
}
|
|
2257
|
+
/** Deleted pages are kept in memory for this session so they can be restored. */
|
|
2258
|
+
trash = /* @__PURE__ */ new Map();
|
|
2259
|
+
deleteCanvas(name) {
|
|
2260
|
+
const file = this.file(name);
|
|
2261
|
+
if (!fs8.existsSync(file)) throw new EditError(`Canvas "${name}" does not exist.`);
|
|
2262
|
+
this.trash.set(name, fs8.readFileSync(file, "utf8"));
|
|
2263
|
+
fs8.unlinkSync(file);
|
|
2264
|
+
this.sources.delete(name);
|
|
2265
|
+
this.canvasesChanged();
|
|
2266
|
+
}
|
|
2267
|
+
restoreCanvas(name) {
|
|
2268
|
+
const source = this.trash.get(name);
|
|
2269
|
+
if (source === void 0) throw new EditError(`Nothing to restore for "${name}".`);
|
|
2270
|
+
const target = this.uniqueCanvasName(name);
|
|
2271
|
+
fs8.writeFileSync(canvasPath(this.config, target), source);
|
|
2272
|
+
this.sources.set(target, source);
|
|
2273
|
+
this.trash.delete(name);
|
|
2274
|
+
this.canvasesChanged();
|
|
2275
|
+
return target;
|
|
2276
|
+
}
|
|
2277
|
+
/** Called by the file watcher. Edits made in a code editor become undoable history too. */
|
|
2278
|
+
externalChange(fileName) {
|
|
2279
|
+
if (!fileName.endsWith(CANVAS_SUFFIX)) return;
|
|
2280
|
+
const canvas = canvasNameOf(fileName);
|
|
2281
|
+
const file = this.file(canvas);
|
|
2282
|
+
if (!fs8.existsSync(file)) {
|
|
2283
|
+
this.sources.delete(canvas);
|
|
2284
|
+
syncRoute(this.config);
|
|
2285
|
+
this.emit({ type: "canvases", canvases: this.canvases() });
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const prev = this.sources.get(canvas);
|
|
2289
|
+
const next = fs8.readFileSync(file, "utf8");
|
|
2290
|
+
if (prev === next) return;
|
|
2291
|
+
this.sources.set(canvas, next);
|
|
2292
|
+
if (prev === void 0) {
|
|
2293
|
+
syncRoute(this.config);
|
|
2294
|
+
this.emit({ type: "canvases", canvases: this.canvases() });
|
|
2295
|
+
} else {
|
|
2296
|
+
this.record(canvas, "Edited in code", { kind: "file" }, prev, next, []);
|
|
2297
|
+
}
|
|
2298
|
+
this.emit({ type: "doc", canvas, doc: this.docFrom(canvas, next) });
|
|
2299
|
+
}
|
|
2300
|
+
// ---------- commands ----------
|
|
2301
|
+
run(cmd, actor) {
|
|
2302
|
+
const job = this.queue.then(async () => {
|
|
2303
|
+
try {
|
|
2304
|
+
return await this.exec(cmd, actor);
|
|
2305
|
+
} catch (err) {
|
|
2306
|
+
if (!(err instanceof ConflictError)) throw err;
|
|
2307
|
+
if (err.file) this.linkedFileChanged(err.file);
|
|
2308
|
+
else this.externalChange(path9.basename(this.file(cmd.canvas)));
|
|
2309
|
+
return this.exec(cmd, actor);
|
|
2310
|
+
}
|
|
2311
|
+
});
|
|
2312
|
+
this.queue = job.catch(() => void 0);
|
|
2313
|
+
return job;
|
|
2314
|
+
}
|
|
2315
|
+
async exec(cmd, actor) {
|
|
2316
|
+
cmd = { ...cmd };
|
|
2317
|
+
if (cmd.op === "add_component_frame" && !fs8.existsSync(this.file(cmd.canvas))) this.createCanvas(cmd.canvas, actor, COMPONENTS_CANVAS);
|
|
2318
|
+
const canvasSource = this.read(cmd.canvas);
|
|
2319
|
+
const canvasDoc = parseCanvas(cmd.canvas, this.relFile(cmd.canvas), canvasSource);
|
|
2320
|
+
if (canvasDoc.error) throw new EditError(`The canvas file does not parse: ${canvasDoc.error}`);
|
|
2321
|
+
const view = resolveLinks(this.config, canvasDoc);
|
|
2322
|
+
cmd = this.routeLinked(cmd, view);
|
|
2323
|
+
let target = this.targetOf(cmd);
|
|
2324
|
+
if (cmd.op === "explore_copy") {
|
|
2325
|
+
const frame = findFrame(canvasDoc, cmd.frame);
|
|
2326
|
+
if (!frame) throw new EditError(`Frame "${cmd.frame}" not found.`);
|
|
2327
|
+
const body = explorationBody(this.config, frame, this.relFile(cmd.canvas));
|
|
2328
|
+
this.pending = { kind: "explore", frame, imports: body.imports, jsx: await this.formatSnippet(body.jsx) };
|
|
2329
|
+
}
|
|
2330
|
+
if (cmd.op === "apply_to_page") {
|
|
2331
|
+
const frame = findFrame(canvasDoc, cmd.frame);
|
|
2332
|
+
if (!frame) throw new EditError(`Frame "${cmd.frame}" not found.`);
|
|
2333
|
+
const plan = applyPlan(this.config, canvasSource, canvasDoc, frame, this.relFile(cmd.canvas));
|
|
2334
|
+
target = plan.page;
|
|
2335
|
+
this.pending = { kind: "apply", frame, ...plan };
|
|
2336
|
+
}
|
|
2337
|
+
const rel = target ?? this.relFile(cmd.canvas);
|
|
2338
|
+
const file = path9.join(this.config.root, rel);
|
|
2339
|
+
const before = target ? fs8.readFileSync(file, "utf8") : canvasSource;
|
|
2340
|
+
const parse2 = (src) => target ? parseTargetDoc(target, src) : parseCanvas(cmd.canvas, rel, src);
|
|
2341
|
+
const doc = parse2(before);
|
|
2342
|
+
if (!doc) throw new EditError(`${rel} has data or logic in its component now, so it can't be edited from the canvas. Explore a copy instead.`);
|
|
2343
|
+
if ((cmd.op === "insert_jsx" || cmd.op === "replace" || cmd.op === "create_frame") && cmd.jsx) {
|
|
2344
|
+
cmd = { ...cmd, jsx: await this.formatSnippet(cmd.jsx) };
|
|
2345
|
+
}
|
|
2346
|
+
if (cmd.op === "import_route") {
|
|
2347
|
+
const routes = listRoutes(this.config);
|
|
2348
|
+
const r = routes.find((x) => x.route === cmd.route || x.file === cmd.route);
|
|
2349
|
+
if (!r) throw new EditError(`No page "${cmd.route}". Pages: ${routes.map((x) => x.route).join(", ") || "none"}`);
|
|
2350
|
+
if (cmd.copy) {
|
|
2351
|
+
const built = routeFrame(this.config, r.file, this.relFile(cmd.canvas));
|
|
2352
|
+
this.pending = { kind: "route", route: r.route, page: null, imports: built.imports, mode: built.mode, jsx: await this.formatSnippet(built.jsx) };
|
|
2353
|
+
} else {
|
|
2354
|
+
const built = linkedFrameBody(this.config, canvasDoc, this.relFile(cmd.canvas), r.file);
|
|
2355
|
+
this.pending = { kind: "route", route: r.route, page: r.file, imports: built.imports, mode: "linked", jsx: built.jsx };
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (cmd.op === "add_animation") {
|
|
2359
|
+
const files = ensureMotionComponents(this.config);
|
|
2360
|
+
if (files.created) this.catalog.invalidate();
|
|
2361
|
+
this.motion = files;
|
|
2362
|
+
}
|
|
2363
|
+
let newComponent = null;
|
|
2364
|
+
let movedNames = /* @__PURE__ */ new Set();
|
|
2365
|
+
if (cmd.op === "create_component") {
|
|
2366
|
+
const name = componentName(cmd.name);
|
|
2367
|
+
if (!name) throw new EditError(`"${cmd.name}" can't be a component name. Use letters and digits, like PricingCard.`);
|
|
2368
|
+
await this.catalog.load().catch(() => []);
|
|
2369
|
+
if (this.catalog.get(name) || doc.declared.includes(name) || doc.imports.some((i) => i.names.includes(name) || i.defaultName === name)) throw new EditError(`There's already a ${name}. Pick another name.`);
|
|
2370
|
+
const fileRel = newComponentPath(this.config, name);
|
|
2371
|
+
let jsx = starterJsx(name);
|
|
2372
|
+
let imports = [];
|
|
2373
|
+
if (cmd.from) {
|
|
2374
|
+
const hit = findNode(doc, cmd.from);
|
|
2375
|
+
if (!hit) throw new EditError(`Node ${cmd.from} not found.`);
|
|
2376
|
+
if (hit.node === hit.frame) throw new EditError("Select layers inside the frame to turn them into a component.");
|
|
2377
|
+
if (hit.node.kind !== "element" && hit.node.kind !== "component" && hit.node.kind !== "fragment") throw new EditError("Select an element (not text) to turn it into a component.");
|
|
2378
|
+
jsx = dedent(before.slice(hit.node.start, hit.node.end));
|
|
2379
|
+
imports = importsFor(this.config, jsx, before, doc, rel, fileRel);
|
|
2380
|
+
movedNames = new Set(componentNamesIn(jsx));
|
|
2381
|
+
}
|
|
2382
|
+
const abs = path9.join(this.config.root, fileRel);
|
|
2383
|
+
newComponent = { rel: fileRel, content: await this.format(componentFile(name, jsx, imports), abs).catch(() => componentFile(name, jsx, imports)) };
|
|
2384
|
+
this.pending = { kind: "component", name, file: fileRel };
|
|
2385
|
+
}
|
|
2386
|
+
await this.catalog.load().catch(() => []);
|
|
2387
|
+
const ed = new CanvasEditor(before, doc);
|
|
2388
|
+
const renamedFrame = cmd.op === "update_frame" && cmd.name !== void 0 ? findFrame(doc, cmd.frame) : null;
|
|
2389
|
+
const usedBefore = cmd.op === "apply_to_page" ? new Set(componentNamesIn(before.slice(doc.frames[0].children[0].start, doc.frames[0].children[0].end))) : null;
|
|
2390
|
+
const label = this.apply(ed, cmd, rel);
|
|
2391
|
+
const pretty = await this.isPrettierClean(before, file);
|
|
2392
|
+
const finish = async (code) => pretty ? this.format(code, file) : validate(code);
|
|
2393
|
+
let result = ed.result();
|
|
2394
|
+
if (usedBefore) result = pruneImports(result, usedBefore);
|
|
2395
|
+
if (cmd.op === "remove_animation") result = pruneImports(result, /* @__PURE__ */ new Set(["Reveal", "TextAnimate"]));
|
|
2396
|
+
if (cmd.op === "create_component" && cmd.from && movedNames.size) result = pruneImports(result, movedNames);
|
|
2397
|
+
let after = await finish(result);
|
|
2398
|
+
let ids = [];
|
|
2399
|
+
const reparse = (src) => {
|
|
2400
|
+
const d = parse2(src);
|
|
2401
|
+
if (!d) throw new EditError(`That edit would put logic into ${path9.basename(rel)}. Only plain JSX can be edited from the canvas.`);
|
|
2402
|
+
return d;
|
|
2403
|
+
};
|
|
2404
|
+
if (after.includes(MARK)) {
|
|
2405
|
+
const marked = reparse(after);
|
|
2406
|
+
const paths = [];
|
|
2407
|
+
for (const f of marked.frames) {
|
|
2408
|
+
const stack = [f];
|
|
2409
|
+
while (stack.length) {
|
|
2410
|
+
const n = stack.pop();
|
|
2411
|
+
if (n.props[MARK]) paths.push(n.path);
|
|
2412
|
+
stack.push(...n.children);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
after = await finish(after.replace(new RegExp(`\\s*${MARK}(?=[\\s/>])`, "g"), ""));
|
|
2416
|
+
const clean = reparse(after);
|
|
2417
|
+
ids = paths.map((p) => pathId(clean, p)).filter(Boolean);
|
|
2418
|
+
} else {
|
|
2419
|
+
const clean = reparse(after);
|
|
2420
|
+
const paths = ed.selectPaths.length ? ed.selectPaths : touchedPaths(doc, cmd);
|
|
2421
|
+
ids = paths.map((p) => pathId(clean, p)).filter(Boolean);
|
|
2422
|
+
}
|
|
2423
|
+
if (cmd.op === "apply_to_page") ids = [findFrame(canvasDoc, cmd.frame).id];
|
|
2424
|
+
if (after === before) return { doc: target ? this.doc(cmd.canvas) : view, ids, label };
|
|
2425
|
+
if (fs8.readFileSync(file, "utf8") !== before) throw new ConflictError(`${path9.basename(rel)} changed during the edit.`, target ?? void 0);
|
|
2426
|
+
if (newComponent) {
|
|
2427
|
+
fs8.mkdirSync(path9.dirname(path9.join(this.config.root, newComponent.rel)), { recursive: true });
|
|
2428
|
+
fs8.writeFileSync(path9.join(this.config.root, newComponent.rel), newComponent.content, { flag: "wx" });
|
|
2429
|
+
this.catalog.invalidate();
|
|
2430
|
+
this.emit({ type: "catalog" });
|
|
2431
|
+
}
|
|
2432
|
+
if (target) this.sources.set(`file:${target}`, after);
|
|
2433
|
+
else this.sources.set(cmd.canvas, after);
|
|
2434
|
+
fs8.writeFileSync(file, after);
|
|
2435
|
+
const newDoc = this.doc(cmd.canvas);
|
|
2436
|
+
this.record(cmd.canvas, label, actor, before, after, ids, target ?? void 0, touchedFrames(cmd, ids, view, newDoc));
|
|
2437
|
+
if (renamedFrame) {
|
|
2438
|
+
const now = newDoc.frames[renamedFrame.path[0]];
|
|
2439
|
+
if (now && now.frameName !== renamedFrame.frameName) {
|
|
2440
|
+
this.comments.renameFrame(cmd.canvas, renamedFrame.frameName, now.frameName);
|
|
2441
|
+
this.emit({ type: "comments", canvas: cmd.canvas });
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
this.emit({ type: "doc", canvas: cmd.canvas, doc: newDoc });
|
|
2445
|
+
if (target) {
|
|
2446
|
+
for (const c of this.canvasesLinking(target)) if (c !== cmd.canvas) this.emit({ type: "doc", canvas: c, doc: this.doc(c) });
|
|
2447
|
+
}
|
|
2448
|
+
return { doc: newDoc, ids, label };
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Linked frames: a frame passed as a parent means the page's root element,
|
|
2452
|
+
* and view-only pages refuse edits on their layers.
|
|
2453
|
+
*/
|
|
2454
|
+
routeLinked(cmd, view) {
|
|
2455
|
+
const viewOnly = (frame) => new EditError(`"${frame.frameName}" is view only: ${frame.link?.reason ?? "its page can't be edited from the canvas."}`);
|
|
2456
|
+
if ("parent" in cmd && cmd.parent && !fileOfId(cmd.parent)) {
|
|
2457
|
+
const f = findFrame(view, cmd.parent);
|
|
2458
|
+
if (f?.link) {
|
|
2459
|
+
if (!f.link.editable) throw viewOnly(f);
|
|
2460
|
+
const src = fs8.readFileSync(path9.join(this.config.root, f.link.page), "utf8");
|
|
2461
|
+
if (f.link.kind === "component") {
|
|
2462
|
+
const own = parseModuleDoc(f.link.page, src)?.frames.find((x) => x.frameName === f.link.route);
|
|
2463
|
+
if (!own) throw viewOnly(f);
|
|
2464
|
+
cmd = { ...cmd, parent: own.children[0].id };
|
|
2465
|
+
} else {
|
|
2466
|
+
const pageDoc = parseSourceDoc(f.link.page, src, false);
|
|
2467
|
+
if (!pageDoc) throw viewOnly(f);
|
|
2468
|
+
cmd = { ...cmd, parent: pageDoc.frames[0].children[0].id };
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
const refs = nodeRefs(cmd);
|
|
2473
|
+
for (const ref of refs) {
|
|
2474
|
+
if (fileOfId(ref)) continue;
|
|
2475
|
+
const hit = findNode(view, ref);
|
|
2476
|
+
if (hit && hit.frame.link && hit.node !== hit.frame) throw viewOnly(hit.frame);
|
|
2477
|
+
}
|
|
2478
|
+
return cmd;
|
|
2479
|
+
}
|
|
2480
|
+
/** The page/layout file the command's layers live in, or null for the canvas. */
|
|
2481
|
+
targetOf(cmd) {
|
|
2482
|
+
const refs = nodeRefs(cmd);
|
|
2483
|
+
const files = new Set(refs.map(fileOfId));
|
|
2484
|
+
if (files.size > 1) {
|
|
2485
|
+
const names = [...files].map((f) => f ? path9.basename(f) : "the canvas");
|
|
2486
|
+
throw new EditError(`These layers live in different files (${names.join(", ")}). Edit them one file at a time.`);
|
|
2487
|
+
}
|
|
2488
|
+
return [...files][0] ?? null;
|
|
2489
|
+
}
|
|
2490
|
+
apply(ed, cmd, rel) {
|
|
2491
|
+
const doc = ed.doc;
|
|
2492
|
+
const guard = (node, verb) => {
|
|
2493
|
+
if (doc.canvasNode) return;
|
|
2494
|
+
if (node.path.length <= 2) throw new EditError(`Can't ${verb} the root element of ${path9.basename(rel)}.`);
|
|
2495
|
+
if (node.kind === "expression" && node.name.trim() === "children" && /^layout\.[jt]sx?$/.test(path9.basename(rel))) throw new EditError(`Can't ${verb} the {children} slot of ${path9.basename(rel)}: that's where the page renders.`);
|
|
2496
|
+
};
|
|
2497
|
+
const literalOnly = (node, key) => {
|
|
2498
|
+
const v = node.props[key];
|
|
2499
|
+
if (!doc.canvasNode && v?.kind === "expression") throw new EditError(`${key} on ${node.name} is computed in code ({${short(v.code, 50)}}). Edit it in ${path9.basename(rel)}, or ask your agent.`);
|
|
2500
|
+
};
|
|
2501
|
+
switch (cmd.op) {
|
|
2502
|
+
case "set_props": {
|
|
2503
|
+
const { node } = ed.node(cmd.id);
|
|
2504
|
+
for (const k of Object.keys(cmd.props)) literalOnly(node, k);
|
|
2505
|
+
for (const [k, v] of Object.entries(cmd.props)) ed.setProp(node, k, v);
|
|
2506
|
+
const entries = Object.entries(cmd.props);
|
|
2507
|
+
if (entries.length === 1) {
|
|
2508
|
+
const [k, v] = entries[0];
|
|
2509
|
+
return v === null ? `Removed ${k} from ${node.name}` : `Set ${k} to ${short(JSON.stringify(v).replace(/^"|"$/g, ""))} on ${node.name}`;
|
|
2510
|
+
}
|
|
2511
|
+
return `Set ${entries.map(([k]) => k).join(", ")} on ${node.name}`;
|
|
2512
|
+
}
|
|
2513
|
+
case "set_text": {
|
|
2514
|
+
const { node, parent } = ed.node(cmd.id);
|
|
2515
|
+
ed.setText(node, cmd.text);
|
|
2516
|
+
const owner = node.kind === "text" ? parent?.name : node.name;
|
|
2517
|
+
return `Changed ${owner && !("frameName" in (parent ?? {})) ? `${owner} ` : ""}text to \u201C${short(cmd.text)}\u201D`;
|
|
2518
|
+
}
|
|
2519
|
+
case "set_class": {
|
|
2520
|
+
const { node } = ed.node(cmd.id);
|
|
2521
|
+
const next = cmd.className.trim() ? cmd.className.trim().replace(/\s+/g, " ") : null;
|
|
2522
|
+
literalOnly(node, "className");
|
|
2523
|
+
ed.setProp(node, "className", next);
|
|
2524
|
+
const old = node.props.className?.kind === "string" ? node.props.className.value.split(/\s+/).filter(Boolean) : [];
|
|
2525
|
+
const now = next?.split(" ") ?? [];
|
|
2526
|
+
const added = now.filter((c) => !old.includes(c));
|
|
2527
|
+
const removed = old.filter((c) => !now.includes(c));
|
|
2528
|
+
const diff = [...added.map((c) => `+${short(c, 28)}`), ...removed.map((c) => `-${short(c, 28)}`)];
|
|
2529
|
+
return diff.length ? `Restyled ${node.name} (${diff.slice(0, 3).join(" ")}${diff.length > 3 ? ` and ${diff.length - 3} more` : ""})` : `Restyled ${node.name}`;
|
|
2530
|
+
}
|
|
2531
|
+
case "insert_jsx": {
|
|
2532
|
+
const parent = ed.container(cmd.parent);
|
|
2533
|
+
parseJsx(cmd.jsx);
|
|
2534
|
+
this.autoImport(ed, rel, cmd.jsx);
|
|
2535
|
+
ed.insertChild(parent, cmd.index, cmd.jsx.trim());
|
|
2536
|
+
const names = componentNamesIn(cmd.jsx);
|
|
2537
|
+
return `Inserted ${names[0] ?? "element"}${names.length > 1 ? ` +${names.length - 1}` : ""}`;
|
|
2538
|
+
}
|
|
2539
|
+
case "insert_component": {
|
|
2540
|
+
const parent = ed.container(cmd.parent);
|
|
2541
|
+
const spec = this.catalog.get(cmd.component);
|
|
2542
|
+
if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
|
|
2543
|
+
const jsx = componentJsx(spec, cmd.props, cmd.text);
|
|
2544
|
+
ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
|
|
2545
|
+
ed.insertChild(parent, cmd.index, jsx);
|
|
2546
|
+
return `Inserted ${spec.name}`;
|
|
2547
|
+
}
|
|
2548
|
+
case "replace": {
|
|
2549
|
+
const { node } = ed.node(cmd.id);
|
|
2550
|
+
parseJsx(cmd.jsx);
|
|
2551
|
+
this.autoImport(ed, rel, cmd.jsx);
|
|
2552
|
+
ed.replace(node, cmd.jsx.trim());
|
|
2553
|
+
return `Replaced ${node.name}`;
|
|
2554
|
+
}
|
|
2555
|
+
case "duplicate": {
|
|
2556
|
+
const nodes = cmd.ids.map((id) => ed.node(id));
|
|
2557
|
+
for (const { node } of nodes) guard(node, "duplicate");
|
|
2558
|
+
for (const { node } of nodes) ed.duplicate(node);
|
|
2559
|
+
return nodes.length === 1 ? `Duplicated ${nodes[0].node.kind === "component" && "frameName" in nodes[0].node ? "frame" : nodes[0].node.name}` : `Duplicated ${nodes.length} layers`;
|
|
2560
|
+
}
|
|
2561
|
+
case "delete": {
|
|
2562
|
+
const nodes = cmd.ids.map((id) => ed.node(id).node);
|
|
2563
|
+
const roots = nodes.filter((n) => !nodes.some((o) => o !== n && n.start >= o.start && n.end <= o.end));
|
|
2564
|
+
for (const n of roots) guard(n, "delete");
|
|
2565
|
+
for (const n of roots) ed.remove(n);
|
|
2566
|
+
return roots.length === 1 ? `Deleted ${"frameName" in roots[0] ? `frame ${roots[0].frameName}` : roots[0].name}` : `Deleted ${roots.length} layers`;
|
|
2567
|
+
}
|
|
2568
|
+
case "move": {
|
|
2569
|
+
const { node } = ed.node(cmd.id);
|
|
2570
|
+
guard(node, "move");
|
|
2571
|
+
const target = ed.container(cmd.parent);
|
|
2572
|
+
ed.move(node, target, cmd.index);
|
|
2573
|
+
return `Moved ${node.name}`;
|
|
2574
|
+
}
|
|
2575
|
+
case "reorder": {
|
|
2576
|
+
const { node, parent } = ed.node(cmd.id);
|
|
2577
|
+
if (!parent) throw new EditError("Frames are positioned with x/y, not reordered.");
|
|
2578
|
+
guard(node, "move");
|
|
2579
|
+
const siblings = parent.children;
|
|
2580
|
+
const i = siblings.findIndex((c) => c.id === node.id);
|
|
2581
|
+
const j = Math.max(0, Math.min(siblings.length - 1, i + cmd.delta));
|
|
2582
|
+
if (i === j) return `Moved ${node.name}`;
|
|
2583
|
+
ed.move(node, parent, j);
|
|
2584
|
+
return `Moved ${node.name} ${cmd.delta < 0 ? "up" : "down"}`;
|
|
2585
|
+
}
|
|
2586
|
+
case "wrap": {
|
|
2587
|
+
const nodes = cmd.ids.map((id) => ed.node(id).node);
|
|
2588
|
+
for (const n of nodes) guard(n, "wrap");
|
|
2589
|
+
const cls = cmd.className ?? "flex flex-col gap-2";
|
|
2590
|
+
ed.wrap(nodes, `<div className="${cls}">`, "</div>");
|
|
2591
|
+
return nodes.length === 1 ? `Wrapped ${nodes[0].name} in auto layout` : `Wrapped ${nodes.length} layers in auto layout`;
|
|
2592
|
+
}
|
|
2593
|
+
case "create_frame": {
|
|
2594
|
+
const device = findDevice(cmd.device);
|
|
2595
|
+
if (cmd.device && !device) throw new EditError(`Unknown device "${cmd.device}". Devices: ${DEVICES.map((d) => d.id).join(", ")}`);
|
|
2596
|
+
const name = uniqueFrameName(doc, cmd.name ?? device?.name ?? "Frame");
|
|
2597
|
+
const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
|
|
2598
|
+
const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
|
|
2599
|
+
if (cmd.jsx) {
|
|
2600
|
+
parseJsx(cmd.jsx);
|
|
2601
|
+
this.autoImport(ed, rel, cmd.jsx);
|
|
2602
|
+
}
|
|
2603
|
+
ed.insertFrame(
|
|
2604
|
+
frameCode({
|
|
2605
|
+
name,
|
|
2606
|
+
x: cmd.x ?? right + 80,
|
|
2607
|
+
y: cmd.y ?? top,
|
|
2608
|
+
width: cmd.width ?? device?.width ?? 1024,
|
|
2609
|
+
height: cmd.height !== void 0 ? cmd.height : device?.height ?? null,
|
|
2610
|
+
theme: cmd.theme ?? null,
|
|
2611
|
+
device: device?.id ?? null,
|
|
2612
|
+
body: cmd.jsx?.trim()
|
|
2613
|
+
})
|
|
2614
|
+
);
|
|
2615
|
+
return `Created frame ${name}`;
|
|
2616
|
+
}
|
|
2617
|
+
case "update_frame": {
|
|
2618
|
+
const f = ed.frame(cmd.frame);
|
|
2619
|
+
const changes = [];
|
|
2620
|
+
if (cmd.name !== void 0) {
|
|
2621
|
+
ed.setProp(f, "name", uniqueFrameName(doc, cmd.name, f.frameName));
|
|
2622
|
+
changes.push("name");
|
|
2623
|
+
}
|
|
2624
|
+
for (const k of ["x", "y", "width"]) {
|
|
2625
|
+
if (cmd[k] !== void 0) {
|
|
2626
|
+
ed.setProp(f, k, Math.round(cmd[k]));
|
|
2627
|
+
changes.push(k);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
if (cmd.height !== void 0) {
|
|
2631
|
+
ed.setProp(f, "height", cmd.height === null ? null : Math.round(cmd.height));
|
|
2632
|
+
changes.push("height");
|
|
2633
|
+
}
|
|
2634
|
+
if (cmd.theme !== void 0) {
|
|
2635
|
+
ed.setProp(f, "theme", cmd.theme);
|
|
2636
|
+
changes.push("theme");
|
|
2637
|
+
}
|
|
2638
|
+
if (cmd.device !== void 0) {
|
|
2639
|
+
const device = findDevice(cmd.device);
|
|
2640
|
+
if (cmd.device && !device) throw new EditError(`Unknown device "${cmd.device}".`);
|
|
2641
|
+
ed.setProp(f, "device", device?.id ?? null);
|
|
2642
|
+
if (device && cmd.width === void 0) ed.setProp(f, "width", device.width);
|
|
2643
|
+
if (device && cmd.height === void 0) ed.setProp(f, "height", device.height);
|
|
2644
|
+
changes.push("device");
|
|
2645
|
+
}
|
|
2646
|
+
ed.selectPaths.push(f.path);
|
|
2647
|
+
if (changes.length === 2 && changes.includes("x") && changes.includes("y")) return `Moved frame ${f.frameName}`;
|
|
2648
|
+
if (changes.length === 1 && (changes[0] === "width" || changes[0] === "height")) return `Resized frame ${f.frameName}`;
|
|
2649
|
+
if (changes.length === 1 && changes[0] === "device") return cmd.device ? `Set ${f.frameName} to ${findDevice(cmd.device).name}` : `Removed device from ${f.frameName}`;
|
|
2650
|
+
return `Updated frame ${f.frameName}`;
|
|
2651
|
+
}
|
|
2652
|
+
case "add_background": {
|
|
2653
|
+
const spec = this.catalog.get(cmd.component);
|
|
2654
|
+
if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
|
|
2655
|
+
let parent = ed.container(cmd.parent);
|
|
2656
|
+
if ("frameName" in parent) {
|
|
2657
|
+
const kids = parent.children.filter((c) => c.kind !== "text");
|
|
2658
|
+
if (kids.length === 1 && kids[0].kind === "element") parent = kids[0];
|
|
2659
|
+
}
|
|
2660
|
+
const preset = cmd.preset ? spec.presets?.find((p) => p.name.toLowerCase() === cmd.preset.toLowerCase()) : void 0;
|
|
2661
|
+
if (cmd.preset && !preset) throw new EditError(`${spec.name} has no preset "${cmd.preset}". Presets: ${spec.presets?.map((p) => p.name).join(", ") || "none"}`);
|
|
2662
|
+
const props = { className: "pointer-events-none absolute inset-0 -z-10 h-full w-full", ...preset ? presetDiff(spec, preset.props) : {} };
|
|
2663
|
+
ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
|
|
2664
|
+
if (parent.kind === "element") {
|
|
2665
|
+
const cls = parent.props.className;
|
|
2666
|
+
if (cls === void 0 || cls.kind === "string") {
|
|
2667
|
+
const tokens = new Set((cls?.kind === "string" ? cls.value : "").split(/\s+/).filter(Boolean));
|
|
2668
|
+
const wanted = ["relative", "isolate"].filter((t) => !tokens.has(t) && !(t === "relative" && [...tokens].some((x) => /^(absolute|fixed|sticky)$/.test(x))));
|
|
2669
|
+
if (wanted.length) ed.setProp(parent, "className", [...wanted, ...tokens].join(" "));
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
ed.insertChild(parent, 0, componentJsx(spec, props));
|
|
2673
|
+
return `Added ${spec.name} background`;
|
|
2674
|
+
}
|
|
2675
|
+
case "apply_preset": {
|
|
2676
|
+
const { node } = ed.node(cmd.id);
|
|
2677
|
+
const spec = this.catalog.get(node.name);
|
|
2678
|
+
const preset = spec?.presets?.find((p) => p.name.toLowerCase() === cmd.preset.toLowerCase());
|
|
2679
|
+
if (!spec || !preset) throw new EditError(`No preset "${cmd.preset}" for ${node.name}. Presets: ${spec?.presets?.map((p) => p.name).join(", ") || "none"}`);
|
|
2680
|
+
const diff = presetDiff(spec, preset.props);
|
|
2681
|
+
const next = {};
|
|
2682
|
+
for (const key of Object.keys(preset.props)) next[key] = key in diff ? diff[key] : null;
|
|
2683
|
+
for (const [k, v] of Object.entries(next)) if (v !== null || node.props[k] !== void 0) ed.setProp(node, k, v);
|
|
2684
|
+
ed.selectPaths.push(node.path);
|
|
2685
|
+
return `Applied ${preset.name} preset to ${spec.name}`;
|
|
2686
|
+
}
|
|
2687
|
+
case "import_route": {
|
|
2688
|
+
const built = this.pending;
|
|
2689
|
+
this.pending = null;
|
|
2690
|
+
for (const stmt of built.imports) ed.addImportStatement(stmt);
|
|
2691
|
+
const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
|
|
2692
|
+
const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
|
|
2693
|
+
const name = uniqueFrameName(doc, cmd.name ?? (built.route === "/" ? "Home" : built.route.split("/").filter(Boolean).map((p) => p.replace(/^./, (c) => c.toUpperCase())).join(" / ")));
|
|
2694
|
+
ed.insertFrame(frameCode({ name, x: cmd.x ?? right + 80, y: cmd.y ?? top, width: cmd.width ?? 1440, height: null, page: built.page ?? void 0, body: built.jsx }));
|
|
2695
|
+
return built.mode === "linked" ? `Linked ${built.route}` : built.mode === "inline" ? `Copied ${built.route} as layers` : `Copied ${built.route}`;
|
|
2696
|
+
}
|
|
2697
|
+
case "explore_copy": {
|
|
2698
|
+
const built = this.pending;
|
|
2699
|
+
this.pending = null;
|
|
2700
|
+
const f = built.frame;
|
|
2701
|
+
for (const stmt of built.imports) ed.addImportStatement(stmt);
|
|
2702
|
+
const h = (fr) => fr.height ?? 2e3;
|
|
2703
|
+
const row = doc.frames.filter((o) => o.y < f.y + h(f) && o.y + h(o) > f.y);
|
|
2704
|
+
const right = Math.max(...row.map((o) => o.x + o.width), f.x + f.width);
|
|
2705
|
+
const name = uniqueFrameName(doc, cmd.name ?? `${f.frameName} exploration`);
|
|
2706
|
+
ed.insertFrame(frameCode({ name, x: right + 80, y: f.y, width: f.width, height: f.height, theme: f.theme, device: f.device, from: f.page, body: built.jsx }));
|
|
2707
|
+
return `Explored a copy of ${f.frameName}`;
|
|
2708
|
+
}
|
|
2709
|
+
case "apply_to_page": {
|
|
2710
|
+
const plan = this.pending;
|
|
2711
|
+
this.pending = null;
|
|
2712
|
+
for (const stmt of plan.imports) ed.addImportStatement(stmt);
|
|
2713
|
+
ed.replace(doc.frames[0].children[0], plan.jsx);
|
|
2714
|
+
return `Applied ${plan.frame.frameName} to ${plan.page}`;
|
|
2715
|
+
}
|
|
2716
|
+
case "add_animation": {
|
|
2717
|
+
const { node, parent } = ed.node(cmd.id);
|
|
2718
|
+
if ("frameName" in node) throw new EditError("Animate a layer inside the frame, not the frame itself.");
|
|
2719
|
+
if (node.kind === "expression") throw new EditError("Expressions can't be animated; pick the element around it.");
|
|
2720
|
+
const files = this.motion ?? motionFiles(this.config);
|
|
2721
|
+
const props = {};
|
|
2722
|
+
if (cmd.effect !== void 0) props.effect = cmd.effect;
|
|
2723
|
+
for (const k of ["delay", "duration", "stagger"]) if (cmd[k] !== void 0 && !(cmd.kind === "reveal" && k === "stagger")) props[k] = Math.round(cmd[k] * 100) / 100;
|
|
2724
|
+
if (cmd.once === false) props.once = false;
|
|
2725
|
+
const attrs = (defaults) => Object.entries({ ...defaults, ...props }).map(([k, v]) => ` ${attrCode(k, v)}`).join("");
|
|
2726
|
+
const solid = (n) => n.children.filter((c) => !(c.kind === "text" && !c.text?.trim()));
|
|
2727
|
+
if (cmd.kind === "reveal") {
|
|
2728
|
+
const wrapper2 = node.name === "Reveal" ? node : parent?.name === "Reveal" && solid(parent).length === 1 ? parent : null;
|
|
2729
|
+
if (wrapper2) {
|
|
2730
|
+
for (const [k, v] of Object.entries(props)) ed.setProp(wrapper2, k, v);
|
|
2731
|
+
ed.selectPaths.push(node.path);
|
|
2732
|
+
return `Updated the reveal of ${wrapper2 === node ? "layer" : node.name}`;
|
|
2733
|
+
}
|
|
2734
|
+
ed.ensureImport("Reveal", importSpecifier(this.config.root, rel, files.reveal));
|
|
2735
|
+
ed.wrap([node], `<Reveal${attrs({ effect: "fade-up" })}>`, "</Reveal>");
|
|
2736
|
+
ed.selectPaths.length = 0;
|
|
2737
|
+
ed.selectPaths.push([...node.path, 0]);
|
|
2738
|
+
return `Added a ${props.effect ?? "fade-up"} reveal to ${node.kind === "text" ? "text" : node.name}`;
|
|
2739
|
+
}
|
|
2740
|
+
const kids = solid(node);
|
|
2741
|
+
const wrapper = node.name === "TextAnimate" ? node : kids.length === 1 && kids[0].name === "TextAnimate" ? kids[0] : parent?.name === "TextAnimate" ? parent : null;
|
|
2742
|
+
if (wrapper) {
|
|
2743
|
+
for (const [k, v] of Object.entries(props)) ed.setProp(wrapper, k, v);
|
|
2744
|
+
ed.selectPaths.push(node.path);
|
|
2745
|
+
return "Updated the text animation";
|
|
2746
|
+
}
|
|
2747
|
+
const hasText = (n) => n.kind === "text" || n.children.some(hasText);
|
|
2748
|
+
if (!hasText(node)) throw new EditError(`${node.name} has no text of its own here. Text inside a component (like <${node.name} />) lives in its file: animate a layer whose text is in this page.`);
|
|
2749
|
+
ed.ensureImport("TextAnimate", importSpecifier(this.config.root, rel, files.text));
|
|
2750
|
+
const open = `<TextAnimate${attrs({ effect: "words" })}>`;
|
|
2751
|
+
if (node.kind === "text") ed.wrapInline([node], open, "</TextAnimate>");
|
|
2752
|
+
else ed.wrapInline(node.children, open, "</TextAnimate>");
|
|
2753
|
+
ed.selectPaths.length = 0;
|
|
2754
|
+
ed.selectPaths.push(node.kind === "text" ? [...node.path, 0] : node.path);
|
|
2755
|
+
return `Added a ${props.effect ?? "words"} text animation to ${node.kind === "text" ? "text" : node.name}`;
|
|
2756
|
+
}
|
|
2757
|
+
case "remove_animation": {
|
|
2758
|
+
const { node, parent } = ed.node(cmd.id);
|
|
2759
|
+
const name = cmd.kind === "reveal" ? "Reveal" : "TextAnimate";
|
|
2760
|
+
const kids = node.children.filter((c) => !(c.kind === "text" && !c.text?.trim()));
|
|
2761
|
+
const wrapper = node.name === name ? node : parent?.name === name ? parent : kids.length === 1 && kids[0].name === name ? kids[0] : null;
|
|
2762
|
+
if (!wrapper) throw new EditError(`${node.name} has no ${cmd.kind === "reveal" ? "reveal" : "text animation"}.`);
|
|
2763
|
+
ed.unwrap(wrapper);
|
|
2764
|
+
ed.selectPaths.length = 0;
|
|
2765
|
+
ed.selectPaths.push(wrapper === kids[0] ? node.path : wrapper.path);
|
|
2766
|
+
return cmd.kind === "reveal" ? "Removed the reveal" : "Removed the text animation";
|
|
2767
|
+
}
|
|
2768
|
+
case "create_component": {
|
|
2769
|
+
const made = this.pending;
|
|
2770
|
+
this.pending = null;
|
|
2771
|
+
ed.ensureImport(made.name, importSpecifier(this.config.root, rel, made.file));
|
|
2772
|
+
if (cmd.from) {
|
|
2773
|
+
const { node } = ed.node(cmd.from);
|
|
2774
|
+
ed.replace(node, `<${made.name} />`);
|
|
2775
|
+
return `Made ${node.name} a component: ${made.name}`;
|
|
2776
|
+
}
|
|
2777
|
+
if (!cmd.parent) return `Created component ${made.name}`;
|
|
2778
|
+
ed.insertChild(ed.container(cmd.parent), cmd.index, `<${made.name} />`);
|
|
2779
|
+
return `Created component ${made.name}`;
|
|
2780
|
+
}
|
|
2781
|
+
case "add_component_frame": {
|
|
2782
|
+
const spec = this.catalog.get(cmd.component);
|
|
2783
|
+
if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
|
|
2784
|
+
if (spec.library) throw new EditError(`${spec.name} comes from ${spec.library}: it can't be edited here.`);
|
|
2785
|
+
const existing = doc.frames.find((f) => f.component === `${spec.file}#${spec.name}`);
|
|
2786
|
+
if (existing) {
|
|
2787
|
+
ed.selectPaths.push(existing.path);
|
|
2788
|
+
return `Opened ${spec.name}`;
|
|
2789
|
+
}
|
|
2790
|
+
ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
|
|
2791
|
+
const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
|
|
2792
|
+
const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
|
|
2793
|
+
ed.insertFrame(
|
|
2794
|
+
frameCode({
|
|
2795
|
+
name: uniqueFrameName(doc, spec.name),
|
|
2796
|
+
x: cmd.x ?? right + 80,
|
|
2797
|
+
y: cmd.y ?? top,
|
|
2798
|
+
width: cmd.width ?? 640,
|
|
2799
|
+
component: `${spec.file}#${spec.name}`,
|
|
2800
|
+
body: `<div className="p-10">
|
|
2801
|
+
${componentJsx(spec)}
|
|
2802
|
+
</div>`
|
|
2803
|
+
})
|
|
2804
|
+
);
|
|
2805
|
+
return `Opened main component ${spec.name}`;
|
|
2806
|
+
}
|
|
2807
|
+
case "create_variants": {
|
|
2808
|
+
const spec = this.catalog.get(cmd.component);
|
|
2809
|
+
if (!spec) throw new EditError(`Unknown component "${cmd.component}".`);
|
|
2810
|
+
const prop = spec.props.find((p) => p.name === cmd.prop);
|
|
2811
|
+
if (!prop) throw new EditError(`${spec.name} has no prop "${cmd.prop}".`);
|
|
2812
|
+
const values = prop.type === "enum" ? prop.options : prop.type === "boolean" ? [false, true] : [];
|
|
2813
|
+
if (!values.length) throw new EditError(`Prop "${cmd.prop}" is ${prop.typeText}; variants need a union of string literals or a boolean.`);
|
|
2814
|
+
ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
|
|
2815
|
+
const label = (v) => typeof v === "string" ? v[0].toUpperCase() + v.slice(1) : `${prop.name}: ${v}`;
|
|
2816
|
+
const rows = indentBlock(values.map((v) => componentJsx(spec, { ...cmd.props, [prop.name]: v }, spec.acceptsChildren ? label(v) : void 0)).join("\n"));
|
|
2817
|
+
const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
|
|
2818
|
+
const name = uniqueFrameName(doc, `${spec.name} \xB7 ${prop.name}`);
|
|
2819
|
+
ed.insertFrame(frameCode({ name, x: cmd.x ?? right + 80, y: cmd.y ?? 0, width: 360, body: `<div className="flex flex-col gap-3 p-4">
|
|
2820
|
+
${rows}
|
|
2821
|
+
</div>` }));
|
|
2822
|
+
return `Created ${spec.name} ${prop.name} variants`;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
autoImport(ed, rel, jsx) {
|
|
2827
|
+
const known = /* @__PURE__ */ new Set(["Frame", "Canvas", "Fragment"]);
|
|
2828
|
+
for (const name of componentNamesIn(jsx)) {
|
|
2829
|
+
if (known.has(name) || ed.doc.declared.includes(name)) continue;
|
|
2830
|
+
if (ed.doc.imports.some((i) => i.names.includes(name) || i.defaultName === name)) continue;
|
|
2831
|
+
const spec = this.catalog.get(name);
|
|
2832
|
+
if (!spec) throw new EditError(`Unknown component <${name}>. It is not imported in the canvas and not in the component catalog. Use list_components.`);
|
|
2833
|
+
ed.ensureImport(name, importSpecifier(this.config.root, rel, spec.file));
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
motion = null;
|
|
2837
|
+
/** work resolved (async) in exec before the synchronous apply */
|
|
2838
|
+
pending = null;
|
|
2839
|
+
cleanCache = /* @__PURE__ */ new Map();
|
|
2840
|
+
async isPrettierClean(source, file) {
|
|
2841
|
+
const hit = this.cleanCache.get(source);
|
|
2842
|
+
if (hit !== void 0) return hit;
|
|
2843
|
+
let clean = false;
|
|
2844
|
+
try {
|
|
2845
|
+
clean = await this.format(source, file) === source;
|
|
2846
|
+
} catch {
|
|
2847
|
+
clean = false;
|
|
2848
|
+
}
|
|
2849
|
+
if (this.cleanCache.size > 20) this.cleanCache.clear();
|
|
2850
|
+
this.cleanCache.set(source, clean);
|
|
2851
|
+
return clean;
|
|
2852
|
+
}
|
|
2853
|
+
/** Pretty-prints an agent's JSX snippet so it lands in the file readable. */
|
|
2854
|
+
async formatSnippet(jsx) {
|
|
2855
|
+
try {
|
|
2856
|
+
this.prettier ??= require2("prettier");
|
|
2857
|
+
const out = await this.prettier.format(`<>${jsx.trim()}</>`, { parser: "typescript", printWidth: 96, semi: false });
|
|
2858
|
+
const lines = out.trimEnd().replace(/^;/, "").split("\n");
|
|
2859
|
+
if (lines[0] !== "<>" || lines[lines.length - 1] !== "</>") return jsx.trim();
|
|
2860
|
+
return lines.slice(1, -1).map((l) => l.replace(/^ {2}/, "")).join("\n");
|
|
2861
|
+
} catch {
|
|
2862
|
+
return jsx.trim();
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
prettier = null;
|
|
2866
|
+
async format(source, file) {
|
|
2867
|
+
try {
|
|
2868
|
+
this.prettier ??= require2("prettier");
|
|
2869
|
+
const options = await this.prettier.resolveConfig(file) ?? {};
|
|
2870
|
+
return await this.prettier.format(source, { printWidth: 100, ...options, filepath: file, parser: "typescript" });
|
|
2871
|
+
} catch (err) {
|
|
2872
|
+
throw new EditError(`Edit produced invalid code: ${err.message.split("\n")[0]}`);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
// ---------- history ----------
|
|
2876
|
+
record(canvas, label, actor, before, after, ids, file, frames) {
|
|
2877
|
+
const entry = { id: ++this.seq, canvas, label, actor, at: Date.now(), before, after, ids, ...file ? { file } : {}, ...frames?.length ? { frames } : {} };
|
|
2878
|
+
const stack = this.undoStacks.get(canvas) ?? [];
|
|
2879
|
+
stack.push(entry);
|
|
2880
|
+
if (stack.length > 200) stack.shift();
|
|
2881
|
+
this.undoStacks.set(canvas, stack);
|
|
2882
|
+
this.redoStacks.set(canvas, []);
|
|
2883
|
+
this.feed.push(entry);
|
|
2884
|
+
if (this.feed.length > 300) this.feed.shift();
|
|
2885
|
+
this.emit({ type: "change", entry: publicEntry(entry), ids });
|
|
2886
|
+
this.emitHistory(canvas);
|
|
2887
|
+
}
|
|
2888
|
+
/** Changes still in effect (not undone) on a canvas since a time, oldest first. */
|
|
2889
|
+
changesSince(canvas, since) {
|
|
2890
|
+
return (this.undoStacks.get(canvas) ?? []).filter((e) => e.at > since).map(publicEntry);
|
|
2891
|
+
}
|
|
2892
|
+
historyState(canvas) {
|
|
2893
|
+
return { undo: this.undoStacks.get(canvas)?.length ?? 0, redo: this.redoStacks.get(canvas)?.length ?? 0 };
|
|
2894
|
+
}
|
|
2895
|
+
emitHistory(canvas) {
|
|
2896
|
+
this.emit({ type: "history", canvas, ...this.historyState(canvas) });
|
|
2897
|
+
}
|
|
2898
|
+
undo(canvas, actor) {
|
|
2899
|
+
return this.step(canvas, actor, "undo");
|
|
2900
|
+
}
|
|
2901
|
+
redo(canvas, actor) {
|
|
2902
|
+
return this.step(canvas, actor, "redo");
|
|
2903
|
+
}
|
|
2904
|
+
step(canvas, actor, dir) {
|
|
2905
|
+
const job = this.queue.then(() => {
|
|
2906
|
+
const from = dir === "undo" ? this.undoStacks : this.redoStacks;
|
|
2907
|
+
const to = dir === "undo" ? this.redoStacks : this.undoStacks;
|
|
2908
|
+
const stack = from.get(canvas) ?? [];
|
|
2909
|
+
const entry = stack[stack.length - 1];
|
|
2910
|
+
if (!entry) throw new EditError(`Nothing to ${dir}.`);
|
|
2911
|
+
if (dir === "undo" && entry.before === "") throw new EditError("Cannot undo creating the canvas; delete the file instead.");
|
|
2912
|
+
stack.pop();
|
|
2913
|
+
const file = entry.file ? path9.join(this.config.root, entry.file) : this.file(canvas);
|
|
2914
|
+
const current = entry.file ? fs8.readFileSync(file, "utf8") : this.read(canvas);
|
|
2915
|
+
const expected = dir === "undo" ? entry.after : entry.before;
|
|
2916
|
+
if (current !== expected) {
|
|
2917
|
+
stack.push(entry);
|
|
2918
|
+
throw new EditError(`Cannot ${dir} "${entry.label}": ${entry.file ? path9.basename(entry.file) : "the file"} changed since.`);
|
|
2919
|
+
}
|
|
2920
|
+
const next = dir === "undo" ? entry.before : entry.after;
|
|
2921
|
+
this.sources.set(entry.file ? `file:${entry.file}` : canvas, next);
|
|
2922
|
+
fs8.writeFileSync(file, next);
|
|
2923
|
+
const list = to.get(canvas) ?? [];
|
|
2924
|
+
list.push(entry);
|
|
2925
|
+
to.set(canvas, list);
|
|
2926
|
+
const doc = this.doc(canvas);
|
|
2927
|
+
this.emit({ type: "doc", canvas, doc });
|
|
2928
|
+
if (entry.file) {
|
|
2929
|
+
for (const c of this.canvasesLinking(entry.file)) if (c !== canvas) this.emit({ type: "doc", canvas: c, doc: this.doc(c) });
|
|
2930
|
+
}
|
|
2931
|
+
const label = `${dir === "undo" ? "Undid" : "Redid"} \u201C${entry.label}\u201D`;
|
|
2932
|
+
const marker = { ...entry, id: ++this.seq, label, actor, at: Date.now() };
|
|
2933
|
+
this.feed.push(marker);
|
|
2934
|
+
this.emit({ type: "change", entry: publicEntry(marker), ids: [] });
|
|
2935
|
+
this.emitHistory(canvas);
|
|
2936
|
+
return { doc, label };
|
|
2937
|
+
});
|
|
2938
|
+
this.queue = job.catch(() => void 0);
|
|
2939
|
+
return job;
|
|
2940
|
+
}
|
|
2941
|
+
publicFeed() {
|
|
2942
|
+
return this.feed.map(publicEntry);
|
|
2943
|
+
}
|
|
2944
|
+
// ---------- agents ----------
|
|
2945
|
+
touchAgent(session, name) {
|
|
2946
|
+
const now = Date.now();
|
|
2947
|
+
const existing = this.agents.get(session);
|
|
2948
|
+
this.agents.set(session, { session, name, since: existing?.since ?? now, lastSeen: now });
|
|
2949
|
+
for (const [id, a] of this.agents) if (id !== session && a.name === name && a.lastSeen < now - 1e3) this.agents.delete(id);
|
|
2950
|
+
if (!existing || existing.name !== name || now - existing.lastSeen > 3e4) this.emit({ type: "agents", agents: [...this.agents.values()] });
|
|
2951
|
+
}
|
|
2952
|
+
/** Agents that went quiet for 15 minutes are shown as disconnected. */
|
|
2953
|
+
pruneAgents(maxIdle = 15 * 6e4) {
|
|
2954
|
+
const before = this.agents.size;
|
|
2955
|
+
for (const [id, a] of this.agents) if (Date.now() - a.lastSeen > maxIdle) this.agents.delete(id);
|
|
2956
|
+
if (this.agents.size !== before) this.emit({ type: "agents", agents: [...this.agents.values()] });
|
|
2957
|
+
}
|
|
2958
|
+
dropAgent(session) {
|
|
2959
|
+
if (this.agents.delete(session)) this.emit({ type: "agents", agents: [...this.agents.values()] });
|
|
2960
|
+
}
|
|
2961
|
+
ensureRoute() {
|
|
2962
|
+
if (!fs8.existsSync(path9.join(this.config.root, this.config.canvasDir))) {
|
|
2963
|
+
fs8.mkdirSync(path9.join(this.config.root, this.config.canvasDir), { recursive: true });
|
|
2964
|
+
}
|
|
2965
|
+
if (!this.canvases().length) {
|
|
2966
|
+
const file = canvasPath(this.config, "home");
|
|
2967
|
+
const content = this.homeCanvas();
|
|
2968
|
+
fs8.writeFileSync(file, content);
|
|
2969
|
+
void this.format(content, file).then((pretty) => {
|
|
2970
|
+
if (fs8.readFileSync(file, "utf8") !== content) return;
|
|
2971
|
+
this.sources.set("home", pretty);
|
|
2972
|
+
fs8.writeFileSync(file, pretty);
|
|
2973
|
+
}).catch(() => {
|
|
2974
|
+
});
|
|
2975
|
+
}
|
|
2976
|
+
return syncRoute(this.config);
|
|
2977
|
+
}
|
|
2978
|
+
/** First canvas of a project: its homepage as a linked frame when there is one, else the placeholder. */
|
|
2979
|
+
homeCanvas() {
|
|
2980
|
+
try {
|
|
2981
|
+
const home = listRoutes(this.config).find((r) => r.route === "/");
|
|
2982
|
+
if (!home) return STARTER_CANVAS;
|
|
2983
|
+
const rel = path9.relative(this.config.root, canvasPath(this.config, "home"));
|
|
2984
|
+
const empty = parseCanvas("home", rel, `import { Canvas, Frame } from "truecanvas";
|
|
2985
|
+
export default function C() { return <Canvas></Canvas>; }
|
|
2986
|
+
`);
|
|
2987
|
+
const built = linkedFrameBody(this.config, empty, rel, home.file);
|
|
2988
|
+
const body = built.jsx.split("\n").join("\n ");
|
|
2989
|
+
return `"use client";
|
|
2990
|
+
import { Canvas, Frame } from "truecanvas";
|
|
2991
|
+
${built.imports.join("\n")}
|
|
2992
|
+
|
|
2993
|
+
/** Your homepage, linked to ${home.file}: editing its layers edits that file. */
|
|
2994
|
+
export default function HomeCanvas() {
|
|
2995
|
+
return (
|
|
2996
|
+
<Canvas>
|
|
2997
|
+
<Frame name="Home" x={0} y={0} width={1440} page=${JSON.stringify(home.file)}>
|
|
2998
|
+
${body}
|
|
2999
|
+
</Frame>
|
|
3000
|
+
</Canvas>
|
|
3001
|
+
);
|
|
3002
|
+
}
|
|
3003
|
+
`;
|
|
3004
|
+
} catch {
|
|
3005
|
+
return STARTER_CANVAS;
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
findFrameOf(canvas, ref) {
|
|
3009
|
+
const doc = this.doc(canvas);
|
|
3010
|
+
const frame = findFrame(doc, ref) ?? (findNode(doc, ref)?.frame ?? null);
|
|
3011
|
+
if (!frame) throw new EditError(`Frame "${ref}" not found in ${canvas}. Frames: ${doc.frames.map((f) => `"${f.frameName}"`).join(", ")}`);
|
|
3012
|
+
return frame;
|
|
3013
|
+
}
|
|
3014
|
+
};
|
|
3015
|
+
var COMPONENTS_CANVAS = `"use client";
|
|
3016
|
+
import { Canvas, Frame } from "truecanvas";
|
|
3017
|
+
|
|
3018
|
+
/** Main components: edit one here and every instance in the app follows. */
|
|
3019
|
+
export default function ComponentsCanvas() {
|
|
3020
|
+
return <Canvas></Canvas>;
|
|
3021
|
+
}
|
|
3022
|
+
`;
|
|
3023
|
+
function validate(code) {
|
|
3024
|
+
try {
|
|
3025
|
+
parseModule(code);
|
|
3026
|
+
} catch (err) {
|
|
3027
|
+
throw new EditError(`Edit produced invalid code: ${err.message}`);
|
|
3028
|
+
}
|
|
3029
|
+
return code;
|
|
3030
|
+
}
|
|
3031
|
+
function publicEntry(e) {
|
|
3032
|
+
const { before: _b, after: _a, ...rest } = e;
|
|
3033
|
+
return rest;
|
|
3034
|
+
}
|
|
3035
|
+
function nodeRefs(cmd) {
|
|
3036
|
+
const refs = [];
|
|
3037
|
+
if ("id" in cmd && cmd.id) refs.push(cmd.id);
|
|
3038
|
+
if ("ids" in cmd) refs.push(...cmd.ids);
|
|
3039
|
+
if ("parent" in cmd && cmd.parent) refs.push(cmd.parent);
|
|
3040
|
+
if ("from" in cmd && cmd.op === "create_component" && cmd.from) refs.push(cmd.from);
|
|
3041
|
+
return refs;
|
|
3042
|
+
}
|
|
3043
|
+
function touchedFrames(cmd, ids, before, after) {
|
|
3044
|
+
const out = /* @__PURE__ */ new Set();
|
|
3045
|
+
const refs = nodeRefs(cmd);
|
|
3046
|
+
if ("frame" in cmd && cmd.frame) refs.push(cmd.frame);
|
|
3047
|
+
for (const ref of refs) {
|
|
3048
|
+
const f = findFrame(before, ref) ?? findNode(before, ref)?.frame;
|
|
3049
|
+
if (f) out.add(f.frameName);
|
|
3050
|
+
}
|
|
3051
|
+
for (const id of ids) {
|
|
3052
|
+
const f = findFrame(after, id) ?? findNode(after, id)?.frame;
|
|
3053
|
+
if (f) out.add(f.frameName);
|
|
3054
|
+
}
|
|
3055
|
+
return [...out];
|
|
3056
|
+
}
|
|
3057
|
+
var short = (s, max = 40) => s.length > max ? `${s.slice(0, max - 1)}\u2026` : s;
|
|
3058
|
+
function pathId(doc, p) {
|
|
3059
|
+
let node = doc.frames[p[0]];
|
|
3060
|
+
for (const i of p.slice(1)) node = node?.children[i];
|
|
3061
|
+
return node?.id ?? null;
|
|
3062
|
+
}
|
|
3063
|
+
function touchedPaths(doc, cmd) {
|
|
3064
|
+
if ("id" in cmd && cmd.id) {
|
|
3065
|
+
const found = findNode(doc, cmd.id);
|
|
3066
|
+
if (found) return [found.node.path];
|
|
3067
|
+
}
|
|
3068
|
+
return [];
|
|
3069
|
+
}
|
|
3070
|
+
function presetDiff(spec, values) {
|
|
3071
|
+
const out = {};
|
|
3072
|
+
for (const [k, v] of Object.entries(values)) {
|
|
3073
|
+
const def = spec.props.find((p) => p.name === k)?.default;
|
|
3074
|
+
if (JSON.stringify(def) !== JSON.stringify(v)) out[k] = v;
|
|
3075
|
+
}
|
|
3076
|
+
return out;
|
|
3077
|
+
}
|
|
3078
|
+
function uniqueFrameName(doc, wanted, current) {
|
|
3079
|
+
const used = new Set(doc.frames.map((f) => f.frameName).filter((n) => n !== current));
|
|
3080
|
+
if (!used.has(wanted)) return wanted;
|
|
3081
|
+
for (let i = 2; ; i++) if (!used.has(`${wanted} ${i}`)) return `${wanted} ${i}`;
|
|
3082
|
+
}
|
|
3083
|
+
function componentJsx(spec, props = {}, text) {
|
|
3084
|
+
const attrs = [];
|
|
3085
|
+
const merged = {};
|
|
3086
|
+
for (const p of spec.props) {
|
|
3087
|
+
if (p.optional || p.default !== void 0 || p.name === "children") continue;
|
|
3088
|
+
if (p.type === "string") merged[p.name] = p.name === "title" || p.name === "label" ? spec.name : p.name;
|
|
3089
|
+
else if (p.type === "enum") merged[p.name] = p.options[0];
|
|
3090
|
+
else if (p.type === "boolean") merged[p.name] = false;
|
|
3091
|
+
else if (p.type === "number") merged[p.name] = 0;
|
|
3092
|
+
}
|
|
3093
|
+
Object.assign(merged, props);
|
|
3094
|
+
for (const [k, v] of Object.entries(merged)) if (v !== null && v !== void 0) attrs.push(attrCode(k, v));
|
|
3095
|
+
const open = `<${spec.name}${attrs.length ? ` ${attrs.join(" ")}` : ""}`;
|
|
3096
|
+
const childText = text ?? (spec.acceptsChildren && spec.props.find((p) => p.name === "children" && !p.optional) ? spec.name : void 0);
|
|
3097
|
+
return childText !== void 0 ? `${open}>${textCode(childText)}</${spec.name}>` : `${open} />`;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// src/core/outline.ts
|
|
3101
|
+
function propText(name, v) {
|
|
3102
|
+
if (v.kind === "boolean") return v.value ? name : `${name}={false}`;
|
|
3103
|
+
if (v.kind === "string") return `${name}=${JSON.stringify(v.value.length > 60 ? `${v.value.slice(0, 57)}\u2026` : v.value)}`;
|
|
3104
|
+
if (v.kind === "number") return `${name}={${v.value}}`;
|
|
3105
|
+
if (v.kind === "array") return `${name}={[${v.value.map((x) => JSON.stringify(x)).join(", ")}]}`;
|
|
3106
|
+
return `${name}={${v.code.length > 40 ? `${v.code.slice(0, 37)}\u2026` : v.code}}`;
|
|
3107
|
+
}
|
|
3108
|
+
function linkText(f) {
|
|
3109
|
+
if (f.link?.kind === "component") {
|
|
3110
|
+
return f.link.editable ? ` MAIN COMPONENT ${f.link.route} (${f.link.page}): layer edits write to that file, every instance follows` : ` MAIN COMPONENT, unavailable: ${f.link.reason ?? ""}`;
|
|
3111
|
+
}
|
|
3112
|
+
if (f.link) {
|
|
3113
|
+
if (!f.link.editable) return ` LINKED to ${f.link.page}, view only: ${f.link.reason ?? ""}`;
|
|
3114
|
+
return ` LINKED to ${f.link.page} (route ${f.link.route}): layer edits write to ${f.link.files.join(" and ")}`;
|
|
3115
|
+
}
|
|
3116
|
+
if (f.from) return ` exploration of ${f.from} (apply_to_page writes it back)`;
|
|
3117
|
+
return "";
|
|
3118
|
+
}
|
|
3119
|
+
function outlineDoc(doc, opts = {}) {
|
|
3120
|
+
const lines = [`canvas "${doc.name}" (${doc.file})`];
|
|
3121
|
+
if (doc.error) lines.push(`\u26A0 parse error: ${doc.error}`);
|
|
3122
|
+
const frames = opts.frame ? doc.frames.filter((f) => f.frameName === opts.frame || f.id === opts.frame) : doc.frames;
|
|
3123
|
+
for (const f of frames) {
|
|
3124
|
+
lines.push(
|
|
3125
|
+
`frame "${f.frameName}" [${f.id}] x=${f.x} y=${f.y} w=${f.width} h=${f.height ?? "hug"} theme=${f.theme ?? "inherit"}${linkText(f)}`
|
|
3126
|
+
);
|
|
3127
|
+
for (const c of f.children) outlineNode(c, 1, lines, opts.depth ?? 99);
|
|
3128
|
+
}
|
|
3129
|
+
if (!frames.length) lines.push("(no frames)");
|
|
3130
|
+
return lines.join("\n");
|
|
3131
|
+
}
|
|
3132
|
+
function outlineNode(n, depth, lines, max) {
|
|
3133
|
+
const pad = " ".repeat(depth);
|
|
3134
|
+
if (n.kind === "text") {
|
|
3135
|
+
lines.push(`${pad}"${n.text}" [${n.id}]`);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
if (n.kind === "expression") {
|
|
3139
|
+
lines.push(`${pad}{${n.name.length > 60 ? `${n.name.slice(0, 57)}\u2026` : n.name}} [${n.id}] (expression, read-only)`);
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
const props = Object.entries(n.props).map(([k, v]) => propText(k, v));
|
|
3143
|
+
if (n.spreads) props.push("{...spread}");
|
|
3144
|
+
const onlyText = n.children.length === 1 && n.children[0].kind === "text";
|
|
3145
|
+
const head = `${pad}<${n.name}${props.length ? ` ${props.join(" ")}` : ""}>`;
|
|
3146
|
+
if (!n.children.length) {
|
|
3147
|
+
lines.push(`${head.slice(0, -1)} /> [${n.id}]`);
|
|
3148
|
+
} else if (onlyText) {
|
|
3149
|
+
lines.push(`${head}${n.children[0].text}</${n.name}> [${n.id}]`);
|
|
3150
|
+
} else if (depth >= max) {
|
|
3151
|
+
lines.push(`${head}\u2026 ${n.children.length} children [${n.id}]`);
|
|
3152
|
+
} else {
|
|
3153
|
+
lines.push(`${head} [${n.id}]`);
|
|
3154
|
+
for (const c of n.children) outlineNode(c, depth + 1, lines, max);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
function describeComponent(c, verbose = false) {
|
|
3158
|
+
const typeOf = (p) => {
|
|
3159
|
+
if (p.type === "enum") return p.options.map((o) => JSON.stringify(o)).join(verbose ? " | " : "|");
|
|
3160
|
+
if (p.type === "color") return "color string";
|
|
3161
|
+
if (p.type === "colors") return "color string[]";
|
|
3162
|
+
if (p.type === "number" && p.min !== void 0) return `number ${p.min}..${p.max}`;
|
|
3163
|
+
return p.type === "other" || verbose ? p.typeText : p.type;
|
|
3164
|
+
};
|
|
3165
|
+
const where = c.library ? `from "${c.library}"` : c.file;
|
|
3166
|
+
if (!verbose) {
|
|
3167
|
+
const props = c.props.filter((p) => p.name !== "className" && !p.advanced).map((p) => `${p.name}${p.optional ? "?" : ""}: ${typeOf(p)}`);
|
|
3168
|
+
const presets = c.presets?.length ? ` presets: ${c.presets.map((p) => p.name).join(", ")}` : "";
|
|
3169
|
+
return `${c.name} (${where}) { ${props.join("; ")} }${presets}`;
|
|
3170
|
+
}
|
|
3171
|
+
const lines = [`${c.name} (${where})`];
|
|
3172
|
+
if (c.description) lines.push(c.description);
|
|
3173
|
+
for (const p of c.props) {
|
|
3174
|
+
const def = p.default !== void 0 ? ` = ${JSON.stringify(p.default)}` : "";
|
|
3175
|
+
lines.push(`- ${p.name}${p.optional ? "?" : ""}: ${typeOf(p)}${def}${p.advanced ? " (advanced)" : ""}${p.description ? `. ${p.description}` : ""}`);
|
|
3176
|
+
}
|
|
3177
|
+
if (c.presets?.length) {
|
|
3178
|
+
lines.push("", "Presets (apply_preset, or copy the values):");
|
|
3179
|
+
for (const p of c.presets) {
|
|
3180
|
+
const diff = Object.fromEntries(Object.entries(p.props).filter(([k, v]) => JSON.stringify(c.props.find((x) => x.name === k)?.default) !== JSON.stringify(v)));
|
|
3181
|
+
lines.push(`- ${p.name}: ${Object.keys(diff).length ? JSON.stringify(diff) : "(defaults)"}`);
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
return lines.join("\n");
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
// src/core/tokens.ts
|
|
3188
|
+
import fs9 from "fs";
|
|
3189
|
+
import path10 from "path";
|
|
3190
|
+
import { globSync } from "tinyglobby";
|
|
3191
|
+
var GROUPS = [
|
|
3192
|
+
["--color-", "Colors", "bg-{name} text-{name} border-{name}"],
|
|
3193
|
+
["--font-", "Fonts", "font-{name}"],
|
|
3194
|
+
["--text-", "Text sizes", "text-{name}"],
|
|
3195
|
+
["--radius-", "Radius", "rounded-{name}"],
|
|
3196
|
+
["--shadow-", "Shadows", "shadow-{name}"],
|
|
3197
|
+
["--spacing", "Spacing", "p-*, gap-*, m-* (multiples of the base)"],
|
|
3198
|
+
["--breakpoint-", "Breakpoints", "{name}:"]
|
|
3199
|
+
];
|
|
3200
|
+
function readDesignTokens(root) {
|
|
3201
|
+
const files = globSync(["**/*.css"], { cwd: root, ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/out/**"], absolute: true }).slice(0, 40);
|
|
3202
|
+
const found = /* @__PURE__ */ new Map();
|
|
3203
|
+
const sources = [];
|
|
3204
|
+
let darkVariant = null;
|
|
3205
|
+
for (const file of files) {
|
|
3206
|
+
const css = fs9.readFileSync(file, "utf8");
|
|
3207
|
+
if (!css.includes("@theme") && !css.includes("@custom-variant")) continue;
|
|
3208
|
+
sources.push(path10.relative(root, file));
|
|
3209
|
+
const dv = /@custom-variant\s+dark\s*\(([^;]+)\);/.exec(css);
|
|
3210
|
+
if (dv) darkVariant = dv[1].trim();
|
|
3211
|
+
for (const block of css.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) {
|
|
3212
|
+
for (const decl of block[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
|
|
3213
|
+
const [, name, value] = decl;
|
|
3214
|
+
const group = GROUPS.find(([prefix]) => name.startsWith(prefix));
|
|
3215
|
+
if (!group) continue;
|
|
3216
|
+
const key = group[1];
|
|
3217
|
+
if (!found.has(key)) found.set(key, /* @__PURE__ */ new Map());
|
|
3218
|
+
const short2 = group[0] === "--spacing" ? name : name.slice(group[0].length);
|
|
3219
|
+
if (short2.includes("*")) continue;
|
|
3220
|
+
found.get(key).set(short2, value.trim());
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
if (!found.size) return "No Tailwind @theme tokens found. Use Tailwind's default palette (e.g. bg-white, text-zinc-500).";
|
|
3225
|
+
const lines = [`Design tokens from ${sources.join(", ")}. Prefer these over Tailwind defaults.`];
|
|
3226
|
+
for (const [prefix, title, usage] of GROUPS) {
|
|
3227
|
+
const entries = found.get(title);
|
|
3228
|
+
if (!entries?.size) continue;
|
|
3229
|
+
lines.push("", `${title} (use as ${usage}):`);
|
|
3230
|
+
const names = [...entries.keys()];
|
|
3231
|
+
if (title === "Colors") lines.push(names.join(", "));
|
|
3232
|
+
else lines.push(names.map((n) => `${n} (${entries.get(n)})`).join(", "));
|
|
3233
|
+
void prefix;
|
|
3234
|
+
}
|
|
3235
|
+
if (darkVariant) lines.push("", `Dark mode: dark: variant \u2192 ${darkVariant}. Tokens already adapt; avoid hard-coded colors.`);
|
|
3236
|
+
return lines.join("\n");
|
|
3237
|
+
}
|
|
3238
|
+
function readTokenData(root) {
|
|
3239
|
+
const files = globSync(["**/*.css"], { cwd: root, ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/out/**"], absolute: true }).slice(0, 40);
|
|
3240
|
+
const theme = /* @__PURE__ */ new Map();
|
|
3241
|
+
const lightVars = /* @__PURE__ */ new Map();
|
|
3242
|
+
const darkVars = /* @__PURE__ */ new Map();
|
|
3243
|
+
const decls = (body, into) => {
|
|
3244
|
+
for (const m of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) into.set(m[1], m[2].trim());
|
|
3245
|
+
};
|
|
3246
|
+
for (const file of files) {
|
|
3247
|
+
const css = fs9.readFileSync(file, "utf8");
|
|
3248
|
+
for (const block of css.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) decls(block[1], theme);
|
|
3249
|
+
for (const block of css.matchAll(/(?:^|\n)\s*:root\s*\{([\s\S]*?)\n\}/g)) decls(block[1], lightVars);
|
|
3250
|
+
for (const block of css.matchAll(/(?:^|\n)\s*(?:\.dark|\[data-theme=["']?dark["']?\]|:root\.dark)\s*\{([\s\S]*?)\n\}/g)) decls(block[1], darkVars);
|
|
3251
|
+
}
|
|
3252
|
+
const resolve = (value, vars, depth = 0) => {
|
|
3253
|
+
const m = /^var\((--[\w-]+)(?:,\s*([^)]+))?\)$/.exec(value.trim());
|
|
3254
|
+
if (!m || depth > 5) return value;
|
|
3255
|
+
const next = vars.get(m[1]) ?? lightVars.get(m[1]) ?? m[2];
|
|
3256
|
+
return next ? resolve(next, vars, depth + 1) : value;
|
|
3257
|
+
};
|
|
3258
|
+
const pick = (prefix) => [...theme.keys()].filter((k) => k.startsWith(prefix) && !k.includes("*")).map((k) => k.slice(prefix.length));
|
|
3259
|
+
const colors = pick("--color-").map((name) => {
|
|
3260
|
+
const raw = theme.get(`--color-${name}`);
|
|
3261
|
+
const light = resolve(raw, lightVars);
|
|
3262
|
+
const dark = resolve(raw, darkVars);
|
|
3263
|
+
return { name, light, dark: dark !== light ? dark : null };
|
|
3264
|
+
});
|
|
3265
|
+
const custom = (prefix, defaults) => {
|
|
3266
|
+
const names = pick(prefix).filter((n) => !n.includes("--"));
|
|
3267
|
+
return names.length ? [.../* @__PURE__ */ new Set([...defaults, ...names])] : defaults;
|
|
3268
|
+
};
|
|
3269
|
+
return {
|
|
3270
|
+
colors,
|
|
3271
|
+
radius: custom("--radius-", ["xs", "sm", "md", "lg", "xl", "2xl", "3xl", "4xl"]),
|
|
3272
|
+
shadows: custom("--shadow-", ["2xs", "xs", "sm", "md", "lg", "xl", "2xl"]),
|
|
3273
|
+
textSizes: custom("--text-", ["xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl", "7xl"]),
|
|
3274
|
+
fonts: custom("--font-", ["sans", "serif", "mono"])
|
|
3275
|
+
};
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// src/core/compare.ts
|
|
3279
|
+
import fs10 from "fs";
|
|
3280
|
+
import path11 from "path";
|
|
3281
|
+
var COMPARE_PREFIX = "__compare__";
|
|
3282
|
+
function writeCompare(config, canvas, source, files = {}, as = canvas) {
|
|
3283
|
+
const routeDir = path11.join(config.root, config.appDir, "truecanvas");
|
|
3284
|
+
const dir = path11.join(routeDir, "compare");
|
|
3285
|
+
fs10.mkdirSync(dir, { recursive: true });
|
|
3286
|
+
const canvasDir = path11.join(config.root, config.canvasDir);
|
|
3287
|
+
const canvasRel = path11.join(config.canvasDir, `${canvas}.canvas.tsx`);
|
|
3288
|
+
const swapped = /* @__PURE__ */ new Map();
|
|
3289
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
3290
|
+
const ext = path11.extname(rel);
|
|
3291
|
+
const name = `${as}--${rel.slice(0, -ext.length).replace(/[^\w.-]+/g, "_")}${ext}`;
|
|
3292
|
+
const original = path11.join(config.root, rel);
|
|
3293
|
+
fs10.writeFileSync(path11.join(dir, name), rewriteImports(content, (spec) => spec.startsWith(".") ? relativeSpec(dir, path11.resolve(path11.dirname(original), spec)) : spec));
|
|
3294
|
+
swapped.set(importSpecifier(config.root, canvasRel, rel), `./${name.slice(0, -ext.length)}`);
|
|
3295
|
+
}
|
|
3296
|
+
const out = rewriteImports(source, (spec) => swapped.get(spec) ?? (spec.startsWith(".") ? relativeSpec(dir, path11.resolve(canvasDir, spec)) : spec));
|
|
3297
|
+
fs10.writeFileSync(path11.join(dir, `${as}.canvas.tsx`), out);
|
|
3298
|
+
syncRoute(config);
|
|
3299
|
+
return `${COMPARE_PREFIX}${as}`;
|
|
3300
|
+
}
|
|
3301
|
+
function relativeSpec(fromDir, target) {
|
|
3302
|
+
const next = path11.relative(fromDir, target);
|
|
3303
|
+
return next.startsWith(".") ? next : `./${next}`;
|
|
3304
|
+
}
|
|
3305
|
+
function rewriteImports(source, map) {
|
|
3306
|
+
const doc = parseCanvas("", "", source);
|
|
3307
|
+
let out = source;
|
|
3308
|
+
for (const imp of [...doc.imports].sort((a, b) => b.start - a.start)) {
|
|
3309
|
+
const next = map(imp.source);
|
|
3310
|
+
if (next === imp.source) continue;
|
|
3311
|
+
const stmt = out.slice(imp.start, imp.end).replace(JSON.stringify(imp.source), JSON.stringify(next)).replace(`'${imp.source}'`, `'${next}'`);
|
|
3312
|
+
out = out.slice(0, imp.start) + stmt + out.slice(imp.end);
|
|
3313
|
+
}
|
|
3314
|
+
return out;
|
|
3315
|
+
}
|
|
3316
|
+
function clearCompare(config, canvas) {
|
|
3317
|
+
const dir = path11.join(config.root, config.appDir, "truecanvas", "compare");
|
|
3318
|
+
if (!fs10.existsSync(dir)) return;
|
|
3319
|
+
for (const f of fs10.readdirSync(dir)) if (!canvas || f === `${canvas}.canvas.tsx` || f.startsWith(`${canvas}--`)) fs10.rmSync(path11.join(dir, f));
|
|
3320
|
+
syncRoute(config);
|
|
3321
|
+
}
|
|
3322
|
+
function frameChanges(before, after) {
|
|
3323
|
+
const a = parseCanvas("", "", before);
|
|
3324
|
+
const b = parseCanvas("", "", after);
|
|
3325
|
+
const norm = (src, start, end) => src.slice(start, end).replace(/\s+/g, " ").replace(/\s?(x|y)=\{-?\d+\}/g, "");
|
|
3326
|
+
const out = {};
|
|
3327
|
+
for (const f of b.frames) {
|
|
3328
|
+
const old = a.frames.find((x) => x.frameName === f.frameName);
|
|
3329
|
+
out[f.frameName] = !old ? "added" : norm(before, old.start, old.end) === norm(after, f.start, f.end) ? "same" : "changed";
|
|
3330
|
+
}
|
|
3331
|
+
for (const f of a.frames) if (!(f.frameName in out)) out[f.frameName] = "removed";
|
|
3332
|
+
return out;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
export {
|
|
3336
|
+
loadConfig,
|
|
3337
|
+
parseModule,
|
|
3338
|
+
idOf,
|
|
3339
|
+
walk,
|
|
3340
|
+
parseCanvas,
|
|
3341
|
+
allNodes,
|
|
3342
|
+
findNode,
|
|
3343
|
+
findFrame,
|
|
3344
|
+
findByPath,
|
|
3345
|
+
EditError,
|
|
3346
|
+
attrCode,
|
|
3347
|
+
textCode,
|
|
3348
|
+
CanvasEditor,
|
|
3349
|
+
listRoutes,
|
|
3350
|
+
layoutFiles,
|
|
3351
|
+
componentsDir,
|
|
3352
|
+
DEVICES,
|
|
3353
|
+
findDevice,
|
|
3354
|
+
Comments,
|
|
3355
|
+
Git,
|
|
3356
|
+
syncComponentRegistry,
|
|
3357
|
+
Workspace,
|
|
3358
|
+
componentJsx,
|
|
3359
|
+
outlineDoc,
|
|
3360
|
+
describeComponent,
|
|
3361
|
+
readDesignTokens,
|
|
3362
|
+
readTokenData,
|
|
3363
|
+
writeCompare,
|
|
3364
|
+
clearCompare,
|
|
3365
|
+
frameChanges
|
|
3366
|
+
};
|