@kidecms/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +28 -0
  2. package/admin/components/AdminCard.astro +25 -0
  3. package/admin/components/AiGenerateButton.tsx +102 -0
  4. package/admin/components/AssetsGrid.tsx +711 -0
  5. package/admin/components/BlockEditor.tsx +996 -0
  6. package/admin/components/CheckboxField.tsx +31 -0
  7. package/admin/components/DocumentActions.tsx +317 -0
  8. package/admin/components/DocumentLock.tsx +54 -0
  9. package/admin/components/DocumentsDataTable.tsx +804 -0
  10. package/admin/components/FieldControl.astro +397 -0
  11. package/admin/components/FocalPointSelector.tsx +100 -0
  12. package/admin/components/ImageBrowseDialog.tsx +176 -0
  13. package/admin/components/ImagePicker.tsx +149 -0
  14. package/admin/components/InternalLinkPicker.tsx +80 -0
  15. package/admin/components/LiveHeading.tsx +17 -0
  16. package/admin/components/MobileSidebar.tsx +29 -0
  17. package/admin/components/RelationField.tsx +204 -0
  18. package/admin/components/RichTextEditor.tsx +685 -0
  19. package/admin/components/SelectField.tsx +65 -0
  20. package/admin/components/SidebarUserMenu.tsx +99 -0
  21. package/admin/components/SlugField.tsx +77 -0
  22. package/admin/components/TaxonomySelect.tsx +52 -0
  23. package/admin/components/Toast.astro +40 -0
  24. package/admin/components/TreeItemsEditor.tsx +790 -0
  25. package/admin/components/TreeSelect.tsx +166 -0
  26. package/admin/components/UnsavedGuard.tsx +181 -0
  27. package/admin/components/tree-utils.ts +86 -0
  28. package/admin/components/ui/alert-dialog.tsx +92 -0
  29. package/admin/components/ui/badge.tsx +83 -0
  30. package/admin/components/ui/button.tsx +53 -0
  31. package/admin/components/ui/card.tsx +70 -0
  32. package/admin/components/ui/checkbox.tsx +28 -0
  33. package/admin/components/ui/collapsible.tsx +26 -0
  34. package/admin/components/ui/command.tsx +88 -0
  35. package/admin/components/ui/dialog.tsx +92 -0
  36. package/admin/components/ui/dropdown-menu.tsx +259 -0
  37. package/admin/components/ui/input.tsx +20 -0
  38. package/admin/components/ui/label.tsx +20 -0
  39. package/admin/components/ui/popover.tsx +42 -0
  40. package/admin/components/ui/select.tsx +165 -0
  41. package/admin/components/ui/separator.tsx +21 -0
  42. package/admin/components/ui/sheet.tsx +104 -0
  43. package/admin/components/ui/skeleton.tsx +7 -0
  44. package/admin/components/ui/table.tsx +74 -0
  45. package/admin/components/ui/textarea.tsx +18 -0
  46. package/admin/components/ui/tooltip.tsx +52 -0
  47. package/admin/layouts/AdminLayout.astro +340 -0
  48. package/admin/lib/utils.ts +19 -0
  49. package/dist/admin.js +92 -0
  50. package/dist/ai.js +67 -0
  51. package/dist/api.js +827 -0
  52. package/dist/assets.js +163 -0
  53. package/dist/auth.js +132 -0
  54. package/dist/blocks.js +110 -0
  55. package/dist/content.js +29 -0
  56. package/dist/create-admin.js +23 -0
  57. package/dist/define.js +36 -0
  58. package/dist/generator.js +370 -0
  59. package/dist/image.js +69 -0
  60. package/dist/index.js +16 -0
  61. package/dist/integration.js +256 -0
  62. package/dist/locks.js +37 -0
  63. package/dist/richtext.js +1 -0
  64. package/dist/runtime.js +26 -0
  65. package/dist/schema.js +13 -0
  66. package/dist/seed.js +84 -0
  67. package/dist/values.js +102 -0
  68. package/middleware/auth.ts +100 -0
  69. package/package.json +102 -0
  70. package/routes/api/cms/[collection]/[...path].ts +366 -0
  71. package/routes/api/cms/ai/alt-text.ts +25 -0
  72. package/routes/api/cms/ai/seo.ts +25 -0
  73. package/routes/api/cms/ai/translate.ts +31 -0
  74. package/routes/api/cms/assets/[id].ts +82 -0
  75. package/routes/api/cms/assets/folders.ts +81 -0
  76. package/routes/api/cms/assets/index.ts +23 -0
  77. package/routes/api/cms/assets/upload.ts +112 -0
  78. package/routes/api/cms/auth/invite.ts +166 -0
  79. package/routes/api/cms/auth/login.ts +124 -0
  80. package/routes/api/cms/auth/logout.ts +33 -0
  81. package/routes/api/cms/auth/setup.ts +77 -0
  82. package/routes/api/cms/cron/publish.ts +33 -0
  83. package/routes/api/cms/img/[...path].ts +24 -0
  84. package/routes/api/cms/locks/[...path].ts +37 -0
  85. package/routes/api/cms/preview/render.ts +36 -0
  86. package/routes/api/cms/references/[collection]/[id].ts +60 -0
  87. package/routes/pages/admin/[...path].astro +1104 -0
  88. package/routes/pages/admin/assets/[id].astro +183 -0
  89. package/routes/pages/admin/assets/index.astro +58 -0
  90. package/routes/pages/admin/invite.astro +116 -0
  91. package/routes/pages/admin/login.astro +57 -0
  92. package/routes/pages/admin/setup.astro +91 -0
  93. package/virtual.d.ts +61 -0
