@beastjs/page-builder 0.0.0-stage → 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/CHANGELOG.md +17 -0
- package/LICENSE +15 -0
- package/README.md +200 -2
- package/dist/client.js +234 -0
- package/dist/rsbuild.d.ts +1 -0
- package/dist/rsbuild.js +883 -0
- package/dist/rspack.d.ts +1 -0
- package/dist/rspack.js +892 -0
- package/dist/types/rsbuild.d.ts +4 -0
- package/dist/types/rspack.d.ts +9 -0
- package/dist/types/server/discovery.d.ts +8 -0
- package/dist/types/server/integration.d.ts +6 -0
- package/dist/types/server/project.d.ts +24 -0
- package/dist/types/server/server.d.ts +10 -0
- package/dist/types/shared/types.d.ts +121 -0
- package/dist/types/vite.d.ts +4 -0
- package/dist/vite.d.ts +1 -0
- package/dist/vite.js +887 -0
- package/package.json +103 -4
package/dist/rsbuild.js
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
// server/server.ts
|
|
2
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// shared/types.ts
|
|
6
|
+
var BASE = "/__beast-page-builder";
|
|
7
|
+
|
|
8
|
+
// server/project.ts
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { lstat, mkdir, readdir, readFile, realpath, writeFile, unlink, link, rename, stat, chmod } from "node:fs/promises";
|
|
11
|
+
import { basename, dirname as dirname2, isAbsolute, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
12
|
+
import { compileBeastResult as compileBeastResult3, componentNameFromPath } from "beast-tsrx";
|
|
13
|
+
import { createOctaneCompiler } from "octane/compiler/bundler";
|
|
14
|
+
|
|
15
|
+
// server/integration.ts
|
|
16
|
+
import ts from "typescript";
|
|
17
|
+
import { compileBeastResult } from "beast-tsrx";
|
|
18
|
+
import { dirname, relative } from "node:path";
|
|
19
|
+
var apply = (source, edits) => edits.sort((a, b) => b.start - a.start).reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), source);
|
|
20
|
+
var quote = (value) => JSON.stringify(value);
|
|
21
|
+
var newline = (source) => source.includes("\r\n") ? "\r\n" : "\n";
|
|
22
|
+
var property = (node, name) => node.properties.find((prop) => ts.isPropertyAssignment(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) && prop.name.text === name)?.initializer;
|
|
23
|
+
var literal = (node) => node && ts.isStringLiteral(node) ? node.text : void 0;
|
|
24
|
+
var unwrap = (node) => ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node) ? unwrap(node.expression) : node;
|
|
25
|
+
function parseTypeScript(source, file) {
|
|
26
|
+
const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
27
|
+
const diagnostics = ast.parseDiagnostics;
|
|
28
|
+
if (diagnostics.length) throw new BuilderError(`${file}: ${ts.flattenDiagnosticMessageText(diagnostics[0].messageText, "\n")}`, 422);
|
|
29
|
+
return ast;
|
|
30
|
+
}
|
|
31
|
+
function appendArray(source, array, item) {
|
|
32
|
+
const close = array.end - 1;
|
|
33
|
+
const lineStart = source.lastIndexOf("\n", close - 1) + 1;
|
|
34
|
+
const closingIndent = source.slice(lineStart, close);
|
|
35
|
+
const multiline = array.getText().includes("\n") && /^\s*$/.test(closingIndent);
|
|
36
|
+
const edits = [];
|
|
37
|
+
const last = array.elements.at(-1);
|
|
38
|
+
if (last && last.end === close && !multiline) return [{ start: close, end: close, text: `, ${item}` }];
|
|
39
|
+
if (last && !array.elements.hasTrailingComma) edits.push({ start: last.end, end: last.end, text: "," });
|
|
40
|
+
edits.push({ start: multiline ? lineStart : close, end: multiline ? lineStart : close, text: multiline ? `${closingIndent} ${item},${newline(source)}` : `${last ? " " : ""}${item}${array.elements.hasTrailingComma ? "," : ""}` });
|
|
41
|
+
return edits;
|
|
42
|
+
}
|
|
43
|
+
function registerRoute(source, file, pageFile, name, path) {
|
|
44
|
+
const ast = parseTypeScript(source, file);
|
|
45
|
+
const routerImport = ast.statements.find((statement) => ts.isImportDeclaration(statement) && literal(statement.moduleSpecifier) === "@octanejs/tanstack-router");
|
|
46
|
+
const bindings = routerImport?.importClause?.namedBindings;
|
|
47
|
+
if (!bindings || !ts.isNamedImports(bindings)) throw new BuilderError(`${file} must import createRoute from @octanejs/tanstack-router.`, 422);
|
|
48
|
+
const binding = (name2) => bindings.elements.find((item) => (item.propertyName ?? item.name).text === name2 && !item.isTypeOnly)?.name.text;
|
|
49
|
+
const createRoute = binding("createRoute");
|
|
50
|
+
if (!createRoute) throw new BuilderError(`${file} must import createRoute.`, 422);
|
|
51
|
+
const trees = [];
|
|
52
|
+
const components = [];
|
|
53
|
+
const identifiers = /* @__PURE__ */ new Set();
|
|
54
|
+
function visit(node) {
|
|
55
|
+
if (ts.isIdentifier(node)) identifiers.add(node.text);
|
|
56
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === createRoute) {
|
|
57
|
+
const options = node.arguments[0];
|
|
58
|
+
if (!options || !ts.isObjectLiteralExpression(options)) throw new BuilderError("Computed route options are unsupported. Use a literal createRoute object.", 422);
|
|
59
|
+
const routePath = property(options, "path");
|
|
60
|
+
if (routePath && !literal(routePath)) throw new BuilderError("Computed route paths cannot be checked for conflicts.", 422);
|
|
61
|
+
const existing = literal(routePath);
|
|
62
|
+
if (existing && "/" + existing.replace(/^\/+|\/+$/g, "") === path) throw new BuilderError(`Route ${path} already exists.`, 409);
|
|
63
|
+
const component = property(options, "component");
|
|
64
|
+
if (component && ts.isCallExpression(component) && ts.isIdentifier(component.expression)) components.push(component.expression.text);
|
|
65
|
+
}
|
|
66
|
+
ts.forEachChild(node, visit);
|
|
67
|
+
}
|
|
68
|
+
visit(ast);
|
|
69
|
+
for (const statement of ast.statements) if (ts.isVariableStatement(statement)) {
|
|
70
|
+
for (const declaration2 of statement.declarationList.declarations) {
|
|
71
|
+
const call = declaration2.initializer;
|
|
72
|
+
if (!call || !ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression) || call.expression.name.text !== "addChildren" || !ts.isIdentifier(call.expression.expression)) continue;
|
|
73
|
+
const array = call.arguments[0];
|
|
74
|
+
if (!array || !ts.isArrayLiteralExpression(array) || array.elements.some(ts.isSpreadElement)) throw new BuilderError("The root route must use addChildren with a literal array of routes.", 422);
|
|
75
|
+
trees.push({ statement, call, array, root: call.expression.expression.text });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (trees.length !== 1) throw new BuilderError(`${file} needs one rootRoute.addChildren([...]) route tree.`, 422);
|
|
79
|
+
const tree = trees[0];
|
|
80
|
+
let lazy = binding("lazyRouteComponent");
|
|
81
|
+
if (components.includes("lazyPage") && ast.statements.some((statement) => ts.isFunctionDeclaration(statement) && statement.name?.text === "lazyPage")) lazy = "lazyPage";
|
|
82
|
+
const edits = [];
|
|
83
|
+
if (!lazy) {
|
|
84
|
+
lazy = "lazyRouteComponent";
|
|
85
|
+
if (identifiers.has(lazy)) throw new BuilderError("lazyRouteComponent conflicts with an existing binding.", 422);
|
|
86
|
+
const last = bindings.elements.at(-1);
|
|
87
|
+
const at = last && !bindings.elements.hasTrailingComma ? last.end : bindings.end - 1;
|
|
88
|
+
edits.push({ start: at, end: at, text: last && !bindings.elements.hasTrailingComma ? ", lazyRouteComponent" : ` lazyRouteComponent${last ? "," : ""} ` });
|
|
89
|
+
}
|
|
90
|
+
let routeName = name[0].toLowerCase() + name.slice(1) + "Route";
|
|
91
|
+
const baseName = routeName;
|
|
92
|
+
for (let suffix = 2; identifiers.has(routeName); suffix++) routeName = baseName + suffix;
|
|
93
|
+
let modulePath = relative(dirname(file), pageFile).replaceAll("\\", "/");
|
|
94
|
+
if (!modulePath.startsWith(".")) modulePath = "./" + modulePath;
|
|
95
|
+
const declaration = `const ${routeName} = ${createRoute}({ getParentRoute: () => ${tree.root}, path: ${quote(path)}, component: ${lazy}(() => import(${quote(modulePath)})) })${newline(source)}${newline(source)}`;
|
|
96
|
+
edits.push({ start: tree.statement.getStart(ast), end: tree.statement.getStart(ast), text: declaration }, ...appendArray(source, tree.array, routeName));
|
|
97
|
+
const result = apply(source, edits);
|
|
98
|
+
parseTypeScript(result, file);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
function inlineTargets(source, file) {
|
|
102
|
+
const ast = compileBeastResult(source, { filename: file }).ast;
|
|
103
|
+
const targets = [];
|
|
104
|
+
function walk(value) {
|
|
105
|
+
if (!value || typeof value !== "object") return;
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
value.forEach(walk);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const object = value;
|
|
111
|
+
if (object.kind === "element" && object.tag === "nav") {
|
|
112
|
+
const node = value;
|
|
113
|
+
if (!node.attrs.some((attr) => attr.kind === "attribute" && attr.name === "role" && attr.value.type === "string" && attr.value.value === "tablist") && node.children.every((child) => child.kind === "element" && ["a", "Link"].includes(child.tag))) {
|
|
114
|
+
const selector = "nav" + (node.id ? `#${node.id}` : "") + node.classes.map((name) => `.${name}`).join("");
|
|
115
|
+
targets.push({ id: `btsx:${file}:${node.lineNo}`, file, label: `${file} \xB7 ${selector}`, node });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const [key, child] of Object.entries(object)) if (["children", "branches", "declarations"].includes(key)) walk(child);
|
|
119
|
+
}
|
|
120
|
+
walk(ast);
|
|
121
|
+
return targets;
|
|
122
|
+
}
|
|
123
|
+
function arrayTargets(source, file, config) {
|
|
124
|
+
const ast = parseTypeScript(source, file);
|
|
125
|
+
const targets = [];
|
|
126
|
+
for (const statement of ast.statements) if (ts.isVariableStatement(statement)) {
|
|
127
|
+
if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
128
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
129
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
|
|
130
|
+
const name = declaration.name.text;
|
|
131
|
+
if (config?.array ? name !== config.array : !/^(navItems|navGroups|navigation|links)$/.test(name)) continue;
|
|
132
|
+
const array = unwrap(declaration.initializer);
|
|
133
|
+
if (!ts.isArrayLiteralExpression(array) || array.elements.some(ts.isSpreadElement)) continue;
|
|
134
|
+
const objects = array.elements.filter(ts.isObjectLiteralExpression);
|
|
135
|
+
const groups = objects.filter((object) => property(object, "items"));
|
|
136
|
+
if (groups.length) {
|
|
137
|
+
for (const group of groups) {
|
|
138
|
+
const title = literal(property(group, "title")) ?? literal(property(group, "label"));
|
|
139
|
+
const items = property(group, "items");
|
|
140
|
+
if (!title || !items || !ts.isArrayLiteralExpression(unwrap(items)) || unwrap(items).elements.some(ts.isSpreadElement) || config?.group && config.group !== title) continue;
|
|
141
|
+
targets.push({ id: `ts:${file}:${name}:${title}`, file, label: `${file} \xB7 ${name} / ${title}`, array: unwrap(items) });
|
|
142
|
+
}
|
|
143
|
+
} else if (!config?.group && objects.length === array.elements.length) targets.push({ id: `ts:${file}:${name}`, file, label: `${file} \xB7 ${name}`, array });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return targets;
|
|
147
|
+
}
|
|
148
|
+
function discoverNavigation(source, file, config) {
|
|
149
|
+
if (file.endsWith(".btsx")) return inlineTargets(source, file).filter((target) => !config?.selector || target.label.endsWith(` \xB7 ${config.selector}`)).map(({ node, ...target }) => target);
|
|
150
|
+
return arrayTargets(source, file, config).map(({ array, ...target }) => target);
|
|
151
|
+
}
|
|
152
|
+
function addNavigation(source, target, path, label, config) {
|
|
153
|
+
if (target.file.endsWith(".btsx")) {
|
|
154
|
+
let end2 = function(node) {
|
|
155
|
+
lastLine = Math.max(lastLine, node.span.end.line);
|
|
156
|
+
for (const child of node.children) if (child.kind === "element") end2(child);
|
|
157
|
+
else lastLine = Math.max(lastLine, child.span.end.line);
|
|
158
|
+
};
|
|
159
|
+
var end = end2;
|
|
160
|
+
const nav = inlineTargets(source, target.file).find((item2) => item2.id === target.id)?.node;
|
|
161
|
+
if (!nav) throw new BuilderError("The selected navigation changed. Rescan the project.", 409);
|
|
162
|
+
const links = nav.children;
|
|
163
|
+
if (links.some((link3) => link3.attrs.some((attr) => attr.kind === "attribute" && ["href", "to"].includes(attr.name) && attr.value.type === "string" && attr.value.value.replace(/\/$/, "") === path))) throw new BuilderError(`Navigation already links to ${path}.`, 409);
|
|
164
|
+
const link2 = links.find((link3) => link3.tag === "Link");
|
|
165
|
+
const indent = " ".repeat(nav.span.start.column - 1 + 2);
|
|
166
|
+
const classes = (link2?.classes ?? links[0]?.classes ?? []).filter((name) => !/active|selected/.test(name));
|
|
167
|
+
const classAttr = link2?.attrs.find((attr) => attr.kind === "attribute" && attr.name === "className" && attr.value.type === "string");
|
|
168
|
+
const classValue = classAttr?.kind === "attribute" && classAttr.value.type === "string" ? ` className=${quote(classAttr.value.value)}` : "";
|
|
169
|
+
let lastLine = nav.span.end.line;
|
|
170
|
+
links.forEach(end2);
|
|
171
|
+
const lines = source.split(/\r?\n/);
|
|
172
|
+
lines.splice(lastLine, 0, `${indent}${link2 ? "Link" : "a"}${classes.map((name) => "." + name).join("")}(${link2 ? "to" : "href"}=${quote(path)}${classValue}) #{${quote(label)}}`);
|
|
173
|
+
return lines.join(newline(source));
|
|
174
|
+
}
|
|
175
|
+
const array = arrayTargets(source, target.file, { ...config, file: target.file, array: target.array ?? config?.array, group: target.group ?? config?.group }).find((item2) => item2.id === target.id)?.array;
|
|
176
|
+
if (!array) throw new BuilderError("The selected navigation changed. Rescan the project.", 409);
|
|
177
|
+
const objects = array.elements.filter(ts.isObjectLiteralExpression);
|
|
178
|
+
if (objects.some((object) => [property(object, "href"), property(object, "to")].some((value) => literal(value)?.replace(/\/$/, "") === path))) throw new BuilderError(`Navigation already links to ${path}.`, 409);
|
|
179
|
+
const template = objects.find((object) => property(object, "href") || property(object, "to")) ?? objects[0];
|
|
180
|
+
const contract = target.itemType?.variants?.find((variant) => variant.fields.some((field2) => ["href", "to"].includes(field2.name))) ?? target.itemType;
|
|
181
|
+
const defaults = config?.itemDefaults ?? {};
|
|
182
|
+
const fields = { ...defaults };
|
|
183
|
+
let keys = template ? template.properties.map((prop) => prop.name && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) ? prop.name.text : "") : ["href", "label"];
|
|
184
|
+
if (contract?.fields.length) keys = [.../* @__PURE__ */ new Set([...template ? keys : [], ...contract.fields.filter((field2) => !field2.optional).map((field2) => field2.name)])];
|
|
185
|
+
if (keys.includes("") || template?.properties.some((prop) => !ts.isPropertyAssignment(prop))) throw new BuilderError("Menu items must be literal objects with named fields.", 422);
|
|
186
|
+
for (const key of keys) {
|
|
187
|
+
if (key in defaults) continue;
|
|
188
|
+
if (key === "href" || key === "to") fields[key] = path;
|
|
189
|
+
else if (["label", "title", "short", "description"].includes(key)) fields[key] = label;
|
|
190
|
+
else if (key === "value" || key === "id") fields[key] = path.slice(1);
|
|
191
|
+
else if (key === "tags") fields[key] = [label.toLowerCase()];
|
|
192
|
+
else if (key === "disabled") fields[key] = false;
|
|
193
|
+
else if (["kind", "type"].includes(key) && template && literal(property(template, key))) fields[key] = literal(property(template, key));
|
|
194
|
+
else if (key === "icon" && template && literal(property(template, key))) fields[key] = literal(property(template, key));
|
|
195
|
+
else throw new BuilderError(`Set navigation.itemDefaults.${key} for this menu.`, 422);
|
|
196
|
+
}
|
|
197
|
+
fields[keys.includes("to") ? "to" : "href"] = path;
|
|
198
|
+
fields[keys.includes("title") && !keys.includes("label") ? "title" : "label"] = label;
|
|
199
|
+
for (const fieldContract of contract?.origin === "declared" ? contract.fields : []) {
|
|
200
|
+
if (!(fieldContract.name in fields)) continue;
|
|
201
|
+
const value = fields[fieldContract.name];
|
|
202
|
+
const type = fieldContract.type.replace(/ \| undefined/g, "");
|
|
203
|
+
if (fieldContract.values?.length && !fieldContract.values.includes(value) && !/(?:^| \| )(?:string|number|boolean)(?:$| \| )/.test(type)) throw new BuilderError(`navigation.itemDefaults.${fieldContract.name} must match ${fieldContract.type}.`, 422);
|
|
204
|
+
if (type === "string" && typeof value !== "string" || type === "number" && typeof value !== "number" || type === "boolean" && typeof value !== "boolean" || type === "string[]" && (!Array.isArray(value) || value.some((item2) => typeof item2 !== "string"))) throw new BuilderError(`navigation.itemDefaults.${fieldContract.name} must be ${type}.`, 422);
|
|
205
|
+
}
|
|
206
|
+
const item = `{ ${Object.entries(fields).map(([key, value]) => `${quote(key)}: ${JSON.stringify(value)}`).join(", ")} }`;
|
|
207
|
+
const result = apply(source, appendArray(source, array, item));
|
|
208
|
+
parseTypeScript(result, target.file);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// server/discovery.ts
|
|
213
|
+
import ts2 from "typescript";
|
|
214
|
+
import { resolve } from "node:path";
|
|
215
|
+
import { compileBeastResult as compileBeastResult2 } from "beast-tsrx";
|
|
216
|
+
var unbox = (node) => ts2.isAsExpression(node) || ts2.isSatisfiesExpression(node) || ts2.isParenthesizedExpression(node) ? unbox(node.expression) : node;
|
|
217
|
+
var nameOf = (node) => node && (ts2.isIdentifier(node) || ts2.isStringLiteral(node)) ? node.text : void 0;
|
|
218
|
+
var field = (node, name) => node.properties.find((prop) => ts2.isPropertyAssignment(prop) && nameOf(prop.name) === name)?.initializer;
|
|
219
|
+
var textOf = (node) => node && (ts2.isStringLiteralLike(node) || ts2.isNumericLiteral(node)) ? node.text : void 0;
|
|
220
|
+
var attribute = (node, name) => node.attrs.find((attr) => attr.kind === "attribute" && attr.name === name);
|
|
221
|
+
var attrText = (node, name) => {
|
|
222
|
+
const attr = attribute(node, name);
|
|
223
|
+
return attr?.kind === "attribute" && attr.value.type === "string" ? attr.value.value : void 0;
|
|
224
|
+
};
|
|
225
|
+
var itemKind = (destination) => !destination ? "unknown" : destination.startsWith("#") ? "anchor" : /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(destination) ? "external-link" : "internal-link";
|
|
226
|
+
var inferExpression = (code) => /^['"`]/.test(code.trim()) ? "string" : /^(?:true|false)$/.test(code.trim()) ? "boolean" : /^-?\d+(?:\.\d+)?$/.test(code.trim()) ? "number" : /=>|^function\b/.test(code.trim()) ? "function" : "unknown";
|
|
227
|
+
function virtualBeast(input, root) {
|
|
228
|
+
const document = compileBeastResult2(input.source, { filename: input.file }).ast;
|
|
229
|
+
const code = [], lines = [];
|
|
230
|
+
function add(text, line = 1) {
|
|
231
|
+
text.split("\n").forEach((text2, index) => {
|
|
232
|
+
code.push(text2);
|
|
233
|
+
lines.push(line + index);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
for (const declaration of document.declarations) {
|
|
237
|
+
if (declaration.kind === "import") add(declaration.code, declaration.lineNo);
|
|
238
|
+
else if (declaration.kind === "module") add(declaration.code, declaration.codeStart.line);
|
|
239
|
+
}
|
|
240
|
+
const props = document.declarations.find((declaration) => declaration.kind === "props");
|
|
241
|
+
add(`declare function __BeastComponent(${props?.kind === "props" ? props.parameter.replace(/;$/, "") : "props: unknown"}): unknown;
|
|
242
|
+
export default __BeastComponent;`);
|
|
243
|
+
add("function __beastSetup() {");
|
|
244
|
+
for (const declaration of document.declarations) if (declaration.kind === "setup") add(declaration.code, declaration.codeStart.line);
|
|
245
|
+
add("}");
|
|
246
|
+
for (const declaration of document.declarations) if (declaration.kind === "component") {
|
|
247
|
+
add(`function ${declaration.name}(${declaration.props?.parameter.replace(/;$/, "") ?? ""}) {`, declaration.lineNo);
|
|
248
|
+
for (const setup of declaration.setup) add(setup.code, setup.codeStart.line);
|
|
249
|
+
add("}");
|
|
250
|
+
}
|
|
251
|
+
return { ...input, document, virtual: resolve(root, input.file + ".ts"), text: code.join("\n"), lines };
|
|
252
|
+
}
|
|
253
|
+
function discoverCollections(root, sources) {
|
|
254
|
+
const inputs = sources.flatMap((source) => {
|
|
255
|
+
try {
|
|
256
|
+
return [source.file.endsWith(".btsx") ? virtualBeast(source, root) : { ...source, virtual: resolve(root, source.file), text: source.source }];
|
|
257
|
+
} catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
const collections = [];
|
|
262
|
+
const references = [];
|
|
263
|
+
const byVirtual = new Map(inputs.map((input) => [input.virtual, input]));
|
|
264
|
+
let options = { target: ts2.ScriptTarget.ESNext, module: ts2.ModuleKind.ESNext, moduleResolution: ts2.ModuleResolutionKind.Bundler, strict: true, skipLibCheck: true, noEmit: true };
|
|
265
|
+
const configPath = ts2.findConfigFile(root, ts2.sys.fileExists);
|
|
266
|
+
if (configPath) {
|
|
267
|
+
const config = ts2.readConfigFile(configPath, ts2.sys.readFile);
|
|
268
|
+
if (!config.error) options = { ...options, ...ts2.parseJsonConfigFileContent(config.config, ts2.sys, resolve(configPath, "..")).options, noEmit: true };
|
|
269
|
+
}
|
|
270
|
+
const host = ts2.createCompilerHost(options);
|
|
271
|
+
const read = host.readFile.bind(host), exists = host.fileExists.bind(host);
|
|
272
|
+
host.readFile = (file) => byVirtual.get(resolve(file))?.text ?? read(file);
|
|
273
|
+
host.fileExists = (file) => byVirtual.has(resolve(file)) || exists(file);
|
|
274
|
+
const program = ts2.createProgram(inputs.map((input) => input.virtual), options, host);
|
|
275
|
+
const checker = program.getTypeChecker();
|
|
276
|
+
const lineOf = (input, node) => {
|
|
277
|
+
const line = node.getSourceFile().getLineAndCharacterOfPosition(node.getStart()).line;
|
|
278
|
+
return input.lines?.[line] ?? line + 1;
|
|
279
|
+
};
|
|
280
|
+
function typeSchema(type, at, declared, variants = true) {
|
|
281
|
+
const flags = ts2.TypeFormatFlags.NoTruncation;
|
|
282
|
+
const typeName = (type2) => checker.typeToString(type2, at, flags).replace(/import\("[^"]+"\)\./g, "");
|
|
283
|
+
const members = type.isUnion() ? type.types : [type];
|
|
284
|
+
if (members.every((member) => member.flags & (ts2.TypeFlags.StringLike | ts2.TypeFlags.NumberLike | ts2.TypeFlags.BooleanLike | ts2.TypeFlags.Null | ts2.TypeFlags.Undefined | ts2.TypeFlags.Any | ts2.TypeFlags.Unknown))) return { name: typeName(type), origin: declared && !(type.flags & (ts2.TypeFlags.Any | ts2.TypeFlags.Unknown)) ? "declared" : "inferred", fields: [] };
|
|
285
|
+
const symbols = new Map(members.flatMap((member) => member.getProperties().map((symbol) => [symbol.name, symbol])));
|
|
286
|
+
const fields = [...symbols].filter(([name]) => !name.startsWith("__@")).map(([name]) => {
|
|
287
|
+
const properties = members.map((member) => member.getProperty(name));
|
|
288
|
+
const types = properties.flatMap((symbol) => symbol ? [checker.getTypeOfSymbolAtLocation(symbol, at)] : []);
|
|
289
|
+
const values = types.flatMap((type2) => (type2.isUnion() ? type2.types : [type2]).flatMap((value) => value.isStringLiteral() || value.isNumberLiteral() ? [value.value] : value.flags & ts2.TypeFlags.BooleanLiteral ? [checker.typeToString(value) === "true"] : []));
|
|
290
|
+
return { name, type: [...new Set(types.map((type2) => typeName(type2)))].join(" | "), optional: properties.some((symbol) => !symbol || !!(symbol.flags & ts2.SymbolFlags.Optional)), ...values.length ? { values: [...new Set(values)] } : {} };
|
|
291
|
+
});
|
|
292
|
+
const known = !(type.flags & (ts2.TypeFlags.Any | ts2.TypeFlags.Unknown)) && fields.length > 0;
|
|
293
|
+
return { name: typeName(type), origin: declared && known ? "declared" : "inferred", fields, ...variants && type.isUnion() && type.types.every((type2) => type2.getProperties().length) ? { variants: type.types.map((member) => typeSchema(member, at, declared, false)) } : {} };
|
|
294
|
+
}
|
|
295
|
+
function elementType(array) {
|
|
296
|
+
const contextual = checker.getContextualType(array);
|
|
297
|
+
return contextual && checker.getIndexTypeOfType(contextual, ts2.IndexKind.Number) || checker.getIndexTypeOfType(checker.getTypeAtLocation(array), ts2.IndexKind.Number);
|
|
298
|
+
}
|
|
299
|
+
function declaredType(array) {
|
|
300
|
+
let node = array;
|
|
301
|
+
while (!ts2.isSourceFile(node)) {
|
|
302
|
+
if (ts2.isVariableDeclaration(node)) return !!node.type || !!(node.initializer && (ts2.isSatisfiesExpression(node.initializer) || ts2.isAsExpression(node.initializer) && node.initializer.type.getText() !== "const"));
|
|
303
|
+
node = node.parent;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
function narrowedType(object, context) {
|
|
308
|
+
if (context?.isUnion()) {
|
|
309
|
+
const candidates = context.types.filter((member) => object.properties.every((prop) => {
|
|
310
|
+
if (!ts2.isPropertyAssignment(prop) || !ts2.isStringLiteralLike(prop.initializer)) return true;
|
|
311
|
+
const symbol = member.getProperty(nameOf(prop.name) ?? "");
|
|
312
|
+
if (!symbol) return false;
|
|
313
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, object);
|
|
314
|
+
return !type.isStringLiteral() || type.value === prop.initializer.text;
|
|
315
|
+
}));
|
|
316
|
+
if (candidates.length === 1) return candidates[0];
|
|
317
|
+
}
|
|
318
|
+
return context && !context.isUnion() && context.getProperties().length ? context : checker.getTypeAtLocation(object);
|
|
319
|
+
}
|
|
320
|
+
for (const input of inputs) if (input.document) {
|
|
321
|
+
let schemaOfControl2 = function(node) {
|
|
322
|
+
const symbol = checker.resolveName(node.tag, file, ts2.SymbolFlags.Value, false);
|
|
323
|
+
if (symbol) {
|
|
324
|
+
const callable = checker.getTypeOfSymbolAtLocation(symbol, file).getCallSignatures()[0];
|
|
325
|
+
const parameter = callable?.parameters[0];
|
|
326
|
+
if (parameter) {
|
|
327
|
+
const type = checker.getTypeOfSymbolAtLocation(parameter, file);
|
|
328
|
+
if (type.getProperties().length) return typeSchema(type, file, true);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const fields = node.attrs.flatMap((attr) => attr.kind !== "attribute" ? [] : [{ name: attr.name, optional: false, type: attr.value.type === "string" ? "string" : attr.value.type === "bool" ? "boolean" : inferExpression(attr.value.code) }]);
|
|
332
|
+
return { name: tagOf(node), origin: "inferred", fields };
|
|
333
|
+
}, visit2 = function(value, active, bindings = /* @__PURE__ */ new Map()) {
|
|
334
|
+
if (!value || typeof value !== "object") return;
|
|
335
|
+
if (Array.isArray(value)) {
|
|
336
|
+
value.forEach((child) => visit2(child, active, bindings));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const node = value;
|
|
340
|
+
if (node.kind === "element") {
|
|
341
|
+
const element = value;
|
|
342
|
+
const kind = containerKind(element);
|
|
343
|
+
if (kind) {
|
|
344
|
+
const name = element.tag + (element.id ? "#" + element.id : "") + element.classes.map((name2) => "." + name2).join("");
|
|
345
|
+
active = { id: `btsx:${input.file}:${element.lineNo}`, file: input.file, line: element.lineNo, name, kind, source: "markup", itemType: { name: "Unknown", origin: "inferred", fields: [] }, items: [], editable: false, bindings: [], consumers: [], dataSources: [] };
|
|
346
|
+
collections.push(active);
|
|
347
|
+
}
|
|
348
|
+
if (active) {
|
|
349
|
+
for (const name of ["items", "tabs", "links"]) {
|
|
350
|
+
const attr = attribute(element, name);
|
|
351
|
+
if (attr?.kind === "attribute" && attr.value.type === "expr") {
|
|
352
|
+
const binding = attr.value.code.match(/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/)?.[0];
|
|
353
|
+
if (binding) references.push({ collection: active, binding: bindings.get(binding.split(".")[0]) ?? binding, input });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const tag = tagOf(element);
|
|
357
|
+
const isTab = active.kind === "tabs" && ["button", "a", "Link"].includes(tag) || attrText(element, "role") === "tab" || /^(?:Tabs?Trigger|Tabs?\.Trigger)$/.test(tag);
|
|
358
|
+
if (isTab || element.tag === "a" || tag === "Link" || /(?:Nav|Menu|Sidebar)Item$/.test(tag) || (["menuitem", "button"].includes(attrText(element, "role") ?? "") || element.tag === "button") && active.kind === "navigation") {
|
|
359
|
+
const destination = attrText(element, "href") ?? attrText(element, "to");
|
|
360
|
+
const label = labelOf(element) || attrText(element, "aria-label") || attrText(element, "label") || tag;
|
|
361
|
+
active.items.push({ label, component: tag, line: element.lineNo, kind: isTab ? "tab" : destination ? itemKind(destination) : attribute(element, "onClick") ? "action" : "unknown", itemType: schemaOfControl2(element), ...destination ? { destination } : {}, ...attrText(element, "value") ? { value: attrText(element, "value") } : {} });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (node.kind === "each") {
|
|
366
|
+
const iterable = String(node.iterable);
|
|
367
|
+
const rootBinding = iterable.match(/^[A-Za-z_$][\w$]*/)?.[0];
|
|
368
|
+
if (rootBinding) {
|
|
369
|
+
const binding = bindings.get(rootBinding) ?? iterable;
|
|
370
|
+
if (active) references.push({ collection: active, binding, input });
|
|
371
|
+
bindings = new Map(bindings);
|
|
372
|
+
bindings.set(String(node.itemName), binding);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const key of ["children", "branches", "declarations", "emptyChildren"]) visit2(node[key], active, bindings);
|
|
376
|
+
};
|
|
377
|
+
var schemaOfControl = schemaOfControl2, visit = visit2;
|
|
378
|
+
const file = program.getSourceFile(input.virtual);
|
|
379
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
380
|
+
for (const statement of file.statements) if (ts2.isImportDeclaration(statement) && statement.importClause?.namedBindings && ts2.isNamedImports(statement.importClause.namedBindings)) {
|
|
381
|
+
for (const binding of statement.importClause.namedBindings.elements) aliases.set(binding.name.text, (binding.propertyName ?? binding.name).text);
|
|
382
|
+
}
|
|
383
|
+
const tagOf = (node) => aliases.get(node.tag) ?? node.tag;
|
|
384
|
+
const containerKind = (node) => attrText(node, "role") === "tablist" || /^(?:Tabs?|TabGroup|Tabs?Root|Tabs?List|Tabs?\.List)$/.test(tagOf(node)) ? "tabs" : node.tag === "nav" || ["navigation", "menu"].includes(attrText(node, "role") ?? "") || /^(?:Nav|Navigation|NavigationMenu|NavMenu|SidebarNav|MenuList)$/.test(tagOf(node)) ? "navigation" : void 0;
|
|
385
|
+
visit2(input.document);
|
|
386
|
+
}
|
|
387
|
+
const definitions = [];
|
|
388
|
+
const usedDeclarations = /* @__PURE__ */ new Set();
|
|
389
|
+
for (const reference of references) {
|
|
390
|
+
const file = program.getSourceFile(reference.input.virtual);
|
|
391
|
+
let symbol = checker.resolveName(reference.binding.split(".")[0], file, ts2.SymbolFlags.Value, false);
|
|
392
|
+
if (symbol?.flags && symbol.flags & ts2.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
|
|
393
|
+
if (symbol?.valueDeclaration) usedDeclarations.add(symbol.valueDeclaration);
|
|
394
|
+
}
|
|
395
|
+
for (const input of inputs) {
|
|
396
|
+
let visit2 = function(node) {
|
|
397
|
+
if (ts2.isVariableDeclaration(node) && ts2.isIdentifier(node.name) && node.initializer) {
|
|
398
|
+
const array = unbox(node.initializer);
|
|
399
|
+
const name = node.name.text;
|
|
400
|
+
const used = usedDeclarations.has(node) || references.some((reference) => reference.input.file === input.file && reference.binding.split(".")[0] === name);
|
|
401
|
+
if (!ts2.isArrayLiteralExpression(array)) {
|
|
402
|
+
const type = checker.getIndexTypeOfType(checker.getTypeAtLocation(node), ts2.IndexKind.Number);
|
|
403
|
+
if (type && (used || /nav|menu|links|tabs|panels/i.test(name) || type.getProperty("href") || type.getProperty("to"))) {
|
|
404
|
+
const collection = { id: `ts:${input.file}:${name}:${lineOf(input, node)}`, file: input.file, line: lineOf(input, node), name, kind: /tabs|panels/i.test(name) ? "tabs" : "navigation", source: "data", itemType: typeSchema(type, node, !!node.type), items: [], editable: false, reason: "Items are computed at runtime. The declared contract is available; values are not executed during scanning.", array: name, bindings: [], consumers: [], dataSources: [] };
|
|
405
|
+
collections.push(collection);
|
|
406
|
+
definitions.push({ declaration: node, collection, input });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (ts2.isArrayLiteralExpression(array)) {
|
|
410
|
+
const objects = array.elements.filter(ts2.isObjectLiteralExpression);
|
|
411
|
+
const arrayType = elementType(array);
|
|
412
|
+
const links = !!(arrayType?.getProperty("href") || arrayType?.getProperty("to")) || objects.some((object) => field(object, "href") || field(object, "to") || field(object, "items"));
|
|
413
|
+
const hint = /nav|menu|links|tabs|panels/i.test(name);
|
|
414
|
+
if (links || hint || used) {
|
|
415
|
+
const kind = /tabs|panels/i.test(name) && !links ? "tabs" : "navigation";
|
|
416
|
+
const groups = objects.filter((object) => field(object, "items"));
|
|
417
|
+
const arrays = groups.length ? groups.flatMap((group) => {
|
|
418
|
+
const items = field(group, "items");
|
|
419
|
+
const label = textOf(field(group, "title")) ?? textOf(field(group, "label"));
|
|
420
|
+
return items && ts2.isArrayLiteralExpression(unbox(items)) && label ? [{ array: unbox(items), group: label }] : [];
|
|
421
|
+
}) : [{ array }];
|
|
422
|
+
for (const entry of arrays) {
|
|
423
|
+
const declared = declaredType(entry.array);
|
|
424
|
+
const context = elementType(entry.array);
|
|
425
|
+
const itemType = context ? typeSchema(context, entry.array, declared) : { name: "Unknown", origin: "inferred", fields: [] };
|
|
426
|
+
const statement = node.parent.parent;
|
|
427
|
+
const exported = ts2.isVariableStatement(statement) && !!statement.modifiers?.some((modifier) => modifier.kind === ts2.SyntaxKind.ExportKeyword);
|
|
428
|
+
const linkShape = !!(context?.getProperty("href") || context?.getProperty("to")) || entry.array.elements.some((item) => ts2.isObjectLiteralExpression(item) && (field(item, "href") || field(item, "to")));
|
|
429
|
+
const editable = kind === "navigation" && linkShape && exported && !input.file.endsWith(".btsx") && entry.array.elements.every(ts2.isObjectLiteralExpression) && (!groups.length || groups.length === array.elements.length);
|
|
430
|
+
const id = `ts:${input.file}:${name}${entry.group ? ":" + entry.group : ""}${exported ? "" : ":" + lineOf(input, node)}`;
|
|
431
|
+
const collection = { id, file: input.file, line: lineOf(input, node), name: name + (entry.group ? " / " + entry.group : ""), kind, source: "data", itemType, items: [], editable, ...editable ? {} : { reason: kind === "tabs" ? "Tab item types and values are available for inspection." : "This data is local, computed, or contains spreads; inspect it before editing." }, array: name, ...entry.group ? { group: entry.group } : {}, bindings: [], consumers: [], dataSources: [] };
|
|
432
|
+
for (const item of entry.array.elements) {
|
|
433
|
+
if (ts2.isObjectLiteralExpression(item)) {
|
|
434
|
+
const destination = textOf(field(item, "href")) ?? textOf(field(item, "to"));
|
|
435
|
+
const label = textOf(field(item, "label")) ?? textOf(field(item, "title")) ?? textOf(field(item, "id")) ?? textOf(field(item, "value")) ?? "Unnamed item";
|
|
436
|
+
collection.items.push({ label, line: lineOf(input, item), kind: kind === "tabs" ? "tab" : destination ? itemKind(destination) : ["onClick", "onSelect", "action"].some((name2) => field(item, name2)) ? "action" : "unknown", itemType: typeSchema(narrowedType(item, context), item, declared), ...destination ? { destination } : {}, ...textOf(field(item, "value")) ?? textOf(field(item, "id")) ? { value: textOf(field(item, "value")) ?? textOf(field(item, "id")) } : {} });
|
|
437
|
+
} else {
|
|
438
|
+
const primitive = textOf(item);
|
|
439
|
+
collection.items.push({ label: primitive ?? item.getText(), line: lineOf(input, item), kind: kind === "tabs" ? "tab" : "unknown", itemType: typeSchema(checker.getTypeAtLocation(item), item, false), ...primitive ? { value: primitive } : {} });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
definitions.push({ declaration: node, collection, input });
|
|
443
|
+
collections.push(collection);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
ts2.forEachChild(node, visit2);
|
|
449
|
+
};
|
|
450
|
+
var visit = visit2;
|
|
451
|
+
const file = program.getSourceFile(input.virtual);
|
|
452
|
+
if (!file) continue;
|
|
453
|
+
visit2(file);
|
|
454
|
+
}
|
|
455
|
+
for (const reference of references) {
|
|
456
|
+
const file = program.getSourceFile(reference.input.virtual);
|
|
457
|
+
const rootName = reference.binding.split(".")[0];
|
|
458
|
+
let symbol = checker.resolveName(rootName, file, ts2.SymbolFlags.Value, false);
|
|
459
|
+
if (symbol && symbol.flags & ts2.SymbolFlags.Alias) {
|
|
460
|
+
try {
|
|
461
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
462
|
+
} catch {
|
|
463
|
+
symbol = void 0;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const matches = definitions.filter((definition) => symbol?.valueDeclaration === definition.declaration || definition.input.file === reference.input.file && definition.declaration.name.getText() === rootName);
|
|
467
|
+
if (!reference.collection.bindings.includes(reference.binding)) reference.collection.bindings.push(reference.binding);
|
|
468
|
+
if (!matches.length) {
|
|
469
|
+
let at = file;
|
|
470
|
+
if (!symbol) {
|
|
471
|
+
let visit2 = function(node) {
|
|
472
|
+
if ((ts2.isVariableDeclaration(node) || ts2.isBindingElement(node) || ts2.isParameter(node)) && ts2.isIdentifier(node.name) && node.name.text === rootName) local.push(node.name);
|
|
473
|
+
ts2.forEachChild(node, visit2);
|
|
474
|
+
};
|
|
475
|
+
var visit = visit2;
|
|
476
|
+
const local = [];
|
|
477
|
+
visit2(file);
|
|
478
|
+
at = local[0] ?? file;
|
|
479
|
+
symbol = checker.getSymbolAtLocation(at);
|
|
480
|
+
}
|
|
481
|
+
let type = symbol && checker.getTypeOfSymbolAtLocation(symbol, at);
|
|
482
|
+
for (const part of reference.binding.split(".").slice(1)) {
|
|
483
|
+
const property2 = type?.getProperty(part);
|
|
484
|
+
type = property2 && checker.getTypeOfSymbolAtLocation(property2, at);
|
|
485
|
+
}
|
|
486
|
+
const item = type && checker.getIndexTypeOfType(type, ts2.IndexKind.Number);
|
|
487
|
+
if (item) {
|
|
488
|
+
reference.collection.itemType = typeSchema(item, at, true);
|
|
489
|
+
reference.collection.reason = "The item contract is resolved from the binding; values are supplied at runtime.";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const { collection } of matches) {
|
|
493
|
+
const consumer = { file: reference.collection.file, line: reference.collection.line, name: reference.collection.name };
|
|
494
|
+
if (!collection.consumers.some((item) => item.file === consumer.file && item.line === consumer.line)) collection.consumers.push(consumer);
|
|
495
|
+
if (!reference.collection.dataSources.includes(collection.id)) reference.collection.dataSources.push(collection.id);
|
|
496
|
+
if (!reference.collection.bindings.includes(reference.binding)) reference.collection.bindings.push(reference.binding);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const collection of collections.filter((collection2) => collection2.source === "data")) {
|
|
500
|
+
if (collection.consumers.length && collection.consumers.every((consumer) => collections.some((rendered) => rendered.source === "markup" && rendered.file === consumer.file && rendered.line === consumer.line && rendered.kind === "tabs"))) {
|
|
501
|
+
collection.kind = "tabs";
|
|
502
|
+
collection.editable = false;
|
|
503
|
+
collection.items.forEach((item) => {
|
|
504
|
+
item.kind = "tab";
|
|
505
|
+
});
|
|
506
|
+
if (collection.reason === "This data is local, computed, or contains spreads; inspect it before editing.") collection.reason = "Tab item types and values are available for inspection.";
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const collection of collections.filter((collection2) => collection2.source === "markup")) {
|
|
510
|
+
const data = collections.filter((data2) => collection.dataSources.includes(data2.id));
|
|
511
|
+
if (data.length) {
|
|
512
|
+
collection.items = data.flatMap((data2) => data2.items.map((item) => collection.kind === "tabs" ? { ...item, kind: "tab" } : item));
|
|
513
|
+
collection.itemType = data.length === 1 ? data[0].itemType : { name: [...new Set(data.map((data2) => data2.itemType.name))].join(" | "), origin: data.every((data2) => data2.itemType.origin === "declared") ? "declared" : "inferred", fields: [], variants: data.map((data2) => data2.itemType) };
|
|
514
|
+
collection.reason = `Renders shared data in ${[...new Set(data.map((data2) => data2.file))].join(", ")}.`;
|
|
515
|
+
} else {
|
|
516
|
+
const types = [...new Map(collection.items.map((item) => [JSON.stringify(item.itemType), item.itemType])).values()];
|
|
517
|
+
if (!collection.reason) collection.itemType = types.length > 1 ? { name: types.map((type) => type.name).join(" | "), origin: types.every((type) => type.origin === "declared") ? "declared" : "inferred", fields: [], variants: types } : types[0] ?? collection.itemType;
|
|
518
|
+
const targetNode = findElement(inputDocument(collection.file), collection.line);
|
|
519
|
+
collection.editable = collection.kind === "navigation" && !sources.find((input) => input.file === collection.file)?.page && !!targetNode && targetNode.tag === "nav" && targetNode.children.every((child) => child.kind === "element" && ["a", "Link"].includes(child.tag));
|
|
520
|
+
if (!collection.editable && !collection.reason) collection.reason = collection.kind === "tabs" ? "Tab controls are detected for inspection; they are not page navigation targets." : "This menu uses a custom layout or belongs to a page.";
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function inputDocument(file) {
|
|
524
|
+
return inputs.find((input) => input.file === file)?.document;
|
|
525
|
+
}
|
|
526
|
+
return collections.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.name.localeCompare(b.name));
|
|
527
|
+
}
|
|
528
|
+
function labelOf(node) {
|
|
529
|
+
return [(node.inlineSpans ?? []).map((span) => span.type === "literal" ? span.text : `#{${span.code}}`).join(""), ...node.children.flatMap((child) => child.kind === "element" ? [labelOf(child)] : child.kind === "text" ? [child.spans.map((span) => span.type === "literal" ? span.text : `#{${span.code}}`).join("")] : [])].filter(Boolean).join(" ");
|
|
530
|
+
}
|
|
531
|
+
function findElement(value, line) {
|
|
532
|
+
if (!value || typeof value !== "object") return;
|
|
533
|
+
if (Array.isArray(value)) return value.map((child) => findElement(child, line)).find(Boolean);
|
|
534
|
+
const node = value;
|
|
535
|
+
if (node.kind === "element" && node.lineNo === line) return value;
|
|
536
|
+
for (const key of ["children", "branches", "declarations"]) {
|
|
537
|
+
const found = findElement(node[key], line);
|
|
538
|
+
if (found) return found;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// server/project.ts
|
|
543
|
+
var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".beast", "dist", "build", "coverage", ".next"]);
|
|
544
|
+
var BuilderError = class extends Error {
|
|
545
|
+
constructor(message, status = 400) {
|
|
546
|
+
super(message);
|
|
547
|
+
this.status = status;
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
var posix = (path) => path.split(sep).join("/");
|
|
551
|
+
var inside = (root, path) => {
|
|
552
|
+
const rel = relative2(root, path);
|
|
553
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
554
|
+
};
|
|
555
|
+
var BuilderProject = class {
|
|
556
|
+
constructor(root, options = {}) {
|
|
557
|
+
this.options = options;
|
|
558
|
+
this.root = resolve2(root);
|
|
559
|
+
this.pagesDir = options.pagesDir ?? "src/pages";
|
|
560
|
+
this.routerFile = options.routerFile ?? "src/router.ts";
|
|
561
|
+
this.include = [.../* @__PURE__ */ new Set([...options.include ?? ["src"], this.pagesDir])];
|
|
562
|
+
for (const path of [...this.include, this.routerFile, ...options.navigation ? [options.navigation.file] : []]) {
|
|
563
|
+
if (isAbsolute(path) || !inside(this.root, resolve2(this.root, path))) throw new BuilderError("Scan and page directories must be inside the project.");
|
|
564
|
+
}
|
|
565
|
+
if (!this.routerFile.endsWith(".ts")) throw new BuilderError("routerFile must be a TypeScript file.");
|
|
566
|
+
if (options.navigation && !/\.(?:tsx?|btsx)$/.test(options.navigation.file)) throw new BuilderError("navigation.file must be .ts, .tsx, or .btsx.");
|
|
567
|
+
}
|
|
568
|
+
root;
|
|
569
|
+
include;
|
|
570
|
+
pagesDir;
|
|
571
|
+
routerFile;
|
|
572
|
+
compiler;
|
|
573
|
+
mutation = Promise.resolve();
|
|
574
|
+
creations = /* @__PURE__ */ new Map();
|
|
575
|
+
/** Check existing ancestors too, so a configured directory cannot escape through a symlink. */
|
|
576
|
+
async safe(path) {
|
|
577
|
+
const canonicalRoot = await realpath(this.root);
|
|
578
|
+
let current = path;
|
|
579
|
+
for (; ; ) {
|
|
580
|
+
try {
|
|
581
|
+
if (!inside(canonicalRoot, await realpath(current))) throw new BuilderError("Path resolves outside the project.", 403);
|
|
582
|
+
return;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (error.code !== "ENOENT") throw error;
|
|
585
|
+
current = dirname2(current);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async scan() {
|
|
590
|
+
const files = [];
|
|
591
|
+
const warnings = [];
|
|
592
|
+
let navigationTargets = [];
|
|
593
|
+
const sources = /* @__PURE__ */ new Map();
|
|
594
|
+
const visited = /* @__PURE__ */ new Set();
|
|
595
|
+
const walk = async (directory) => {
|
|
596
|
+
if (visited.has(directory)) return;
|
|
597
|
+
visited.add(directory);
|
|
598
|
+
try {
|
|
599
|
+
await this.safe(directory);
|
|
600
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
601
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
602
|
+
const path = resolve2(directory, entry.name);
|
|
603
|
+
if (entry.isSymbolicLink()) continue;
|
|
604
|
+
if (entry.isDirectory() && !IGNORED.has(entry.name) && !entry.name.startsWith(".")) await walk(path);
|
|
605
|
+
else if (entry.isFile() && entry.name.endsWith(".btsx")) {
|
|
606
|
+
if (sources.size >= 5e3) throw new BuilderError("Scan limited to 5,000 files. Narrow the include directories.");
|
|
607
|
+
const source = await readFile(path, "utf8");
|
|
608
|
+
const name = componentNameFromPath(path);
|
|
609
|
+
let components = [name];
|
|
610
|
+
let error = null;
|
|
611
|
+
try {
|
|
612
|
+
const result = compileBeastResult3(source, { filename: path });
|
|
613
|
+
components = [name, ...result.ast.declarations.flatMap((d) => d.kind === "component" ? [d.name] : [])];
|
|
614
|
+
} catch (cause) {
|
|
615
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
616
|
+
}
|
|
617
|
+
const filePath = posix(relative2(this.root, path));
|
|
618
|
+
files.push({ path: filePath, name, kind: inside(resolve2(this.root, this.pagesDir), path) || /(?:^|\/)(pages|routes)\//.test(filePath) || /Page$/.test(name) ? "page" : "component", lines: source.split("\n").length, components, error });
|
|
619
|
+
sources.set(filePath, { file: filePath, source, page: files.at(-1).kind === "page" });
|
|
620
|
+
} else if (entry.isFile() && /\.tsx?$/.test(entry.name)) {
|
|
621
|
+
if (sources.size >= 5e3) throw new BuilderError("Scan limited to 5,000 files. Narrow the include directories.");
|
|
622
|
+
const file = posix(relative2(this.root, path));
|
|
623
|
+
sources.set(file, { file, source: await readFile(path, "utf8"), page: false });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
} catch (cause) {
|
|
627
|
+
if (cause.code !== "ENOENT") warnings.push(`${posix(relative2(this.root, directory))}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
for (const directory of this.include) await walk(resolve2(this.root, directory));
|
|
631
|
+
if (this.options.navigation) {
|
|
632
|
+
try {
|
|
633
|
+
const file = posix(relative2(this.root, resolve2(this.root, this.options.navigation.file)));
|
|
634
|
+
await this.safe(resolve2(this.root, file));
|
|
635
|
+
const source = await readFile(resolve2(this.root, file), "utf8");
|
|
636
|
+
sources.set(file, { file, source, page: false });
|
|
637
|
+
navigationTargets.push(...discoverNavigation(source, file, this.options.navigation));
|
|
638
|
+
} catch (cause) {
|
|
639
|
+
warnings.push(cause instanceof Error ? cause.message : String(cause));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const collections = discoverCollections(this.root, [...sources.values()]);
|
|
643
|
+
if (this.options.navigation) {
|
|
644
|
+
navigationTargets = navigationTargets.filter((target) => collections.find((collection) => collection.id === target.id)?.kind !== "tabs").map((target) => ({ ...target, itemType: collections.find((collection) => collection.id === target.id)?.itemType }));
|
|
645
|
+
} else {
|
|
646
|
+
navigationTargets = collections.filter((collection) => collection.editable && collection.kind === "navigation").map((collection) => ({ id: collection.id, file: collection.file, label: `${collection.file} \xB7 ${collection.name}`, array: collection.array, group: collection.group, itemType: collection.itemType }));
|
|
647
|
+
}
|
|
648
|
+
return { collections, name: basename(this.root), files: files.sort((a, b) => a.path.localeCompare(b.path)), scannedAt: (/* @__PURE__ */ new Date()).toISOString(), warnings, navigationTargets };
|
|
649
|
+
}
|
|
650
|
+
async source(path) {
|
|
651
|
+
if (typeof path !== "string" || !path.endsWith(".btsx") || isAbsolute(path)) throw new BuilderError("Expected a project-relative .btsx path.");
|
|
652
|
+
const target = resolve2(this.root, path);
|
|
653
|
+
if (!this.include.some((dir) => inside(resolve2(this.root, dir), target))) throw new BuilderError("File is outside the scan directories.", 403);
|
|
654
|
+
await this.safe(target);
|
|
655
|
+
return readFile(target, "utf8");
|
|
656
|
+
}
|
|
657
|
+
async preview(draft) {
|
|
658
|
+
if (typeof draft.name !== "string" || !/^[A-Z][A-Za-z0-9]{0,79}$/.test(draft.name)) throw new BuilderError("Use a PascalCase page name, such as PricingPage.");
|
|
659
|
+
if (typeof draft.source !== "string" || !draft.source.trim() || Buffer.byteLength(draft.source) > 6e4) throw new BuilderError("Page source must contain between 1 and 60,000 bytes.");
|
|
660
|
+
const path = posix(relative2(this.root, resolve2(this.root, this.pagesDir, `${draft.name}.btsx`)));
|
|
661
|
+
const target = resolve2(this.root, path);
|
|
662
|
+
await this.safe(target);
|
|
663
|
+
try {
|
|
664
|
+
await lstat(target);
|
|
665
|
+
throw new BuilderError(`${path} already exists. Choose another name.`, 409);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
if (error.code !== "ENOENT") throw error;
|
|
668
|
+
}
|
|
669
|
+
const source = draft.source.replace(/\r\n/g, "\n").trimEnd() + "\n";
|
|
670
|
+
try {
|
|
671
|
+
const result = compileBeastResult3(source, { filename: target, componentName: draft.name });
|
|
672
|
+
this.compiler ??= createOctaneCompiler({ root: this.root, environment: "client", hmr: false, dev: true });
|
|
673
|
+
this.compiler.transform(result.code, target.replace(/\.btsx$/, ".tsrx"), { environment: "client", dev: true });
|
|
674
|
+
} catch (cause) {
|
|
675
|
+
throw new BuilderError(cause instanceof Error ? cause.message : String(cause), 422);
|
|
676
|
+
}
|
|
677
|
+
const derived = draft.name.replace(/Page$/, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
678
|
+
const routePath = draft.routePath ?? "/" + (derived || "page");
|
|
679
|
+
if (typeof routePath !== "string" || !/^\/[a-z0-9]+(?:[-/][a-z0-9]+)*$/.test(routePath) || routePath.length > 200) throw new BuilderError("Use a static URL such as /about or /company/about.");
|
|
680
|
+
const navLabel = draft.navLabel ?? draft.name.replace(/Page$/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
|
681
|
+
if (typeof navLabel !== "string" || !navLabel.trim() || navLabel.length > 100 || /[\r\n\x00-\x1f]/.test(navLabel)) throw new BuilderError("Use a navigation label between 1 and 100 characters.");
|
|
682
|
+
const routerPath = posix(relative2(this.root, resolve2(this.root, this.routerFile)));
|
|
683
|
+
await this.safe(resolve2(this.root, routerPath));
|
|
684
|
+
let routerSource;
|
|
685
|
+
try {
|
|
686
|
+
routerSource = await readFile(resolve2(this.root, routerPath), "utf8");
|
|
687
|
+
} catch (cause) {
|
|
688
|
+
if (cause.code === "ENOENT") throw new BuilderError(`${routerPath} is missing. Set up @octanejs/tanstack-router before adding pages.`, 422);
|
|
689
|
+
throw cause;
|
|
690
|
+
}
|
|
691
|
+
const routerAfter = registerRoute(routerSource, routerPath, path, draft.name, routePath);
|
|
692
|
+
const { navigationTargets } = await this.scan();
|
|
693
|
+
const navigation = draft.navigationTarget ? navigationTargets.find((target2) => target2.id === draft.navigationTarget) : navigationTargets.length === 1 ? navigationTargets[0] : void 0;
|
|
694
|
+
if (!navigation) throw new BuilderError(navigationTargets.length ? "Choose a navigation menu for the new link." : "No editable navigation found. Configure navigation.file with a BTSX nav or a TypeScript menu array.", 422);
|
|
695
|
+
await this.safe(resolve2(this.root, navigation.file));
|
|
696
|
+
const navSource = await readFile(resolve2(this.root, navigation.file), "utf8");
|
|
697
|
+
const navAfter = addNavigation(navSource, navigation, routePath, navLabel.trim(), this.options.navigation);
|
|
698
|
+
if (navigation.file.endsWith(".btsx")) {
|
|
699
|
+
const result = compileBeastResult3(navAfter, { filename: navigation.file });
|
|
700
|
+
this.compiler.transform(result.code, navigation.file.replace(/\.btsx$/, ".tsrx"), { environment: "client", dev: true });
|
|
701
|
+
}
|
|
702
|
+
if ((/* @__PURE__ */ new Set([path, routerPath, navigation.file])).size !== 3) throw new BuilderError("Page, router, and navigation must use separate files.");
|
|
703
|
+
const files = [
|
|
704
|
+
{ path, action: "create", before: null, source },
|
|
705
|
+
{ path: routerPath, action: "edit", before: routerSource, source: routerAfter },
|
|
706
|
+
{ path: navigation.file, action: "edit", before: navSource, source: navAfter }
|
|
707
|
+
];
|
|
708
|
+
const hash = createHash("sha256").update(JSON.stringify({ routePath, navLabel: navLabel.trim(), files })).digest("hex");
|
|
709
|
+
return { path, source, hash, routePath, navLabel: navLabel.trim(), files };
|
|
710
|
+
}
|
|
711
|
+
async create(draft, hash) {
|
|
712
|
+
if (typeof hash !== "string" || !/^[a-f0-9]{64}$/.test(hash)) throw new BuilderError("Review the page before saving.");
|
|
713
|
+
const operation = this.mutation.then(async () => {
|
|
714
|
+
const preview = await this.preview(draft);
|
|
715
|
+
if (hash !== preview.hash) throw new BuilderError("The draft, router, or navigation changed. Review again before saving.", 409);
|
|
716
|
+
for (const file of preview.files) {
|
|
717
|
+
const target = resolve2(this.root, file.path);
|
|
718
|
+
await this.safe(target);
|
|
719
|
+
if (file.before !== null && await readFile(target, "utf8") !== file.before) throw new BuilderError(`${file.path} changed. Review again.`, 409);
|
|
720
|
+
}
|
|
721
|
+
const written = [];
|
|
722
|
+
const staged = [];
|
|
723
|
+
try {
|
|
724
|
+
for (const file of preview.files) {
|
|
725
|
+
const target = resolve2(this.root, file.path);
|
|
726
|
+
await mkdir(dirname2(target), { recursive: true });
|
|
727
|
+
await this.safe(target);
|
|
728
|
+
if (file.before !== null && (await lstat(target)).isSymbolicLink()) throw new BuilderError(`${file.path} is a symlink. Configure a regular source file.`, 403);
|
|
729
|
+
const mode = file.before === null ? 438 : (await stat(target)).mode & 511;
|
|
730
|
+
const temporary = resolve2(dirname2(target), `.beast-page-builder-${randomUUID()}.tmp`);
|
|
731
|
+
staged.push({ file, temporary, mode });
|
|
732
|
+
await writeFile(temporary, file.source, { flag: "wx", mode });
|
|
733
|
+
if (file.before !== null) await chmod(temporary, mode);
|
|
734
|
+
}
|
|
735
|
+
for (const { file, temporary } of staged) {
|
|
736
|
+
const target = resolve2(this.root, file.path);
|
|
737
|
+
await this.safe(target);
|
|
738
|
+
if (file.before !== null && await readFile(target, "utf8") !== file.before) throw new BuilderError(`${file.path} changed during save. Review again.`, 409);
|
|
739
|
+
if (file.before === null) await link(temporary, target);
|
|
740
|
+
else await rename(temporary, target);
|
|
741
|
+
written.push(file);
|
|
742
|
+
}
|
|
743
|
+
} catch (cause) {
|
|
744
|
+
const failures = [];
|
|
745
|
+
for (const file of written.reverse()) {
|
|
746
|
+
const target = resolve2(this.root, file.path);
|
|
747
|
+
try {
|
|
748
|
+
await this.safe(target);
|
|
749
|
+
const current = await readFile(target, "utf8");
|
|
750
|
+
if (current !== file.source) {
|
|
751
|
+
failures.push(file.path);
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (file.before === null) await unlink(target);
|
|
755
|
+
else {
|
|
756
|
+
const stage = staged.find((item) => item.file === file);
|
|
757
|
+
await writeFile(stage.temporary, file.before, { flag: "wx", mode: stage.mode });
|
|
758
|
+
await rename(stage.temporary, target);
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
failures.push(file.path);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (failures.length) throw new BuilderError(`Save failed; could not restore ${failures.join(", ")}. Inspect these files before retrying.`, 500);
|
|
765
|
+
if (cause.code === "EEXIST") throw new BuilderError("A file with this name now exists. Review again.", 409);
|
|
766
|
+
throw cause;
|
|
767
|
+
} finally {
|
|
768
|
+
await Promise.all(staged.map((item) => unlink(item.temporary).catch(() => {
|
|
769
|
+
})));
|
|
770
|
+
}
|
|
771
|
+
return preview;
|
|
772
|
+
});
|
|
773
|
+
this.mutation = operation.then(() => {
|
|
774
|
+
}, () => {
|
|
775
|
+
});
|
|
776
|
+
this.creations.set(hash, operation);
|
|
777
|
+
if (this.creations.size > 20) this.creations.delete(this.creations.keys().next().value);
|
|
778
|
+
return operation;
|
|
779
|
+
}
|
|
780
|
+
/** A reload can interrupt the browser response while the file transaction finishes. */
|
|
781
|
+
async creation(hash) {
|
|
782
|
+
const result = this.creations.get(hash);
|
|
783
|
+
if (!result) throw new BuilderError("Save result is unavailable. Inspect the project before retrying.", 404);
|
|
784
|
+
return result;
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
// server/server.ts
|
|
789
|
+
var CLIENT_ENTRY = fileURLToPath(new URL("./client.js", import.meta.url));
|
|
790
|
+
var SHORTCUT = /^(?:Mod|Meta|Ctrl)(?:\+Shift)?\+[A-Za-z]$/;
|
|
791
|
+
function clientConfig(options) {
|
|
792
|
+
const shortcut = options.shortcut ?? "Mod+B";
|
|
793
|
+
if (!SHORTCUT.test(shortcut)) throw new BuilderError("Shortcut must be Mod+B, Meta+B, Ctrl+B, or include +Shift.");
|
|
794
|
+
if (options.position && !["center", "bottom-right"].includes(options.position)) throw new BuilderError("Unknown widget position.");
|
|
795
|
+
if (options.theme && !["light", "dark", "auto"].includes(options.theme)) throw new BuilderError("Unknown widget theme.");
|
|
796
|
+
const scanDuration = options.scanDuration ?? 1600;
|
|
797
|
+
if (!Number.isFinite(scanDuration) || scanDuration < 0 || scanDuration > 1e4) throw new BuilderError("scanDuration must be between 0 and 10,000 milliseconds.");
|
|
798
|
+
return { shortcut, position: options.position ?? "center", theme: options.theme ?? "light", scanDuration, launcher: options.launcher ?? true, pagesDir: options.pagesDir ?? "src/pages", routerFile: options.routerFile ?? "src/router.ts" };
|
|
799
|
+
}
|
|
800
|
+
function checkOrigin(req) {
|
|
801
|
+
if (req.headers["sec-fetch-site"] === "cross-site") throw new BuilderError("Cross-site requests are refused.", 403);
|
|
802
|
+
const origin = req.headers.origin;
|
|
803
|
+
if (origin) {
|
|
804
|
+
let host;
|
|
805
|
+
try {
|
|
806
|
+
host = new URL(origin).host;
|
|
807
|
+
} catch {
|
|
808
|
+
throw new BuilderError("Invalid origin.", 403);
|
|
809
|
+
}
|
|
810
|
+
if (host !== req.headers.host) throw new BuilderError("Request must come from the development page.", 403);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async function jsonBody(req) {
|
|
814
|
+
if (!req.headers["content-type"]?.startsWith("application/json")) throw new BuilderError("Expected application/json.", 415);
|
|
815
|
+
const chunks = [];
|
|
816
|
+
let length = 0;
|
|
817
|
+
for await (const chunk of req) {
|
|
818
|
+
length += Buffer.byteLength(chunk);
|
|
819
|
+
if (length > 65536) throw new BuilderError("Request is too large.", 413);
|
|
820
|
+
chunks.push(Buffer.from(chunk));
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
824
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error();
|
|
825
|
+
return result;
|
|
826
|
+
} catch {
|
|
827
|
+
throw new BuilderError("Invalid JSON body.");
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function createBuilderServer(root, options = {}, basePath = "/") {
|
|
831
|
+
const config = clientConfig(options);
|
|
832
|
+
const project = new BuilderProject(root, options);
|
|
833
|
+
const middleware = (req, res, next) => {
|
|
834
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
835
|
+
const prefixedBase = basePath.replace(/\/$/, "") + BASE;
|
|
836
|
+
const routeBase = url.pathname.startsWith(prefixedBase + "/") ? prefixedBase : BASE;
|
|
837
|
+
if (!url.pathname.startsWith(routeBase + "/")) return next();
|
|
838
|
+
const send = (status, data) => {
|
|
839
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
|
|
840
|
+
res.end(JSON.stringify(data));
|
|
841
|
+
};
|
|
842
|
+
void (async () => {
|
|
843
|
+
checkOrigin(req);
|
|
844
|
+
const route = url.pathname.slice(routeBase.length);
|
|
845
|
+
if (req.method === "GET" && route === "/client.js") {
|
|
846
|
+
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" });
|
|
847
|
+
res.end(await readFile2(CLIENT_ENTRY, "utf8"));
|
|
848
|
+
} else if (req.method === "GET" && route === "/config") send(200, config);
|
|
849
|
+
else if (req.method === "GET" && route === "/scan") send(200, await project.scan());
|
|
850
|
+
else if (req.method === "GET" && route === "/creation") send(200, await project.creation(url.searchParams.get("hash") ?? ""));
|
|
851
|
+
else if (req.method === "GET" && route === "/source") send(200, { source: await project.source(url.searchParams.get("path") ?? "") });
|
|
852
|
+
else if (req.method === "POST" && (route === "/preview" || route === "/create")) {
|
|
853
|
+
const body = await jsonBody(req);
|
|
854
|
+
const draft = { name: body.name, source: body.source, routePath: body.routePath, navLabel: body.navLabel, navigationTarget: body.navigationTarget };
|
|
855
|
+
send(route === "/create" ? 201 : 200, route === "/create" ? await project.create(draft, body.hash) : await project.preview(draft));
|
|
856
|
+
} else send(404, { error: "Unknown page-builder endpoint." });
|
|
857
|
+
})().catch((error) => {
|
|
858
|
+
const code = error.code;
|
|
859
|
+
send(error instanceof BuilderError ? error.status : code === "ENOENT" ? 404 : 500, { error: error instanceof Error ? error.message : String(error) });
|
|
860
|
+
});
|
|
861
|
+
};
|
|
862
|
+
return { middleware, project, clientEntry: CLIENT_ENTRY };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// rsbuild.ts
|
|
866
|
+
function beastPageBuilder(options = {}) {
|
|
867
|
+
return {
|
|
868
|
+
name: "beast:page-builder",
|
|
869
|
+
apply: "serve",
|
|
870
|
+
setup(api) {
|
|
871
|
+
api.modifyEnvironmentConfig((config, { mergeEnvironmentConfig }) => {
|
|
872
|
+
if (config.output.target !== "web") return config;
|
|
873
|
+
return mergeEnvironmentConfig(config, { source: { preEntry: [CLIENT_ENTRY] } });
|
|
874
|
+
});
|
|
875
|
+
api.onBeforeStartDevServer(({ server }) => {
|
|
876
|
+
server.middlewares.use(createBuilderServer(api.context.rootPath, options).middleware);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
export {
|
|
882
|
+
beastPageBuilder
|
|
883
|
+
};
|