@inneranimalmedia/agentsam-sdk 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/scaffold.mjs +17 -0
- package/package.json +11 -4
- package/src/cli.js +15 -0
- package/src/commands/http-server.js +97 -0
- package/src/commands/tunnel.js +232 -0
- package/src/lib/scaffold/index.js +61 -0
- package/src/lib/scaffold/templates/cms/index.js +472 -0
- package/src/lib/scaffold/templates/worker-api/index.js +204 -0
- package/src/lib/scaffold/wizards/cms.js +175 -0
- package/src/lib/scaffold/wizards/worker-api.js +76 -0
- package/src/lib/scaffold/writer.js +22 -0
- package/src/ui/splash-xterm.js +290 -0
- package/src/ui/splash.js +426 -0
- package/src/ui/terminal-lanes.css +162 -0
- package/src/ui/theme.js +315 -0
- package/test/smoke.mjs +9 -2
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS template generator.
|
|
3
|
+
* Returns a flat file-tree object keyed by relative path → file content string.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function cmsTemplates(config) {
|
|
7
|
+
const { projectName, siteTitle, navStyle, pages, templateStyle, contactEmail, cfAccountId } = config;
|
|
8
|
+
|
|
9
|
+
const files = {};
|
|
10
|
+
|
|
11
|
+
// ── package.json ──────────────────────────────────────────────────────────
|
|
12
|
+
files['package.json'] = JSON.stringify({
|
|
13
|
+
name: projectName,
|
|
14
|
+
version: '0.1.0',
|
|
15
|
+
private: true,
|
|
16
|
+
scripts: {
|
|
17
|
+
deploy: 'wrangler deploy',
|
|
18
|
+
dev: 'wrangler dev',
|
|
19
|
+
'db:migrate': `wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote`,
|
|
20
|
+
},
|
|
21
|
+
devDependencies: {
|
|
22
|
+
wrangler: '^3.0.0',
|
|
23
|
+
},
|
|
24
|
+
}, null, 2);
|
|
25
|
+
|
|
26
|
+
// ── wrangler.toml ─────────────────────────────────────────────────────────
|
|
27
|
+
files['wrangler.toml'] = `name = "${projectName}"
|
|
28
|
+
main = "src/index.js"
|
|
29
|
+
compatibility_date = "2024-01-01"
|
|
30
|
+
account_id = "${cfAccountId}"
|
|
31
|
+
|
|
32
|
+
[[d1_databases]]
|
|
33
|
+
binding = "DB"
|
|
34
|
+
database_name = "${projectName}"
|
|
35
|
+
database_id = "REPLACE_WITH_YOUR_D1_ID"
|
|
36
|
+
|
|
37
|
+
[[r2_buckets]]
|
|
38
|
+
binding = "ASSETS"
|
|
39
|
+
bucket_name = "${projectName}"
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
// ── D1 migration ──────────────────────────────────────────────────────────
|
|
43
|
+
let migrationSql = `-- ${projectName} initial schema\n\n`;
|
|
44
|
+
|
|
45
|
+
migrationSql += `CREATE TABLE IF NOT EXISTS cms_pages (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
slug TEXT NOT NULL UNIQUE,
|
|
48
|
+
title TEXT NOT NULL,
|
|
49
|
+
template TEXT NOT NULL DEFAULT 'default',
|
|
50
|
+
content_json TEXT,
|
|
51
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
52
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
53
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
54
|
+
);\n\n`;
|
|
55
|
+
|
|
56
|
+
migrationSql += `CREATE TABLE IF NOT EXISTS cms_nav_items (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
label TEXT NOT NULL,
|
|
59
|
+
href TEXT NOT NULL,
|
|
60
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
parent_id TEXT,
|
|
62
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
63
|
+
);\n\n`;
|
|
64
|
+
|
|
65
|
+
if (pages.includes('blog')) {
|
|
66
|
+
migrationSql += `CREATE TABLE IF NOT EXISTS cms_posts (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
slug TEXT NOT NULL UNIQUE,
|
|
69
|
+
title TEXT NOT NULL,
|
|
70
|
+
body TEXT,
|
|
71
|
+
published_at INTEGER,
|
|
72
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
73
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
74
|
+
);\n\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (contactEmail) {
|
|
78
|
+
migrationSql += `CREATE TABLE IF NOT EXISTS cms_contact_submissions (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
name TEXT,
|
|
81
|
+
email TEXT,
|
|
82
|
+
message TEXT,
|
|
83
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
84
|
+
);\n\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Seed nav items from selected pages
|
|
88
|
+
const navOrder = ['home', 'about', 'services', 'blog', 'contact'];
|
|
89
|
+
const navPages = navOrder.filter(p => pages.includes(p));
|
|
90
|
+
navPages.forEach((page, i) => {
|
|
91
|
+
const label = page.charAt(0).toUpperCase() + page.slice(1);
|
|
92
|
+
const href = page === 'home' ? '/' : `/${page}`;
|
|
93
|
+
migrationSql += `INSERT OR IGNORE INTO cms_nav_items (id, label, href, position) VALUES ('nav_${page}', '${label}', '${href}', ${i});\n`;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
files['migrations/001_init.sql'] = migrationSql;
|
|
97
|
+
|
|
98
|
+
// ── Worker entry point ────────────────────────────────────────────────────
|
|
99
|
+
files['src/index.js'] = workerEntry(config);
|
|
100
|
+
|
|
101
|
+
// ── Route handlers ────────────────────────────────────────────────────────
|
|
102
|
+
files['src/routes/pages.js'] = pagesRoute(config);
|
|
103
|
+
files['src/routes/nav.js'] = navRoute();
|
|
104
|
+
|
|
105
|
+
if (pages.includes('blog')) {
|
|
106
|
+
files['src/routes/blog.js'] = blogRoute();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (contactEmail) {
|
|
110
|
+
files['src/routes/contact.js'] = contactRoute(contactEmail);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── HTML shell template ───────────────────────────────────────────────────
|
|
114
|
+
files['src/templates/shell.js'] = htmlShell(config);
|
|
115
|
+
|
|
116
|
+
// ── Nav component ─────────────────────────────────────────────────────────
|
|
117
|
+
files['src/templates/nav.js'] = navComponent(navStyle, siteTitle);
|
|
118
|
+
|
|
119
|
+
// ── Page templates ────────────────────────────────────────────────────────
|
|
120
|
+
pages.forEach(page => {
|
|
121
|
+
files[`src/templates/pages/${page}.js`] = pageTemplate(page, siteTitle, templateStyle);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── README ────────────────────────────────────────────────────────────────
|
|
125
|
+
files['README.md'] = readme(config);
|
|
126
|
+
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Worker entry ─────────────────────────────────────────────────────────────
|
|
131
|
+
function workerEntry({ projectName, pages, contactEmail }) {
|
|
132
|
+
const imports = [`import { handlePages } from './routes/pages.js';`,
|
|
133
|
+
`import { handleNav } from './routes/nav.js';`];
|
|
134
|
+
|
|
135
|
+
if (pages.includes('blog')) imports.push(`import { handleBlog } from './routes/blog.js';`);
|
|
136
|
+
if (contactEmail) imports.push(`import { handleContact } from './routes/contact.js';`);
|
|
137
|
+
|
|
138
|
+
return `${imports.join('\n')}
|
|
139
|
+
|
|
140
|
+
export default {
|
|
141
|
+
async fetch(request, env, ctx) {
|
|
142
|
+
const url = new URL(request.url);
|
|
143
|
+
const { pathname } = url;
|
|
144
|
+
|
|
145
|
+
// CORS preflight
|
|
146
|
+
if (request.method === 'OPTIONS') {
|
|
147
|
+
return new Response(null, {
|
|
148
|
+
headers: {
|
|
149
|
+
'Access-Control-Allow-Origin': '*',
|
|
150
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
151
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// API routes
|
|
157
|
+
if (pathname.startsWith('/api/nav')) return handleNav(request, env);
|
|
158
|
+
${pages.includes('blog') ? ` if (pathname.startsWith('/api/blog')) return handleBlog(request, env);\n` : ''}${contactEmail ? ` if (pathname === '/api/contact' && request.method === 'POST') return handleContact(request, env);\n` : ''}
|
|
159
|
+
// Page routes — catch-all
|
|
160
|
+
return handlePages(request, env);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Route: pages ─────────────────────────────────────────────────────────────
|
|
167
|
+
function pagesRoute({ pages, templateStyle, siteTitle }) {
|
|
168
|
+
return `import { renderShell } from '../templates/shell.js';
|
|
169
|
+
import { renderNav } from '../templates/nav.js';
|
|
170
|
+
${pages.map(p => `import { render${cap(p)}Page } from '../templates/pages/${p}.js';`).join('\n')}
|
|
171
|
+
|
|
172
|
+
const PAGE_MAP = {
|
|
173
|
+
${pages.map(p => ` '${p === 'home' ? '/' : `/${p}`}': render${cap(p)}Page,`).join('\n')}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export async function handlePages(request, env) {
|
|
177
|
+
const url = new URL(request.url);
|
|
178
|
+
const slug = url.pathname.replace(/\\/$/, '') || '/';
|
|
179
|
+
|
|
180
|
+
const renderPage = PAGE_MAP[slug];
|
|
181
|
+
if (!renderPage) {
|
|
182
|
+
return new Response(renderShell('404', renderNav([]), '<h1>Page not found</h1>'), {
|
|
183
|
+
status: 404,
|
|
184
|
+
headers: { 'Content-Type': 'text/html;charset=UTF-8' },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Load nav from D1
|
|
189
|
+
const navRows = await env.DB.prepare(
|
|
190
|
+
'SELECT label, href FROM cms_nav_items ORDER BY position ASC'
|
|
191
|
+
).all();
|
|
192
|
+
const nav = renderNav(navRows.results ?? []);
|
|
193
|
+
|
|
194
|
+
${templateStyle === 'fragment'
|
|
195
|
+
? ` // Load page fragment from R2
|
|
196
|
+
const fragmentKey = \`pages/\${slug === '/' ? 'home' : slug.slice(1)}/content.html\`;
|
|
197
|
+
const fragment = await env.ASSETS.get(fragmentKey);
|
|
198
|
+
const content = fragment ? await fragment.text() : renderPage();`
|
|
199
|
+
: templateStyle === 'json'
|
|
200
|
+
? ` // Load page content from D1
|
|
201
|
+
const pageRow = await env.DB.prepare(
|
|
202
|
+
'SELECT content_json FROM cms_pages WHERE slug = ? AND status = \\'published\\''
|
|
203
|
+
).bind(slug === '/' ? 'home' : slug.slice(1)).first();
|
|
204
|
+
const content = renderPage(pageRow?.content_json ? JSON.parse(pageRow.content_json) : {});`
|
|
205
|
+
: ` const content = renderPage();`}
|
|
206
|
+
|
|
207
|
+
const html = renderShell('${siteTitle}', nav, content);
|
|
208
|
+
return new Response(html, {
|
|
209
|
+
headers: { 'Content-Type': 'text/html;charset=UTF-8' },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Route: nav ───────────────────────────────────────────────────────────────
|
|
216
|
+
function navRoute() {
|
|
217
|
+
return `export async function handleNav(request, env) {
|
|
218
|
+
const rows = await env.DB.prepare(
|
|
219
|
+
'SELECT id, label, href, position FROM cms_nav_items ORDER BY position ASC'
|
|
220
|
+
).all();
|
|
221
|
+
return Response.json(rows.results ?? []);
|
|
222
|
+
}
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Route: blog ──────────────────────────────────────────────────────────────
|
|
227
|
+
function blogRoute() {
|
|
228
|
+
return `import { renderShell } from '../templates/shell.js';
|
|
229
|
+
import { renderNav } from '../templates/nav.js';
|
|
230
|
+
|
|
231
|
+
export async function handleBlog(request, env) {
|
|
232
|
+
const url = new URL(request.url);
|
|
233
|
+
const slug = url.pathname.replace('/blog/', '').replace('/blog', '');
|
|
234
|
+
|
|
235
|
+
if (slug && slug !== '/') {
|
|
236
|
+
// Single post
|
|
237
|
+
const post = await env.DB.prepare(
|
|
238
|
+
'SELECT * FROM cms_posts WHERE slug = ? AND status = \\'published\\''
|
|
239
|
+
).bind(slug).first();
|
|
240
|
+
|
|
241
|
+
if (!post) return new Response('Post not found', { status: 404 });
|
|
242
|
+
|
|
243
|
+
const navRows = await env.DB.prepare('SELECT label, href FROM cms_nav_items ORDER BY position ASC').all();
|
|
244
|
+
const html = renderShell(post.title, renderNav(navRows.results ?? []), \`
|
|
245
|
+
<article>
|
|
246
|
+
<h1>\${post.title}</h1>
|
|
247
|
+
<div class="post-body">\${post.body ?? ''}</div>
|
|
248
|
+
</article>
|
|
249
|
+
\`);
|
|
250
|
+
return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Post list
|
|
254
|
+
const posts = await env.DB.prepare(
|
|
255
|
+
'SELECT slug, title, published_at FROM cms_posts WHERE status = \\'published\\' ORDER BY published_at DESC LIMIT 20'
|
|
256
|
+
).all();
|
|
257
|
+
|
|
258
|
+
const navRows = await env.DB.prepare('SELECT label, href FROM cms_nav_items ORDER BY position ASC').all();
|
|
259
|
+
const listHtml = (posts.results ?? []).map(p =>
|
|
260
|
+
\`<li><a href="/blog/\${p.slug}">\${p.title}</a></li>\`
|
|
261
|
+
).join('');
|
|
262
|
+
|
|
263
|
+
const html = renderShell('Blog', renderNav(navRows.results ?? []), \`
|
|
264
|
+
<h1>Blog</h1>
|
|
265
|
+
<ul class="post-list">\${listHtml}</ul>
|
|
266
|
+
\`);
|
|
267
|
+
return new Response(html, { headers: { 'Content-Type': 'text/html;charset=UTF-8' } });
|
|
268
|
+
}
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Route: contact ────────────────────────────────────────────────────────────
|
|
273
|
+
function contactRoute(email) {
|
|
274
|
+
return `export async function handleContact(request, env) {
|
|
275
|
+
let body;
|
|
276
|
+
try {
|
|
277
|
+
body = await request.json();
|
|
278
|
+
} catch {
|
|
279
|
+
return Response.json({ error: 'Invalid JSON' }, { status: 400 });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const { name, email: fromEmail, message } = body;
|
|
283
|
+
if (!name || !fromEmail || !message) {
|
|
284
|
+
return Response.json({ error: 'name, email, and message are required' }, { status: 400 });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Save to D1
|
|
288
|
+
const id = crypto.randomUUID();
|
|
289
|
+
await env.DB.prepare(
|
|
290
|
+
'INSERT INTO cms_contact_submissions (id, name, email, message) VALUES (?, ?, ?, ?)'
|
|
291
|
+
).bind(id, name, fromEmail, message).run();
|
|
292
|
+
|
|
293
|
+
// Send via Resend (requires RESEND_API_KEY secret)
|
|
294
|
+
if (env.RESEND_API_KEY) {
|
|
295
|
+
await fetch('https://api.resend.com/emails', {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: {
|
|
298
|
+
Authorization: \`Bearer \${env.RESEND_API_KEY}\`,
|
|
299
|
+
'Content-Type': 'application/json',
|
|
300
|
+
},
|
|
301
|
+
body: JSON.stringify({
|
|
302
|
+
from: 'noreply@yourdomain.com',
|
|
303
|
+
to: '${email}',
|
|
304
|
+
subject: \`New contact form submission from \${name}\`,
|
|
305
|
+
text: \`Name: \${name}\\nEmail: \${fromEmail}\\n\\n\${message}\`,
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return Response.json({ ok: true, id });
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Template: HTML shell ──────────────────────────────────────────────────────
|
|
316
|
+
function htmlShell({ siteTitle }) {
|
|
317
|
+
return `export function renderShell(pageTitle, navHtml, bodyHtml) {
|
|
318
|
+
return \`<!DOCTYPE html>
|
|
319
|
+
<html lang="en">
|
|
320
|
+
<head>
|
|
321
|
+
<meta charset="UTF-8" />
|
|
322
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
323
|
+
<title>\${pageTitle} — ${siteTitle}</title>
|
|
324
|
+
<style>
|
|
325
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
326
|
+
body { font-family: system-ui, sans-serif; color: #1a1a1a; background: #fff; }
|
|
327
|
+
a { color: inherit; }
|
|
328
|
+
main { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem; }
|
|
329
|
+
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
|
330
|
+
h2 { font-size: 1.4rem; margin-bottom: 0.75rem; }
|
|
331
|
+
p { line-height: 1.65; margin-bottom: 1rem; }
|
|
332
|
+
</style>
|
|
333
|
+
</head>
|
|
334
|
+
<body>
|
|
335
|
+
\${navHtml}
|
|
336
|
+
<main>\${bodyHtml}</main>
|
|
337
|
+
</body>
|
|
338
|
+
</html>\`;
|
|
339
|
+
}
|
|
340
|
+
`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Template: nav component ───────────────────────────────────────────────────
|
|
344
|
+
function navComponent(navStyle, siteTitle) {
|
|
345
|
+
const styles = {
|
|
346
|
+
topbar: `
|
|
347
|
+
nav { display: flex; align-items: center; gap: 2rem; background: #111; padding: 0 1.5rem; height: 56px; }
|
|
348
|
+
nav .site-title { color: #fff; font-weight: 700; font-size: 1.1rem; text-decoration: none; margin-right: auto; }
|
|
349
|
+
nav a { color: #ccc; text-decoration: none; font-size: 0.9rem; }
|
|
350
|
+
nav a:hover { color: #fff; }`,
|
|
351
|
+
sidebar: `
|
|
352
|
+
.nav-sidebar { position: fixed; top: 0; left: 0; width: 220px; height: 100vh; background: #111; padding: 1.5rem 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
353
|
+
.nav-sidebar .site-title { color: #fff; font-weight: 700; font-size: 1.1rem; text-decoration: none; margin-bottom: 1rem; }
|
|
354
|
+
.nav-sidebar a { color: #ccc; text-decoration: none; font-size: 0.9rem; padding: 0.4rem 0.6rem; border-radius: 4px; }
|
|
355
|
+
.nav-sidebar a:hover { background: #222; color: #fff; }
|
|
356
|
+
body { padding-left: 220px; }`,
|
|
357
|
+
minimal: `
|
|
358
|
+
nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid #eee; }
|
|
359
|
+
nav .site-title { font-weight: 700; font-size: 1.1rem; text-decoration: none; }
|
|
360
|
+
.nav-toggle { background: none; border: none; cursor: pointer; font-size: 1.2rem; }`,
|
|
361
|
+
}[navStyle];
|
|
362
|
+
|
|
363
|
+
const navClass = navStyle === 'sidebar' ? 'class="nav-sidebar"' : '';
|
|
364
|
+
|
|
365
|
+
return `export function renderNav(items) {
|
|
366
|
+
const links = items.map(i => \`<a href="\${i.href}">\${i.label}</a>\`).join('');
|
|
367
|
+
return \`<style>${styles}</style>
|
|
368
|
+
<nav ${navClass}>
|
|
369
|
+
<a class="site-title" href="/">${siteTitle}</a>
|
|
370
|
+
\${links}
|
|
371
|
+
</nav>\`;
|
|
372
|
+
}
|
|
373
|
+
`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Template: individual page templates ──────────────────────────────────────
|
|
377
|
+
function pageTemplate(page, siteTitle, templateStyle) {
|
|
378
|
+
const pageContent = {
|
|
379
|
+
home: `<section class="hero">
|
|
380
|
+
<h1>Welcome to ${siteTitle}</h1>
|
|
381
|
+
<p>Your tagline goes here.</p>
|
|
382
|
+
</section>`,
|
|
383
|
+
about: `<h1>About Us</h1>
|
|
384
|
+
<p>Tell your story here.</p>`,
|
|
385
|
+
services: `<h1>Services</h1>
|
|
386
|
+
<ul>
|
|
387
|
+
<li>Service one</li>
|
|
388
|
+
<li>Service two</li>
|
|
389
|
+
<li>Service three</li>
|
|
390
|
+
</ul>`,
|
|
391
|
+
contact: `<h1>Contact</h1>
|
|
392
|
+
<form id="contact-form">
|
|
393
|
+
<div><label>Name<br><input type="text" name="name" required /></label></div>
|
|
394
|
+
<div><label>Email<br><input type="email" name="email" required /></label></div>
|
|
395
|
+
<div><label>Message<br><textarea name="message" required></textarea></label></div>
|
|
396
|
+
<button type="submit">Send</button>
|
|
397
|
+
</form>
|
|
398
|
+
<script>
|
|
399
|
+
document.getElementById('contact-form').addEventListener('submit', async (e) => {
|
|
400
|
+
e.preventDefault();
|
|
401
|
+
const data = Object.fromEntries(new FormData(e.target));
|
|
402
|
+
const res = await fetch('/api/contact', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
|
403
|
+
if (res.ok) { e.target.reset(); alert('Message sent!'); }
|
|
404
|
+
});
|
|
405
|
+
</script>`,
|
|
406
|
+
blog: `<h1>Blog</h1><p>Loading posts...</p>`,
|
|
407
|
+
privacy: `<h1>Privacy Policy</h1>
|
|
408
|
+
<p>Last updated: ${new Date().toLocaleDateString()}</p>
|
|
409
|
+
<p>We do not sell your data. Replace this with your actual privacy policy.</p>`,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const body = pageContent[page] ?? `<h1>${cap(page)}</h1><p>Content coming soon.</p>`;
|
|
413
|
+
|
|
414
|
+
if (templateStyle === 'json') {
|
|
415
|
+
return `export function render${cap(page)}Page(data = {}) {
|
|
416
|
+
return \`${body}\`;
|
|
417
|
+
}
|
|
418
|
+
`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return `export function render${cap(page)}Page() {
|
|
422
|
+
return \`${body}\`;
|
|
423
|
+
}
|
|
424
|
+
`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── README ────────────────────────────────────────────────────────────────────
|
|
428
|
+
function readme({ projectName, siteTitle, pages, navStyle, templateStyle }) {
|
|
429
|
+
return `# ${siteTitle}
|
|
430
|
+
|
|
431
|
+
Scaffolded by [@inneranimalmedia/agentsam-sdk](https://github.com/SamPrimeaux/agentsam-sdk).
|
|
432
|
+
|
|
433
|
+
## Stack
|
|
434
|
+
|
|
435
|
+
- Cloudflare Worker (entry: \`src/index.js\`)
|
|
436
|
+
- D1 database (binding: \`DB\`)
|
|
437
|
+
- R2 bucket (binding: \`ASSETS\`)
|
|
438
|
+
- Template style: \`${templateStyle}\`
|
|
439
|
+
- Nav style: \`${navStyle}\`
|
|
440
|
+
|
|
441
|
+
## Pages
|
|
442
|
+
|
|
443
|
+
${pages.map(p => `- \`${p === 'home' ? '/' : `/${p}`}\` — ${cap(p)}`).join('\n')}
|
|
444
|
+
|
|
445
|
+
## Deploy
|
|
446
|
+
|
|
447
|
+
\`\`\`bash
|
|
448
|
+
npm install
|
|
449
|
+
|
|
450
|
+
# Create D1 (copy the returned database_id into wrangler.toml)
|
|
451
|
+
npx wrangler d1 create ${projectName}
|
|
452
|
+
|
|
453
|
+
# Run migration
|
|
454
|
+
npx wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote
|
|
455
|
+
|
|
456
|
+
# Deploy
|
|
457
|
+
npx wrangler deploy
|
|
458
|
+
\`\`\`
|
|
459
|
+
|
|
460
|
+
## Environment secrets
|
|
461
|
+
|
|
462
|
+
| Secret | Purpose |
|
|
463
|
+
|--------|---------|
|
|
464
|
+
| \`RESEND_API_KEY\` | Contact form email delivery (optional) |
|
|
465
|
+
|
|
466
|
+
Set with: \`npx wrangler secret put RESEND_API_KEY\`
|
|
467
|
+
`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function cap(str) {
|
|
471
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
472
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker API template generator.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function workerApiTemplates({ projectName, routes, cfAccountId }) {
|
|
6
|
+
const files = {};
|
|
7
|
+
|
|
8
|
+
files['package.json'] = JSON.stringify({
|
|
9
|
+
name: projectName,
|
|
10
|
+
version: '0.1.0',
|
|
11
|
+
private: true,
|
|
12
|
+
scripts: {
|
|
13
|
+
deploy: 'wrangler deploy',
|
|
14
|
+
dev: 'wrangler dev',
|
|
15
|
+
'db:migrate': `wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote`,
|
|
16
|
+
},
|
|
17
|
+
devDependencies: { wrangler: '^3.0.0' },
|
|
18
|
+
}, null, 2);
|
|
19
|
+
|
|
20
|
+
files['wrangler.toml'] = `name = "${projectName}"
|
|
21
|
+
main = "src/index.js"
|
|
22
|
+
compatibility_date = "2024-01-01"
|
|
23
|
+
account_id = "${cfAccountId}"
|
|
24
|
+
|
|
25
|
+
[[d1_databases]]
|
|
26
|
+
binding = "DB"
|
|
27
|
+
database_name = "${projectName}"
|
|
28
|
+
database_id = "REPLACE_WITH_YOUR_D1_ID"
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
// Entry
|
|
32
|
+
const routeImports = routes.map(r => `import { handle${cap(r)} } from './routes/${r}.js';`).join('\n');
|
|
33
|
+
const routeMatches = routes.map(r => {
|
|
34
|
+
const path = r === 'health' ? `pathname === '/health'`
|
|
35
|
+
: r === 'auth' ? `pathname.startsWith('/auth')`
|
|
36
|
+
: r === 'webhook' ? `pathname.startsWith('/webhooks')`
|
|
37
|
+
: `pathname.startsWith('/${r}')`;
|
|
38
|
+
return ` if (${path}) return handle${cap(r)}(request, env);`;
|
|
39
|
+
}).join('\n');
|
|
40
|
+
|
|
41
|
+
files['src/index.js'] = `${routeImports}
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
async fetch(request, env) {
|
|
45
|
+
const url = new URL(request.url);
|
|
46
|
+
const { pathname } = url;
|
|
47
|
+
|
|
48
|
+
if (request.method === 'OPTIONS') {
|
|
49
|
+
return new Response(null, {
|
|
50
|
+
headers: {
|
|
51
|
+
'Access-Control-Allow-Origin': '*',
|
|
52
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
53
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
${routeMatches}
|
|
59
|
+
|
|
60
|
+
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
// Route stubs
|
|
66
|
+
if (routes.includes('health')) {
|
|
67
|
+
files['src/routes/health.js'] = `export async function handleHealth(request, env) {
|
|
68
|
+
return Response.json({ ok: true, ts: Date.now() });
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (routes.includes('auth')) {
|
|
74
|
+
files['src/routes/auth.js'] = `export async function handleAuth(request, env) {
|
|
75
|
+
const url = new URL(request.url);
|
|
76
|
+
if (url.pathname === '/auth/token' && request.method === 'POST') {
|
|
77
|
+
// TODO: issue token
|
|
78
|
+
return Response.json({ token: 'replace-me' });
|
|
79
|
+
}
|
|
80
|
+
return Response.json({ error: 'Unknown auth route' }, { status: 404 });
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (routes.includes('users')) {
|
|
86
|
+
files['src/routes/users.js'] = `export async function handleUsers(request, env) {
|
|
87
|
+
const url = new URL(request.url);
|
|
88
|
+
const id = url.pathname.replace('/users/', '').replace('/users', '') || null;
|
|
89
|
+
|
|
90
|
+
if (request.method === 'GET' && !id) {
|
|
91
|
+
const rows = await env.DB.prepare('SELECT * FROM users LIMIT 50').all();
|
|
92
|
+
return Response.json(rows.results ?? []);
|
|
93
|
+
}
|
|
94
|
+
if (request.method === 'GET' && id) {
|
|
95
|
+
const row = await env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(id).first();
|
|
96
|
+
if (!row) return Response.json({ error: 'Not found' }, { status: 404 });
|
|
97
|
+
return Response.json(row);
|
|
98
|
+
}
|
|
99
|
+
if (request.method === 'POST') {
|
|
100
|
+
const body = await request.json();
|
|
101
|
+
const newId = crypto.randomUUID();
|
|
102
|
+
await env.DB.prepare('INSERT INTO users (id, email) VALUES (?, ?)').bind(newId, body.email).run();
|
|
103
|
+
return Response.json({ id: newId }, { status: 201 });
|
|
104
|
+
}
|
|
105
|
+
if (request.method === 'DELETE' && id) {
|
|
106
|
+
await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
|
|
107
|
+
return Response.json({ ok: true });
|
|
108
|
+
}
|
|
109
|
+
return Response.json({ error: 'Method not allowed' }, { status: 405 });
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (routes.includes('content')) {
|
|
115
|
+
files['src/routes/content.js'] = `export async function handleContent(request, env) {
|
|
116
|
+
const url = new URL(request.url);
|
|
117
|
+
const slug = url.pathname.replace('/content/', '').replace('/content', '') || null;
|
|
118
|
+
|
|
119
|
+
if (request.method === 'GET' && !slug) {
|
|
120
|
+
const rows = await env.DB.prepare("SELECT id, slug, title, status FROM cms_pages LIMIT 50").all();
|
|
121
|
+
return Response.json(rows.results ?? []);
|
|
122
|
+
}
|
|
123
|
+
if (request.method === 'GET' && slug) {
|
|
124
|
+
const row = await env.DB.prepare('SELECT * FROM cms_pages WHERE slug = ?').bind(slug).first();
|
|
125
|
+
if (!row) return Response.json({ error: 'Not found' }, { status: 404 });
|
|
126
|
+
return Response.json(row);
|
|
127
|
+
}
|
|
128
|
+
if (request.method === 'POST') {
|
|
129
|
+
const body = await request.json();
|
|
130
|
+
const id = crypto.randomUUID();
|
|
131
|
+
await env.DB.prepare(
|
|
132
|
+
'INSERT INTO cms_pages (id, slug, title, template, content_json) VALUES (?, ?, ?, ?, ?)'
|
|
133
|
+
).bind(id, body.slug, body.title, body.template ?? 'default', JSON.stringify(body.content ?? {})).run();
|
|
134
|
+
return Response.json({ id }, { status: 201 });
|
|
135
|
+
}
|
|
136
|
+
return Response.json({ error: 'Method not allowed' }, { status: 405 });
|
|
137
|
+
}
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (routes.includes('webhook')) {
|
|
142
|
+
files['src/routes/webhook.js'] = `export async function handleWebhook(request, env) {
|
|
143
|
+
const url = new URL(request.url);
|
|
144
|
+
const type = url.pathname.replace('/webhooks/', '').replace('/webhooks', '') || 'unknown';
|
|
145
|
+
|
|
146
|
+
let body;
|
|
147
|
+
try { body = await request.json(); } catch { body = {}; }
|
|
148
|
+
|
|
149
|
+
console.log(\`[webhook] type=\${type}\`, JSON.stringify(body));
|
|
150
|
+
|
|
151
|
+
// TODO: dispatch based on type
|
|
152
|
+
return Response.json({ received: true, type });
|
|
153
|
+
}
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Migration
|
|
158
|
+
let sql = `-- ${projectName} initial schema\n\n`;
|
|
159
|
+
if (routes.includes('users')) {
|
|
160
|
+
sql += `CREATE TABLE IF NOT EXISTS users (
|
|
161
|
+
id TEXT PRIMARY KEY,
|
|
162
|
+
email TEXT NOT NULL UNIQUE,
|
|
163
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
164
|
+
);\n\n`;
|
|
165
|
+
}
|
|
166
|
+
if (routes.includes('content')) {
|
|
167
|
+
sql += `CREATE TABLE IF NOT EXISTS cms_pages (
|
|
168
|
+
id TEXT PRIMARY KEY,
|
|
169
|
+
slug TEXT NOT NULL UNIQUE,
|
|
170
|
+
title TEXT NOT NULL,
|
|
171
|
+
template TEXT NOT NULL DEFAULT 'default',
|
|
172
|
+
content_json TEXT,
|
|
173
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
174
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
175
|
+
);\n\n`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
files['migrations/001_init.sql'] = sql;
|
|
179
|
+
|
|
180
|
+
files['README.md'] = `# ${projectName}
|
|
181
|
+
|
|
182
|
+
Scaffolded by [@inneranimalmedia/agentsam-sdk](https://github.com/SamPrimeaux/agentsam-sdk).
|
|
183
|
+
|
|
184
|
+
## Routes
|
|
185
|
+
|
|
186
|
+
${routes.map(r => `- \`/${r}\``).join('\n')}
|
|
187
|
+
|
|
188
|
+
## Deploy
|
|
189
|
+
|
|
190
|
+
\`\`\`bash
|
|
191
|
+
npm install
|
|
192
|
+
npx wrangler d1 create ${projectName}
|
|
193
|
+
# paste database_id into wrangler.toml
|
|
194
|
+
npx wrangler d1 execute ${projectName} --file=migrations/001_init.sql --remote
|
|
195
|
+
npx wrangler deploy
|
|
196
|
+
\`\`\`
|
|
197
|
+
`;
|
|
198
|
+
|
|
199
|
+
return files;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function cap(str) {
|
|
203
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
204
|
+
}
|