@@ -0,0 +1,256 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, watch, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ function runGenerator(cwd, generatorPath) {
6
+ execSync(`node --import tsx ${generatorPath}`, {
7
+ stdio: "inherit",
8
+ cwd,
9
+ });
10
+ }
11
+ function pushSchema(cwd) {
12
+ execSync("npx drizzle-kit push --force", {
13
+ stdio: "inherit",
14
+ cwd,
15
+ });
16
+ }
17
+ function isCloudflareD1(cwd) {
18
+ const wranglerPath = path.join(cwd, "wrangler.toml");
19
+ if (!existsSync(wranglerPath))
20
+ return false;
21
+ try {
22
+ const content = readFileSync(wranglerPath, "utf-8");
23
+ return content.includes("[[d1_databases]]");
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function hasLocalD1Database(cwd) {
30
+ const dir = path.join(cwd, ".wrangler/state/v3/d1/miniflare-D1DatabaseObject");
31
+ try {
32
+ return readdirSync(dir).some((file) => file.endsWith(".sqlite") && file !== "*.sqlite");
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function getD1DatabaseName(cwd) {
39
+ const wranglerPath = path.join(cwd, "wrangler.toml");
40
+ try {
41
+ const content = readFileSync(wranglerPath, "utf-8");
42
+ const match = content.match(/database_name\s*=\s*"([^"]+)"/);
43
+ return match ? match[1] : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function initLocalD1(cwd) {
50
+ const dbName = getD1DatabaseName(cwd);
51
+ if (!dbName)
52
+ throw new Error("No database_name found in wrangler.toml");
53
+ execSync(`npx wrangler d1 execute "${dbName}" --local --command="SELECT 1"`, {
54
+ stdio: "pipe",
55
+ cwd,
56
+ });
57
+ }
58
+ export default function cmsIntegration(options) {
59
+ const configPath = options?.configPath ?? "src/cms/cms.config";
60
+ const runtimePath = options?.runtimePath ?? "src/cms/internals/runtime";
61
+ const generatedPath = options?.generatedPath ?? "src/cms/.generated";
62
+ const adaptersPath = options?.adaptersPath ?? "src/cms/adapters";
63
+ const generatorPath = options?.generatorPath ?? "src/cms/internals/generator.ts";
64
+ return {
65
+ name: "kide-cms",
66
+ hooks: {
67
+ "astro:config:setup": ({ command, updateConfig, injectRoute, addMiddleware }) => {
68
+ const root = process.cwd();
69
+ // Generate a wrapper CSS that adds @source directives and imports user's admin CSS
70
+ const corePkgDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
71
+ const adminDir = path.join(corePkgDir, "admin");
72
+ const routesDir = path.join(corePkgDir, "routes");
73
+ const twAnimateCssPath = path.join(corePkgDir, "node_modules", "tw-animate-css", "dist", "tw-animate.css");
74
+ const userAdminCss = path.resolve(root, "src/styles/admin.css");
75
+ const generatedDir = path.join(root, "node_modules", ".kide");
76
+ mkdirSync(generatedDir, { recursive: true });
77
+ const wrapperCss = path.join(generatedDir, "admin.css");
78
+ writeFileSync(wrapperCss, [
79
+ `@source "${adminDir}";`,
80
+ `@source "${routesDir}";`,
81
+ `@import "${twAnimateCssPath}";`,
82
+ `@import "${userAdminCss}";`,
83
+ "",
84
+ "/* shadcn component styles (accordion, state variants) */",
85
+ "@theme inline {",
86
+ " @keyframes accordion-down { from { height: 0 } to { height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto)) } }",
87
+ " @keyframes accordion-up { from { height: var(--radix-accordion-content-height, var(--accordion-panel-height, auto)) } to { height: 0 } }",
88
+ "}",
89
+ '@custom-variant data-open { &:where([data-state="open"]), &:where([data-open]:not([data-open="false"])) { @slot; } }',
90
+ '@custom-variant data-closed { &:where([data-state="closed"]), &:where([data-closed]:not([data-closed="false"])) { @slot; } }',
91
+ '@custom-variant data-checked { &:where([data-state="checked"]), &:where([data-checked]:not([data-checked="false"])) { @slot; } }',
92
+ '@custom-variant data-unchecked { &:where([data-state="unchecked"]), &:where([data-unchecked]:not([data-unchecked="false"])) { @slot; } }',
93
+ '@custom-variant data-selected { &:where([data-selected="true"]) { @slot; } }',
94
+ '@custom-variant data-disabled { &:where([data-disabled="true"]), &:where([data-disabled]:not([data-disabled="false"])) { @slot; } }',
95
+ '@custom-variant data-active { &:where([data-state="active"]), &:where([data-active]:not([data-active="false"])) { @slot; } }',
96
+ '@custom-variant data-horizontal { &:where([data-orientation="horizontal"]) { @slot; } }',
97
+ '@custom-variant data-vertical { &:where([data-orientation="vertical"]) { @slot; } }',
98
+ "@utility no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; &::-webkit-scrollbar { display: none; } }",
99
+ "",
100
+ ].join("\n"));
101
+ // Generate custom field components barrel
102
+ const customFieldsDir = path.resolve(root, "src/cms/admin/fields");
103
+ const customFieldsBarrel = path.join(generatedDir, "custom-fields.ts");
104
+ const generateFieldsBarrel = () => {
105
+ if (existsSync(customFieldsDir)) {
106
+ const files = readdirSync(customFieldsDir).filter((f) => f.endsWith(".tsx"));
107
+ const imports = files.map((f) => {
108
+ const name = f.replace(".tsx", "");
109
+ return ` "${name}": (await import("${path.join(customFieldsDir, f)}")).default,`;
110
+ });
111
+ writeFileSync(customFieldsBarrel, `export const customFields: Record<string, any> = {\n${imports.join("\n")}\n};\n`);
112
+ }
113
+ else {
114
+ writeFileSync(customFieldsBarrel, "export const customFields: Record<string, any> = {};\n");
115
+ }
116
+ };
117
+ generateFieldsBarrel();
118
+ // Virtual module aliases — resolve route imports to the user's app files
119
+ updateConfig({
120
+ vite: {
121
+ resolve: {
122
+ alias: {
123
+ "virtual:kide/config": path.resolve(root, configPath),
124
+ "virtual:kide/api": path.resolve(root, generatedPath, "api"),
125
+ "virtual:kide/schema": path.resolve(root, generatedPath, "schema"),
126
+ "virtual:kide/runtime": path.resolve(root, runtimePath),
127
+ "virtual:kide/db": path.resolve(root, adaptersPath, "db"),
128
+ "virtual:kide/email": path.resolve(root, adaptersPath, "email"),
129
+ "virtual:kide/block-renderer": path.resolve(root, "src/components/BlockRenderer.astro"),
130
+ "virtual:kide/admin-css": wrapperCss,
131
+ "virtual:kide/custom-fields": customFieldsBarrel,
132
+ },
133
+ },
134
+ },
135
+ });
136
+ // Inject admin pages
137
+ injectRoute({ pattern: "/admin/login", entrypoint: "@kidecms/core/routes/pages/admin/login.astro" });
138
+ injectRoute({ pattern: "/admin/setup", entrypoint: "@kidecms/core/routes/pages/admin/setup.astro" });
139
+ injectRoute({ pattern: "/admin/invite", entrypoint: "@kidecms/core/routes/pages/admin/invite.astro" });
140
+ injectRoute({ pattern: "/admin/assets", entrypoint: "@kidecms/core/routes/pages/admin/assets/index.astro" });
141
+ injectRoute({ pattern: "/admin/assets/[id]", entrypoint: "@kidecms/core/routes/pages/admin/assets/[id].astro" });
142
+ injectRoute({ pattern: "/admin/[...path]", entrypoint: "@kidecms/core/routes/pages/admin/[...path].astro" });
143
+ // Inject API routes
144
+ injectRoute({ pattern: "/api/cms/auth/login", entrypoint: "@kidecms/core/routes/api/cms/auth/login.ts" });
145
+ injectRoute({ pattern: "/api/cms/auth/logout", entrypoint: "@kidecms/core/routes/api/cms/auth/logout.ts" });
146
+ injectRoute({ pattern: "/api/cms/auth/setup", entrypoint: "@kidecms/core/routes/api/cms/auth/setup.ts" });
147
+ injectRoute({ pattern: "/api/cms/auth/invite", entrypoint: "@kidecms/core/routes/api/cms/auth/invite.ts" });
148
+ injectRoute({ pattern: "/api/cms/assets/upload", entrypoint: "@kidecms/core/routes/api/cms/assets/upload.ts" });
149
+ injectRoute({ pattern: "/api/cms/assets/folders", entrypoint: "@kidecms/core/routes/api/cms/assets/folders.ts" });
150
+ injectRoute({ pattern: "/api/cms/assets/[id]", entrypoint: "@kidecms/core/routes/api/cms/assets/[id].ts" });
151
+ injectRoute({ pattern: "/api/cms/assets", entrypoint: "@kidecms/core/routes/api/cms/assets/index.ts" });
152
+ injectRoute({ pattern: "/api/cms/ai/alt-text", entrypoint: "@kidecms/core/routes/api/cms/ai/alt-text.ts" });
153
+ injectRoute({ pattern: "/api/cms/ai/seo", entrypoint: "@kidecms/core/routes/api/cms/ai/seo.ts" });
154
+ injectRoute({ pattern: "/api/cms/ai/translate", entrypoint: "@kidecms/core/routes/api/cms/ai/translate.ts" });
155
+ injectRoute({ pattern: "/api/cms/cron/publish", entrypoint: "@kidecms/core/routes/api/cms/cron/publish.ts" });
156
+ injectRoute({
157
+ pattern: "/api/cms/locks/[...path]",
158
+ entrypoint: "@kidecms/core/routes/api/cms/locks/[...path].ts",
159
+ });
160
+ injectRoute({ pattern: "/api/cms/preview/render", entrypoint: "@kidecms/core/routes/api/cms/preview/render.ts" });
161
+ injectRoute({
162
+ pattern: "/api/cms/references/[collection]/[id]",
163
+ entrypoint: "@kidecms/core/routes/api/cms/references/[collection]/[id].ts",
164
+ });
165
+ injectRoute({ pattern: "/api/cms/img/[...path]", entrypoint: "@kidecms/core/routes/api/cms/img/[...path].ts" });
166
+ injectRoute({
167
+ pattern: "/api/cms/[collection]/[...path]",
168
+ entrypoint: "@kidecms/core/routes/api/cms/[collection]/[...path].ts",
169
+ });
170
+ // Inject auth middleware
171
+ addMiddleware({ entrypoint: "@kidecms/core/middleware/auth.ts", order: "pre" });
172
+ // Generate schema, types, validators, and API
173
+ console.log(" [cms] Generating schema, types, validators, and API...");
174
+ try {
175
+ runGenerator(root, generatorPath);
176
+ }
177
+ catch (error) {
178
+ console.error(" [cms] Generator failed:", error.message);
179
+ }
180
+ if (command === "dev") {
181
+ const useD1 = isCloudflareD1(root);
182
+ if (useD1) {
183
+ const isFirstRun = !hasLocalD1Database(root);
184
+ if (isFirstRun) {
185
+ console.log(" \x1b[36m[cms]\x1b[0m First run — setting up database...");
186
+ try {
187
+ initLocalD1(root);
188
+ }
189
+ catch (error) {
190
+ console.error(" \x1b[31m[cms]\x1b[0m Failed to initialize D1:", error.message);
191
+ }
192
+ }
193
+ try {
194
+ pushSchema(root);
195
+ if (isFirstRun) {
196
+ console.log(" \x1b[36m[cms]\x1b[0m Database ready. Open /admin to create your admin account.");
197
+ }
198
+ }
199
+ catch (error) {
200
+ console.error(" \x1b[31m[cms]\x1b[0m Database setup failed:", error.message);
201
+ console.error(" \x1b[31m[cms]\x1b[0m Try running: npx drizzle-kit push --force");
202
+ }
203
+ }
204
+ else {
205
+ const dbPath = path.join(root, "data", "cms.db");
206
+ const isFirstRun = !existsSync(dbPath);
207
+ if (isFirstRun) {
208
+ console.log(" \x1b[36m[cms]\x1b[0m First run — setting up database...");
209
+ }
210
+ try {
211
+ mkdirSync(path.join(root, "data"), { recursive: true });
212
+ pushSchema(root);
213
+ if (isFirstRun) {
214
+ console.log(" \x1b[36m[cms]\x1b[0m Database ready. Open /admin to create your admin account.");
215
+ }
216
+ }
217
+ catch (error) {
218
+ console.error(" \x1b[31m[cms]\x1b[0m Database setup failed:", error.message);
219
+ console.error(" \x1b[31m[cms]\x1b[0m Try running: npx drizzle-kit push --force");
220
+ }
221
+ }
222
+ const configFilePath = path.join(root, configPath.replace(/\/?$/, ".ts"));
223
+ let debounceTimer = null;
224
+ watch(configFilePath, () => {
225
+ if (debounceTimer)
226
+ clearTimeout(debounceTimer);
227
+ debounceTimer = setTimeout(() => {
228
+ console.log(" [cms] Config changed, regenerating...");
229
+ try {
230
+ runGenerator(root, generatorPath);
231
+ pushSchema(root);
232
+ console.log(" [cms] Schema updated.");
233
+ }
234
+ catch (error) {
235
+ console.error(" [cms] Regeneration failed:", error.message);
236
+ }
237
+ }, 500);
238
+ });
239
+ }
240
+ },
241
+ "astro:server:start": ({ address }) => {
242
+ const host = address.family === "IPv6" ? `[${address.address}]` : address.address;
243
+ const base = `http://${host === "[::1]" || host === "127.0.0.1" || host === "[::]" ? "localhost" : host}:${address.port}`;
244
+ console.log(` \x1b[36m[cms]\x1b[0m Admin panel: \x1b[36m${base}/admin\x1b[0m`);
245
+ },
246
+ "astro:build:done": () => {
247
+ const entryPath = path.join(process.cwd(), "dist/server/entry.mjs");
248
+ if (!existsSync(entryPath))
249
+ return;
250
+ let content = readFileSync(entryPath, "utf-8");
251
+ content = content.replace(/export\s*\{\s*(\w+)\s+as\s+default\s*\}/, (_, name) => `const _astroWorker = ${name};\nexport default {\n fetch: (...args) => _astroWorker.fetch(...args),\n async scheduled(event, env, ctx) {\n const headers = env.CRON_SECRET ? { Authorization: "Bearer " + env.CRON_SECRET } : {};\n const res = await _astroWorker.fetch(new Request("https://dummy/api/cms/cron/publish", { headers }), env, ctx);\n if (!res.ok) console.error("Cron publish failed:", res.status, await res.text());\n else console.log("Cron publish:", await res.text());\n }\n};`);
252
+ writeFileSync(entryPath, content);
253
+ },
254
+ },
255
+ };
256
+ }
package/dist/locks.js ADDED
@@ -0,0 +1,37 @@
1
+ import { and, eq, lte } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import { getDb } from "./runtime";
4
+ import { getSchema } from "./schema";
5
+ const LOCK_TTL_MS = 5 * 60 * 1000;
6
+ const getLockTable = () => getSchema().cmsLocks;
7
+ const isExpired = (lockedAt) => new Date(lockedAt).getTime() + LOCK_TTL_MS < Date.now();
8
+ export const acquireLock = async (collection, documentId, userId, userEmail) => {
9
+ const db = await getDb();
10
+ const table = getLockTable();
11
+ await db.delete(table).where(lte(table.lockedAt, new Date(Date.now() - LOCK_TTL_MS).toISOString()));
12
+ const rows = await db
13
+ .select()
14
+ .from(table)
15
+ .where(and(eq(table.collection, collection), eq(table.documentId, documentId)));
16
+ const existing = rows[0];
17
+ const now = new Date().toISOString();
18
+ if (existing) {
19
+ if (existing.userId === userId) {
20
+ await db.update(table).set({ lockedAt: now }).where(eq(table._id, existing._id));
21
+ return { acquired: true };
22
+ }
23
+ if (!isExpired(existing.lockedAt)) {
24
+ return { acquired: false, userEmail: existing.userEmail };
25
+ }
26
+ await db.delete(table).where(eq(table._id, existing._id));
27
+ }
28
+ await db.insert(table).values({ _id: nanoid(), collection, documentId, userId, userEmail, lockedAt: now });
29
+ return { acquired: true };
30
+ };
31
+ export const releaseLock = async (collection, documentId, userId) => {
32
+ const db = await getDb();
33
+ const table = getLockTable();
34
+ await db
35
+ .delete(table)
36
+ .where(and(eq(table.collection, collection), eq(table.documentId, documentId), eq(table.userId, userId)));
37
+ };
@@ -0,0 +1 @@
1
+ export { createRichTextFromPlainText, renderRichText, richTextToPlainText } from "./values";
@@ -0,0 +1,26 @@
1
+ let runtime = null;
2
+ const runtimeError = () => new Error("@kidecms/core runtime not initialized. Call configureCmsRuntime(...) and initSchema(...) from your app before using runtime APIs.");
3
+ export const configureCmsRuntime = (config) => {
4
+ runtime = config;
5
+ };
6
+ export const resetCmsRuntime = () => {
7
+ runtime = null;
8
+ };
9
+ export const getCmsRuntime = () => {
10
+ if (!runtime)
11
+ throw runtimeError();
12
+ return runtime;
13
+ };
14
+ export const getDb = () => getCmsRuntime().getDb();
15
+ export const closeDb = () => {
16
+ getCmsRuntime().closeDb?.();
17
+ };
18
+ export const getStorage = () => getCmsRuntime().storage;
19
+ export const getEmail = () => {
20
+ const email = getCmsRuntime().email;
21
+ return (email ?? {
22
+ sendInviteEmail: async () => false,
23
+ isEmailConfigured: () => false,
24
+ });
25
+ };
26
+ export const readEnv = (key) => getCmsRuntime().env?.(key) ?? process.env[key];
package/dist/schema.js ADDED
@@ -0,0 +1,13 @@
1
+ let schema = null;
2
+ export const initSchema = (nextSchema) => {
3
+ schema = nextSchema;
4
+ };
5
+ export const resetSchema = () => {
6
+ schema = null;
7
+ };
8
+ export const getSchema = () => {
9
+ if (!schema) {
10
+ throw new Error("@kidecms/core schema not initialized. Call initSchema(...) from your app before using runtime APIs.");
11
+ }
12
+ return schema;
13
+ };
package/dist/seed.js ADDED
@@ -0,0 +1,84 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import { hashPassword } from "./auth";
4
+ import { getDb } from "./runtime";
5
+ import { getSchema } from "./schema";
6
+ import { cloneValue, slugify } from "./values";
7
+ const isJsonField = (field) => field.type === "richText" ||
8
+ field.type === "array" ||
9
+ field.type === "json" ||
10
+ field.type === "blocks" ||
11
+ (field.type === "relation" && field.hasMany);
12
+ const serializeForDb = (collection, data) => {
13
+ const result = {};
14
+ for (const [key, value] of Object.entries(data)) {
15
+ const field = collection.fields[key];
16
+ result[key] = field && isJsonField(field) && value !== undefined && value !== null ? JSON.stringify(value) : value;
17
+ }
18
+ return result;
19
+ };
20
+ export const seedDatabase = async (config, seedData) => {
21
+ const db = await getDb();
22
+ const schema = getSchema();
23
+ for (const collection of config.collections) {
24
+ const collectionSeed = seedData[collection.slug];
25
+ if (!collectionSeed || collectionSeed.length === 0)
26
+ continue;
27
+ const tables = schema.cmsTables[collection.slug];
28
+ if (!tables)
29
+ continue;
30
+ const countResult = await db.select({ count: sql `count(*)` }).from(tables.main);
31
+ const rowCount = Number(countResult[0]?.count ?? 0);
32
+ if (rowCount > 0)
33
+ continue;
34
+ console.log(` Seeding ${collection.labels.plural}...`);
35
+ for (const seedDoc of collectionSeed) {
36
+ const timestamp = new Date().toISOString();
37
+ const docId = typeof seedDoc._id === "string" ? String(seedDoc._id) : nanoid();
38
+ const { _id, _status, ...fieldData } = seedDoc;
39
+ for (const [fieldName, field] of Object.entries(collection.fields)) {
40
+ if (field.type !== "slug")
41
+ continue;
42
+ if (fieldData[fieldName]) {
43
+ fieldData[fieldName] = slugify(String(fieldData[fieldName]));
44
+ }
45
+ else if (field.from && fieldData[field.from]) {
46
+ fieldData[fieldName] = slugify(String(fieldData[field.from]));
47
+ }
48
+ }
49
+ for (const [fieldName, field] of Object.entries(collection.fields)) {
50
+ if (fieldData[fieldName] === undefined && field.defaultValue !== undefined) {
51
+ fieldData[fieldName] = cloneValue(field.defaultValue);
52
+ }
53
+ }
54
+ if (collection.auth && typeof fieldData.password === "string") {
55
+ fieldData.password = await hashPassword(fieldData.password);
56
+ }
57
+ const serialized = serializeForDb(collection, fieldData);
58
+ const docValues = {
59
+ _id: docId,
60
+ ...serialized,
61
+ };
62
+ if (collection.drafts) {
63
+ const status = _status === "published" ? "published" : "draft";
64
+ docValues._status = status;
65
+ if (status === "published")
66
+ docValues._publishedAt = timestamp;
67
+ }
68
+ if (collection.timestamps !== false) {
69
+ docValues._createdAt = timestamp;
70
+ docValues._updatedAt = timestamp;
71
+ }
72
+ await db.insert(tables.main).values(docValues);
73
+ if (collection.versions && tables.versions) {
74
+ await db.insert(tables.versions).values({
75
+ _id: nanoid(),
76
+ _docId: docId,
77
+ _version: 1,
78
+ _snapshot: JSON.stringify({ ...fieldData, _status: docValues._status }),
79
+ _createdAt: timestamp,
80
+ });
81
+ }
82
+ }
83
+ }
84
+ };
package/dist/values.js ADDED
@@ -0,0 +1,102 @@
1
+ import { cmsImage, cmsSrcset } from "./image";
2
+ export const cloneValue = (value) => JSON.parse(JSON.stringify(value));
3
+ export const slugify = (value) => value
4
+ .normalize("NFKD")
5
+ .replace(/[^\w\s-]/g, "")
6
+ .trim()
7
+ .toLowerCase()
8
+ .replace(/[\s_-]+/g, "-")
9
+ .replace(/^-+|-+$/g, "");
10
+ export const escapeHtml = (value) => value
11
+ .replaceAll("&", "&amp;")
12
+ .replaceAll("<", "&lt;")
13
+ .replaceAll(">", "&gt;")
14
+ .replaceAll('"', "&quot;")
15
+ .replaceAll("'", "&#39;");
16
+ export const createRichTextFromPlainText = (text) => ({
17
+ type: "root",
18
+ children: text
19
+ .split(/\n{2,}/)
20
+ .map((paragraph) => paragraph.trim())
21
+ .filter(Boolean)
22
+ .map((paragraph) => ({
23
+ type: "paragraph",
24
+ children: paragraph
25
+ .split("\n")
26
+ .map((line, index, lines) => `${line}${index < lines.length - 1 ? "\n" : ""}`)
27
+ .filter(Boolean)
28
+ .map((line) => ({ type: "text", value: line })),
29
+ })),
30
+ });
31
+ const renderNode = (node) => {
32
+ if (node.type === "text") {
33
+ let content = escapeHtml(String(node.value ?? ""));
34
+ if (node.bold)
35
+ content = `<strong>${content}</strong>`;
36
+ if (node.italic)
37
+ content = `<em>${content}</em>`;
38
+ if (node.href) {
39
+ const href = escapeHtml(String(node.href));
40
+ const isExternal = String(node.href).startsWith("http://") || String(node.href).startsWith("https://");
41
+ const target = isExternal ? ' target="_blank" rel="noopener noreferrer"' : "";
42
+ content = `<a href="${href}"${target}>${content}</a>`;
43
+ }
44
+ return content;
45
+ }
46
+ if (node.type === "paragraph") {
47
+ return `<p>${(node.children ?? []).map(renderNode).join("")}</p>`;
48
+ }
49
+ if (node.type === "heading") {
50
+ const level = Math.min(Math.max(Number(node.level ?? 2), 1), 6);
51
+ return `<h${level}>${(node.children ?? []).map(renderNode).join("")}</h${level}>`;
52
+ }
53
+ if (node.type === "list") {
54
+ const tag = node.ordered ? "ol" : "ul";
55
+ return `<${tag}>${(node.children ?? []).map(renderNode).join("")}</${tag}>`;
56
+ }
57
+ if (node.type === "list-item") {
58
+ return `<li>${(node.children ?? []).map(renderNode).join("")}</li>`;
59
+ }
60
+ if (node.type === "quote") {
61
+ return `<blockquote>${(node.children ?? []).map(renderNode).join("")}</blockquote>`;
62
+ }
63
+ if (node.type === "image") {
64
+ const src = String(node.src ?? "");
65
+ const alt = escapeHtml(String(node.alt ?? ""));
66
+ const isLocal = src.startsWith("/uploads/");
67
+ if (isLocal) {
68
+ return `<img src="${escapeHtml(cmsImage(src, 1024))}" srcset="${escapeHtml(cmsSrcset(src))}" sizes="(max-width: 768px) 100vw, 768px" alt="${alt}" loading="lazy" class="h-auto max-w-full rounded-lg" />`;
69
+ }
70
+ return `<img src="${escapeHtml(src)}" alt="${alt}" loading="lazy" class="max-w-full rounded-lg" />`;
71
+ }
72
+ return "";
73
+ };
74
+ export const renderRichText = (document) => {
75
+ if (!document || document.type !== "root")
76
+ return "";
77
+ return document.children.map(renderNode).join("");
78
+ };
79
+ export const richTextToPlainText = (document) => {
80
+ if (!document?.children)
81
+ return "";
82
+ const flatten = (node) => {
83
+ if (node.type === "text") {
84
+ return String(node.value ?? "");
85
+ }
86
+ return (node.children ?? []).map(flatten).join("");
87
+ };
88
+ return document.children.map(flatten).join("\n\n").trim();
89
+ };
90
+ export const serializeFieldValue = (field, value) => {
91
+ if (value === null || value === undefined)
92
+ return "";
93
+ if (field.type === "richText")
94
+ return richTextToPlainText(value);
95
+ if (field.type === "array")
96
+ return Array.isArray(value) ? value.map((item) => String(item ?? "")).join(", ") : "";
97
+ if (field.type === "json" || field.type === "blocks")
98
+ return JSON.stringify(value, null, 2);
99
+ if (field.type === "boolean")
100
+ return value ? "true" : "false";
101
+ return String(value);
102
+ };
@@ -0,0 +1,100 @@
1
+ import { defineMiddleware } from "astro:middleware";
2
+ import { getSessionUser } from "virtual:kide/runtime";
3
+ import { getDb } from "virtual:kide/db";
4
+
5
+ let hasUsers: boolean | null = null;
6
+
7
+ export const resetUserCache = () => {
8
+ hasUsers = null;
9
+ };
10
+
11
+ export const onRequest = defineMiddleware(async (context, next) => {
12
+ const { pathname } = context.url;
13
+
14
+ // Skip auth for public pages and static assets
15
+ const isAdminRoute = pathname.startsWith("/admin");
16
+ const isAdminApiRoute = pathname.startsWith("/api/cms");
17
+ const isLoginPage = pathname === "/admin/login";
18
+ const isLoginApi = pathname === "/api/cms/auth/login";
19
+ const isSetupPage = pathname === "/admin/setup";
20
+ const isSetupApi = pathname === "/api/cms/auth/setup";
21
+ const isInvitePage = pathname === "/admin/invite";
22
+ const isInviteApi = pathname === "/api/cms/auth/invite";
23
+
24
+ if (!isAdminRoute && !isAdminApiRoute) {
25
+ return next();
26
+ }
27
+
28
+ // Security headers for all admin routes
29
+ const addSecurityHeaders = (response: Response) => {
30
+ response.headers.set("X-Content-Type-Options", "nosniff");
31
+ response.headers.set("X-Frame-Options", "DENY");
32
+ return response;
33
+ };
34
+
35
+ // CSRF protection: verify Origin on state-changing requests
36
+ if (context.request.method !== "GET" && context.request.method !== "HEAD") {
37
+ const origin = context.request.headers.get("origin");
38
+ const host = context.url.origin;
39
+ if (origin && origin !== host) {
40
+ return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 });
41
+ }
42
+ }
43
+
44
+ // Check if any users exist (cached after first check)
45
+ if (hasUsers === null || !hasUsers) {
46
+ try {
47
+ const db = await getDb();
48
+ const schema = await import("virtual:kide/schema");
49
+ const tables = schema.cmsTables as Record<string, { main: any }>;
50
+ if (tables.users) {
51
+ const rows = await db.select().from(tables.users.main).limit(1);
52
+ hasUsers = rows.length > 0;
53
+ } else {
54
+ hasUsers = true;
55
+ }
56
+ } catch {
57
+ // Tables may not exist yet (first run, schema not pushed)
58
+ hasUsers = false;
59
+ }
60
+ }
61
+
62
+ // No users yet — redirect to setup
63
+ if (!hasUsers) {
64
+ if (isSetupPage || isSetupApi) return addSecurityHeaders(await next());
65
+ if (isAdminApiRoute) {
66
+ return new Response(JSON.stringify({ error: "Setup required" }), { status: 403 });
67
+ }
68
+ return context.redirect("/admin/setup");
69
+ }
70
+
71
+ // After setup, always allow setup API (it self-guards) but redirect setup page to login
72
+ if (isSetupPage) {
73
+ return context.redirect("/admin/login");
74
+ }
75
+
76
+ // Always allow login page, login API, and cron endpoint (has its own auth)
77
+ const isCronApi = pathname === "/api/cms/cron/publish";
78
+ if (isLoginPage || isLoginApi || isSetupApi || isCronApi || isInvitePage || isInviteApi) {
79
+ return addSecurityHeaders(await next());
80
+ }
81
+
82
+ const user = await getSessionUser(context.request);
83
+
84
+ if (!user) {
85
+ // API routes → 401
86
+ if (isAdminApiRoute) {
87
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
88
+ status: 401,
89
+ headers: { "Content-Type": "application/json" },
90
+ });
91
+ }
92
+ // Admin pages → redirect to login
93
+ return context.redirect("/admin/login");
94
+ }
95
+
96
+ // Attach user to locals for downstream use
97
+ context.locals.user = user;
98
+
99
+ return addSecurityHeaders(await next());
100
+ });