koguma 0.6.6 → 2.0.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 (44) hide show
  1. package/README.md +109 -139
  2. package/cli/auth.ts +101 -0
  3. package/cli/config.ts +149 -0
  4. package/cli/constants.ts +38 -0
  5. package/cli/content.ts +503 -0
  6. package/cli/dev-sync.ts +305 -0
  7. package/cli/exec.ts +61 -0
  8. package/cli/index.ts +779 -1545
  9. package/cli/log.ts +49 -0
  10. package/cli/preflight.ts +105 -0
  11. package/cli/scaffold.ts +680 -0
  12. package/cli/typegen.ts +190 -0
  13. package/cli/ui.ts +55 -0
  14. package/cli/wrangler.ts +367 -0
  15. package/package.json +7 -4
  16. package/src/admin/_bundle.ts +1 -1
  17. package/src/api/router.integration.test.ts +63 -80
  18. package/src/api/router.ts +85 -59
  19. package/src/config/define.ts +1 -1
  20. package/src/config/field.ts +10 -9
  21. package/src/config/index.ts +1 -13
  22. package/src/config/meta.ts +7 -7
  23. package/src/config/types.ts +1 -95
  24. package/src/db/init.ts +68 -0
  25. package/src/db/queries.ts +120 -211
  26. package/src/db/sql.ts +10 -25
  27. package/src/media/index.ts +105 -47
  28. package/src/react/Markdown.test.tsx +195 -0
  29. package/src/react/Markdown.tsx +40 -0
  30. package/src/react/index.ts +6 -22
  31. package/src/react/types.ts +3 -112
  32. package/src/db/migrate.ts +0 -182
  33. package/src/db/schema.ts +0 -122
  34. package/src/react/RichText.test.tsx +0 -535
  35. package/src/react/RichText.tsx +0 -350
  36. package/src/rich-text/index.ts +0 -4
  37. package/src/rich-text/koguma-to-lexical.ts +0 -340
  38. package/src/rich-text/lexical-compat.test.ts +0 -513
  39. package/src/rich-text/lexical-to-koguma.test.ts +0 -906
  40. package/src/rich-text/lexical-to-koguma.ts +0 -400
  41. package/src/rich-text/markdown-to-koguma.ts +0 -164
  42. package/src/rich-text/plain.test.ts +0 -208
  43. package/src/rich-text/plain.ts +0 -114
  44. package/src/rich-text/snapshots.test.ts +0 -284
@@ -3,13 +3,11 @@
3
3
  *
4
4
  * Integration tests for the Hono API router.
5
5
  *
