@avocadostudio-ai/orchestrator-core 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 +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for site-agent tools — used by both the Agent SDK MCP server
|
|
3
|
+
* and the standalone stdio MCP server (CLI mode).
|
|
4
|
+
*
|
|
5
|
+
* Contains: prop validation/correction, CSS patching, port allocation, site ID sanitization.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { resolve, join, dirname } from "node:path";
|
|
10
|
+
import { getAllBlockMeta, defaultPropsForType, validateBlockProps } from "@avocadostudio-ai/shared";
|
|
11
|
+
// ── Utility helpers ──
|
|
12
|
+
export function sanitizeSiteId(name) {
|
|
13
|
+
return name
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
16
|
+
.replace(/^-+|-+$/g, "")
|
|
17
|
+
.slice(0, 40) || "my-site";
|
|
18
|
+
}
|
|
19
|
+
export function monorepoRoot() {
|
|
20
|
+
return resolve(dirname(new URL(import.meta.url).pathname), "../../../../");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the DRAFT_MODE_SECRET to write into a scaffolded site's `.env.local`.
|
|
24
|
+
* Reads from the orchestrator's own environment so the scaffolded site lines up
|
|
25
|
+
* with the editor's build-time `VITE_SITE_DRAFT_SECRET`. Falls back to a known
|
|
26
|
+
* dev placeholder if the orchestrator wasn't started with one — that placeholder
|
|
27
|
+
* is intentionally weak so production deployments fail closed if env config is
|
|
28
|
+
* forgotten.
|
|
29
|
+
*/
|
|
30
|
+
export function getDraftModeSecret() {
|
|
31
|
+
return process.env.DRAFT_MODE_SECRET ?? "top-secret";
|
|
32
|
+
}
|
|
33
|
+
export async function findAvailablePort(root) {
|
|
34
|
+
const appsDir = join(root, "apps");
|
|
35
|
+
const usedPorts = new Set([3000, 4100, 4200]);
|
|
36
|
+
try {
|
|
37
|
+
for (const dir of [appsDir, join(root, "examples")]) {
|
|
38
|
+
if (!existsSync(dir))
|
|
39
|
+
continue;
|
|
40
|
+
const entries = await readdir(dir);
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const pkgPath = join(dir, entry, "package.json");
|
|
43
|
+
if (!existsSync(pkgPath))
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
47
|
+
const devScript = pkg.scripts?.dev ?? "";
|
|
48
|
+
const portMatches = devScript.matchAll(/(?:-p\s*|--port\s+)(\d+)/g);
|
|
49
|
+
for (const m of portMatches)
|
|
50
|
+
usedPorts.add(Number(m[1]));
|
|
51
|
+
}
|
|
52
|
+
catch { /* skip */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch { /* skip */ }
|
|
57
|
+
const { createServer } = await import("node:net");
|
|
58
|
+
let port = 3500;
|
|
59
|
+
while (usedPorts.has(port) || !(await isPortFree(createServer, port)))
|
|
60
|
+
port++;
|
|
61
|
+
console.log(`[sites-agent] Selected port ${port} (used: ${[...usedPorts].sort().join(", ")})`);
|
|
62
|
+
return port;
|
|
63
|
+
}
|
|
64
|
+
function isPortFree(createServer, port) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const server = createServer();
|
|
67
|
+
server.once("error", () => resolve(false));
|
|
68
|
+
server.once("listening", () => { server.close(() => resolve(true)); });
|
|
69
|
+
server.listen(port, "127.0.0.1");
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export const THEME_PRESETS = [
|
|
73
|
+
{
|
|
74
|
+
name: "ocean",
|
|
75
|
+
description: "Clean and professional — tech, SaaS, corporate. Cool blue tones with crisp white.",
|
|
76
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap",
|
|
77
|
+
overrides: {
|
|
78
|
+
"--bg-0": "#ffffff", "--bg-100": "#f0f4f8", "--section-bg": "#f0f4f8",
|
|
79
|
+
"--heading": "#0f172a", "--body": "#334155", "--body-secondary": "#64748b", "--caption": "#94a3b8",
|
|
80
|
+
"--brand": "#2563eb", "--brand-hover": "#1d4ed8", "--brand-subtle": "#dbeafe", "--brand-fg": "#ffffff",
|
|
81
|
+
"--surface": "#ffffff", "--border": "#e2e8f0", "--card-bg": "#f8fafc", "--card-shadow": "0 1px 3px rgba(0,0,0,0.08)",
|
|
82
|
+
"--footer-bg": "#0f172a", "--footer-text": "#94a3b8", "--footer-heading": "#f1f5f9", "--footer-link": "#64748b", "--footer-link-hover": "#e2e8f0", "--footer-border": "#1e293b",
|
|
83
|
+
"--font-heading": "'Inter', sans-serif", "--font-body": "'Inter', sans-serif",
|
|
84
|
+
"--radius-btn": "8px", "--radius-card": "12px", "--radius-feature": "12px",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "forest",
|
|
89
|
+
description: "Warm and grounded — nature, wellness, eco, organic. Earthy greens with warm neutrals.",
|
|
90
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap",
|
|
91
|
+
overrides: {
|
|
92
|
+
"--bg-0": "#fafaf8", "--bg-100": "#f2f0eb", "--section-bg": "#f2f0eb",
|
|
93
|
+
"--heading": "#1a2e1a", "--body": "#3d4a3d", "--body-secondary": "#6b7b6b", "--caption": "#8a9a8a",
|
|
94
|
+
"--brand": "#16a34a", "--brand-hover": "#15803d", "--brand-subtle": "#dcfce7", "--brand-fg": "#ffffff",
|
|
95
|
+
"--surface": "#ffffff", "--border": "#d4d8cd", "--card-bg": "#f7f7f3", "--card-shadow": "0 1px 3px rgba(0,0,0,0.06)",
|
|
96
|
+
"--footer-bg": "#1a2e1a", "--footer-text": "#a3b8a3", "--footer-heading": "#e8f0e8", "--footer-link": "#7a9a7a", "--footer-link-hover": "#d4e8d4", "--footer-border": "#2d4a2d",
|
|
97
|
+
"--font-heading": "'DM Sans', sans-serif", "--font-body": "'DM Sans', sans-serif",
|
|
98
|
+
"--radius-btn": "8px", "--radius-card": "10px", "--radius-feature": "10px",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "luxury",
|
|
103
|
+
description: "Refined and elegant — real estate, hospitality, high-end brands. Warm gold on cream with serif headings.",
|
|
104
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Jost:wght@300;400;500;600&display=swap",
|
|
105
|
+
overrides: {
|
|
106
|
+
"--bg-0": "#faf8f5", "--bg-100": "#f2ede6", "--section-bg": "#f2ede6",
|
|
107
|
+
"--heading": "#2c1810", "--body": "#4a3728", "--body-secondary": "#7a6a5a", "--caption": "#9a8a7a",
|
|
108
|
+
"--brand": "#b8860b", "--brand-hover": "#9a7209", "--brand-subtle": "#fdf3e0", "--brand-fg": "#ffffff",
|
|
109
|
+
"--surface": "#ffffff", "--border": "#e8ddd0", "--card-bg": "#faf7f2", "--card-shadow": "0 2px 8px rgba(44,24,16,0.06)",
|
|
110
|
+
"--footer-bg": "#2c1810", "--footer-text": "#b8a898", "--footer-heading": "#f0e8e0", "--footer-link": "#9a8a7a", "--footer-link-hover": "#e0d0c0", "--footer-border": "#4a3728",
|
|
111
|
+
"--font-heading": "'Cormorant Garamond', serif", "--font-body": "'Jost', sans-serif",
|
|
112
|
+
"--radius-btn": "4px", "--radius-card": "6px", "--radius-feature": "6px",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "coral",
|
|
117
|
+
description: "Vibrant and warm — creative agencies, food, lifestyle, events. Energetic coral with clean whites.",
|
|
118
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap",
|
|
119
|
+
overrides: {
|
|
120
|
+
"--bg-0": "#ffffff", "--bg-100": "#fef7f4", "--section-bg": "#fef7f4",
|
|
121
|
+
"--heading": "#1a1a1a", "--body": "#3a3a3a", "--body-secondary": "#6b6b6b", "--caption": "#8a8a8a",
|
|
122
|
+
"--brand": "#e76f51", "--brand-hover": "#d4603f", "--brand-subtle": "#fde8e0", "--brand-fg": "#ffffff",
|
|
123
|
+
"--surface": "#ffffff", "--border": "#f0e0d8", "--card-bg": "#fefaf8", "--card-shadow": "0 1px 4px rgba(0,0,0,0.06)",
|
|
124
|
+
"--footer-bg": "#1a1a1a", "--footer-text": "#b0b0b0", "--footer-heading": "#f0f0f0", "--footer-link": "#8a8a8a", "--footer-link-hover": "#e0e0e0", "--footer-border": "#333333",
|
|
125
|
+
"--font-heading": "'Outfit', sans-serif", "--font-body": "'Outfit', sans-serif",
|
|
126
|
+
"--radius-btn": "10px", "--radius-card": "14px", "--radius-feature": "14px",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "midnight",
|
|
131
|
+
description: "Dark and premium — fintech, gaming, developer tools, SaaS dashboards. Deep navy with muted blue accents.",
|
|
132
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap",
|
|
133
|
+
overrides: {
|
|
134
|
+
"--bg-0": "#0f172a", "--bg-100": "#1e293b", "--section-bg": "#1e293b",
|
|
135
|
+
"--heading": "#f1f5f9", "--body": "#cbd5e1", "--body-secondary": "#94a3b8", "--caption": "#64748b",
|
|
136
|
+
"--brand": "#38bdf8", "--brand-hover": "#7dd3fc", "--brand-subtle": "#0c4a6e", "--brand-fg": "#0f172a",
|
|
137
|
+
"--surface": "#1e293b", "--border": "#334155", "--card-bg": "#1e293b", "--card-shadow": "0 1px 4px rgba(0,0,0,0.3)",
|
|
138
|
+
"--footer-bg": "#020617", "--footer-text": "#64748b", "--footer-heading": "#e2e8f0", "--footer-link": "#475569", "--footer-link-hover": "#94a3b8", "--footer-border": "#1e293b",
|
|
139
|
+
"--font-heading": "'Space Grotesk', sans-serif", "--font-body": "'Space Grotesk', sans-serif",
|
|
140
|
+
"--radius-btn": "8px", "--radius-card": "12px", "--radius-feature": "12px",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "rose",
|
|
145
|
+
description: "Soft and feminine — beauty, health, fashion, wellness. Rose accents on blush backgrounds with elegant serif headings.",
|
|
146
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Lato:wght@300;400;700&display=swap",
|
|
147
|
+
overrides: {
|
|
148
|
+
"--bg-0": "#fffbfc", "--bg-100": "#fdf2f4", "--section-bg": "#fdf2f4",
|
|
149
|
+
"--heading": "#1a1a2e", "--body": "#3a3a4a", "--body-secondary": "#6b6b7b", "--caption": "#8a8a9a",
|
|
150
|
+
"--brand": "#e11d48", "--brand-hover": "#be123c", "--brand-subtle": "#ffe4e6", "--brand-fg": "#ffffff",
|
|
151
|
+
"--surface": "#ffffff", "--border": "#f0d4da", "--card-bg": "#fef8f9", "--card-shadow": "0 1px 4px rgba(225,29,72,0.06)",
|
|
152
|
+
"--footer-bg": "#1a1a2e", "--footer-text": "#b0a0b0", "--footer-heading": "#f0e8f0", "--footer-link": "#8a7a8a", "--footer-link-hover": "#e0d0e0", "--footer-border": "#2d2d4a",
|
|
153
|
+
"--font-heading": "'Playfair Display', serif", "--font-body": "'Lato', sans-serif",
|
|
154
|
+
"--radius-btn": "6px", "--radius-card": "10px", "--radius-feature": "10px",
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "slate",
|
|
159
|
+
description: "Minimal and editorial — portfolios, magazines, studios, photography. Monochrome with strong typography.",
|
|
160
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Source+Sans+3:wght@400;500;600;700&display=swap",
|
|
161
|
+
overrides: {
|
|
162
|
+
"--bg-0": "#ffffff", "--bg-100": "#f5f5f5", "--section-bg": "#f5f5f5",
|
|
163
|
+
"--heading": "#111111", "--body": "#333333", "--body-secondary": "#666666", "--caption": "#999999",
|
|
164
|
+
"--brand": "#111111", "--brand-hover": "#333333", "--brand-subtle": "#f0f0f0", "--brand-fg": "#ffffff",
|
|
165
|
+
"--surface": "#ffffff", "--border": "#e0e0e0", "--card-bg": "#fafafa", "--card-shadow": "0 1px 2px rgba(0,0,0,0.05)",
|
|
166
|
+
"--footer-bg": "#111111", "--footer-text": "#999999", "--footer-heading": "#f0f0f0", "--footer-link": "#777777", "--footer-link-hover": "#cccccc", "--footer-border": "#333333",
|
|
167
|
+
"--font-heading": "'Libre Baskerville', serif", "--font-body": "'Source Sans 3', sans-serif",
|
|
168
|
+
"--radius-btn": "2px", "--radius-card": "4px", "--radius-feature": "4px",
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "sunset",
|
|
173
|
+
description: "Bold and energetic — startups, sports, fitness, youth brands. Vibrant orange with punchy sans-serif.",
|
|
174
|
+
googleFontsUrl: "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800;900&display=swap",
|
|
175
|
+
overrides: {
|
|
176
|
+
"--bg-0": "#ffffff", "--bg-100": "#fff8f3", "--section-bg": "#fff8f3",
|
|
177
|
+
"--heading": "#1a1a1a", "--body": "#333333", "--body-secondary": "#666666", "--caption": "#888888",
|
|
178
|
+
"--brand": "#ea580c", "--brand-hover": "#c2410c", "--brand-subtle": "#ffedd5", "--brand-fg": "#ffffff",
|
|
179
|
+
"--surface": "#ffffff", "--border": "#f0ddd0", "--card-bg": "#fffbf7", "--card-shadow": "0 1px 4px rgba(234,88,12,0.08)",
|
|
180
|
+
"--footer-bg": "#1a1a1a", "--footer-text": "#aaaaaa", "--footer-heading": "#f0f0f0", "--footer-link": "#888888", "--footer-link-hover": "#dddddd", "--footer-border": "#333333",
|
|
181
|
+
"--font-heading": "'Montserrat', sans-serif", "--font-body": "'Montserrat', sans-serif",
|
|
182
|
+
"--radius-btn": "8px", "--radius-card": "12px", "--radius-feature": "12px",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
/**
|
|
187
|
+
* Format theme presets as a catalog for the system prompt.
|
|
188
|
+
* Shows name, description, and the full CSS variable overrides so the LLM
|
|
189
|
+
* can copy them into themeOverrides.
|
|
190
|
+
*/
|
|
191
|
+
export function buildThemePresetsCatalog() {
|
|
192
|
+
const lines = ["### Theme Presets\nPick the preset that best matches the site's purpose and tone. Pass its `overrides` as `themeOverrides` in `bootstrap_pages`. You may tweak individual values (e.g. swap brand color) while keeping the rest of the palette.\n"];
|
|
193
|
+
for (const preset of THEME_PRESETS) {
|
|
194
|
+
const overridesJson = JSON.stringify(preset.overrides, null, 2);
|
|
195
|
+
lines.push(`#### ${preset.name}\n${preset.description}\n\`\`\`json\n${overridesJson}\n\`\`\``);
|
|
196
|
+
if (preset.googleFontsUrl) {
|
|
197
|
+
lines.push(`Google Fonts: \`${preset.googleFontsUrl}\`\n`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
lines.push(`\n**Custom tweaks are encouraged.** If the user says "nature site but teal", start from \`forest\` and swap \`--brand\` to teal. Always keep hover/subtle/fg consistent with the brand hue.`);
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
// ── Sites agent model tiers ──
|
|
204
|
+
/** Model tiers for the sites agent pipeline. */
|
|
205
|
+
export const SITES_AGENT_MODELS = {
|
|
206
|
+
/** Haiku — triage, intent detection, simple Q&A ($0.001/call).
|
|
207
|
+
* Full model ID for direct Anthropic API calls (not Agent SDK). */
|
|
208
|
+
fast: process.env.SITES_AGENT_MODEL_FAST ?? "claude-haiku-4-5-20251001",
|
|
209
|
+
/** Sonnet — main agent, subagents, standard migrations.
|
|
210
|
+
* Agent SDK enum value (SDK resolves to latest Sonnet). */
|
|
211
|
+
balanced: "sonnet",
|
|
212
|
+
/** Opus — complex migrations, multi-page sites, custom block design.
|
|
213
|
+
* Agent SDK enum value (SDK resolves to latest Opus). */
|
|
214
|
+
powerful: "opus",
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* Fast triage using Haiku — classifies intent, extracts params, answers simple questions.
|
|
218
|
+
* ~200ms, ~$0.001 per call.
|
|
219
|
+
*/
|
|
220
|
+
export async function triageWithHaiku(message, apiKey) {
|
|
221
|
+
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
222
|
+
const client = new Anthropic({ apiKey });
|
|
223
|
+
const response = await client.messages.create({
|
|
224
|
+
model: SITES_AGENT_MODELS.fast,
|
|
225
|
+
max_tokens: 256,
|
|
226
|
+
messages: [{ role: "user", content: message }],
|
|
227
|
+
system: `You are a triage router for a site creation agent. Classify the user's message and extract parameters.
|
|
228
|
+
|
|
229
|
+
Reply with ONLY a JSON object (no markdown, no explanation):
|
|
230
|
+
{
|
|
231
|
+
"intent": "create" | "migrate" | "integrate" | "question",
|
|
232
|
+
"url": "extracted URL if any",
|
|
233
|
+
"siteName": "extracted site name if any",
|
|
234
|
+
"scope": "e.g. 'homepage only', 'all pages', specific pages",
|
|
235
|
+
"answer": "direct answer if intent is 'question'"
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
Intent definitions:
|
|
239
|
+
- "create": user wants to build a new site from scratch
|
|
240
|
+
- "migrate": user wants to recreate/copy an existing website from a URL
|
|
241
|
+
- "integrate": user wants to add the editor SDK to an existing Next.js project (mentions GitHub, repo, existing project, codebase)
|
|
242
|
+
- "question": user is asking a question about capabilities, block types, how things work — answer it directly in "answer" field
|
|
243
|
+
|
|
244
|
+
Available block types: Hero, FeatureGrid, CardGrid, CTA, FAQAccordion, Stats, RichText, Testimonials, TwoColumn, Table, Gallery, Quote, Banner, Tabs, Carousel, Video, Embed, Card. Custom blocks can also be created during migration.
|
|
245
|
+
|
|
246
|
+
If intent is "migrate" but no URL is provided, or "integrate" but no repo/project is mentioned, set intent to "question" and ask for the missing info in "answer".
|
|
247
|
+
|
|
248
|
+
Keep your "answer" short and well-formatted using markdown:
|
|
249
|
+
- Use **bold** for emphasis
|
|
250
|
+
- Use bullet points or numbered lists (not inline "1) ... 2) ...")
|
|
251
|
+
- Max 2-3 sentences
|
|
252
|
+
- Be specific about what you need (e.g. "Please share the URL of the website you'd like to migrate.")
|
|
253
|
+
- Do NOT be overly enthusiastic or verbose
|
|
254
|
+
|
|
255
|
+
Extract URLs, site names, and scope constraints from the message. If not present, omit the field.`,
|
|
256
|
+
});
|
|
257
|
+
const text = response.content[0]?.type === "text" ? response.content[0].text : "{}";
|
|
258
|
+
try {
|
|
259
|
+
// Strip markdown code fences if present
|
|
260
|
+
const clean = text.replace(/^```json?\s*|\s*```$/g, "").trim();
|
|
261
|
+
const parsed = JSON.parse(clean);
|
|
262
|
+
console.log(`[sites-agent] Triage: intent=${parsed.intent} url=${parsed.url ?? "-"} site=${parsed.siteName ?? "-"} scope=${parsed.scope ?? "-"}`);
|
|
263
|
+
return parsed;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
console.log(`[sites-agent] Triage parse failed, defaulting to migrate: ${text.slice(0, 100)}`);
|
|
267
|
+
return { intent: "migrate" };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// ── CSS variable persistence ──
|
|
271
|
+
export async function patchGlobalsCssVars(cssPath, vars) {
|
|
272
|
+
if (!existsSync(cssPath))
|
|
273
|
+
return;
|
|
274
|
+
let css = await readFile(cssPath, "utf-8");
|
|
275
|
+
// Remove generic dark-mode and .dark overrides — migrated sites have a single
|
|
276
|
+
// authoritative palette extracted from the source; the template defaults would
|
|
277
|
+
// stomp on it for users with prefers-color-scheme: dark.
|
|
278
|
+
css = css.replace(/\/\*\s*Dark mode\s*\*\/\s*\n@media\s*\(prefers-color-scheme:\s*dark\)\s*\{[^}]*\{[^}]*\}\s*\}/g, "");
|
|
279
|
+
css = css.replace(/\.dark\s*\{[^}]*\}/g, "");
|
|
280
|
+
css = css.replace(/\n{3,}/g, "\n\n"); // collapse leftover blank lines
|
|
281
|
+
// Handle Google Fonts import — inject @import url(...) after @import "tailwindcss"
|
|
282
|
+
const googleFontsUrl = vars["--google-fonts-import"];
|
|
283
|
+
if (googleFontsUrl) {
|
|
284
|
+
// Remove existing Google Fonts imports to avoid duplicates
|
|
285
|
+
css = css.replace(/@import\s+url\(["']https:\/\/fonts\.googleapis\.com[^)]*\);\s*\n?/g, "");
|
|
286
|
+
// Insert after the last @import line
|
|
287
|
+
const lastImportIdx = css.lastIndexOf("@import");
|
|
288
|
+
if (lastImportIdx >= 0) {
|
|
289
|
+
const lineEnd = css.indexOf("\n", lastImportIdx);
|
|
290
|
+
css = css.slice(0, lineEnd + 1) + `@import url("${googleFontsUrl}");\n` + css.slice(lineEnd + 1);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
css = `@import url("${googleFontsUrl}");\n` + css;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const [prop, value] of Object.entries(vars)) {
|
|
297
|
+
if (prop === "--google-fonts-import")
|
|
298
|
+
continue; // handled above
|
|
299
|
+
const escaped = prop.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
300
|
+
const varRegex = new RegExp(`(${escaped}):\\s*[^;]+;`, "g");
|
|
301
|
+
if (varRegex.test(css)) {
|
|
302
|
+
css = css.replace(new RegExp(`(${escaped}):\\s*[^;]+;`, "g"), `$1: ${value};`);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
css = css.replace(/(:root\s*\{[^}]*)(\})/, `$1 ${prop}: ${value};\n$2`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
await writeFile(cssPath, css, "utf-8");
|
|
309
|
+
}
|
|
310
|
+
// ── Schema validation + auto-correction ──
|
|
311
|
+
const PROP_RENAME_RULES = {
|
|
312
|
+
FAQAccordion: { question: "q", answer: "a" },
|
|
313
|
+
FeatureGrid: { items: "features" },
|
|
314
|
+
Stats: { items: "stats" },
|
|
315
|
+
CardGrid: { items: "cards" },
|
|
316
|
+
CTA: { heading: "title", buttonText: "ctaText", buttonHref: "ctaHref" },
|
|
317
|
+
Testimonials: { testimonials: "items" },
|
|
318
|
+
Footer: { heading: "title" },
|
|
319
|
+
};
|
|
320
|
+
export function fixFooterLinks(props) {
|
|
321
|
+
// Case 1: flat "links" string without columns — wrap into a single column
|
|
322
|
+
if (typeof props.links === "string" && !Array.isArray(props.columns)) {
|
|
323
|
+
const { links, tagline, ...rest } = props;
|
|
324
|
+
return { ...rest, columns: [{ title: typeof tagline === "string" ? tagline : "Links", links }] };
|
|
325
|
+
}
|
|
326
|
+
const columns = props.columns;
|
|
327
|
+
if (!Array.isArray(columns))
|
|
328
|
+
return props;
|
|
329
|
+
return {
|
|
330
|
+
...props,
|
|
331
|
+
columns: columns.map((col) => {
|
|
332
|
+
if (!col || typeof col !== "object")
|
|
333
|
+
return col;
|
|
334
|
+
const c = col;
|
|
335
|
+
const links = c.links;
|
|
336
|
+
if (typeof links === "string")
|
|
337
|
+
return c;
|
|
338
|
+
if (Array.isArray(links)) {
|
|
339
|
+
const formatted = links
|
|
340
|
+
.filter((l) => l && typeof l === "object")
|
|
341
|
+
.map((l) => {
|
|
342
|
+
const link = l;
|
|
343
|
+
const label = String(link.label ?? link.text ?? "");
|
|
344
|
+
const href = String(link.href ?? link.url ?? "");
|
|
345
|
+
return href ? `${label}|${href}` : label;
|
|
346
|
+
})
|
|
347
|
+
.join("\n");
|
|
348
|
+
return { ...c, links: formatted };
|
|
349
|
+
}
|
|
350
|
+
return c;
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function renameKeys(obj, renames) {
|
|
355
|
+
const result = {};
|
|
356
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
357
|
+
const newKey = renames[key] ?? key;
|
|
358
|
+
if (Array.isArray(value)) {
|
|
359
|
+
result[newKey] = value.map(item => item && typeof item === "object" && !Array.isArray(item)
|
|
360
|
+
? renameKeys(item, renames)
|
|
361
|
+
: item);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
result[newKey] = value;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
export function validateAndCorrectProps(blockType, props) {
|
|
370
|
+
const first = validateBlockProps(blockType, props);
|
|
371
|
+
if (first.success)
|
|
372
|
+
return { props, corrected: false };
|
|
373
|
+
let corrected = { ...props };
|
|
374
|
+
for (const [key, value] of Object.entries(corrected)) {
|
|
375
|
+
if (typeof value === "boolean")
|
|
376
|
+
corrected[key] = String(value);
|
|
377
|
+
}
|
|
378
|
+
const renames = PROP_RENAME_RULES[blockType];
|
|
379
|
+
if (renames)
|
|
380
|
+
corrected = renameKeys(corrected, renames);
|
|
381
|
+
if (blockType === "Footer")
|
|
382
|
+
corrected = fixFooterLinks(corrected);
|
|
383
|
+
const second = validateBlockProps(blockType, corrected);
|
|
384
|
+
if (second.success) {
|
|
385
|
+
console.log(`[sites-agent] Auto-corrected ${blockType} props`);
|
|
386
|
+
return { props: corrected, corrected: true };
|
|
387
|
+
}
|
|
388
|
+
const errorMsg = first.success === false && "error" in first
|
|
389
|
+
? first.error.issues?.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") ?? "validation failed"
|
|
390
|
+
: "validation failed";
|
|
391
|
+
return { props, corrected: false, error: errorMsg };
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Normalize and validate blocks for a page — applies prop correction, strips chrome blocks,
|
|
395
|
+
* extracts footer. Shared between SDK and stdio bootstrap_pages implementations.
|
|
396
|
+
*/
|
|
397
|
+
export function normalizePageBlocks(blocks) {
|
|
398
|
+
let strippedCount = 0;
|
|
399
|
+
let footerBlock = null;
|
|
400
|
+
const contentBlocks = blocks
|
|
401
|
+
.filter(b => {
|
|
402
|
+
if (b.type === "SiteHeader") {
|
|
403
|
+
strippedCount++;
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (b.type === "Footer") {
|
|
407
|
+
if (!footerBlock) {
|
|
408
|
+
let fp = fixFooterLinks(b.props);
|
|
409
|
+
const v = validateAndCorrectProps(b.type, fp);
|
|
410
|
+
if (v.corrected)
|
|
411
|
+
fp = v.props;
|
|
412
|
+
footerBlock = { type: b.type, props: fp };
|
|
413
|
+
}
|
|
414
|
+
strippedCount++;
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
return true;
|
|
418
|
+
})
|
|
419
|
+
.map((b, i) => {
|
|
420
|
+
const blockMeta = getAllBlockMeta()[b.type];
|
|
421
|
+
const baseProps = blockMeta ? defaultPropsForType(b.type) : {};
|
|
422
|
+
let mergedProps = { ...baseProps };
|
|
423
|
+
for (const [key, value] of Object.entries(b.props)) {
|
|
424
|
+
mergedProps[key] = value;
|
|
425
|
+
}
|
|
426
|
+
if (blockMeta) {
|
|
427
|
+
const validation = validateAndCorrectProps(b.type, mergedProps);
|
|
428
|
+
if (validation.corrected) {
|
|
429
|
+
mergedProps = { ...baseProps };
|
|
430
|
+
for (const [key, value] of Object.entries(validation.props)) {
|
|
431
|
+
mergedProps[key] = value;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else if (validation.error) {
|
|
435
|
+
console.warn(`[sites-agent] ${b.type} block validation: ${validation.error}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { id: `b_${b.type.toLowerCase()}_${i + 1}`, type: b.type, props: mergedProps };
|
|
439
|
+
});
|
|
440
|
+
return { contentBlocks, footerBlock, strippedCount };
|
|
441
|
+
}
|
|
442
|
+
// ── Site scaffolding templates ──
|
|
443
|
+
export function packageJson(siteId, name, port) {
|
|
444
|
+
return JSON.stringify({
|
|
445
|
+
name: `@ai-site-editor/${siteId}`,
|
|
446
|
+
version: "0.0.1",
|
|
447
|
+
private: true,
|
|
448
|
+
type: "module",
|
|
449
|
+
scripts: { dev: `next dev -p ${port}`, build: "next build", start: `next start -p ${port}`, typecheck: "tsc --noEmit" },
|
|
450
|
+
dependencies: {
|
|
451
|
+
"@avocadostudio-ai/blocks": "workspace:*", "@avocadostudio-ai/shared": "workspace:*", "@avocadostudio-ai/site-sdk": "workspace:*",
|
|
452
|
+
"@tailwindcss/postcss": "^4.2.1", next: "15.2.8", react: "^19.0.0", "react-dom": "^19.0.0", tailwindcss: "^4.2.1", zod: "^4.3.6",
|
|
453
|
+
},
|
|
454
|
+
devDependencies: { "@types/node": "^22.13.10", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", typescript: "^5.7.3" },
|
|
455
|
+
}, null, 2) + "\n";
|
|
456
|
+
}
|
|
457
|
+
export function nextConfigTs() {
|
|
458
|
+
return `import type { NextConfig } from "next"
|
|
459
|
+
|
|
460
|
+
const nextConfig: NextConfig = {
|
|
461
|
+
transpilePackages: [
|
|
462
|
+
"@avocadostudio-ai/preview-adapter",
|
|
463
|
+
"@avocadostudio-ai/site-sdk",
|
|
464
|
+
"@avocadostudio-ai/blocks",
|
|
465
|
+
"@avocadostudio-ai/shared",
|
|
466
|
+
],
|
|
467
|
+
images: {
|
|
468
|
+
remotePatterns: [
|
|
469
|
+
{ protocol: "https", hostname: "images.unsplash.com" },
|
|
470
|
+
{ protocol: "https", hostname: "plus.unsplash.com" },
|
|
471
|
+
{ protocol: "https", hostname: "oaidalleapiprodscus.blob.core.windows.net" },
|
|
472
|
+
{ protocol: "https", hostname: "placehold.co" },
|
|
473
|
+
{ protocol: "http", hostname: "localhost" },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
webpack: (config, { dev }) => {
|
|
477
|
+
if (dev) {
|
|
478
|
+
config.watchOptions = { ...config.watchOptions, followSymlinks: true, poll: 1000 }
|
|
479
|
+
config.resolve = { ...config.resolve, symlinks: false }
|
|
480
|
+
config.cache = { ...config.cache, version: \`\${process.env.WORKSPACE_CACHE_BUST ?? Date.now()}\` }
|
|
481
|
+
}
|
|
482
|
+
return config
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export default nextConfig
|
|
487
|
+
`;
|
|
488
|
+
}
|
|
489
|
+
export function tsconfigJson() {
|
|
490
|
+
return JSON.stringify({
|
|
491
|
+
compilerOptions: {
|
|
492
|
+
target: "ES2022", lib: ["dom", "dom.iterable", "es2022"], allowJs: false, skipLibCheck: true,
|
|
493
|
+
strict: true, noEmit: true, esModuleInterop: true, module: "esnext", moduleResolution: "bundler",
|
|
494
|
+
resolveJsonModule: true, isolatedModules: true, allowImportingTsExtensions: true, jsx: "preserve",
|
|
495
|
+
incremental: true, plugins: [{ name: "next" }],
|
|
496
|
+
},
|
|
497
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
498
|
+
exclude: ["node_modules"],
|
|
499
|
+
}, null, 2) + "\n";
|
|
500
|
+
}
|
|
501
|
+
export function postcssConfig() {
|
|
502
|
+
return `export default {\n plugins: {\n "@tailwindcss/postcss": {}\n }\n}\n`;
|
|
503
|
+
}
|
|
504
|
+
export function layoutTsx(siteName) {
|
|
505
|
+
return `import "./globals.css"
|
|
506
|
+
import type { Metadata } from "next"
|
|
507
|
+
import type { ReactNode } from "react"
|
|
508
|
+
|
|
509
|
+
export const metadata: Metadata = {
|
|
510
|
+
title: { default: ${JSON.stringify("__SITE_NAME__")}, template: \`%s · __SITE_NAME__\` },
|
|
511
|
+
description: ${JSON.stringify("__SITE_NAME__")},
|
|
512
|
+
icons: { icon: "/favicon.svg" },
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const themeScript = \`(function(){try{var t=localStorage.getItem('site-theme-v1');if(t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches))document.documentElement.classList.add('dark')}catch(e){}})()\`
|
|
516
|
+
|
|
517
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
518
|
+
return (
|
|
519
|
+
<html lang="en" suppressHydrationWarning>
|
|
520
|
+
<head>
|
|
521
|
+
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
522
|
+
</head>
|
|
523
|
+
<body>{children}</body>
|
|
524
|
+
</html>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
`.replaceAll("__SITE_NAME__", siteName);
|
|
528
|
+
}
|
|
529
|
+
export function globalsCss() {
|
|
530
|
+
return `@import "tailwindcss";
|
|
531
|
+
@import "@avocadostudio-ai/blocks/styles.css";
|
|
532
|
+
|
|
533
|
+
:root {
|
|
534
|
+
/* Backgrounds */
|
|
535
|
+
--bg-0: #ffffff;
|
|
536
|
+
--bg-100: #f8f9fa;
|
|
537
|
+
--bg-1: #ffffff;
|
|
538
|
+
--section-bg: var(--bg-100);
|
|
539
|
+
|
|
540
|
+
/* Text */
|
|
541
|
+
--text-100: #1a1a2e;
|
|
542
|
+
--text-200: #4a4a6a;
|
|
543
|
+
--heading: #1a1a2e;
|
|
544
|
+
--body: #333355;
|
|
545
|
+
--body-secondary: #6b7280;
|
|
546
|
+
--text-300: #52525b;
|
|
547
|
+
--caption: #64748b;
|
|
548
|
+
|
|
549
|
+
/* Brand */
|
|
550
|
+
--brand: #2563eb;
|
|
551
|
+
--brand-hover: #1d4ed8;
|
|
552
|
+
--brand-subtle: #dbeafe;
|
|
553
|
+
--brand-fg: #ffffff;
|
|
554
|
+
|
|
555
|
+
/* Surfaces */
|
|
556
|
+
--surface: #ffffff;
|
|
557
|
+
--surface-border: #e5e7eb;
|
|
558
|
+
--border: #e5e7eb;
|
|
559
|
+
--card-bg: #f8fafc;
|
|
560
|
+
--card-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
561
|
+
--hero-bg: var(--bg-0);
|
|
562
|
+
--cta-bg: var(--bg-100);
|
|
563
|
+
--placeholder-img: #e2e8f0;
|
|
564
|
+
|
|
565
|
+
/* Footer */
|
|
566
|
+
--footer-bg: #1a1a2e;
|
|
567
|
+
--footer-text: #cbd5e1;
|
|
568
|
+
--footer-heading: #f1f5f9;
|
|
569
|
+
--footer-link: #94a3b8;
|
|
570
|
+
--footer-link-hover: #e2e8f0;
|
|
571
|
+
--footer-border: #2d2d4a;
|
|
572
|
+
|
|
573
|
+
/* Typography */
|
|
574
|
+
--font-body: system-ui, -apple-system, sans-serif;
|
|
575
|
+
--font-heading: system-ui, -apple-system, sans-serif;
|
|
576
|
+
|
|
577
|
+
/* Shapes */
|
|
578
|
+
--radius-btn: 6px;
|
|
579
|
+
--radius-card: 8px;
|
|
580
|
+
--radius-feature: 8px;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* Dark mode */
|
|
584
|
+
@media (prefers-color-scheme: dark) {
|
|
585
|
+
:root:not(.light) {
|
|
586
|
+
--bg-0: #0f172a; --bg-100: #1e293b; --bg-1: #0f172a; --section-bg: var(--bg-100);
|
|
587
|
+
--text-100: #f1f5f9; --text-200: #cbd5e1; --heading: #f1f5f9; --body: #cbd5e1; --body-secondary: #94a3b8; --text-300: #94a3b8; --caption: #94a3b8;
|
|
588
|
+
--brand: #3b82f6; --brand-hover: #60a5fa; --brand-subtle: #1e3a5f; --brand-fg: #ffffff;
|
|
589
|
+
--surface: #1e293b; --surface-border: #334155; --border: #334155; --card-bg: #1e293b; --card-shadow: 0 1px 3px rgba(0,0,0,0.3); --hero-bg: var(--bg-0); --cta-bg: var(--bg-100); --placeholder-img: #334155;
|
|
590
|
+
--footer-bg: #020617; --footer-text: #94a3b8; --footer-heading: #e2e8f0; --footer-link: #64748b; --footer-link-hover: #cbd5e1; --footer-border: #1e293b;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
.dark {
|
|
594
|
+
--bg-0: #0f172a; --bg-100: #1e293b; --bg-1: #0f172a; --section-bg: var(--bg-100);
|
|
595
|
+
--text-100: #f1f5f9; --text-200: #cbd5e1; --heading: #f1f5f9; --body: #cbd5e1; --body-secondary: #94a3b8; --text-300: #94a3b8; --caption: #94a3b8;
|
|
596
|
+
--brand: #3b82f6; --brand-hover: #60a5fa; --brand-subtle: #1e3a5f; --brand-fg: #ffffff;
|
|
597
|
+
--surface: #1e293b; --surface-border: #334155; --border: #334155; --card-bg: #1e293b; --card-shadow: 0 1px 3px rgba(0,0,0,0.3); --hero-bg: var(--bg-0); --cta-bg: var(--bg-100); --placeholder-img: #334155;
|
|
598
|
+
--footer-bg: #020617; --footer-text: #94a3b8; --footer-heading: #e2e8f0; --footer-link: #64748b; --footer-link-hover: #cbd5e1; --footer-border: #1e293b;
|
|
599
|
+
}
|
|
600
|
+
`;
|
|
601
|
+
}
|
|
602
|
+
export function defaultsTs(siteId, siteName) {
|
|
603
|
+
return `export const DEFAULT_SITE_ID = process.env.NEXT_PUBLIC_DEFAULT_SITE_ID?.trim() || ${JSON.stringify(siteId)}
|
|
604
|
+
export const DEFAULT_SESSION = process.env.DRAFT_DEFAULT_SESSION?.trim() || "dev"
|
|
605
|
+
export const DEFAULT_SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME?.trim() || ${JSON.stringify(siteName)}
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
export function editorApiRoute() {
|
|
609
|
+
return `import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
|
|
610
|
+
import { createJsonFilePublishHandler } from "@avocadostudio-ai/site-sdk/publish-handlers/json-file"
|
|
611
|
+
import { resolve } from "node:path"
|
|
612
|
+
import { readFile } from "node:fs/promises"
|
|
613
|
+
import type { PageDoc } from "@avocadostudio-ai/shared"
|
|
614
|
+
|
|
615
|
+
import "../../../../blocks/register"
|
|
616
|
+
|
|
617
|
+
const PAGES_PATH = resolve(process.cwd(), "content/pages.json")
|
|
618
|
+
|
|
619
|
+
async function loadPages(): Promise<PageDoc[]> {
|
|
620
|
+
try {
|
|
621
|
+
return JSON.parse(await readFile(PAGES_PATH, "utf-8")) as PageDoc[]
|
|
622
|
+
} catch { return [] }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export const { GET, POST, OPTIONS } = createEditorApiHandler({
|
|
626
|
+
getPages: loadPages,
|
|
627
|
+
publishSecret: process.env.PUBLISH_TOKEN?.trim() || undefined,
|
|
628
|
+
onPublish: createJsonFilePublishHandler(PAGES_PATH),
|
|
629
|
+
})
|
|
630
|
+
`;
|
|
631
|
+
}
|
|
632
|
+
export function pageTsx(siteId, options) {
|
|
633
|
+
const chromeOpt = options?.chrome === false ? "\n chrome: false," : "";
|
|
634
|
+
return `import { createSitePage } from "@avocadostudio-ai/site-sdk/page"
|
|
635
|
+
import { resolve } from "node:path"
|
|
636
|
+
import { readFile } from "node:fs/promises"
|
|
637
|
+
import { readFileSync, existsSync } from "node:fs"
|
|
638
|
+
import type { PageDoc } from "@avocadostudio-ai/shared"
|
|
639
|
+
|
|
640
|
+
import "../../blocks/register"
|
|
641
|
+
|
|
642
|
+
const PAGES_PATH = resolve(process.cwd(), "content/pages.json")
|
|
643
|
+
const CONFIG_PATH = resolve(process.cwd(), "content/site-config.json")
|
|
644
|
+
|
|
645
|
+
async function loadPages(): Promise<PageDoc[]> {
|
|
646
|
+
try {
|
|
647
|
+
const raw = await readFile(PAGES_PATH, "utf-8")
|
|
648
|
+
return JSON.parse(raw) as PageDoc[]
|
|
649
|
+
} catch { return [] }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function loadSiteConfig() {
|
|
653
|
+
try {
|
|
654
|
+
return JSON.parse(await readFile(CONFIG_PATH, "utf-8"))
|
|
655
|
+
} catch { return {} }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const _siteConfig = existsSync(CONFIG_PATH)
|
|
659
|
+
? (() => { try { return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) } catch { return {} } })()
|
|
660
|
+
: {}
|
|
661
|
+
|
|
662
|
+
const { Page, generateStaticParams } = createSitePage({
|
|
663
|
+
siteId: ${JSON.stringify(siteId)},
|
|
664
|
+
getPage: async (slug) => {
|
|
665
|
+
const pages = await loadPages()
|
|
666
|
+
return pages.find((p) => p.slug === slug) ?? null
|
|
667
|
+
},
|
|
668
|
+
getSlugs: async () => {
|
|
669
|
+
const pages = await loadPages()
|
|
670
|
+
return pages.map((p) => p.slug)
|
|
671
|
+
},
|
|
672
|
+
getSiteConfig: loadSiteConfig,
|
|
673
|
+
footer: _siteConfig.footer ?? undefined,${chromeOpt}
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
export default Page
|
|
677
|
+
export { generateStaticParams }
|
|
678
|
+
`;
|
|
679
|
+
}
|
|
680
|
+
export function hybridPageTsx(siteId) {
|
|
681
|
+
return `import { resolve } from "node:path"
|
|
682
|
+
import { readFile } from "node:fs/promises"
|
|
683
|
+
import type { Metadata } from "next"
|
|
684
|
+
import type { PageDoc } from "@avocadostudio-ai/shared"
|
|
685
|
+
|
|
686
|
+
import EditorPage, { generateStaticParams as editorStaticParams } from "./_editor-page"
|
|
687
|
+
import OriginalPage from "./_original-page"
|
|
688
|
+
|
|
689
|
+
// Re-export original generateStaticParams if available, or provide fallback
|
|
690
|
+
const origStaticParams = (OriginalPage as unknown as { generateStaticParams?: () => Promise<Array<{ slug?: string[] }>> }).generateStaticParams
|
|
691
|
+
|
|
692
|
+
const PAGES_PATH = resolve(process.cwd(), "content/pages.json")
|
|
693
|
+
|
|
694
|
+
let _editorSlugsPromise: Promise<Set<string>> | null = null
|
|
695
|
+
function loadEditorSlugs(): Promise<Set<string>> {
|
|
696
|
+
if (!_editorSlugsPromise) {
|
|
697
|
+
_editorSlugsPromise = (async () => {
|
|
698
|
+
try {
|
|
699
|
+
const raw = await readFile(PAGES_PATH, "utf-8")
|
|
700
|
+
const pages = JSON.parse(raw) as PageDoc[]
|
|
701
|
+
return new Set(pages.map((p) => p.slug))
|
|
702
|
+
} catch { return new Set() }
|
|
703
|
+
})()
|
|
704
|
+
}
|
|
705
|
+
return _editorSlugsPromise
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function buildSlug(parts?: string[]): string {
|
|
709
|
+
if (!parts || parts.length === 0) return "/"
|
|
710
|
+
return "/" + parts.join("/")
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function isEditorManaged(slug: string): Promise<boolean> {
|
|
714
|
+
// Check if this slug exists in editor content (pages.json)
|
|
715
|
+
const editorSlugs = await loadEditorSlugs()
|
|
716
|
+
if (editorSlugs.has(slug)) return true
|
|
717
|
+
|
|
718
|
+
// In dev mode, also check orchestrator for draft pages
|
|
719
|
+
if (process.env.NODE_ENV !== "production") {
|
|
720
|
+
const orchestratorUrl = process.env.ORCHESTRATOR_URL ?? "http://localhost:4200"
|
|
721
|
+
const siteId = process.env.NEXT_PUBLIC_DEFAULT_SITE_ID ?? ${JSON.stringify(siteId)}
|
|
722
|
+
try {
|
|
723
|
+
const res = await fetch(
|
|
724
|
+
orchestratorUrl + "/draft/pages?session=" + encodeURIComponent(siteId + "::dev") + "&siteId=" + encodeURIComponent(siteId) + "&slug=" + encodeURIComponent(slug),
|
|
725
|
+
{ cache: "no-store", signal: AbortSignal.timeout(1000) }
|
|
726
|
+
)
|
|
727
|
+
if (res.ok) return true
|
|
728
|
+
} catch { /* orchestrator not running or slug not found */ }
|
|
729
|
+
}
|
|
730
|
+
return false
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
type PageProps = { params: Promise<{ slug?: string[] }>; searchParams: Promise<Record<string, string | string[] | undefined>> }
|
|
734
|
+
|
|
735
|
+
export default async function HybridPage(props: PageProps) {
|
|
736
|
+
const slug = buildSlug((await props.params).slug)
|
|
737
|
+
const sp = await props.searchParams
|
|
738
|
+
|
|
739
|
+
// When accessed from editor iframe, always use editor rendering
|
|
740
|
+
// so blocks are selectable and editable. The editor passes these
|
|
741
|
+
// params via the draft enable URL / postMessage bridge.
|
|
742
|
+
const isEditorAccess = sp.__editor === "1" || !!sp.editorOrigin || !!sp.session
|
|
743
|
+
if (isEditorAccess || await isEditorManaged(slug)) {
|
|
744
|
+
return <EditorPage {...props} />
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Standalone/production: use original site rendering
|
|
748
|
+
return <OriginalPage {...props} />
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export async function generateStaticParams() {
|
|
752
|
+
const editorParams = editorStaticParams ? await editorStaticParams() : []
|
|
753
|
+
const originalParams = origStaticParams ? await origStaticParams() : []
|
|
754
|
+
// Merge, deduplicating by slug
|
|
755
|
+
const seen = new Set<string>()
|
|
756
|
+
const merged = []
|
|
757
|
+
for (const p of [...editorParams, ...originalParams]) {
|
|
758
|
+
const key = (p.slug ?? []).join("/")
|
|
759
|
+
if (!seen.has(key)) { seen.add(key); merged.push(p) }
|
|
760
|
+
}
|
|
761
|
+
return merged
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
|
765
|
+
const slug = buildSlug((await props.params).slug)
|
|
766
|
+
if (await isEditorManaged(slug)) {
|
|
767
|
+
// Editor pages get metadata from createSitePage
|
|
768
|
+
const editorMeta = (EditorPage as unknown as { generateMetadata?: (props: PageProps) => Promise<Metadata> }).generateMetadata
|
|
769
|
+
if (editorMeta) return editorMeta(props)
|
|
770
|
+
}
|
|
771
|
+
// Fall through to original page metadata
|
|
772
|
+
const origMeta = (OriginalPage as unknown as { generateMetadata?: (props: PageProps) => Promise<Metadata> }).generateMetadata
|
|
773
|
+
if (origMeta) return origMeta(props)
|
|
774
|
+
return {}
|
|
775
|
+
}
|
|
776
|
+
`;
|
|
777
|
+
}
|
|
778
|
+
export function blocksRegisterTsx() {
|
|
779
|
+
return `// Custom block renderers — register site-specific components here.
|
|
780
|
+
// Use JSX syntax for adapters: <Comp block={...} /> (NOT function calls).
|
|
781
|
+
import { registerCustomRenderer } from "@avocadostudio-ai/blocks"
|
|
782
|
+
`;
|
|
783
|
+
}
|
|
784
|
+
export function samplePagesJson() {
|
|
785
|
+
return `[\n {\n "id": "home",\n "slug": "/",\n "title": "Home",\n "blocks": [],\n "updatedAt": "${new Date().toISOString()}"\n }\n]\n`;
|
|
786
|
+
}
|
|
787
|
+
export function defaultLogoSvg(siteName) {
|
|
788
|
+
const initials = siteInitials(siteName);
|
|
789
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 38 38" width="38" height="38">
|
|
790
|
+
<rect width="38" height="38" rx="8" fill="var(--brand, #2563eb)"/>
|
|
791
|
+
<text x="19" y="20" text-anchor="middle" dominant-baseline="central" font-family="system-ui, sans-serif" font-weight="700" font-size="16" fill="var(--brand-fg, #fff)">${initials}</text>
|
|
792
|
+
</svg>`;
|
|
793
|
+
}
|
|
794
|
+
export function faviconSvg(siteName) {
|
|
795
|
+
const initials = siteInitials(siteName);
|
|
796
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
|
797
|
+
<rect width="32" height="32" rx="6" fill="#2563eb"/>
|
|
798
|
+
<text x="16" y="17" text-anchor="middle" dominant-baseline="central" font-family="system-ui, sans-serif" font-weight="700" font-size="14" fill="#fff">${initials}</text>
|
|
799
|
+
</svg>`;
|
|
800
|
+
}
|
|
801
|
+
function siteInitials(siteName) {
|
|
802
|
+
return siteName.split(/\s+/).map(w => w[0]?.toUpperCase() ?? "").join("").slice(0, 2) || "S";
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Scaffold a complete Next.js site project. Used by both SDK and CLI create_site tools.
|
|
806
|
+
* Returns the site config and port. Does NOT start the dev server (caller handles that).
|
|
807
|
+
*/
|
|
808
|
+
export async function scaffoldSiteProject(options) {
|
|
809
|
+
const { siteId, name, purpose, tone } = options;
|
|
810
|
+
const root = monorepoRoot();
|
|
811
|
+
const projectDir = join(root, "apps", siteId);
|
|
812
|
+
if (existsSync(projectDir)) {
|
|
813
|
+
throw new Error(`Directory apps/${siteId} already exists`);
|
|
814
|
+
}
|
|
815
|
+
const port = options.port || await findAvailablePort(root);
|
|
816
|
+
await mkdir(projectDir, { recursive: true });
|
|
817
|
+
await writeFile(join(projectDir, "package.json"), packageJson(siteId, name, port), "utf-8");
|
|
818
|
+
await writeFile(join(projectDir, "next.config.ts"), nextConfigTs(), "utf-8");
|
|
819
|
+
await writeFile(join(projectDir, "tsconfig.json"), tsconfigJson(), "utf-8");
|
|
820
|
+
await writeFile(join(projectDir, "postcss.config.mjs"), postcssConfig(), "utf-8");
|
|
821
|
+
await mkdir(join(projectDir, "app"), { recursive: true });
|
|
822
|
+
await writeFile(join(projectDir, "app/layout.tsx"), layoutTsx(name), "utf-8");
|
|
823
|
+
await writeFile(join(projectDir, "app/globals.css"), globalsCss(), "utf-8");
|
|
824
|
+
await mkdir(join(projectDir, "app/api/editor/[...path]"), { recursive: true });
|
|
825
|
+
await writeFile(join(projectDir, "app/api/editor/[...path]/route.ts"), editorApiRoute(), "utf-8");
|
|
826
|
+
await mkdir(join(projectDir, "app/[[...slug]]"), { recursive: true });
|
|
827
|
+
await writeFile(join(projectDir, "app/[[...slug]]/page.tsx"), pageTsx(siteId), "utf-8");
|
|
828
|
+
await mkdir(join(projectDir, "content"), { recursive: true });
|
|
829
|
+
await writeFile(join(projectDir, "content/pages.json"), samplePagesJson(), "utf-8");
|
|
830
|
+
await mkdir(join(projectDir, "lib"), { recursive: true });
|
|
831
|
+
await writeFile(join(projectDir, "lib/defaults.ts"), defaultsTs(siteId, name), "utf-8");
|
|
832
|
+
await mkdir(join(projectDir, "public"), { recursive: true });
|
|
833
|
+
await writeFile(join(projectDir, "public/.gitkeep"), "", "utf-8");
|
|
834
|
+
await mkdir(join(projectDir, "blocks"), { recursive: true });
|
|
835
|
+
await writeFile(join(projectDir, "blocks/register.tsx"), blocksRegisterTsx(), "utf-8");
|
|
836
|
+
await writeFile(join(projectDir, "public/logo.svg"), defaultLogoSvg(name), "utf-8");
|
|
837
|
+
await writeFile(join(projectDir, "public/favicon.svg"), faviconSvg(name), "utf-8");
|
|
838
|
+
const envContent = `ORCHESTRATOR_URL=http://localhost:4200\nDRAFT_MODE_SECRET=${getDraftModeSecret()}\nNEXT_PUBLIC_DEFAULT_SITE_ID=${siteId}\nNEXT_PUBLIC_SITE_NAME=${name}\nNEXT_PUBLIC_EDITOR_ORIGIN=http://localhost:4100\n`;
|
|
839
|
+
await writeFile(join(projectDir, ".env.local"), envContent, "utf-8");
|
|
840
|
+
// Run pnpm install
|
|
841
|
+
if (!existsSync(join(projectDir, "node_modules"))) {
|
|
842
|
+
const { execFile } = await import("node:child_process");
|
|
843
|
+
const { promisify } = await import("node:util");
|
|
844
|
+
await promisify(execFile)("pnpm", ["install", "--no-frozen-lockfile"], { cwd: root, timeout: 60_000 });
|
|
845
|
+
}
|
|
846
|
+
return { siteId, port, projectDir };
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Analyze an existing site project to determine its structure, framework,
|
|
850
|
+
* CMS, and what's needed to integrate with the AI Site Editor.
|
|
851
|
+
*/
|
|
852
|
+
export async function analyzeCodebase(projectPath) {
|
|
853
|
+
const result = {
|
|
854
|
+
framework: "other",
|
|
855
|
+
nextVersion: null,
|
|
856
|
+
packageManager: "npm",
|
|
857
|
+
hasTypeScript: false,
|
|
858
|
+
hasTailwind: false,
|
|
859
|
+
stylingApproach: "css",
|
|
860
|
+
existingRoutes: [],
|
|
861
|
+
existingCms: null,
|
|
862
|
+
hasEditorIntegration: false,
|
|
863
|
+
layoutPath: null,
|
|
864
|
+
globalCssPath: null,
|
|
865
|
+
isMonorepoChild: false,
|
|
866
|
+
dependencies: {},
|
|
867
|
+
};
|
|
868
|
+
// Read package.json
|
|
869
|
+
const pkgPath = join(projectPath, "package.json");
|
|
870
|
+
let pkg = {};
|
|
871
|
+
try {
|
|
872
|
+
pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
return result; // not a valid project
|
|
876
|
+
}
|
|
877
|
+
const allDeps = {
|
|
878
|
+
...(pkg.dependencies ?? {}),
|
|
879
|
+
...(pkg.devDependencies ?? {}),
|
|
880
|
+
};
|
|
881
|
+
result.dependencies = allDeps;
|
|
882
|
+
// Detect Next.js version
|
|
883
|
+
if (allDeps.next) {
|
|
884
|
+
result.nextVersion = allDeps.next;
|
|
885
|
+
// Detect app router vs pages router
|
|
886
|
+
if (existsSync(join(projectPath, "app"))) {
|
|
887
|
+
result.framework = "nextjs-app-router";
|
|
888
|
+
}
|
|
889
|
+
else if (existsSync(join(projectPath, "pages")) || existsSync(join(projectPath, "src/pages"))) {
|
|
890
|
+
result.framework = "nextjs-pages-router";
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
result.framework = "nextjs-app-router"; // assume app router for new Next.js
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
// Package manager detection
|
|
897
|
+
if (existsSync(join(projectPath, "pnpm-lock.yaml")))
|
|
898
|
+
result.packageManager = "pnpm";
|
|
899
|
+
else if (existsSync(join(projectPath, "yarn.lock")))
|
|
900
|
+
result.packageManager = "yarn";
|
|
901
|
+
else
|
|
902
|
+
result.packageManager = "npm";
|
|
903
|
+
// Check if inside monorepo
|
|
904
|
+
const parentPkgPath = resolve(projectPath, "../../package.json");
|
|
905
|
+
try {
|
|
906
|
+
const parentPkg = JSON.parse(await readFile(parentPkgPath, "utf-8"));
|
|
907
|
+
if (parentPkg.workspaces || existsSync(resolve(projectPath, "../../pnpm-workspace.yaml"))) {
|
|
908
|
+
result.isMonorepoChild = true;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
catch { /* standalone project */ }
|
|
912
|
+
// TypeScript
|
|
913
|
+
result.hasTypeScript = existsSync(join(projectPath, "tsconfig.json"));
|
|
914
|
+
// Styling
|
|
915
|
+
if (allDeps.tailwindcss || existsSync(join(projectPath, "tailwind.config.js")) || existsSync(join(projectPath, "tailwind.config.ts"))) {
|
|
916
|
+
result.hasTailwind = true;
|
|
917
|
+
result.stylingApproach = "tailwind";
|
|
918
|
+
}
|
|
919
|
+
else if (allDeps["styled-components"]) {
|
|
920
|
+
result.stylingApproach = "styled-components";
|
|
921
|
+
}
|
|
922
|
+
else if (allDeps["@emotion/react"] || allDeps["@emotion/styled"]) {
|
|
923
|
+
result.stylingApproach = "emotion";
|
|
924
|
+
}
|
|
925
|
+
// CMS detection
|
|
926
|
+
if (allDeps.contentful || allDeps["@contentful/rich-text-react-renderer"])
|
|
927
|
+
result.existingCms = "contentful";
|
|
928
|
+
else if (allDeps["@sanity/client"] || allDeps["next-sanity"])
|
|
929
|
+
result.existingCms = "sanity";
|
|
930
|
+
else if (allDeps.strapi || allDeps["@strapi/strapi"])
|
|
931
|
+
result.existingCms = "strapi";
|
|
932
|
+
else if (allDeps["@wordpress/api-fetch"] || allDeps.wpapi)
|
|
933
|
+
result.existingCms = "wordpress";
|
|
934
|
+
else if (allDeps["@prismic/client"] || allDeps["@prismicio/react"])
|
|
935
|
+
result.existingCms = "prismic";
|
|
936
|
+
// Editor integration already present?
|
|
937
|
+
result.hasEditorIntegration = !!allDeps["@avocadostudio-ai/site-sdk"];
|
|
938
|
+
// Find layout and CSS files
|
|
939
|
+
for (const candidate of ["app/layout.tsx", "app/layout.jsx", "app/layout.js", "src/app/layout.tsx"]) {
|
|
940
|
+
if (existsSync(join(projectPath, candidate))) {
|
|
941
|
+
result.layoutPath = candidate;
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
for (const candidate of ["app/globals.css", "app/global.css", "styles/globals.css", "src/app/globals.css"]) {
|
|
946
|
+
if (existsSync(join(projectPath, candidate))) {
|
|
947
|
+
result.globalCssPath = candidate;
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// Discover existing routes (app router)
|
|
952
|
+
if (result.framework === "nextjs-app-router") {
|
|
953
|
+
const appDir = existsSync(join(projectPath, "src/app")) ? join(projectPath, "src/app") : join(projectPath, "app");
|
|
954
|
+
result.existingRoutes = await discoverAppRoutes(appDir, appDir);
|
|
955
|
+
}
|
|
956
|
+
return result;
|
|
957
|
+
}
|
|
958
|
+
/** Recursively discover routes in a Next.js app directory. */
|
|
959
|
+
async function discoverAppRoutes(baseDir, dir) {
|
|
960
|
+
const routes = [];
|
|
961
|
+
try {
|
|
962
|
+
const entries = await readdir(dir);
|
|
963
|
+
for (const entry of entries) {
|
|
964
|
+
if (entry.startsWith(".") || entry === "node_modules" || entry === "api")
|
|
965
|
+
continue;
|
|
966
|
+
const entryPath = join(dir, entry);
|
|
967
|
+
const s = await stat(entryPath).catch(() => null);
|
|
968
|
+
if (!s?.isDirectory()) {
|
|
969
|
+
// Check if this is a page file
|
|
970
|
+
if (/^page\.(tsx?|jsx?)$/.test(entry)) {
|
|
971
|
+
const rel = dir.slice(baseDir.length) || "/";
|
|
972
|
+
const slug = rel
|
|
973
|
+
.replace(/\\/g, "/")
|
|
974
|
+
.replace(/\/\([^)]+\)/g, ""); // strip route groups like (marketing)
|
|
975
|
+
routes.push(slug || "/");
|
|
976
|
+
}
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
routes.push(...await discoverAppRoutes(baseDir, entryPath));
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
catch { /* dir doesn't exist */ }
|
|
983
|
+
return routes;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Clone a GitHub repo into apps/{dirName}, or pull if it already exists.
|
|
987
|
+
*/
|
|
988
|
+
export async function cloneRepo(url, targetDir) {
|
|
989
|
+
const { execFile } = await import("node:child_process");
|
|
990
|
+
const { promisify } = await import("node:util");
|
|
991
|
+
const exec = promisify(execFile);
|
|
992
|
+
const repoName = url.replace(/\.git$/, "").split("/").pop() ?? "cloned-site";
|
|
993
|
+
const dirName = targetDir ?? repoName;
|
|
994
|
+
const root = monorepoRoot();
|
|
995
|
+
const localPath = join(root, "apps", dirName);
|
|
996
|
+
if (existsSync(localPath)) {
|
|
997
|
+
try {
|
|
998
|
+
const { stdout } = await exec("git", ["pull", "--ff-only"], { cwd: localPath, timeout: 30_000 });
|
|
999
|
+
const branch = (await exec("git", ["branch", "--show-current"], { cwd: localPath })).stdout.trim();
|
|
1000
|
+
return { status: "updated", localPath, branch, repoName: dirName, message: stdout.trim() };
|
|
1001
|
+
}
|
|
1002
|
+
catch {
|
|
1003
|
+
return { status: "exists", localPath, branch: "unknown", repoName: dirName, message: "Directory exists, pull failed — using as-is" };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// Try gh CLI first (handles auth for private repos), then git clone
|
|
1007
|
+
let cloneError = null;
|
|
1008
|
+
try {
|
|
1009
|
+
await exec("gh", ["repo", "clone", url, localPath], { timeout: 120_000 });
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
try {
|
|
1013
|
+
await exec("git", ["clone", "--depth", "1", url, localPath], { timeout: 120_000 });
|
|
1014
|
+
}
|
|
1015
|
+
catch (gitErr) {
|
|
1016
|
+
cloneError = gitErr instanceof Error ? gitErr.message : String(gitErr);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
if (cloneError)
|
|
1020
|
+
throw new Error(`Failed to clone: ${cloneError}`);
|
|
1021
|
+
const branch = (await exec("git", ["branch", "--show-current"], { cwd: localPath }).catch(() => ({ stdout: "unknown" }))).stdout.trim();
|
|
1022
|
+
const commitCount = Number((await exec("git", ["rev-list", "--count", "HEAD"], { cwd: localPath }).catch(() => ({ stdout: "0" }))).stdout.trim());
|
|
1023
|
+
return { status: "cloned", localPath, branch, commitCount, repoName: dirName };
|
|
1024
|
+
}
|
|
1025
|
+
// ── Site registration helpers ──
|
|
1026
|
+
/**
|
|
1027
|
+
* Detect the dev server port from an existing site's package.json.
|
|
1028
|
+
*/
|
|
1029
|
+
export async function detectSitePort(projectDir) {
|
|
1030
|
+
try {
|
|
1031
|
+
const pkg = JSON.parse(await readFile(join(projectDir, "package.json"), "utf-8"));
|
|
1032
|
+
const portMatch = pkg.scripts?.dev?.match(/-p\s*(\d+)/);
|
|
1033
|
+
if (portMatch)
|
|
1034
|
+
return Number(portMatch[1]);
|
|
1035
|
+
}
|
|
1036
|
+
catch { /* no package.json */ }
|
|
1037
|
+
// Allocate a new port
|
|
1038
|
+
return findAvailablePort(monorepoRoot());
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Start a Next.js dev server and wait for it to report "Ready" (or timeout).
|
|
1042
|
+
* Replaces fire-and-forget spawn patterns that reported success before the server was up.
|
|
1043
|
+
*/
|
|
1044
|
+
export async function startAndWaitForDevServer(opts) {
|
|
1045
|
+
const { spawn } = await import("node:child_process");
|
|
1046
|
+
const args = opts.useFilter
|
|
1047
|
+
? ["--filter", `@ai-site-editor/${opts.siteId}`, "dev"]
|
|
1048
|
+
: ["dev"];
|
|
1049
|
+
// Kill any existing process on the port first
|
|
1050
|
+
try {
|
|
1051
|
+
const { execSync } = await import("node:child_process");
|
|
1052
|
+
const pids = execSync(`lsof -ti:${opts.port} 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
1053
|
+
if (pids) {
|
|
1054
|
+
for (const pid of pids.split("\n")) {
|
|
1055
|
+
try {
|
|
1056
|
+
process.kill(Number(pid), "SIGKILL");
|
|
1057
|
+
}
|
|
1058
|
+
catch { /* already dead */ }
|
|
1059
|
+
}
|
|
1060
|
+
console.log(`[sites-agent] Killed existing process(es) on port ${opts.port}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
catch { /* no process on port — fine */ }
|
|
1064
|
+
console.log(`[sites-agent] Starting dev server for ${opts.siteId} on port ${opts.port}...`);
|
|
1065
|
+
const devProcess = spawn("pnpm", args, {
|
|
1066
|
+
cwd: opts.cwd,
|
|
1067
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1068
|
+
detached: true,
|
|
1069
|
+
env: {
|
|
1070
|
+
...process.env,
|
|
1071
|
+
// Reduce file-watcher FD usage: poll instead of native FSEvents.
|
|
1072
|
+
// Without this, each Next.js dev server opens thousands of FDs for
|
|
1073
|
+
// node_modules watchers, quickly hitting EMFILE when multiple sites run.
|
|
1074
|
+
WATCHPACK_POLLING: "true",
|
|
1075
|
+
WATCHPACK_POLLING_INTERVAL: "1000",
|
|
1076
|
+
CHOKIDAR_USEPOLLING: "true",
|
|
1077
|
+
CHOKIDAR_INTERVAL: "1000",
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
1080
|
+
devProcess.unref();
|
|
1081
|
+
let serverReady = false;
|
|
1082
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
1083
|
+
await new Promise((resolve) => {
|
|
1084
|
+
const timer = setTimeout(() => resolve(), timeoutMs);
|
|
1085
|
+
const onData = (chunk) => {
|
|
1086
|
+
const text = chunk.toString();
|
|
1087
|
+
if (text.includes("Ready") || text.includes(`localhost:${opts.port}`)) {
|
|
1088
|
+
serverReady = true;
|
|
1089
|
+
clearTimeout(timer);
|
|
1090
|
+
devProcess.stdout?.removeAllListeners();
|
|
1091
|
+
devProcess.stderr?.removeAllListeners();
|
|
1092
|
+
resolve();
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
devProcess.stdout?.on("data", onData);
|
|
1096
|
+
devProcess.stderr?.on("data", onData);
|
|
1097
|
+
devProcess.on("exit", () => { clearTimeout(timer); resolve(); });
|
|
1098
|
+
});
|
|
1099
|
+
console.log(`[sites-agent] Dev server ${serverReady ? "ready" : "timed out"} for ${opts.siteId} at http://localhost:${opts.port}`);
|
|
1100
|
+
return { serverReady };
|
|
1101
|
+
}
|