weifuwu 0.19.7 → 0.19.9

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/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // env.ts
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ function isDev() {
5
+ return process.env.NODE_ENV === "development";
6
+ }
4
7
  function loadEnv(path2) {
5
8
  const filePath = resolve(process.cwd(), path2 ?? ".env");
6
9
  if (!existsSync(filePath)) return;
@@ -92,7 +95,7 @@ async function sendResponse(res, response) {
92
95
  res.end();
93
96
  }
94
97
  async function createTestServer(handler) {
95
- const server = serve(handler, { port: 0 });
98
+ const server = serve(handler, { port: 0, shutdown: false });
96
99
  await server.ready;
97
100
  return { server, url: `http://localhost:${server.port}` };
98
101
  }
@@ -452,9 +455,9 @@ var Router = class _Router {
452
455
  for (let i = 0; i < segments.length; i++) {
453
456
  pathMws.push(...node.pathMws);
454
457
  if (node.wildcard) {
455
- const h = node.handlers.get("*") || node.handlers.get(method);
456
- if (h) {
457
- wildcardHandler = h;
458
+ const h2 = node.handlers.get("*") || node.handlers.get(method);
459
+ if (h2) {
460
+ wildcardHandler = h2;
458
461
  wildcardMws = node.middlewares.get(method) || node.middlewares.get("*") || [];
459
462
  wildcardIdx = i;
460
463
  }
@@ -1260,11 +1263,11 @@ function helmet(options) {
1260
1263
  }
1261
1264
  return async (req, ctx, next) => {
1262
1265
  const res = await next(req, ctx);
1263
- const h = new Headers(res.headers);
1266
+ const h2 = new Headers(res.headers);
1264
1267
  for (const [k, v] of headers) {
1265
- if (!h.has(k)) h.set(k, v);
1268
+ if (!h2.has(k)) h2.set(k, v);
1266
1269
  }
1267
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
1270
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h2 });
1268
1271
  };
1269
1272
  }
1270
1273
 
@@ -1279,9 +1282,9 @@ function requestId(options) {
1279
1282
  ctx.requestId = id3;
1280
1283
  const res = await next(req, ctx);
1281
1284
  if (res.headers.has(header)) return res;
1282
- const h = new Headers(res.headers);
1283
- h.set(header, id3);
1284
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
1285
+ const h2 = new Headers(res.headers);
1286
+ h2.set(header, id3);
1287
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h2 });
1285
1288
  };
1286
1289
  }
1287
1290
 
@@ -2845,7 +2848,7 @@ function createHub(opts) {
2845
2848
  }
2846
2849
  });
2847
2850
  }