6
- * Tests the critical invariant that was broken by the initial implementation:
7
- * - Public routes (/api/content/*) → richText fields are KogumaDocument
8
- * - Admin routes (/api/admin/*) → richText fields stay as raw Lexical JSON
6
+ * V2: markdown fields are stored as markdown strings (not Lexical JSON).
7
+ * Both public and admin routes return the same markdown value.
9
8
  *
10
- * This is the exact bug class that broke the production admin editor.
11
- *
12
- * Also covers: CRUD, auth gating, schema endpoint, 404 handling.
9
+ * Also covers: CRUD, auth gating, schema endpoint, 404 handling,
10
+ * and the media PATCH rename endpoint.
13
11
  */
14
12
  import { describe, test, expect } from 'bun:test';
15
13
  import { createApiRouter } from './router.ts';
@@ -17,35 +15,37 @@ import { contentType, field, defineConfig } from '../config/index.ts';
17
15
 
18
16
  // ── Minimal in-memory D1 stub ─────────────────────────────────────────────
19
17
 
20
- const SAMPLE_LEXICAL_JSON = {
21
- root: {
22
- children: [
23
- {
24
- type: 'paragraph',
25
- version: 1,
26
- children: [{ type: 'text', text: 'Hello world', version: 1, format: 0 }]
27
- }
28
- ],
29
- type: 'root',
30
- version: 1
31
- }
18
+ const SAMPLE_MARKDOWN = '## Hello\n\nThis is a **bold** paragraph.';
19
+
20
+ // The new schema stores all entry fields inside a JSON `data` blob,
21
+ // with system columns (id, content_type, slug, status, etc.) at top level.
22
+ const ENTRY_ROW = {
23
+ id: 'entry-1',
24
+ content_type: 'article',
25
+ slug: 'test-entry',
26
+ data: JSON.stringify({
27
+ title: 'Test Entry',
28
+ body: SAMPLE_MARKDOWN
29
+ }),
30
+ status: 'published',
31
+ publish_at: null,
32
+ created_at: '2025-01-01T00:00:00.000Z',
33
+ updated_at: '2025-01-01T00:00:00.000Z'
32
34
  };
33
35
 
34
- const SAMPLE_LEXICAL_STRING = JSON.stringify(SAMPLE_LEXICAL_JSON);
36
+ // Asset row (JSON data blob)
37
+ const ASSET_ROW = {
38
+ id: 'asset-1',
39
+ data: JSON.stringify({
40
+ title: 'Original Title',
41
+ url: '/api/media/asset-1.jpg',
42
+ content_type: 'image/jpeg'
43
+ }),
44
+ created_at: '2025-01-01T00:00:00.000Z',
45
+ updated_at: '2025-01-01T00:00:00.000Z'
46
+ };
35
47
 
36
48
  function createFakeDb() {
37
- const entries: Record<string, Record<string, unknown>> = {
38
- 'entry-1': {
39
- id: 'entry-1',
40
- title: 'Test Entry',
41
- body: SAMPLE_LEXICAL_STRING,
42
- status: 'published',
43
- created_at: '2025-01-01T00:00:00.000Z',
44
- updated_at: '2025-01-01T00:00:00.000Z',
45
- publishAt: null
46
- }
47
- };
48
-
49
49
  return {
50
50
  prepare(sql: string) {
51
51
  let boundArgs: unknown[] = [];
@@ -59,21 +59,25 @@ function createFakeDb() {
59
59
  if (sql.includes('_sessions')) return null;
60
60
  // User lookup for login tests
61
61
  if (sql.includes('_admin_users')) return null;
62
- // SELECT by id
63
- if (sql.includes('WHERE id = ?')) {
64
- const id = boundArgs[0] as string;
65
- return (entries[id] ?? null) as T | null;
62
+ // SELECT from entries by content_type + id
63
+ if (sql.includes('FROM entries') && sql.includes('id = ?')) {
64
+ const id = boundArgs[1] ?? boundArgs[0];
65
+ if (id === 'entry-1') return ENTRY_ROW as T;
66
+ return null;
67
+ }
68
+ // SELECT from assets by id
69
+ if (sql.includes('FROM assets') && sql.includes('id = ?')) {
70
+ const id = boundArgs[0];
71
+ if (id === 'asset-1') return ASSET_ROW as T;
72
+ return null;
66
73
  }
67
74
  return null;
68
75
  },
69
76
  async all<T = Record<string, unknown>>() {
70
- // join table for refs return empty
71
- if (sql.includes('__'))
72
- return { results: [] as T[], success: true, meta: {} };
73
- // SELECT * FROM article
74
- if (sql.includes('FROM article')) {
77
+ // SELECT * FROM entries WHERE content_type = ?
78
+ if (sql.includes('FROM entries')) {
75
79
  return {
76
- results: Object.values(entries) as T[],
80
+ results: [ENTRY_ROW] as T[],
77
81
  success: true,
78
82
  meta: {}
79
83
  };
@@ -105,7 +109,7 @@ const articleType = contentType({
105
109
  displayField: 'title',
106
110
  fields: {
107
111
  title: field.text('Title').required(),
108
- body: field.richText('Body')
112
+ body: field.markdown('Body')
109
113
  }
110
114
  });
111
115
 
@@ -142,18 +146,15 @@ describe('Public content routes', () => {
142
146
  expect(Array.isArray(body.entries)).toBe(true);
143
147
  });
144
148
 
145
- test('richText fields are KogumaDocument (not raw Lexical JSON)', async () => {
149
+ test('markdown fields are markdown strings (V2)', async () => {
146
150
  const res = await makeRequest('/api/content/article');
147
151
  const body = (await res.json()) as {
148
152
  entries: Array<Record<string, unknown>>;
149
153
  };
150
154
  const entry = body.entries[0]!;
151
- // KogumaDocument has a `nodes` array at top level
152
- const doc = entry.body as Record<string, unknown>;
153
- expect(doc).toHaveProperty('nodes');
154
- expect(Array.isArray(doc.nodes)).toBe(true);
155
- // Must NOT be raw Lexical JSON (which has a `root` key)
156
- expect(doc).not.toHaveProperty('root');
155
+ // V2: markdown field is stored as a markdown string, returned as-is
156
+ expect(typeof entry.body).toBe('string');
157
+ expect(entry.body).toBe(SAMPLE_MARKDOWN);
157
158
  });
158
159
 
159
160
  test('returns 404 for unknown content type', async () => {
@@ -170,12 +171,11 @@ describe('Public content routes', () => {
170
171
  expect(body.id).toBe('entry-1');
171
172
  });
172
173
 
173
- test('richText field is KogumaDocument on single-entry public route', async () => {
174
+ test('markdown field is markdown string on single-entry public route', async () => {
174
175
  const res = await makeRequest('/api/content/article/entry-1');
175
176
  const body = (await res.json()) as Record<string, unknown>;
176
- const doc = body.body as Record<string, unknown>;
177
- expect(doc).toHaveProperty('nodes');
178
- expect(doc).not.toHaveProperty('root');
177
+ expect(typeof body.body).toBe('string');
178
+ expect(body.body).toBe(SAMPLE_MARKDOWN);
179
179
  });
180
180
 
181
181
  test('returns 404 for missing entry', async () => {
@@ -205,34 +205,21 @@ async function makeSessionCookie(secret: string): Promise<string> {
205
205
  return `koguma_session=${token}`;
206
206
  }
207
207
 
208
- describe('Admin routes — richText format invariant (authenticated)', () => {
209
- /**
210
- * THE REGRESSION TEST.
211
- *
212
- * This test would have caught the original bug where lexicalToKoguma() was
213
- * applied in queries.ts (shared by all routes), so the admin route returned
214
- * KogumaDocument instead of raw Lexical JSON and the editor crashed.
215
- *
216
- * If this test fails, it means admin GET routes are returning KogumaDocument
217
- * format, which will break the Lexical editor.
218
- */
219
- test('GET /api/admin/:type/:id returns raw Lexical JSON (has `root`, not `nodes`)', async () => {
208
+ describe('Admin routes — markdown format (authenticated)', () => {
209
+ test('GET /api/admin/:type/:id returns markdown string for markdown field', async () => {
220
210
  const cookie = await makeSessionCookie('test-secret');
221
211
  const res = await makeRequest('/api/admin/article/entry-1', {
222
212
  headers: { Cookie: cookie }
223
213
  });
224
214
  expect(res.status).toBe(200);
225
215
  const body = (await res.json()) as Record<string, unknown>;
226
- const doc = body.body as Record<string, unknown>;
227
-
228
- // Admin must return raw Lexical JSON (editor needs this to parse editor state)
229
- expect(doc).toHaveProperty('root');
230
216
 
231
- // Must NOT be KogumaDocument (which would crash the Lexical editor)
232
- expect(doc).not.toHaveProperty('nodes');
217
+ // V2: Admin route also returns markdown string (same as public)
218
+ expect(typeof body.body).toBe('string');
219
+ expect(body.body).toBe(SAMPLE_MARKDOWN);
233
220
  });
234
221
 
235
- test('GET /api/admin/:type returns list with raw Lexical JSON richText', async () => {
222
+ test('GET /api/admin/:type returns list with markdown field', async () => {
236
223
  const cookie = await makeSessionCookie('test-secret');
237
224
  const res = await makeRequest('/api/admin/article', {
238
225
  headers: { Cookie: cookie }
@@ -242,10 +229,9 @@ describe('Admin routes — richText format invariant (authenticated)', () => {
242
229
  entries: Array<Record<string, unknown>>;
243
230
  };
244
231
  const entry = body.entries[0]!;
245
- const doc = entry.body as Record<string, unknown>;
246
232
 
247
- expect(doc).toHaveProperty('root');
248
- expect(doc).not.toHaveProperty('nodes');
233
+ expect(typeof entry.body).toBe('string');
234
+ expect(entry.body).toBe(SAMPLE_MARKDOWN);
249
235
  });
250
236
  });
251
237
 
@@ -295,10 +281,7 @@ describe('Schema endpoint', () => {
295
281
 
296
282
  describe('Media admin routes — PATCH rename', () => {
297
283
  test('PATCH /api/admin/media/:id renames asset and returns 200', async () => {
298
- // This test would have caught the original bug:
299
- // The D1 stub's run() returned meta:{} (no `changes`), causing the handler
300
- // to call c.notFound() even for existing assets.
301
- const cookie = await makeSessionCookie("test-secret");
284
+ const cookie = await makeSessionCookie('test-secret');
302
285
  const res = await makeRequest('/api/admin/media/asset-1', {
303
286
  method: 'PATCH',
304
287
  headers: {
@@ -315,7 +298,7 @@ describe('Media admin routes — PATCH rename', () => {
315
298
  });
316
299
 
317
300
  test('PATCH /api/admin/media/:id returns 400 when title is empty', async () => {
318
- const cookie = await makeSessionCookie("test-secret");
301
+ const cookie = await makeSessionCookie('test-secret');
319
302
  const res = await makeRequest('/api/admin/media/asset-1', {
320
303
  method: 'PATCH',
321
304
  headers: {
package/src/api/router.ts CHANGED
@@ -31,7 +31,6 @@ import {
31
31
  updateEntry,
32
32
  deleteEntry
33
33
  } from '../db/queries.ts';
34
- import { lexicalToKoguma } from '../rich-text/index.ts';
35
34
  import { handleLogin, handleLogout, requireAuth } from '../auth/index.ts';
36
35
  import {
37
36
  serveMedia,
@@ -55,6 +54,25 @@ export function createApiRouter(config: KogumaConfig): Hono {
55
54
  ctMap.set(ct.id, ct);
56
55
  }
57
56
 
57
+ // ── Dev sync webhook ──────────────────────────────────────────────
58
+ // Fire-and-forget notification to the dev sync server.
59
+ // Only active when KOGUMA_DEV_SYNC env var is set (by `koguma dev`).
60
+ function notifyDevSync(
61
+ env: Record<string, unknown>,
62
+ action: 'create' | 'update' | 'delete',
63
+ contentType: string,
64
+ entry?: Record<string, unknown> | null,
65
+ entryId?: string
66
+ ): void {
67
+ const url = env.KOGUMA_DEV_SYNC as string | undefined;
68
+ if (!url) return;
69
+ fetch(`${url}/sync`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify({ action, contentType, entry, entryId })
73
+ }).catch(() => {}); // fire-and-forget
74
+ }
75
+
58
76
  // ── Reference resolution ─────────────────────────────────────────
59
77
  // Resolves ref, refs, and image fields from flat IDs into nested objects.
60
78
  // depth=1 resolves the entry's refs; depth=2 resolves refs-of-refs.
@@ -71,32 +89,46 @@ export function createApiRouter(config: KogumaConfig): Hono {
71
89
  if (meta.fieldType === 'image') {
72
90
  const assetId = entry[fieldId] as string | null;
73
91
  if (assetId) {
74
- const asset = await db
75
- .prepare('SELECT * FROM _assets WHERE id = ?')
92
+ const row = await db
93
+ .prepare('SELECT * FROM assets WHERE id = ?')
76
94
  .bind(assetId)
77
95
  .first();
78
- resolved[fieldId] = asset ?? null;
96
+ if (row) {
97
+ const data =
98
+ typeof row.data === 'string'
99
+ ? JSON.parse(row.data as string)
100
+ : row.data;
101
+ resolved[fieldId] = { id: row.id, ...data };
102
+ } else {
103
+ resolved[fieldId] = null;
104
+ }
79
105
  }
80
106
  } else if (meta.fieldType === 'images') {
81
- // images stores a JSON array of asset IDs — resolve each
82
- const raw = entry[fieldId] as string | null;
107
+ // images stores an array of asset IDs — resolve each
108
+ const raw = entry[fieldId];
83
109
  if (raw) {
84
- try {
85
- const ids = typeof raw === 'string' ? JSON.parse(raw) : raw;
86
- if (Array.isArray(ids) && ids.length > 0) {
87
- const assets = await Promise.all(
88
- ids.map((id: string) =>
89
- db
90
- .prepare('SELECT * FROM _assets WHERE id = ?')
91
- .bind(id)
92
- .first()
93
- )
94
- );
95
- resolved[fieldId] = assets.filter(Boolean);
96
- } else {
97
- resolved[fieldId] = [];
98
- }
99
- } catch {
110
+ const ids = Array.isArray(raw)
111
+ ? raw
112
+ : typeof raw === 'string'
113
+ ? JSON.parse(raw)
114
+ : [];
115
+ if (Array.isArray(ids) && ids.length > 0) {
116
+ const assets = await Promise.all(
117
+ ids.map(async (id: string) => {
118
+ const row = await db
119
+ .prepare('SELECT * FROM assets WHERE id = ?')
120
+ .bind(id)
121
+ .first();
122
+ if (!row) return null;
123
+ const data =
124
+ typeof row.data === 'string'
125
+ ? JSON.parse(row.data as string)
126
+ : row.data;
127
+ return { id: row.id, ...data };
128
+ })
129
+ );
130
+ resolved[fieldId] = assets.filter(Boolean);
131
+ } else {
100
132
  resolved[fieldId] = [];
101
133
  }
102
134
  }
@@ -105,7 +137,7 @@ export function createApiRouter(config: KogumaConfig): Hono {
105
137
  if (refId) {
106
138
  const refCt = ctMap.get(meta.refContentType);
107
139
  if (refCt) {
108
- const refEntry = await getEntry(db, refCt, refId);
140
+ const refEntry = await getEntry(db, meta.refContentType, refId);
109
141
  resolved[fieldId] = refEntry
110
142
  ? await resolveEntry(db, refEntry, refCt, depth - 1)
111
143
  : null;
@@ -118,7 +150,7 @@ export function createApiRouter(config: KogumaConfig): Hono {
118
150
  if (refCt) {
119
151
  const items = await Promise.all(
120
152
  ids.map(async id => {
121
- const refEntry = await getEntry(db, refCt, id);
153
+ const refEntry = await getEntry(db, meta.refContentType!, id);
122
154
  return refEntry
123
155
  ? resolveEntry(db, refEntry, refCt, depth - 1)
124
156
  : null;
@@ -133,24 +165,6 @@ export function createApiRouter(config: KogumaConfig): Hono {
133
165
  return resolved;
134
166
  }
135
167
 
136
- /**
137
- * Converts raw Lexical JSON richText fields → KogumaDocument.
138
- * Applied only to public /api/content/* responses.
139
- * Admin routes receive raw Lexical JSON so the editor can parse it.
140
- */
141
- function convertRichTextFields(
142
- entry: Record<string, unknown>,
143
- ct: ContentTypeConfig
144
- ): Record<string, unknown> {
145
- const out = { ...entry };
146
- for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
147
- if (meta.fieldType === 'richText' && out[fieldId] != null) {
148
- out[fieldId] = lexicalToKoguma(out[fieldId] as Record<string, unknown>);
149
- }
150
- }
151
- return out;
152
- }
153
-
154
168
  // ── Auth routes ──────────────────────────────────────────────────
155
169
  app.post('/api/auth/login', handleLogin);
156
170
  app.post('/api/auth/logout', handleLogout);
@@ -166,14 +180,14 @@ export function createApiRouter(config: KogumaConfig): Hono {
166
180
  typeof getEntries
167
181
  >[0];
168
182
  const includeDrafts = c.req.query('drafts') === 'true';
169
- const entries = await getEntries(db, ct, { publishedOnly: !includeDrafts });
183
+ const entries = await getEntries(db, ct.id, {
184
+ publishedOnly: !includeDrafts
185
+ });
170
186
  // Always resolve references for public API
171
187
  const resolved = await Promise.all(
172
188
  entries.map(e => resolveEntry(db, e, ct, 2))
173
189
  );
174
- // Convert Lexical JSON → KogumaDocument for public consumers
175
- const converted = resolved.map(e => convertRichTextFields(e, ct));
176
- return c.json({ entries: converted });
190
+ return c.json({ entries: resolved });
177
191
  });
178
192
 
179
193
  app.get('/api/content/:type/:id', async c => {
@@ -183,11 +197,10 @@ export function createApiRouter(config: KogumaConfig): Hono {
183
197
  const db = (c.env as Record<string, unknown>).DB as Parameters<
184
198
  typeof getEntry
185
199
  >[0];
186
- const entry = await getEntry(db, ct, c.req.param('id'));
200
+ const entry = await getEntry(db, ct.id, c.req.param('id'));
187
201
  if (!entry) return c.notFound();
188
202
  const resolved = await resolveEntry(db, entry, ct, 2);
189
- // Convert Lexical JSON → KogumaDocument for public consumers
190
- return c.json(convertRichTextFields(resolved, ct));
203
+ return c.json(resolved);
191
204
  });
192
205
 
193
206
  // ── Media serving (public) ───────────────────────────────────────
@@ -234,7 +247,7 @@ export function createApiRouter(config: KogumaConfig): Hono {
234
247
  const db = (c.env as Record<string, unknown>).DB as Parameters<
235
248
  typeof getEntries
236
249
  >[0];
237
- const entries = await getEntries(db, ct);
250
+ const entries = await getEntries(db, ct.id);
238
251
  return c.json({ entries });
239
252
  });
240
253
 
@@ -245,7 +258,7 @@ export function createApiRouter(config: KogumaConfig): Hono {
245
258
  const db = (c.env as Record<string, unknown>).DB as Parameters<
246
259
  typeof getEntry
247
260
  >[0];
248
- const entry = await getEntry(db, ct, c.req.param('id'));
261
+ const entry = await getEntry(db, ct.id, c.req.param('id'));
249
262
  if (!entry) return c.notFound();
250
263
  return c.json(entry);
251
264
  });
@@ -280,7 +293,8 @@ export function createApiRouter(config: KogumaConfig): Hono {
280
293
  return c.json({ error: 'Validation failed', issues }, 422);
281
294
  }
282
295
 
283
- const entry = await createEntry(db, ct, data);
296
+ const entry = await createEntry(db, ct.id, data);
297
+ notifyDevSync(c.env as Record<string, unknown>, 'create', ct.id, entry);
284
298
  return c.json(entry, 201);
285
299
  });
286
300
 
@@ -316,8 +330,9 @@ export function createApiRouter(config: KogumaConfig): Hono {
316
330
  return c.json({ error: 'Validation failed', issues }, 422);
317
331
  }
318
332
 
319
- const entry = await updateEntry(db, ct, c.req.param('id'), data);
333
+ const entry = await updateEntry(db, ct.id, c.req.param('id'), data);
320
334
  if (!entry) return c.notFound();
335
+ notifyDevSync(c.env as Record<string, unknown>, 'update', ct.id, entry);
321
336
  return c.json(entry);
322
337
  });
323
338
 
@@ -329,16 +344,17 @@ export function createApiRouter(config: KogumaConfig): Hono {
329
344
  const db = (c.env as Record<string, unknown>).DB as Parameters<
330
345
  typeof getEntries
331
346
  >[0];
332
- const entries = await getEntries(db, ct);
347
+ const entries = await getEntries(db, ct.id);
333
348
  if (entries.length === 0) return c.json({ error: 'No entry found' }, 404);
334
349
 
335
350
  const data = await c.req.json();
336
351
  const entry = await updateEntry(
337
352
  db as Parameters<typeof updateEntry>[0],
338
- ct,
353
+ ct.id,
339
354
  entries[0]!.id as string,
340
355
  data
341
356
  );
357
+ notifyDevSync(c.env as Record<string, unknown>, 'update', ct.id, entry);
342
358
  return c.json(entry);
343
359
  });
344
360
 
@@ -349,7 +365,14 @@ export function createApiRouter(config: KogumaConfig): Hono {
349
365
  const db = (c.env as Record<string, unknown>).DB as Parameters<
350
366
  typeof deleteEntry
351
367
  >[0];
352
- await deleteEntry(db, ct, c.req.param('id'));
368
+ await deleteEntry(db, ct.id, c.req.param('id'));
369
+ notifyDevSync(
370
+ c.env as Record<string, unknown>,
371
+ 'delete',
372
+ ct.id,
373
+ null,
374
+ c.req.param('id')
375
+ );
353
376
  return c.json({ ok: true });
354
377
  });
355
378
 
@@ -361,11 +384,12 @@ export function createApiRouter(config: KogumaConfig): Hono {
361
384
  const db = (c.env as Record<string, unknown>).DB as Parameters<
362
385
  typeof updateEntry
363
386
  >[0];
364
- const entry = await updateEntry(db, ct, c.req.param('id'), {
387
+ const entry = await updateEntry(db, ct.id, c.req.param('id'), {
365
388
  status: 'published',
366
389
  publishAt: null // clear scheduled time — publish immediately
367
390
  });
368
391
  if (!entry) return c.notFound();
392
+ notifyDevSync(c.env as Record<string, unknown>, 'update', ct.id, entry);
369
393
  return c.json(entry);
370
394
  });
371
395
 
@@ -376,11 +400,12 @@ export function createApiRouter(config: KogumaConfig): Hono {
376
400
  const db = (c.env as Record<string, unknown>).DB as Parameters<
377
401
  typeof updateEntry
378
402
  >[0];
379
- const entry = await updateEntry(db, ct, c.req.param('id'), {
403
+ const entry = await updateEntry(db, ct.id, c.req.param('id'), {
380
404
  status: 'draft',
381
405
  publishAt: null // clear scheduled time
382
406
  });
383
407
  if (!entry) return c.notFound();
408
+ notifyDevSync(c.env as Record<string, unknown>, 'update', ct.id, entry);
384
409
  return c.json(entry);
385
410
  });
386
411
 
@@ -396,11 +421,12 @@ export function createApiRouter(config: KogumaConfig): Hono {
396
421
  if (!body.publishAt) {
397
422
  return c.json({ error: 'publishAt is required' }, 400);
398
423
  }
399
- const entry = await updateEntry(db, ct, c.req.param('id'), {
424
+ const entry = await updateEntry(db, ct.id, c.req.param('id'), {
400
425
  status: 'published',
401
426
  publishAt: body.publishAt
402
427
  });
403
428
  if (!entry) return c.notFound();
429
+ notifyDevSync(c.env as Record<string, unknown>, 'update', ct.id, entry);
404
430
  return c.json(entry);
405
431
  });
406
432
 
@@ -59,7 +59,7 @@ export interface ContentTypeConfig<S extends z.ZodType = z.ZodType> {
59
59
  * displayField: "title",
60
60
  * fields: {
61
61
  * title: field.text("Title").required(),
62
- * body: field.richText("Body"),
62
+ * body: field.markdown("Body"),
63
63
  * },
64
64
  * });
65
65
  *
@@ -5,19 +5,19 @@
5
5
  * The admin label is always the first argument for readability:
6
6
  *
7
7
  * field.text("Page Title").required().max(100)
8
- * field.richText("Body Text")
8
+ * field.markdown("Body Text")
9
9
  * field.image("Hero Photo")
10
10
  * field.refs("featureCard", "Highlights")
11
11
  */
12
12
  import { z } from 'zod/v4';
13
- import type { KogumaAsset, KogumaDocument, EntryReference } from './types.ts';
13
+ import type { KogumaAsset, EntryReference } from './types.ts';
14
14
 
15
15
  // ── Field metadata (extracted by the admin + schema generator) ──────
16
16
 
17
17
  export type FieldType =
18
18
  | 'text'
19
19
  | 'longText'
20
- | 'richText'
20
+ | 'markdown'
21
21
  | 'url'
22
22
  | 'email'
23
23
  | 'phone'
@@ -116,12 +116,13 @@ export const field = {
116
116
  });
117
117
  },
118
118
 
119
- /** Rich text field (Lexical rich text document) */
120
- richText(label: string) {
121
- return new FieldBuilder(
122
- z.custom<KogumaDocument>().optional().describe(label),
123
- { label, fieldType: 'richText', required: false }
124
- );
119
+ /** Markdown field (WYSIWYG editor, stored as markdown string) */
120
+ markdown(label: string) {
121
+ return new FieldBuilder(z.string().optional().describe(label), {
122
+ label,
123
+ fieldType: 'markdown',
124
+ required: false
125
+ });
125
126
  },
126
127
 
127
128
  /** URL field with validation */
@@ -20,19 +20,7 @@ export { field } from './field.ts';
20
20
  export type { FieldBuilder, FieldType, FieldMeta } from './field.ts';
21
21
 
22
22
  // Runtime types
23
- export type {
24
- KogumaAsset,
25
- KogumaDocument,
26
- KogumaBlockNode,
27
- KogumaInlineNode,
28
- KogumaListItem,
29
- KogumaTableRow,
30
- KogumaTableCell,
31
- EntryReference
32
- } from './types.ts';
33
-
34
- // Rich text utilities
35
- export { richTextToPlain } from '../rich-text/index.ts';
23
+ export type { KogumaAsset, EntryReference } from './types.ts';
36
24
 
37
25
  // Meta registry (for dev tools like the Schema Builder)
38
26
  export { fieldRegistry, fieldSuggestions } from './meta.ts';
@@ -43,11 +43,11 @@ export const fieldRegistry: FieldTypeMeta[] = [
43
43
  modifiers: ['required', 'min', 'max', 'default']
44
44
  },
45
45
  {
46
- type: 'richText',
47
- label: 'Rich Text',
46
+ type: 'markdown',
47
+ label: 'Markdown',
48
48
  icon: '📝',
49
49
  category: 'basics',
50
- description: 'Rich text editor',
50
+ description: 'Markdown editor',
51
51
  args: 'label',
52
52
  modifiers: ['required']
53
53
  },
@@ -205,10 +205,10 @@ export const fieldSuggestions: Record<string, FieldSuggestion> = {
205
205
  bio: { type: 'longText' },
206
206
  notes: { type: 'longText' },
207
207
  excerpt: { type: 'longText' },
208
- // Rich text
209
- body: { type: 'richText' },
210
- content: { type: 'richText' },
211
- story: { type: 'richText' },
208
+ // Markdown
209
+ body: { type: 'markdown' },
210
+ content: { type: 'markdown' },
211
+ story: { type: 'markdown' },
212
212
  // URL
213
213
  email: { type: 'email' }, // was incorrectly 'url'
214
214
  website: { type: 'url' },