2848
- function join9(key, ws) {
2851
+ function join10(key, ws) {
2849
2852
  if (!channels.has(key)) {
2850
2853
  channels.set(key, /* @__PURE__ */ new Set());
2851
2854
  redisSub?.subscribe(`${prefix}${key}`);
@@ -2886,7 +2889,7 @@ function createHub(opts) {
2886
2889
  await redisSub.quit();
2887
2890
  }
2888
2891
  }
2889
- return { join: join9, leave, broadcast, close };
2892
+ return { join: join10, leave, broadcast, close };
2890
2893
  }
2891
2894
 
2892
2895
  // queue/index.ts
@@ -2905,10 +2908,10 @@ function cronNext(expr, from = /* @__PURE__ */ new Date()) {
2905
2908
  for (let i = 0; i < 525600; i++) {
2906
2909
  const m = candidate.getMonth() + 1;
2907
2910
  const d = candidate.getDate();
2908
- const h = candidate.getHours();
2911
+ const h2 = candidate.getHours();
2909
2912
  const min = candidate.getMinutes();
2910
2913
  const dw = candidate.getDay();
2911
- if (fields[4].has(dw) && fields[3].has(m) && fields[2].has(d) && fields[1].has(h) && fields[0].has(min)) {
2914
+ if (fields[4].has(dw) && fields[3].has(m) && fields[2].has(d) && fields[1].has(h2) && fields[0].has(min)) {
2912
2915
  return candidate.getTime();
2913
2916
  }
2914
2917
  candidate.setTime(candidate.getTime() + 6e4);
@@ -3033,7 +3036,7 @@ function queue(opts) {
3033
3036
  running = true;
3034
3037
  poll();
3035
3038
  };
3036
- mw.stop = function stop() {
3039
+ mw.stop = function stop2() {
3037
3040
  running = false;
3038
3041
  epoch++;
3039
3042
  if (pollTimer) {
@@ -5139,7 +5142,7 @@ async function compileTsxDev(path2) {
5139
5142
  return mod;
5140
5143
  }
5141
5144
  function compile(path2) {
5142
- return process.env.NODE_ENV !== "production" ? compileTsxDev(path2) : compileTsx(path2);
5145
+ return isDev() ? compileTsxDev(path2) : compileTsx(path2);
5143
5146
  }
5144
5147
  var vendorBundle = null;
5145
5148
  async function compileVendorBundle() {
@@ -5177,7 +5180,7 @@ async function compileVendorBundle() {
5177
5180
  }
5178
5181
  async function compileHotComponent(path2) {
5179
5182
  const absPath = resolve3(path2);
5180
- const h = id(absPath);
5183
+ const h2 = id(absPath);
5181
5184
  const stdin = `import C from ${JSON.stringify(absPath)};
5182
5185
  (window.__WFW_REFRESH||function(){})(C)`;
5183
5186
  const result = await esbuild.build({
@@ -5192,10 +5195,10 @@ async function compileHotComponent(path2) {
5192
5195
  });
5193
5196
  let code = new TextDecoder().decode(result.outputFiles[0].contents);
5194
5197
  if (code.includes("__require") && (code.includes('"react"') || code.includes("'react'"))) {
5195
- code = `import __r from '/__wfw/v/bundle';
5198
+ code = `import * as __r from 'react';
5196
5199
  ` + code.replace(/__require\(["']react["']\)/g, "__r");
5197
5200
  }
5198
- return { hash: h, code };
5201
+ return { hash: h2, code };
5199
5202
  }
5200
5203
 
5201
5204
  // stream.ts
@@ -5212,10 +5215,10 @@ function getPublicEnv() {
5212
5215
  return _publicEnv;
5213
5216
  }
5214
5217
  function buildHeadPayload(opts) {
5215
- const { ctx, compiledTailwindCss, isDev: isDev2 } = opts;
5218
+ const { ctx, compiledTailwindCss, isDev: isDev3 } = opts;
5216
5219
  const rb = opts.rootBase || "";
5217
5220
  let result = "";
5218
- if (isDev2) {
5221
+ if (isDev3) {
5219
5222
  const vUrl = `${rb}/__wfw/v/bundle`;
5220
5223
  result += `<script type="importmap">{
5221
5224
  "imports": {
@@ -5233,8 +5236,8 @@ function buildHeadPayload(opts) {
5233
5236
  `;
5234
5237
  }
5235
5238
  if (compiledTailwindCss) {
5236
- const cssUrl = ctx.tailwindCssUrl || "/__wfw/style.css";
5237
- result += `<link rel="stylesheet" href="${cssUrl}" />
5239
+ const cssUrl = ctx.tailwindCssUrl;
5240
+ if (cssUrl) result += `<link rel="stylesheet" href="${cssUrl}" />
5238
5241
  `;
5239
5242
  }
5240
5243
  const localeData = ctx.parsed?.__localeData ?? globalThis.__LOCALE_DATA__;
@@ -5337,10 +5340,13 @@ function streamResponse(reactStream, opts) {
5337
5340
  });
5338
5341
  }
5339
5342
 
5343
+ // ssr-entries.ts
5344
+ var ssrEntries = /* @__PURE__ */ new Map();
5345
+
5340
5346
  // ssr.ts
5347
+ var isDev2 = isDev();
5341
5348
  var als = new AsyncLocalStorage();
5342
5349
  __registerAls(() => als.getStore());
5343
- var isDev = process.env.NODE_ENV !== "production";
5344
5350
  var bundleCache = /* @__PURE__ */ new Map();
5345
5351
  var _bundleDirty = false;
5346
5352
  function markClientBundleDirty() {
@@ -5375,7 +5381,7 @@ async function buildClientBundle(entryPath, layoutPaths) {
5375
5381
  const _sc = `(function(){var k='__WEIFUWU_CTX_STORE';var s=typeof globalThis!='undefined'&&globalThis[k];if(!s)return function(){};return function(v){s._ctx={...s._ctx,...v};s._snapshot={params:s._ctx.params,query:s._ctx.query,user:s._ctx.user,parsed:s._ctx.parsed,prefs:s._ctx.prefs,env:s._ctx.env};s._listeners.forEach(function(fn){fn()})}})()`;
5376
5382
  const code = [
5377
5383
  layoutImports,
5378
- `${isDev ? "import{createRoot}from'react-dom/client';" : "import{hydrateRoot}from'react-dom/client';"}`,
5384
+ `${isDev2 ? "import{createRoot}from'react-dom/client';" : "import{hydrateRoot}from'react-dom/client';"}`,
5379
5385
  `import{createElement}from'react';`,
5380
5386
  `import{TsxContext}from'weifuwu/react';`,
5381
5387
  `import P from${JSON.stringify(absEntry)};`,
@@ -5383,15 +5389,15 @@ async function buildClientBundle(entryPath, layoutPaths) {
5383
5389
  `const c=document.getElementById('__weifuwu_root');`,
5384
5390
  `if(window.__WEIFUWU_PROPS)setCtx({loaderData:window.__WEIFUWU_PROPS});`,
5385
5391
  // Dev: stable proxy chain — _P → _W (stable) → actual component
5386
- isDev ? `const _W=function(props){return(_W._fn||P)(props)};_W._fn=P;const _P=function(props){return createElement(_W,props)};` : "",
5392
+ isDev2 ? `const _W=function(props){return(_W._fn||P)(props)};_W._fn=P;const _P=function(props){return createElement(_W,props)};` : "",
5387
5393
  // Dev: HMR handler — updates proxy + re-renders root
5388
- isDev ? `window.__WFW_ENTRY=${JSON.stringify(id2(absEntry))};window.__WFW_REFRESH=function(n){_W._fn=n;window.__WFW_ROOT.render(createElement(App))};` : "",
5394
+ isDev2 ? `window.__WFW_ENTRY=${JSON.stringify(id2(absEntry))};window.__WFW_REFRESH=function(n){_W._fn=n;window.__WFW_ROOT.render(createElement(App))};` : "",
5389
5395
  `function App(){`,
5390
5396
  `const ctx=window.__WEIFUWU_CTX||{};`,
5391
5397
  `return createElement(TsxContext.Provider,{value:ctx},`,
5392
- isDev ? `createElement(_P,null))` : `createElement(P,null))`,
5398
+ isDev2 ? `createElement(_P,null))` : `createElement(P,null))`,
5393
5399
  `}`,
5394
- isDev ? `window.__WFW_ROOT=createRoot(c);window.__WFW_ROOT.render(createElement(App));` : `hydrateRoot(c,createElement(App));`
5400
+ isDev2 ? `window.__WFW_ROOT=createRoot(c);window.__WFW_ROOT.render(createElement(App));` : `hydrateRoot(c,createElement(App));`
5395
5401
  ].filter(Boolean).join("");
5396
5402
  const { default: esbuild2 } = await import("esbuild");
5397
5403
  const result = await esbuild2.build({
@@ -5402,9 +5408,9 @@ async function buildClientBundle(entryPath, layoutPaths) {
5402
5408
  jsxImportSource: "react",
5403
5409
  banner: { js: "self.process={env:{}};" },
5404
5410
  loader: { ".node": "empty" },
5405
- external: isDev ? ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "weifuwu", "weifuwu/react"] : void 0,
5411
+ external: isDev2 ? ["react", "react-dom", "react-dom/client", "react/jsx-runtime", "weifuwu", "weifuwu/react"] : void 0,
5406
5412
  write: false,
5407
- minify: !isDev
5413
+ minify: !isDev2
5408
5414
  });
5409
5415
  return result.outputFiles[0].contents;
5410
5416
  } catch (err) {
@@ -5413,7 +5419,9 @@ async function buildClientBundle(entryPath, layoutPaths) {
5413
5419
  }
5414
5420
  }
5415
5421
  function ssr(path2) {
5416
- const entryId = id2(resolve4(path2));
5422
+ const absPath = resolve4(path2);
5423
+ const entryId = id2(absPath);
5424
+ ssrEntries.set(entryId, { path: absPath });
5417
5425
  const bundleKey = `/__ssr/${entryId}.js`;
5418
5426
  const r = new Router();
5419
5427
  r.get("/__ssr/:path", (req, ctx) => {
@@ -5487,7 +5495,7 @@ function ssr(path2) {
5487
5495
  ctx,
5488
5496
  base,
5489
5497
  rootBase: ctx.rootLayoutBase || "",
5490
- isDev,
5498
+ isDev: isDev2,
5491
5499
  bundle,
5492
5500
  loaderData,
5493
5501
  compiledTailwindCss: ctx.compiledTailwindCss
@@ -5562,7 +5570,7 @@ ${src}`;
5562
5570
  // live.ts
5563
5571
  import chokidar from "chokidar";
5564
5572
  import { existsSync as existsSync4 } from "node:fs";
5565
- import { join as join4, resolve as resolve6 } from "node:path";
5573
+ import { dirname as dirname3, join as join4, resolve as resolve6 } from "node:path";
5566
5574
  var clients = /* @__PURE__ */ new Set();
5567
5575
  var hotBundleCache = /* @__PURE__ */ new Map();
5568
5576
  var hotKeys = [];
@@ -5625,6 +5633,24 @@ function liveReload(dir) {
5625
5633
  ignored: /(^|[/\\])\.|node_modules|[/\\]\.weifuwu[/\\]/,
5626
5634
  ignoreInitial: true
5627
5635
  });
5636
+ function findEntries(changedPath) {
5637
+ const matched = [];
5638
+ for (const [, entry] of ssrEntries) {
5639
+ if (!entry.path.startsWith(resolved)) continue;
5640
+ if (entry.path === changedPath) {
5641
+ matched.push(entry.path);
5642
+ } else {
5643
+ const ed = dirname3(entry.path);
5644
+ if (changedPath.startsWith(ed)) matched.push(entry.path);
5645
+ }
5646
+ }
5647
+ if (matched.length === 0) {
5648
+ for (const [, entry] of ssrEntries) {
5649
+ if (entry.path.startsWith(resolved)) matched.push(entry.path);
5650
+ }
5651
+ }
5652
+ return matched;
5653
+ }
5628
5654
  watcher.on("change", async (filePath) => {
5629
5655
  if (/\.tsx?$/i.test(filePath)) {
5630
5656
  if (filePath.endsWith("layout.tsx")) {
@@ -5632,25 +5658,28 @@ function liveReload(dir) {
5632
5658
  }
5633
5659
  clearCompileCache();
5634
5660
  markClientBundleDirty();
5661
+ const targets = existsSync4(entryPath) ? [entryPath] : findEntries(resolve6(filePath));
5662
+ if (targets.length === 0) return broadcastReload();
5635
5663
  try {
5636
- const target = existsSync4(entryPath) ? entryPath : filePath;
5637
- await compileTsxDev(target);
5638
- const { hash, code } = await compileHotComponent(target);
5639
- setHot(hash, code);
5640
5664
  let css;
5641
5665
  const cssPath = join4(resolved, "app.css");
5642
5666
  if (existsSync4(cssPath)) {
5643
5667
  css = await compileTailwindCss(cssPath, resolved);
5644
5668
  }
5645
- const entry = id(entryPath);
5646
- const msg = { type: "component", hash, entry };
5647
- if (css) msg.css = css;
5648
- const str = JSON.stringify(msg);
5649
- for (const ws of clients) {
5650
- try {
5651
- ws.send(str);
5652
- } catch {
5653
- clients.delete(ws);
5669
+ for (const target of targets) {
5670
+ await compileTsxDev(target);
5671
+ const { hash, code } = await compileHotComponent(target);
5672
+ setHot(hash, code);
5673
+ const entry = id(target);
5674
+ const msg = { type: "component", hash, entry };
5675
+ if (css) msg.css = css;
5676
+ const str = JSON.stringify(msg);
5677
+ for (const ws of clients) {
5678
+ try {
5679
+ ws.send(str);
5680
+ } catch {
5681
+ clients.delete(ws);
5682
+ }
5654
5683
  }
5655
5684
  }
5656
5685
  } catch (e) {
@@ -5676,7 +5705,6 @@ function liveReload(dir) {
5676
5705
  function rootLayout(dir) {
5677
5706
  const r = new Router();
5678
5707
  const resolved = resolve7(dir);
5679
- const isDev2 = process.env.NODE_ENV !== "production";
5680
5708
  const layoutPath = join5(resolved, "layout.tsx");
5681
5709
  r.use(async (req, ctx, next) => {
5682
5710
  const mod = await compile(layoutPath);
@@ -5687,7 +5715,7 @@ function rootLayout(dir) {
5687
5715
  if (existsSync5(join5(resolved, "app.css"))) {
5688
5716
  r.use(tailwind(resolved));
5689
5717
  }
5690
- if (isDev2) {
5718
+ if (isDev()) {
5691
5719
  const lr = liveReload(resolved);
5692
5720
  r.use(lr);
5693
5721
  r.close = lr.close;
@@ -5993,7 +6021,7 @@ function createReadTool(ctx) {
5993
6021
  import { tool as tool5 } from "ai";
5994
6022
  import { z as z7 } from "zod";
5995
6023
  import { writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "node:fs";
5996
- import { resolve as resolve9, dirname as dirname3 } from "node:path";
6024
+ import { resolve as resolve9, dirname as dirname4 } from "node:path";
5997
6025
  function createWriteTool(ctx) {
5998
6026
  return tool5({
5999
6027
  description: "Create or overwrite a file. Parent directories are created automatically.",
@@ -6006,7 +6034,7 @@ function createWriteTool(ctx) {
6006
6034
  if (!isPathAllowed(resolved, ctx.workspace, ctx.permissions)) {
6007
6035
  return { error: "Path not allowed" };
6008
6036
  }
6009
- mkdirSync3(dirname3(resolved), { recursive: true });
6037
+ mkdirSync3(dirname4(resolved), { recursive: true });
6010
6038
  writeFileSync2(resolved, content, "utf-8");
6011
6039
  return { path: path2, size: content.length };
6012
6040
  }
@@ -7064,9 +7092,9 @@ function seoMiddleware(options) {
7064
7092
  const url = new URL(req.url);
7065
7093
  const robotTag = getRobotsHeader(headers, url.pathname);
7066
7094
  if (robotTag) {
7067
- const h = new Headers(res.headers);
7068
- h.set("X-Robots-Tag", robotTag);
7069
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h });
7095
+ const h2 = new Headers(res.headers);
7096
+ h2.set("X-Robots-Tag", robotTag);
7097
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers: h2 });
7070
7098
  }
7071
7099
  return res;
7072
7100
  };
@@ -8387,7 +8415,7 @@ function notFound(path2) {
8387
8415
  return streamResponse(stream, {
8388
8416
  ctx,
8389
8417
  base,
8390
- isDev: process.env.NODE_ENV !== "production",
8418
+ isDev: isDev(),
8391
8419
  compiledTailwindCss: ctx.compiledTailwindCss,
8392
8420
  status: 404
8393
8421
  });
@@ -8434,13 +8462,1244 @@ function errorBoundary(errorPath) {
8434
8462
  return streamResponse(stream, {
8435
8463
  ctx,
8436
8464
  base,
8437
- isDev: process.env.NODE_ENV !== "production",
8465
+ isDev: isDev(),
8438
8466
  compiledTailwindCss: ctx.compiledTailwindCss,
8439
8467
  status: 500
8440
8468
  });
8441
8469
  }
8442
8470
  };
8443
8471
  }
8472
+
8473
+ // cms/content.ts
8474
+ async function createContentType(sql2, slug, label, fields, config) {
8475
+ const rows = await sql2`
8476
+ INSERT INTO "_cms_content_types" ("slug", "label", "fields", "config")
8477
+ VALUES (${slug}, ${label}, ${sql2.json(fields)}, ${sql2.json(config ?? {})})
8478
+ RETURNING *
8479
+ `;
8480
+ return mapContentType(rows[0]);
8481
+ }
8482
+ async function getContentType(sql2, slug) {
8483
+ const rows = await sql2`SELECT * FROM "_cms_content_types" WHERE "slug" = ${slug}`;
8484
+ return rows[0] ? mapContentType(rows[0]) : null;
8485
+ }
8486
+ async function listContentTypes(sql2) {
8487
+ const rows = await sql2`SELECT * FROM "_cms_content_types" ORDER BY "created_at" ASC`;
8488
+ return rows.map(mapContentType);
8489
+ }
8490
+ async function updateContentType(sql2, slug, data) {
8491
+ const sets = [];
8492
+ const vals = [];
8493
+ let idx = 1;
8494
+ if (data.label !== void 0) {
8495
+ sets.push(`"label" = $${idx++}`);
8496
+ vals.push(data.label);
8497
+ }
8498
+ if (data.description !== void 0) {
8499
+ sets.push(`"description" = $${idx++}`);
8500
+ vals.push(data.description);
8501
+ }
8502
+ if (data.fields !== void 0) {
8503
+ sets.push(`"fields" = $${idx++}`);
8504
+ vals.push(JSON.stringify(data.fields));
8505
+ }
8506
+ if (data.config !== void 0) {
8507
+ sets.push(`"config" = $${idx++}`);
8508
+ vals.push(JSON.stringify(data.config));
8509
+ }
8510
+ if (sets.length === 0) {
8511
+ const existing = await getContentType(sql2, slug);
8512
+ return existing;
8513
+ }
8514
+ sets.push(`"updated_at" = NOW()`);
8515
+ const query = `UPDATE "_cms_content_types" SET ${sets.join(", ")} WHERE "slug" = $${idx} RETURNING *`;
8516
+ const rows = await sql2.unsafe(query, [...vals, slug]);
8517
+ return mapContentType(rows[0]);
8518
+ }
8519
+ async function deleteContentType(sql2, slug) {
8520
+ await sql2`DELETE FROM "_cms_content_types" WHERE "slug" = ${slug}`;
8521
+ }
8522
+ async function createEntry(sql2, data) {
8523
+ const rows = await sql2`
8524
+ INSERT INTO "_cms_entries" ("content_type", "slug", "title", "data", "status", "created_by", "updated_by")
8525
+ VALUES (${data.contentType}, ${data.slug}, ${data.title}, ${sql2.json(data.entryData)}, ${data.status ?? "draft"}, ${data.createdBy ?? null}, ${data.createdBy ?? null})
8526
+ RETURNING *
8527
+ `;
8528
+ return mapEntry(rows[0]);
8529
+ }
8530
+ async function getEntry(sql2, id3) {
8531
+ const rows = await sql2`SELECT * FROM "_cms_entries" WHERE "id" = ${id3}`;
8532
+ return rows[0] ? mapEntry(rows[0]) : null;
8533
+ }
8534
+ async function getEntryBySlug(sql2, contentType, slug, status) {
8535
+ const rows = status ? await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} AND "slug" = ${slug} AND "status" = ${status}` : await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} AND "slug" = ${slug}`;
8536
+ return rows[0] ? mapEntry(rows[0]) : null;
8537
+ }
8538
+ async function listEntries(sql2, contentType, status) {
8539
+ const rows = status ? await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} AND "status" = ${status} ORDER BY "updated_at" DESC` : await sql2`SELECT * FROM "_cms_entries" WHERE "content_type" = ${contentType} ORDER BY "updated_at" DESC`;
8540
+ return rows.map(mapEntry);
8541
+ }
8542
+ async function updateEntry(sql2, id3, data) {
8543
+ const sets = [];
8544
+ const vals = [];
8545
+ let idx = 1;
8546
+ if (data.slug !== void 0) {
8547
+ sets.push(`"slug" = $${idx++}`);
8548
+ vals.push(data.slug);
8549
+ }
8550
+ if (data.title !== void 0) {
8551
+ sets.push(`"title" = $${idx++}`);
8552
+ vals.push(data.title);
8553
+ }
8554
+ if (data.entryData !== void 0) {
8555
+ sets.push(`"data" = $${idx++}::jsonb`);
8556
+ vals.push(JSON.stringify(data.entryData));
8557
+ }
8558
+ if (data.updatedBy !== void 0) {
8559
+ sets.push(`"updated_by" = $${idx++}`);
8560
+ vals.push(data.updatedBy);
8561
+ }
8562
+ if (sets.length === 0) {
8563
+ const existing = await getEntry(sql2, id3);
8564
+ return existing;
8565
+ }
8566
+ sets.push(`"updated_at" = NOW()`);
8567
+ const query = `UPDATE "_cms_entries" SET ${sets.join(", ")} WHERE "id" = $${idx} RETURNING *`;
8568
+ const rows = await sql2.unsafe(query, [...vals, id3]);
8569
+ return mapEntry(rows[0]);
8570
+ }
8571
+ async function publishEntry(sql2, id3, userId) {
8572
+ const rows = await sql2`
8573
+ UPDATE "_cms_entries"
8574
+ SET "status" = 'published', "published_at" = NOW(), "updated_by" = ${userId ?? null}, "updated_at" = NOW()
8575
+ WHERE "id" = ${id3}
8576
+ RETURNING *
8577
+ `;
8578
+ return mapEntry(rows[0]);
8579
+ }
8580
+ async function archiveEntry(sql2, id3) {
8581
+ const rows = await sql2`
8582
+ UPDATE "_cms_entries"
8583
+ SET "status" = 'archived', "updated_at" = NOW()
8584
+ WHERE "id" = ${id3}
8585
+ RETURNING *
8586
+ `;
8587
+ return mapEntry(rows[0]);
8588
+ }
8589
+ async function deleteEntry(sql2, id3) {
8590
+ await sql2`DELETE FROM "_cms_entries" WHERE "id" = ${id3}`;
8591
+ }
8592
+ async function searchEntries(sql2, contentType, query, status) {
8593
+ const q = `%${query}%`;
8594
+ const rows = status ? await sql2`
8595
+ SELECT * FROM "_cms_entries"
8596
+ WHERE "content_type" = ${contentType}
8597
+ AND "status" = ${status}
8598
+ AND ("title" ILIKE ${q} OR "data"::text ILIKE ${q})
8599
+ ORDER BY "updated_at" DESC
8600
+ LIMIT 50
8601
+ ` : await sql2`
8602
+ SELECT * FROM "_cms_entries"
8603
+ WHERE "content_type" = ${contentType}
8604
+ AND ("title" ILIKE ${q} OR "data"::text ILIKE ${q})
8605
+ ORDER BY "updated_at" DESC
8606
+ LIMIT 50
8607
+ `;
8608
+ return rows.map(mapEntry);
8609
+ }
8610
+ async function createVersion(sql2, entryId, entryData, userId) {
8611
+ const maxVer = await sql2`SELECT COALESCE(MAX("version"), 0) + 1 AS next FROM "_cms_versions" WHERE "entry_id" = ${entryId}`;
8612
+ const nextVer = maxVer[0].next;
8613
+ const rows = await sql2`
8614
+ INSERT INTO "_cms_versions" ("entry_id", "version", "data", "created_by")
8615
+ VALUES (${entryId}, ${nextVer}, ${sql2.json(entryData)}, ${userId ?? null})
8616
+ RETURNING *
8617
+ `;
8618
+ return mapVersion(rows[0]);
8619
+ }
8620
+ async function listVersions(sql2, entryId) {
8621
+ const rows = await sql2`
8622
+ SELECT * FROM "_cms_versions" WHERE "entry_id" = ${entryId} ORDER BY "version" DESC
8623
+ `;
8624
+ return rows.map(mapVersion);
8625
+ }
8626
+ async function getVersion(sql2, entryId, version) {
8627
+ const rows = await sql2`
8628
+ SELECT * FROM "_cms_versions" WHERE "entry_id" = ${entryId} AND "version" = ${version}
8629
+ `;
8630
+ return rows[0] ? mapVersion(rows[0]) : null;
8631
+ }
8632
+ function mapContentType(row) {
8633
+ return {
8634
+ id: row.id,
8635
+ slug: row.slug,
8636
+ label: row.label,
8637
+ description: row.description ?? "",
8638
+ fields: typeof row.fields === "string" ? JSON.parse(row.fields) : row.fields,
8639
+ config: typeof row.config === "string" ? JSON.parse(row.config) : row.config ?? {},
8640
+ createdAt: row.created_at,
8641
+ updatedAt: row.updated_at
8642
+ };
8643
+ }
8644
+ function mapEntry(row) {
8645
+ return {
8646
+ id: row.id,
8647
+ contentType: row.content_type,
8648
+ slug: row.slug,
8649
+ title: row.title,
8650
+ status: row.status,
8651
+ data: typeof row.data === "string" ? JSON.parse(row.data) : row.data ?? {},
8652
+ locale: row.locale ?? null,
8653
+ createdBy: row.created_by ?? null,
8654
+ updatedBy: row.updated_by ?? null,
8655
+ publishedAt: row.published_at ?? null,
8656
+ createdAt: row.created_at,
8657
+ updatedAt: row.updated_at
8658
+ };
8659
+ }
8660
+ function mapVersion(row) {
8661
+ return {
8662
+ id: row.id,
8663
+ entryId: row.entry_id,
8664
+ version: row.version,
8665
+ data: typeof row.data === "string" ? JSON.parse(row.data) : row.data ?? {},
8666
+ createdBy: row.created_by ?? null,
8667
+ createdAt: row.created_at
8668
+ };
8669
+ }
8670
+
8671
+ // cms/admin.ts
8672
+ function esc(s) {
8673
+ if (s === null || s === void 0) return "";
8674
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
8675
+ }
8676
+ function h(s) {
8677
+ return esc(s);
8678
+ }
8679
+ function redirect(to, status = 303) {
8680
+ return new Response(null, { status, headers: { location: to } });
8681
+ }
8682
+ var ADMIN_CSS = `
8683
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
8684
+ :root{--bg:#f5f6fa;--card:#fff;--text:#1a1a2e;--text-muted:#6b7280;--primary:#4f46e5;--primary-hover:#4338ca;--danger:#ef4444;--danger-hover:#dc2626;--success:#10b981;--border:#e5e7eb;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08)}
8685
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh;display:flex}
8686
+ a{color:var(--primary);text-decoration:none}
8687
+ a:hover{text-decoration:underline}
8688
+ h1{font-size:1.5rem;font-weight:700;margin-bottom:1rem}
8689
+ h2{font-size:1.2rem;font-weight:600;margin-bottom:.75rem}
8690
+ .sidebar{width:240px;background:var(--card);border-right:1px solid var(--border);padding:1.5rem;position:sticky;top:0;height:100vh;overflow-y:auto;flex-shrink:0}
8691
+ .sidebar h2{font-size:1rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:1rem}
8692
+ .sidebar nav{display:flex;flex-direction:column;gap:.25rem}
8693
+ .sidebar a{display:block;padding:.5rem .75rem;border-radius:6px;color:var(--text);font-size:.9rem;transition:background .15s}
8694
+ .sidebar a:hover{background:#f0f0ff;text-decoration:none}
8695
+ .sidebar a.active{background:#eef2ff;color:var(--primary);font-weight:600}
8696
+ .sidebar .section{margin-top:1.5rem}
8697
+ .main{flex:1;padding:2rem;max-width:1200px}
8698
+ .card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.5rem;margin-bottom:1.5rem}
8699
+ .stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
8700
+ .stat-card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.25rem;text-align:center}
8701
+ .stat-card .num{font-size:2rem;font-weight:700;color:var(--primary)}
8702
+ .stat-card .label{font-size:.85rem;color:var(--text-muted);margin-top:.25rem}
8703
+ table{width:100%;border-collapse:collapse;font-size:.9rem}
8704
+ th,td{padding:.75rem;text-align:left;border-bottom:1px solid var(--border)}
8705
+ th{font-weight:600;color:var(--text-muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.05em}
8706
+ tr:hover td{background:#f9fafb}
8707
+ .badge{display:inline-block;padding:.15rem .5rem;border-radius:999px;font-size:.75rem;font-weight:500}
8708
+ .badge-draft{background:#fef3c7;color:#92400e}
8709
+ .badge-published{background:#d1fae5;color:#065f46}
8710
+ .badge-archived{background:#f3f4f6;color:#374151}
8711
+ .btn{display:inline-block;padding:.5rem 1rem;border-radius:6px;font-size:.875rem;font-weight:500;border:none;cursor:pointer;transition:all .15s;text-decoration:none;line-height:1.4}
8712
+ .btn-primary{background:var(--primary);color:#fff}
8713
+ .btn-primary:hover{background:var(--primary-hover);text-decoration:none}
8714
+ .btn-danger{background:var(--danger);color:#fff}
8715
+ .btn-danger:hover{background:var(--danger-hover);text-decoration:none}
8716
+ .btn-success{background:var(--success);color:#fff}
8717
+ .btn-success:hover{opacity:.9;text-decoration:none}
8718
+ .btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
8719
+ .btn-outline:hover{background:var(--bg);text-decoration:none}
8720
+ .btn-sm{padding:.3rem .6rem;font-size:.8rem}
8721
+ .btn-group{display:flex;gap:.5rem;flex-wrap:wrap}
8722
+ .mb-1{margin-bottom:.5rem}
8723
+ .mb-2{margin-bottom:1rem}
8724
+ .mt-2{margin-top:1rem}
8725
+ .flex{display:flex}
8726
+ .justify-between{justify-content:space-between}
8727
+ .items-center{align-items:center}
8728
+ .gap-2{gap:.5rem}
8729
+ .form-group{margin-bottom:1rem}
8730
+ .form-group label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.25rem;color:var(--text)}
8731
+ .form-group .help{font-size:.8rem;color:var(--text-muted);margin-top:.25rem}
8732
+ input[type=text],input[type=number],input[type=url],input[type=datetime-local],select,textarea{width:100%;padding:.5rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;font-family:inherit;transition:border-color .15s}
8733
+ input:focus,select:focus,textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(79,70,229,.1)}
8734
+ textarea.code{font-family:'SF Mono','Fira Code',monospace;font-size:.85rem}
8735
+ input[type=checkbox]{margin-right:.5rem}
8736
+ .alert{padding:.75rem 1rem;border-radius:6px;margin-bottom:1rem;font-size:.9rem}
8737
+ .alert-success{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
8738
+ .alert-error{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
8739
+ .empty{text-align:center;padding:3rem 1rem;color:var(--text-muted)}
8740
+ .empty p{font-size:1rem;margin-bottom:1rem}
8741
+ .pagination{display:flex;gap:.5rem;justify-content:center;margin-top:1rem}
8742
+ .media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1rem}
8743
+ .media-item{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden;transition:box-shadow .15s}
8744
+ .media-item:hover{box-shadow:0 4px 12px rgba(0,0,0,.12)}
8745
+ .media-item img{width:100%;height:140px;object-fit:cover;display:block}
8746
+ .media-item .meta{padding:.5rem;font-size:.8rem}
8747
+ .media-item .meta .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
8748
+ .media-item .meta .info{color:var(--text-muted);font-size:.75rem}
8749
+ .field-row{display:flex;gap:.75rem;align-items:center;margin-bottom:.5rem;padding:.5rem;background:var(--bg);border-radius:6px}
8750
+ .field-row input[type=text]{flex:1}
8751
+ .field-row select{width:160px;flex:none}
8752
+ `;
8753
+ function flashMessage(ctx) {
8754
+ const msg = ctx.query?.message;
8755
+ const err = ctx.query?.error;
8756
+ if (msg) return `<div class="alert alert-success">${h(msg)}</div>`;
8757
+ if (err) return `<div class="alert alert-error">${h(err)}</div>`;
8758
+ return "";
8759
+ }
8760
+ function adminLayout(title, content, ctx, activeNav) {
8761
+ const base = (ctx.mountPath || "").replace(/\/+$/, "");
8762
+ function navItem(href, label, icon, match) {
8763
+ const active = activeNav && match && activeNav.startsWith(match);
8764
+ return `<a href="${base}${href}" class="${active ? "active" : ""}">${icon} ${label}</a>`;
8765
+ }
8766
+ return `<!DOCTYPE html>
8767
+ <html lang="en">
8768
+ <head>
8769
+ <meta charset="UTF-8">
8770
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8771
+ <title>${h(title)} \u2014 CMS</title>
8772
+ <style>${ADMIN_CSS}</style>
8773
+ </head>
8774
+ <body>
8775
+ <div class="sidebar">
8776
+ <h2>\u{1F4E6} CMS</h2>
8777
+ <nav>
8778
+ ${navItem("/admin", "Dashboard", "", "")}
8779
+ </nav>
8780
+ <nav class="section">
8781
+ ${navItem("/admin/content-types", "Content Types", "", "/admin/content-types")}
8782
+ ${navItem("/admin/media", "Media Library", "", "/admin/media")}
8783
+ </nav>
8784
+ </div>
8785
+ <div class="main">
8786
+ ${flashMessage(ctx)}
8787
+ ${content}
8788
+ </div>
8789
+ </body>
8790
+ </html>`;
8791
+ }
8792
+ async function renderDashboard2(sql2, ctx) {
8793
+ const types = await listContentTypes(sql2);
8794
+ const totalTypes = types.length;
8795
+ let totalEntries = 0;
8796
+ let publishedEntries = 0;
8797
+ for (const t of types) {
8798
+ const all = await listEntries(sql2, t.slug);
8799
+ totalEntries += all.length;
8800
+ publishedEntries += all.filter((e) => e.status === "published").length;
8801
+ }
8802
+ const recentEntries = [];
8803
+ for (const t of types) {
8804
+ const entries = await listEntries(sql2, t.slug);
8805
+ recentEntries.push(...entries.slice(0, 5));
8806
+ }
8807
+ recentEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
8808
+ const recent = recentEntries.slice(0, 10);
8809
+ const stats = `
8810
+ <div class="stat-grid">
8811
+ <div class="stat-card"><div class="num">${totalTypes}</div><div class="label">Content Types</div></div>
8812
+ <div class="stat-card"><div class="num">${totalEntries}</div><div class="label">Total Entries</div></div>
8813
+ <div class="stat-card"><div class="num">${publishedEntries}</div><div class="label">Published</div></div>
8814
+ </div>
8815
+ `;
8816
+ let content = `<h1>Dashboard</h1>${stats}`;
8817
+ if (types.length === 0) {
8818
+ content += `
8819
+ <div class="card empty">
8820
+ <p>No content types yet.</p>
8821
+ <a href="${baseHref(ctx)}/admin/content-types/new" class="btn btn-primary">Create your first Content Type</a>
8822
+ </div>
8823
+ `;
8824
+ return adminLayout("Dashboard", content, ctx, "");
8825
+ }
8826
+ content += `<div class="card"><h2>Recent Entries</h2>`;
8827
+ if (recent.length === 0) {
8828
+ content += `<div class="empty"><p>No entries yet.</p></div>`;
8829
+ } else {
8830
+ content += `<table><thead><tr><th>Title</th><th>Type</th><th>Status</th><th>Updated</th></tr></thead><tbody>`;
8831
+ for (const e of recent) {
8832
+ const ct = types.find((t) => t.slug === e.contentType);
8833
+ content += `<tr><td><a href="${baseHref(ctx)}/admin/content/${e.contentType}/${e.id}/edit">${h(e.title)}</a></td><td>${h(ct?.label || e.contentType)}</td><td>${statusBadge(e.status)}</td><td>${formatDate(e.updatedAt)}</td></tr>`;
8834
+ }
8835
+ content += `</tbody></table>`;
8836
+ }
8837
+ content += `</div>`;
8838
+ content += `<div class="card"><h2>Quick Actions</h2>
8839
+ <div class="btn-group">
8840
+ <a href="${baseHref(ctx)}/admin/content-types/new" class="btn btn-primary">New Content Type</a>
8841
+ ${types.map((t) => `<a href="${baseHref(ctx)}/admin/content/${t.slug}/new" class="btn btn-outline">New ${h(t.label)}</a>`).join("")}
8842
+ </div>
8843
+ </div>`;
8844
+ return adminLayout("Dashboard", content, ctx, "");
8845
+ }
8846
+ function baseHref(ctx) {
8847
+ return (ctx.mountPath || "").replace(/\/+$/, "");
8848
+ }
8849
+ function statusBadge(status) {
8850
+ const cls = status === "published" ? "badge-published" : status === "draft" ? "badge-draft" : "badge-archived";
8851
+ return `<span class="badge ${cls}">${status}</span>`;
8852
+ }
8853
+ function formatDate(d) {
8854
+ if (!d) return "";
8855
+ const date = new Date(d);
8856
+ return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
8857
+ }
8858
+ async function renderContentTypeList(sql2, ctx) {
8859
+ const types = await listContentTypes(sql2);
8860
+ let content = `<div class="flex justify-between items-center mb-2">
8861
+ <h1>Content Types</h1>
8862
+ <a href="${baseHref(ctx)}/admin/content-types/new" class="btn btn-primary">+ New Type</a>
8863
+ </div>`;
8864
+ if (types.length === 0) {
8865
+ content += `<div class="card empty"><p>No content types defined.</p></div>`;
8866
+ } else {
8867
+ content += `<div class="card"><table><thead><tr><th>Label</th><th>Slug</th><th>Fields</th><th>Entries</th><th>Created</th><th></th></tr></thead><tbody>`;
8868
+ for (const t of types) {
8869
+ const entries = await listEntries(sql2, t.slug);
8870
+ content += `<tr>
8871
+ <td><a href="${baseHref(ctx)}/admin/content/${t.slug}"><strong>${h(t.label)}</strong></a></td>
8872
+ <td><code>${h(t.slug)}</code></td>
8873
+ <td>${t.fields.length}</td>
8874
+ <td>${entries.length}</td>
8875
+ <td>${formatDate(t.createdAt)}</td>
8876
+ <td>
8877
+ <div class="btn-group">
8878
+ <a href="${baseHref(ctx)}/admin/content-types/${t.slug}/edit" class="btn btn-outline btn-sm">Edit</a>
8879
+ <a href="${baseHref(ctx)}/admin/content/${t.slug}" class="btn btn-outline btn-sm">Entries</a>
8880
+ <form method="POST" action="${baseHref(ctx)}/admin/content-types/${t.slug}/delete" onsubmit="return confirm('Delete this content type and all its entries?')">
8881
+ <button type="submit" class="btn btn-danger btn-sm">Delete</button>
8882
+ </form>
8883
+ </div>
8884
+ </td>
8885
+ </tr>`;
8886
+ }
8887
+ content += `</tbody></table></div>`;
8888
+ }
8889
+ return adminLayout("Content Types", content, ctx, "/admin/content-types");
8890
+ }
8891
+ function renderContentTypeForm(ctx, existing) {
8892
+ const base = baseHref(ctx);
8893
+ const isEdit = !!existing;
8894
+ const action = isEdit ? `${base}/admin/content-types/${existing.slug}` : `${base}/admin/content-types`;
8895
+ const title = isEdit ? `Edit ${existing.label}` : "New Content Type";
8896
+ const ct = existing || { slug: "", label: "", description: "", fields: [], config: {} };
8897
+ const fieldsJson = JSON.stringify(ct.fields, null, 2);
8898
+ const configJson = JSON.stringify(ct.config || {}, null, 2);
8899
+ let content = `<h1>${title}</h1>
8900
+ <div class="card">
8901
+ <form method="POST" action="${action}">
8902
+ <div class="form-group">
8903
+ <label for="slug">Slug</label>
8904
+ <input type="text" id="slug" name="slug" value="${h(ct.slug)}" required${isEdit ? ' readonly style="background:#f3f4f6"' : ""} placeholder="e.g., post, page, product">
8905
+ <div class="help">Unique identifier used in URLs and API</div>
8906
+ </div>
8907
+ <div class="form-group">
8908
+ <label for="label">Label</label>
8909
+ <input type="text" id="label" name="label" value="${h(ct.label)}" required placeholder="e.g., Post, Page, Product">
8910
+ </div>
8911
+ <div class="form-group">
8912
+ <label for="description">Description</label>
8913
+ <textarea id="description" name="description" rows="2">${h(ct.description)}</textarea>
8914
+ </div>
8915
+ <div class="form-group">
8916
+ <label for="fields">Fields (JSON)</label>
8917
+ <textarea id="fields" name="fields" rows="12" class="code" required>${h(fieldsJson)}</textarea>
8918
+ <div class="help">Array of field definitions. <a href="#" onclick="return false" style="cursor:help">See docs</a></div>
8919
+ </div>
8920
+ <div class="form-group">
8921
+ <label for="config">Config (JSON)</label>
8922
+ <textarea id="config" name="config" rows="4" class="code">${h(configJson)}</textarea>
8923
+ </div>
8924
+ <div class="btn-group">
8925
+ <button type="submit" class="btn btn-primary">${isEdit ? "Update" : "Create"}</button>
8926
+ <a href="${base}/admin/content-types" class="btn btn-outline">Cancel</a>
8927
+ </div>
8928
+ </form>
8929
+ </div>`;
8930
+ return adminLayout(title, content, ctx, "/admin/content-types");
8931
+ }
8932
+ async function renderEntryList(sql2, typeSlug, ctx) {
8933
+ const ct = await getContentType(sql2, typeSlug);
8934
+ if (!ct) {
8935
+ return adminLayout("Not Found", `<div class="card"><p>Content type "${h(typeSlug)}" not found.</p></div>`, ctx, "/admin/content-types");
8936
+ }
8937
+ const entries = await listEntries(sql2, typeSlug);
8938
+ const searchQ = ctx.query?.q;
8939
+ let content = `<div class="flex justify-between items-center mb-2">
8940
+ <h1>${h(ct.label)} <span style="font-weight:400;font-size:.9rem;color:var(--text-muted)">(${h(ct.slug)})</span></h1>
8941
+ <a href="${baseHref(ctx)}/admin/content/${typeSlug}/new" class="btn btn-primary">+ New ${h(ct.label)}</a>
8942
+ </div>`;
8943
+ if (entries.length === 0) {
8944
+ content += `<div class="card empty">
8945
+ <p>No entries yet.</p>
8946
+ <a href="${baseHref(ctx)}/admin/content/${typeSlug}/new" class="btn btn-primary">Create first entry</a>
8947
+ </div>`;
8948
+ } else {
8949
+ const titleField = ct.config?.titleField || "title";
8950
+ content += `<div class="card"><table><thead><tr><th>${h(titleField === "title" ? "Title" : titleField)}</th><th>Slug</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>`;
8951
+ for (const e of entries) {
8952
+ const displayTitle = e.title || e.data[titleField] || `(ID: ${e.id})`;
8953
+ content += `<tr>
8954
+ <td><a href="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/edit"><strong>${h(displayTitle)}</strong></a></td>
8955
+ <td><code>${h(e.slug)}</code></td>
8956
+ <td>${statusBadge(e.status)}</td>
8957
+ <td>${formatDate(e.updatedAt)}</td>
8958
+ <td>
8959
+ <div class="btn-group">
8960
+ <a href="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/edit" class="btn btn-outline btn-sm">Edit</a>
8961
+ ${e.status === "draft" ? `<form method="POST" action="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/publish"><button type="submit" class="btn btn-success btn-sm">Publish</button></form>` : ""}
8962
+ ${e.status === "published" ? `<form method="POST" action="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/archive"><button type="submit" class="btn btn-outline btn-sm">Archive</button></form>` : ""}
8963
+ ${e.status !== "published" ? `<form method="POST" action="${baseHref(ctx)}/admin/content/${typeSlug}/${e.id}/delete" onsubmit="return confirm('Delete this entry?')"><button type="submit" class="btn btn-danger btn-sm">Delete</button></form>` : ""}
8964
+ </div>
8965
+ </td>
8966
+ </tr>`;
8967
+ }
8968
+ content += `</tbody></table></div>`;
8969
+ }
8970
+ return adminLayout(ct.label, content, ctx, "/admin/content-types");
8971
+ }
8972
+ async function renderEntryForm(sql2, typeSlug, ctx, existing) {
8973
+ const ct = await getContentType(sql2, typeSlug);
8974
+ if (!ct) {
8975
+ return adminLayout("Not Found", `<div class="card"><p>Content type "${h(typeSlug)}" not found.</p></div>`, ctx, "/admin/content-types");
8976
+ }
8977
+ const base = baseHref(ctx);
8978
+ const isEdit = !!existing;
8979
+ const action = isEdit ? `${base}/admin/content/${typeSlug}/${existing.id}` : `${base}/admin/content/${typeSlug}`;
8980
+ const title = isEdit ? `Edit ${existing.title || existing.slug || `Entry #${existing.id}`}` : `New ${ct.label}`;
8981
+ const entry = existing || { slug: "", title: "", data: {}, status: "draft", id: 0, locale: null, createdBy: null, updatedBy: null, publishedAt: null, createdAt: "", updatedAt: "", contentType: typeSlug };
8982
+ const data = entry.data || {};
8983
+ let content = `<div class="flex justify-between items-center mb-2">
8984
+ <h1>${h(title)}</h1>
8985
+ ${isEdit ? `<div class="btn-group">
8986
+ <span class="badge ${entry.status === "published" ? "badge-published" : entry.status === "draft" ? "badge-draft" : "badge-archived"}" style="font-size:.9rem;padding:.3rem .75rem">${entry.status}</span>
8987
+ </div>` : ""}
8988
+ </div>`;
8989
+ if (isEdit) {
8990
+ content += `<div class="btn-group mb-2">
8991
+ ${entry.status === "draft" ? `<form method="POST" action="${base}/admin/content/${typeSlug}/${entry.id}/publish"><button type="submit" class="btn btn-success btn-sm">Publish</button></form>` : ""}
8992
+ ${entry.status === "published" ? `<form method="POST" action="${base}/admin/content/${typeSlug}/${entry.id}/archive"><button type="submit" class="btn btn-outline btn-sm">Archive</button></form>` : ""}
8993
+ ${entry.status !== "published" ? `<form method="POST" action="${base}/admin/content/${typeSlug}/${entry.id}/delete" onsubmit="return confirm('Delete this entry?')"><button type="submit" class="btn btn-danger btn-sm">Delete</button></form>` : ""}
8994
+ ${isEdit ? `<a href="${base}/admin/content/${typeSlug}/${entry.id}/versions" class="btn btn-outline btn-sm">Versions</a>` : ""}
8995
+ </div>`;
8996
+ }
8997
+ content += `<div class="card">
8998
+ <form method="POST" action="${action}">
8999
+ <div class="form-group">
9000
+ <label for="title">${h(ct.config?.titleField || "Title")}</label>
9001
+ <input type="text" id="title" name="title" value="${h(entry.title)}" required placeholder="Enter title">
9002
+ </div>
9003
+ <div class="form-group">
9004
+ <label for="slug">Slug</label>
9005
+ <input type="text" id="slug" name="slug" value="${h(entry.slug)}" placeholder="Auto-generated from title if empty">
9006
+ </div>`;
9007
+ for (const field of ct.fields) {
9008
+ content += renderFormField(field, data[field.name], "data");
9009
+ }
9010
+ content += `
9011
+ <div class="btn-group mt-2">
9012
+ <button type="submit" class="btn btn-primary">${isEdit ? "Update" : "Create"}</button>
9013
+ <a href="${base}/admin/content/${typeSlug}" class="btn btn-outline">Cancel</a>
9014
+ </div>
9015
+ </form>
9016
+ </div>`;
9017
+ return adminLayout(title, content, ctx, "/admin/content-types");
9018
+ }
9019
+ function renderFormField(field, value, prefix) {
9020
+ const name = `${prefix}[${field.name}]`;
9021
+ const val = value !== void 0 && value !== null ? String(value) : "";
9022
+ let input = "";
9023
+ switch (field.type) {
9024
+ case "string":
9025
+ case "slug":
9026
+ input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" ${field.required ? "required" : ""} placeholder="${h(field.placeholder || "")}">`;
9027
+ break;
9028
+ case "richtext":
9029
+ input = `<textarea id="field-${field.name}" name="${name}" rows="10" ${field.required ? "required" : ""}>${h(val)}</textarea>`;
9030
+ break;
9031
+ case "integer":
9032
+ input = `<input type="number" id="field-${field.name}" name="${name}" value="${h(val)}" step="1" ${field.required ? "required" : ""}>`;
9033
+ break;
9034
+ case "float":
9035
+ input = `<input type="number" id="field-${field.name}" name="${name}" value="${h(val)}" step="0.01" ${field.required ? "required" : ""}>`;
9036
+ break;
9037
+ case "boolean":
9038
+ input = `<input type="hidden" name="${name}" value="false"><input type="checkbox" id="field-${field.name}" name="${name}" value="true" ${val === "true" ? "checked" : ""}>`;
9039
+ break;
9040
+ case "datetime":
9041
+ input = `<input type="datetime-local" id="field-${field.name}" name="${name}" value="${h(val)}" ${field.required ? "required" : ""}>`;
9042
+ break;
9043
+ case "json":
9044
+ input = `<textarea id="field-${field.name}" name="${name}" rows="8" class="code">${h(val)}</textarea>`;
9045
+ break;
9046
+ case "enum": {
9047
+ const opts = (field.options || []).map(
9048
+ (o) => `<option value="${h(o)}" ${o === val ? "selected" : ""}>${h(o)}</option>`
9049
+ ).join("");
9050
+ input = `<select id="field-${field.name}" name="${name}" ${field.required ? "required" : ""}><option value="">\u2014 Select \u2014</option>${opts}</select>`;
9051
+ break;
9052
+ }
9053
+ case "image":
9054
+ input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" placeholder="Media URL or path" ${field.required ? "required" : ""}>`;
9055
+ break;
9056
+ case "gallery":
9057
+ input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" placeholder="Comma-separated media URLs">`;
9058
+ break;
9059
+ case "relation":
9060
+ input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}" ${field.required ? "required" : ""} placeholder="Related ${h(field.relation?.contentType || "entry")} ID or slug">`;
9061
+ break;
9062
+ default:
9063
+ input = `<input type="text" id="field-${field.name}" name="${name}" value="${h(val)}">`;
9064
+ }
9065
+ return `
9066
+ <div class="form-group">
9067
+ <label for="field-${field.name}">${h(field.name)}${field.required ? ' <span style="color:var(--danger)">*</span>' : ""}</label>
9068
+ ${input}
9069
+ ${field.helpText ? `<div class="help">${h(field.helpText)}</div>` : ""}
9070
+ </div>`;
9071
+ }
9072
+ async function renderVersions(sql2, typeSlug, entryId, ctx) {
9073
+ const ct = await getContentType(sql2, typeSlug);
9074
+ const entry = await getEntry(sql2, entryId);
9075
+ if (!ct || !entry) {
9076
+ return adminLayout("Not Found", `<div class="card"><p>Entry not found.</p></div>`, ctx, "/admin/content-types");
9077
+ }
9078
+ const versions = await listVersions(sql2, entryId);
9079
+ const base = baseHref(ctx);
9080
+ let content = `<div class="flex justify-between items-center mb-2">
9081
+ <h1>Versions: ${h(entry.title)}</h1>
9082
+ <a href="${base}/admin/content/${typeSlug}/${entryId}/edit" class="btn btn-outline">\u2190 Back to Editor</a>
9083
+ </div>`;
9084
+ if (versions.length === 0) {
9085
+ content += `<div class="card empty"><p>No versions saved.</p></div>`;
9086
+ } else {
9087
+ content += `<div class="card"><table><thead><tr><th>Version</th><th>Created</th><th></th></tr></thead><tbody>`;
9088
+ for (const v of versions) {
9089
+ content += `<tr>
9090
+ <td><strong>#${v.version}</strong></td>
9091
+ <td>${formatDate(v.createdAt)}</td>
9092
+ <td>
9093
+ <div class="btn-group">
9094
+ <form method="POST" action="${base}/admin/content/${typeSlug}/${entryId}/restore/${v.version}" onsubmit="return confirm('Restore version #${v.version}? Current data will be replaced.')">
9095
+ <button type="submit" class="btn btn-outline btn-sm">Restore</button>
9096
+ </form>
9097
+ </div>
9098
+ </td>
9099
+ </tr>`;
9100
+ }
9101
+ content += `</tbody></table></div>`;
9102
+ }
9103
+ return adminLayout(`Versions: ${entry.title}`, content, ctx, "/admin/content-types");
9104
+ }
9105
+ function registerAdminRoutes(router, sql2) {
9106
+ const h2 = (handler) => (req, ctx) => handler(sql2, req, ctx);
9107
+ router.get("/admin", h2(async (_sql, req, ctx) => {
9108
+ const html = await renderDashboard2(sql2, ctx);
9109
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9110
+ }));
9111
+ router.get("/admin/content-types", h2(async (_sql, req, ctx) => {
9112
+ const html = await renderContentTypeList(sql2, ctx);
9113
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9114
+ }));
9115
+ router.get("/admin/content-types/new", h2(async (_sql, req, ctx) => {
9116
+ const html = renderContentTypeForm(ctx);
9117
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9118
+ }));
9119
+ router.post("/admin/content-types", h2(async (_sql, req, ctx) => {
9120
+ try {
9121
+ const fd = await req.formData();
9122
+ const slug = fd.get("slug")?.trim();
9123
+ const label = fd.get("label")?.trim();
9124
+ const description = fd.get("description") || "";
9125
+ const fieldsRaw = fd.get("fields") || "[]";
9126
+ const configRaw = fd.get("config") || "{}";
9127
+ let fields;
9128
+ let config;
9129
+ try {
9130
+ fields = JSON.parse(fieldsRaw);
9131
+ } catch {
9132
+ fields = [];
9133
+ }
9134
+ try {
9135
+ config = JSON.parse(configRaw);
9136
+ } catch {
9137
+ config = {};
9138
+ }
9139
+ if (!slug || !label) throw new Error("Slug and label are required");
9140
+ await createContentType(sql2, slug, label, fields, config);
9141
+ return redirect(`${baseHref(ctx)}/admin/content-types?message=${encodeURIComponent(`Content type "${label}" created`)}`, 303);
9142
+ } catch (err) {
9143
+ return redirect(`${baseHref(ctx)}/admin/content-types/new?error=${encodeURIComponent(err.message)}`, 303);
9144
+ }
9145
+ }));
9146
+ router.get("/admin/content-types/:slug/edit", h2(async (_sql, req, ctx) => {
9147
+ const ct = await getContentType(sql2, ctx.params.slug);
9148
+ if (!ct) {
9149
+ return redirect(`${baseHref(ctx)}/admin/content-types?error=${encodeURIComponent("Content type not found")}`, 303);
9150
+ }
9151
+ const html = renderContentTypeForm(ctx, ct);
9152
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9153
+ }));
9154
+ router.post("/admin/content-types/:slug", h2(async (_sql, req, ctx) => {
9155
+ try {
9156
+ const fd = await req.formData();
9157
+ const label = fd.get("label")?.trim();
9158
+ const description = fd.get("description") || "";
9159
+ const fieldsRaw = fd.get("fields") || "[]";
9160
+ const configRaw = fd.get("config") || "{}";
9161
+ let fields;
9162
+ let config;
9163
+ try {
9164
+ fields = JSON.parse(fieldsRaw);
9165
+ } catch {
9166
+ fields = [];
9167
+ }
9168
+ try {
9169
+ config = JSON.parse(configRaw);
9170
+ } catch {
9171
+ config = {};
9172
+ }
9173
+ await updateContentType(sql2, ctx.params.slug, { label, description, fields, config });
9174
+ return redirect(`${baseHref(ctx)}/admin/content-types?message=${encodeURIComponent(`Content type updated`)}`, 303);
9175
+ } catch (err) {
9176
+ return redirect(`${baseHref(ctx)}/admin/content-types/${ctx.params.slug}/edit?error=${encodeURIComponent(err.message)}`, 303);
9177
+ }
9178
+ }));
9179
+ router.post("/admin/content-types/:slug/delete", h2(async (_sql, req, ctx) => {
9180
+ try {
9181
+ await deleteContentType(sql2, ctx.params.slug);
9182
+ return redirect(`${baseHref(ctx)}/admin/content-types?message=Content type deleted`, 303);
9183
+ } catch (err) {
9184
+ return redirect(`${baseHref(ctx)}/admin/content-types?error=${encodeURIComponent(err.message)}`, 303);
9185
+ }
9186
+ }));
9187
+ router.get("/admin/content/:type", h2(async (_sql, req, ctx) => {
9188
+ const html = await renderEntryList(sql2, ctx.params.type, ctx);
9189
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9190
+ }));
9191
+ router.get("/admin/content/:type/new", h2(async (_sql, req, ctx) => {
9192
+ const html = await renderEntryForm(sql2, ctx.params.type, ctx);
9193
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9194
+ }));
9195
+ router.post("/admin/content/:type", h2(async (_sql, req, ctx) => {
9196
+ try {
9197
+ const ct = await getContentType(sql2, ctx.params.type);
9198
+ if (!ct) throw new Error("Content type not found");
9199
+ const fd = await req.formData();
9200
+ const title = fd.get("title")?.trim() || "Untitled";
9201
+ let slug = fd.get("slug")?.trim() || "";
9202
+ if (!slug) slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || `entry-${Date.now()}`;
9203
+ const entryData = parseFormDataToObject(fd, ct.fields);
9204
+ const entry = await createEntry(sql2, { contentType: ct.slug, slug, title, entryData });
9205
+ await createVersion(sql2, entry.id, entryData);
9206
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entry.id}/edit?message=Created`, 303);
9207
+ } catch (err) {
9208
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/new?error=${encodeURIComponent(err.message)}`, 303);
9209
+ }
9210
+ }));
9211
+ router.get("/admin/content/:type/:id/edit", h2(async (_sql, req, ctx) => {
9212
+ const entryId = parseInt(ctx.params.id, 10);
9213
+ if (isNaN(entryId)) {
9214
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?error=Invalid ID`, 303);
9215
+ }
9216
+ const entry = await getEntry(sql2, entryId);
9217
+ if (!entry) {
9218
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?error=Entry not found`, 303);
9219
+ }
9220
+ const html = await renderEntryForm(sql2, ctx.params.type, ctx, entry);
9221
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9222
+ }));
9223
+ router.post("/admin/content/:type/:id", h2(async (_sql, req, ctx) => {
9224
+ try {
9225
+ const entryId = parseInt(ctx.params.id, 10);
9226
+ const ct = await getContentType(sql2, ctx.params.type);
9227
+ if (!ct) throw new Error("Content type not found");
9228
+ const entry = await getEntry(sql2, entryId);
9229
+ if (!entry) throw new Error("Entry not found");
9230
+ const fd = await req.formData();
9231
+ const title = fd.get("title")?.trim() || entry.title;
9232
+ let slug = fd.get("slug")?.trim() || "";
9233
+ if (!slug) slug = entry.slug;
9234
+ const entryData = parseFormDataToObject(fd, ct.fields);
9235
+ const oldData = entry.data;
9236
+ await createVersion(sql2, entryId, oldData);
9237
+ await updateEntry(sql2, entryId, { slug, title, entryData });
9238
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Updated`, 303);
9239
+ } catch (err) {
9240
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
9241
+ }
9242
+ }));
9243
+ router.post("/admin/content/:type/:id/publish", h2(async (_sql, req, ctx) => {
9244
+ try {
9245
+ const entryId = parseInt(ctx.params.id, 10);
9246
+ await publishEntry(sql2, entryId);
9247
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Published`, 303);
9248
+ } catch (err) {
9249
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
9250
+ }
9251
+ }));
9252
+ router.post("/admin/content/:type/:id/archive", h2(async (_sql, req, ctx) => {
9253
+ try {
9254
+ const entryId = parseInt(ctx.params.id, 10);
9255
+ await archiveEntry(sql2, entryId);
9256
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Archived`, 303);
9257
+ } catch (err) {
9258
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
9259
+ }
9260
+ }));
9261
+ router.post("/admin/content/:type/:id/delete", h2(async (_sql, req, ctx) => {
9262
+ try {
9263
+ const entryId = parseInt(ctx.params.id, 10);
9264
+ await deleteEntry(sql2, entryId);
9265
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?message=Entry deleted`, 303);
9266
+ } catch (err) {
9267
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}?error=${encodeURIComponent(err.message)}`, 303);
9268
+ }
9269
+ }));
9270
+ router.get("/admin/content/:type/:id/versions", h2(async (_sql, req, ctx) => {
9271
+ const entryId = parseInt(ctx.params.id, 10);
9272
+ const html = await renderVersions(sql2, ctx.params.type, entryId, ctx);
9273
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9274
+ }));
9275
+ router.post("/admin/content/:type/:id/restore/:version", h2(async (_sql, req, ctx) => {
9276
+ try {
9277
+ const entryId = parseInt(ctx.params.id, 10);
9278
+ const version = parseInt(ctx.params.version, 10);
9279
+ const ct = await getContentType(sql2, ctx.params.type);
9280
+ if (!ct) throw new Error("Content type not found");
9281
+ const entry = await getEntry(sql2, entryId);
9282
+ if (!entry) throw new Error("Entry not found");
9283
+ const ver = await getVersion(sql2, entryId, version);
9284
+ if (!ver) throw new Error("Version not found");
9285
+ await createVersion(sql2, entryId, entry.data);
9286
+ await updateEntry(sql2, entryId, { entryData: ver.data });
9287
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${entryId}/edit?message=Version ${version} restored`, 303);
9288
+ } catch (err) {
9289
+ return redirect(`${baseHref(ctx)}/admin/content/${ctx.params.type}/${ctx.params.id}/edit?error=${encodeURIComponent(err.message)}`, 303);
9290
+ }
9291
+ }));
9292
+ }
9293
+ function parseFormDataToObject(fd, fields) {
9294
+ const data = {};
9295
+ for (const field of fields) {
9296
+ const val = fd.get(`data[${field.name}]`);
9297
+ if (val === null) continue;
9298
+ const strVal = val;
9299
+ switch (field.type) {
9300
+ case "integer":
9301
+ data[field.name] = strVal ? parseInt(strVal, 10) : null;
9302
+ break;
9303
+ case "float":
9304
+ data[field.name] = strVal ? parseFloat(strVal) : null;
9305
+ break;
9306
+ case "boolean":
9307
+ data[field.name] = strVal === "true";
9308
+ break;
9309
+ case "json":
9310
+ try {
9311
+ data[field.name] = JSON.parse(strVal);
9312
+ } catch {
9313
+ data[field.name] = strVal;
9314
+ }
9315
+ break;
9316
+ default:
9317
+ data[field.name] = strVal;
9318
+ }
9319
+ }
9320
+ return data;
9321
+ }
9322
+
9323
+ // cms/api.ts
9324
+ function registerApiRoutes(router, sql2) {
9325
+ router.get("/api/:type", async (req, ctx) => {
9326
+ try {
9327
+ const ct = await getContentType(sql2, ctx.params.type);
9328
+ if (!ct) {
9329
+ return Response.json({ error: "Content type not found" }, { status: 404 });
9330
+ }
9331
+ const q = ctx.query?.q;
9332
+ let entries;
9333
+ if (q) {
9334
+ entries = await searchEntries(sql2, ct.slug, q, "published");
9335
+ } else {
9336
+ entries = await listEntries(sql2, ct.slug, "published");
9337
+ }
9338
+ const fields = ct.fields;
9339
+ return Response.json({
9340
+ data: entries.map((e) => serializeEntry(e, fields)),
9341
+ meta: {
9342
+ total: entries.length,
9343
+ type: ct.slug
9344
+ }
9345
+ });
9346
+ } catch (err) {
9347
+ return Response.json({ error: err.message }, { status: 500 });
9348
+ }
9349
+ });
9350
+ router.get("/api/:type/:slug", async (req, ctx) => {
9351
+ try {
9352
+ const ct = await getContentType(sql2, ctx.params.type);
9353
+ if (!ct) {
9354
+ return Response.json({ error: "Content type not found" }, { status: 404 });
9355
+ }
9356
+ const entry = await getEntryBySlug(sql2, ct.slug, ctx.params.slug, "published");
9357
+ if (!entry) {
9358
+ return Response.json({ error: "Entry not found" }, { status: 404 });
9359
+ }
9360
+ return Response.json({
9361
+ data: serializeEntry(entry, ct.fields)
9362
+ });
9363
+ } catch (err) {
9364
+ return Response.json({ error: err.message }, { status: 500 });
9365
+ }
9366
+ });
9367
+ }
9368
+ function serializeEntry(entry, fields) {
9369
+ return {
9370
+ id: entry.id,
9371
+ slug: entry.slug,
9372
+ title: entry.title,
9373
+ ...entry.data,
9374
+ meta: {
9375
+ type: entry.contentType,
9376
+ status: entry.status,
9377
+ publishedAt: entry.publishedAt,
9378
+ createdAt: entry.createdAt,
9379
+ updatedAt: entry.updatedAt
9380
+ }
9381
+ };
9382
+ }
9383
+
9384
+ // cms/media.ts
9385
+ import { writeFile as writeFile2, mkdir as mkdir3 } from "node:fs/promises";
9386
+ import { join as join9, extname as extname3 } from "node:path";
9387
+ import { randomUUID as randomUUID3 } from "node:crypto";
9388
+ import { existsSync as existsSync8 } from "node:fs";
9389
+ function redirect2(to, status = 303) {
9390
+ return new Response(null, { status, headers: { location: to } });
9391
+ }
9392
+ async function createMediaTable(sql2) {
9393
+ await sql2.unsafe(`
9394
+ CREATE TABLE IF NOT EXISTS "_cms_media" (
9395
+ "id" SERIAL PRIMARY KEY,
9396
+ "filename" TEXT NOT NULL,
9397
+ "original_name" TEXT NOT NULL,
9398
+ "mimetype" TEXT NOT NULL DEFAULT 'application/octet-stream',
9399
+ "size" INTEGER NOT NULL DEFAULT 0,
9400
+ "width" INTEGER DEFAULT NULL,
9401
+ "height" INTEGER DEFAULT NULL,
9402
+ "alt" TEXT DEFAULT '',
9403
+ "created_by" INTEGER DEFAULT NULL,
9404
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
9405
+ )
9406
+ `);
9407
+ }
9408
+ async function saveMedia(sql2, file, mediaDir, userId) {
9409
+ const ext = extname3(file.name) || "";
9410
+ const filename = `${randomUUID3()}${ext}`;
9411
+ const dir = mediaDir;
9412
+ if (!existsSync8(dir)) {
9413
+ await mkdir3(dir, { recursive: true });
9414
+ }
9415
+ const filepath = join9(dir, filename);
9416
+ await writeFile2(filepath, file.data);
9417
+ const rows = await sql2`
9418
+ INSERT INTO "_cms_media" ("filename", "original_name", "mimetype", "size", "created_by")
9419
+ VALUES (${filename}, ${file.name}, ${file.mimetype}, ${file.data.length}, ${userId ?? null})
9420
+ RETURNING *
9421
+ `;
9422
+ return mapMedia(rows[0]);
9423
+ }
9424
+ async function listMedia(sql2) {
9425
+ const rows = await sql2`SELECT * FROM "_cms_media" ORDER BY "created_at" DESC`;
9426
+ return rows.map(mapMedia);
9427
+ }
9428
+ async function getMedia(sql2, id3) {
9429
+ const rows = await sql2`SELECT * FROM "_cms_media" WHERE "id" = ${id3}`;
9430
+ return rows[0] ? mapMedia(rows[0]) : null;
9431
+ }
9432
+ async function deleteMedia(sql2, id3, mediaDir) {
9433
+ const media = await getMedia(sql2, id3);
9434
+ if (media) {
9435
+ try {
9436
+ const { unlink } = await import("node:fs/promises");
9437
+ await unlink(join9(mediaDir, media.filename));
9438
+ } catch {
9439
+ }
9440
+ }
9441
+ await sql2`DELETE FROM "_cms_media" WHERE "id" = ${id3}`;
9442
+ }
9443
+ function mapMedia(row) {
9444
+ return {
9445
+ id: row.id,
9446
+ filename: row.filename,
9447
+ originalName: row.original_name,
9448
+ mimetype: row.mimetype,
9449
+ size: row.size,
9450
+ width: row.width ?? null,
9451
+ height: row.height ?? null,
9452
+ alt: row.alt ?? "",
9453
+ createdBy: row.created_by ?? null,
9454
+ createdAt: row.created_at
9455
+ };
9456
+ }
9457
+ function registerMediaRoutes(router, sql2, mediaDir) {
9458
+ router.get("/admin/media", async (req, ctx) => {
9459
+ const media = await listMedia(sql2);
9460
+ const base = (ctx.mountPath || "").replace(/\/+$/, "");
9461
+ const msg = ctx.query?.message;
9462
+ const err = ctx.query?.error;
9463
+ let content = `<div class="flex justify-between items-center mb-2">
9464
+ <h1>Media Library</h1>
9465
+ </div>
9466
+ ${msg ? `<div class="alert alert-success">${esc2(msg)}</div>` : ""}
9467
+ ${err ? `<div class="alert alert-error">${esc2(err)}</div>` : ""}`;
9468
+ if (media.length === 0) {
9469
+ content += `<div class="card empty"><p>No media uploaded yet.</p></div>`;
9470
+ } else {
9471
+ content += `<div class="media-grid">`;
9472
+ for (const m of media) {
9473
+ const isImage = m.mimetype.startsWith("image/");
9474
+ const fileUrl = `${base}/admin/media/${m.id}/file`;
9475
+ content += `<div class="media-item">
9476
+ ${isImage ? `<img src="${esc2(fileUrl)}" alt="${esc2(m.alt || m.originalName)}" loading="lazy">` : `<div style="height:140px;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-size:2rem;color:var(--text-muted)">\u{1F4C4}</div>`}
9477
+ <div class="meta">
9478
+ <div class="name">${esc2(m.originalName)}</div>
9479
+ <div class="info">${formatSize(m.size)}</div>
9480
+ <form method="POST" action="${base}/admin/media/${m.id}/delete" onsubmit="return confirm('Delete this file?')" style="margin-top:.25rem">
9481
+ <button type="submit" class="btn btn-danger btn-sm">Delete</button>
9482
+ </form>
9483
+ </div>
9484
+ </div>`;
9485
+ }
9486
+ content += `</div>`;
9487
+ }
9488
+ content += `<div class="card" style="margin-top:1rem">
9489
+ <h2>Upload File</h2>
9490
+ <form method="POST" action="${base}/admin/media/upload" enctype="multipart/form-data">
9491
+ <div class="form-group">
9492
+ <input type="file" name="file" required>
9493
+ </div>
9494
+ <button type="submit" class="btn btn-primary">Upload</button>
9495
+ </form>
9496
+ </div>`;
9497
+ const html = adminPageContent("Media Library", content, ctx, "/admin/media");
9498
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
9499
+ });
9500
+ router.post("/admin/media/upload", async (req, ctx) => {
9501
+ try {
9502
+ const fd = await req.formData();
9503
+ const file = fd.get("file");
9504
+ if (!file) throw new Error("No file provided");
9505
+ const buf = Buffer.from(await file.arrayBuffer());
9506
+ await saveMedia(sql2, { name: file.name, data: buf, mimetype: file.type }, mediaDir);
9507
+ const base = (ctx.mountPath || "").replace(/\/+$/, "");
9508
+ return redirect2(`${base}/admin/media?message=File uploaded`, 303);
9509
+ } catch (err) {
9510
+ const base = (ctx.mountPath || "").replace(/\/+$/, "");
9511
+ return redirect2(`${base}/admin/media?error=${encodeURIComponent(err.message)}`, 303);
9512
+ }
9513
+ });
9514
+ router.post("/admin/media/:id/delete", async (req, ctx) => {
9515
+ try {
9516
+ const id3 = parseInt(ctx.params.id, 10);
9517
+ await deleteMedia(sql2, id3, mediaDir);
9518
+ const base = (ctx.mountPath || "").replace(/\/+$/, "");
9519
+ return redirect2(`${base}/admin/media?message=File deleted`, 303);
9520
+ } catch (err) {
9521
+ const base = (ctx.mountPath || "").replace(/\/+$/, "");
9522
+ return redirect2(`${base}/admin/media?error=${encodeURIComponent(err.message)}`, 303);
9523
+ }
9524
+ });
9525
+ router.get("/admin/media/:id/file", async (req, ctx) => {
9526
+ try {
9527
+ const id3 = parseInt(ctx.params.id, 10);
9528
+ const media = await getMedia(sql2, id3);
9529
+ if (!media) {
9530
+ return new Response("Not found", { status: 404 });
9531
+ }
9532
+ const { readFile: readFile3 } = await import("node:fs/promises");
9533
+ const filepath = join9(mediaDir, media.filename);
9534
+ const data = await readFile3(filepath);
9535
+ return new Response(data, {
9536
+ headers: {
9537
+ "content-type": media.mimetype,
9538
+ "content-length": String(media.size),
9539
+ "cache-control": "public, max-age=31536000"
9540
+ }
9541
+ });
9542
+ } catch (err) {
9543
+ return new Response("Not found", { status: 404 });
9544
+ }
9545
+ });
9546
+ }
9547
+ function formatSize(bytes) {
9548
+ if (bytes < 1024) return `${bytes} B`;
9549
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
9550
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
9551
+ }
9552
+ function esc2(s) {
9553
+ if (s === null || s === void 0) return "";
9554
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9555
+ }
9556
+ function adminPageContent(title, content, ctx, activeNav) {
9557
+ const base = (ctx.mountPath || "").replace(/\/+$/, "");
9558
+ const ADMIN_CSS2 = `
9559
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9560
+ :root{--bg:#f5f6fa;--card:#fff;--text:#1a1a2e;--text-muted:#6b7280;--primary:#4f46e5;--primary-hover:#4338ca;--danger:#ef4444;--danger-hover:#dc2626;--success:#10b981;--border:#e5e7eb;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08)}
9561
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh;display:flex}
9562
+ a{color:var(--primary);text-decoration:none}
9563
+ a:hover{text-decoration:underline}
9564
+ h1{font-size:1.5rem;font-weight:700;margin-bottom:1rem}
9565
+ h2{font-size:1.2rem;font-weight:600;margin-bottom:.75rem}
9566
+ .sidebar{width:240px;background:var(--card);border-right:1px solid var(--border);padding:1.5rem;position:sticky;top:0;height:100vh;overflow-y:auto;flex-shrink:0}
9567
+ .sidebar h2{font-size:1rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:1rem}
9568
+ .sidebar nav{display:flex;flex-direction:column;gap:.25rem}
9569
+ .sidebar a{display:block;padding:.5rem .75rem;border-radius:6px;color:var(--text);font-size:.9rem;transition:background .15s}
9570
+ .sidebar a:hover{background:#f0f0ff;text-decoration:none}
9571
+ .sidebar a.active{background:#eef2ff;color:var(--primary);font-weight:600}
9572
+ .sidebar .section{margin-top:1.5rem}
9573
+ .main{flex:1;padding:2rem;max-width:1200px}
9574
+ .card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:1.5rem;margin-bottom:1.5rem}
9575
+ .btn{display:inline-block;padding:.5rem 1rem;border-radius:6px;font-size:.875rem;font-weight:500;border:none;cursor:pointer;transition:all .15s;text-decoration:none;line-height:1.4}
9576
+ .btn-primary{background:var(--primary);color:#fff}
9577
+ .btn-primary:hover{background:var(--primary-hover);text-decoration:none}
9578
+ .btn-danger{background:var(--danger);color:#fff}
9579
+ .btn-danger:hover{background:var(--danger-hover);text-decoration:none}
9580
+ .form-group{margin-bottom:1rem}
9581
+ .form-group label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.25rem}
9582
+ input[type=file]{padding:.5rem 0}
9583
+ .alert{padding:.75rem 1rem;border-radius:6px;margin-bottom:1rem;font-size:.9rem}
9584
+ .alert-success{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
9585
+ .alert-error{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
9586
+ .empty{text-align:center;padding:3rem 1rem;color:var(--text-muted)}
9587
+ .media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1rem}
9588
+ .media-item{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
9589
+ .media-item img{width:100%;height:140px;object-fit:cover;display:block}
9590
+ .media-item .meta{padding:.5rem;font-size:.8rem}
9591
+ .media-item .meta .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
9592
+ .media-item .meta .info{color:var(--text-muted);font-size:.75rem}
9593
+ `;
9594
+ return `<!DOCTYPE html>
9595
+ <html lang="en">
9596
+ <head>
9597
+ <meta charset="UTF-8">
9598
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9599
+ <title>${esc2(title)} \u2014 CMS</title>
9600
+ <style>${ADMIN_CSS2}</style>
9601
+ </head>
9602
+ <body>
9603
+ <div class="sidebar">
9604
+ <h2>\u{1F4E6} CMS</h2>
9605
+ <nav>
9606
+ <a href="${base}/admin" class="${!activeNav || activeNav === "" ? "active" : ""}"> Dashboard</a>
9607
+ </nav>
9608
+ <nav class="section">
9609
+ <a href="${base}/admin/content-types" class="${activeNav?.startsWith("/admin/content-types") ? "active" : ""}"> Content Types</a>
9610
+ <a href="${base}/admin/media" class="active"> Media Library</a>
9611
+ </nav>
9612
+ </div>
9613
+ <div class="main">${content}</div>
9614
+ </body>
9615
+ </html>`;
9616
+ }
9617
+
9618
+ // cms/client.ts
9619
+ function cms(options) {
9620
+ const pg = options.pg;
9621
+ const sql2 = pg.sql;
9622
+ const base = new PgModule(pg);
9623
+ const mediaDir = options.mediaDir ?? "./cms-media";
9624
+ async function migrate() {
9625
+ await sql2.unsafe(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
9626
+ await sql2.unsafe(`
9627
+ CREATE TABLE IF NOT EXISTS "_cms_content_types" (
9628
+ "id" SERIAL PRIMARY KEY,
9629
+ "slug" TEXT NOT NULL UNIQUE,
9630
+ "label" TEXT NOT NULL,
9631
+ "description" TEXT DEFAULT '',
9632
+ "fields" JSONB NOT NULL DEFAULT '[]'::jsonb,
9633
+ "config" JSONB DEFAULT '{}'::jsonb,
9634
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9635
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
9636
+ )
9637
+ `);
9638
+ await sql2.unsafe(`
9639
+ CREATE TABLE IF NOT EXISTS "_cms_entries" (
9640
+ "id" SERIAL PRIMARY KEY,
9641
+ "content_type" TEXT NOT NULL REFERENCES "_cms_content_types"("slug") ON DELETE CASCADE,
9642
+ "slug" TEXT NOT NULL DEFAULT '',
9643
+ "title" TEXT NOT NULL DEFAULT '',
9644
+ "status" TEXT NOT NULL DEFAULT 'draft',
9645
+ "data" JSONB NOT NULL DEFAULT '{}'::jsonb,
9646
+ "locale" TEXT DEFAULT NULL,
9647
+ "created_by" INTEGER DEFAULT NULL,
9648
+ "updated_by" INTEGER DEFAULT NULL,
9649
+ "published_at" TIMESTAMPTZ DEFAULT NULL,
9650
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9651
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
9652
+ )
9653
+ `);
9654
+ await sql2.unsafe(`CREATE UNIQUE INDEX IF NOT EXISTS "_cms_entries_unique_idx" ON "_cms_entries" ("content_type", "slug")`);
9655
+ await sql2.unsafe(`CREATE INDEX IF NOT EXISTS "_cms_entries_content_type_idx" ON "_cms_entries" ("content_type")`);
9656
+ await sql2.unsafe(`CREATE INDEX IF NOT EXISTS "_cms_entries_status_idx" ON "_cms_entries" ("status")`);
9657
+ await sql2.unsafe(`CREATE INDEX IF NOT EXISTS "_cms_entries_data_gin_idx" ON "_cms_entries" USING GIN ("data" jsonb_path_ops)`);
9658
+ await sql2.unsafe(`
9659
+ CREATE TABLE IF NOT EXISTS "_cms_versions" (
9660
+ "id" SERIAL PRIMARY KEY,
9661
+ "entry_id" INTEGER NOT NULL REFERENCES "_cms_entries"("id") ON DELETE CASCADE,
9662
+ "version" INTEGER NOT NULL,
9663
+ "data" JSONB NOT NULL DEFAULT '{}'::jsonb,
9664
+ "created_by" INTEGER DEFAULT NULL,
9665
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9666
+ UNIQUE("entry_id", "version")
9667
+ )
9668
+ `);
9669
+ await sql2.unsafe(`
9670
+ CREATE INDEX IF NOT EXISTS "_cms_versions_entry_idx" ON "_cms_versions" ("entry_id")
9671
+ `);
9672
+ await createMediaTable(sql2);
9673
+ await sql2.unsafe(`
9674
+ CREATE TABLE IF NOT EXISTS "_cms_webhooks" (
9675
+ "id" SERIAL PRIMARY KEY,
9676
+ "name" TEXT NOT NULL,
9677
+ "url" TEXT NOT NULL,
9678
+ "events" TEXT[] NOT NULL DEFAULT '{}',
9679
+ "secret" TEXT DEFAULT '',
9680
+ "active" BOOLEAN NOT NULL DEFAULT true,
9681
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
9682
+ )
9683
+ `);
9684
+ await sql2.unsafe(`
9685
+ CREATE TABLE IF NOT EXISTS "_cms_redirects" (
9686
+ "id" SERIAL PRIMARY KEY,
9687
+ "from_path" TEXT NOT NULL UNIQUE,
9688
+ "to_path" TEXT NOT NULL,
9689
+ "type" INTEGER NOT NULL DEFAULT 301,
9690
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
9691
+ )
9692
+ `);
9693
+ }
9694
+ const r = new Router();
9695
+ registerAdminRoutes(r, sql2);
9696
+ registerApiRoutes(r, sql2);
9697
+ registerMediaRoutes(r, sql2, mediaDir);
9698
+ const mod = r;
9699
+ mod.migrate = migrate;
9700
+ mod.close = () => base.close();
9701
+ return mod;
9702
+ }
8444
9703
  export {
8445
9704
  Router,
8446
9705
  TsxContext,
@@ -8449,6 +9708,7 @@ export {
8449
9708
  analytics,
8450
9709
  auth,
8451
9710
  clearCompileCache,
9711
+ cms,
8452
9712
  compress,
8453
9713
  cors,
8454
9714
  createHub,
@@ -8472,6 +9732,7 @@ export {
8472
9732
  health,
8473
9733
  helmet,
8474
9734
  iii,
9735
+ isDev,
8475
9736
  layout,
8476
9737
  loadEnv,
8477
9738
  logdb,
@@ -8498,6 +9759,7 @@ export {
8498
9759
  setCookie,
8499
9760
  smoothStream,
8500
9761
  ssr,
9762
+ ssrEntries,
8501
9763
  streamObject,
8502
9764
  streamText,
8503
9765
  tenant,