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.
- package/README.md +109 -139
- package/cli/auth.ts +101 -0
- package/cli/config.ts +149 -0
- package/cli/constants.ts +38 -0
- package/cli/content.ts +503 -0
- package/cli/dev-sync.ts +305 -0
- package/cli/exec.ts +61 -0
- package/cli/index.ts +779 -1545
- package/cli/log.ts +49 -0
- package/cli/preflight.ts +105 -0
- package/cli/scaffold.ts +680 -0
- package/cli/typegen.ts +190 -0
- package/cli/ui.ts +55 -0
- package/cli/wrangler.ts +367 -0
- package/package.json +7 -4
- package/src/admin/_bundle.ts +1 -1
- package/src/api/router.integration.test.ts +63 -80
- package/src/api/router.ts +85 -59
- package/src/config/define.ts +1 -1
- package/src/config/field.ts +10 -9
- package/src/config/index.ts +1 -13
- package/src/config/meta.ts +7 -7
- package/src/config/types.ts +1 -95
- package/src/db/init.ts +68 -0
- package/src/db/queries.ts +120 -211
- package/src/db/sql.ts +10 -25
- package/src/media/index.ts +105 -47
- package/src/react/Markdown.test.tsx +195 -0
- package/src/react/Markdown.tsx +40 -0
- package/src/react/index.ts +6 -22
- package/src/react/types.ts +3 -112
- package/src/db/migrate.ts +0 -182
- package/src/db/schema.ts +0 -122
- package/src/react/RichText.test.tsx +0 -535
- package/src/react/RichText.tsx +0 -350
- package/src/rich-text/index.ts +0 -4
- package/src/rich-text/koguma-to-lexical.ts +0 -340
- package/src/rich-text/lexical-compat.test.ts +0 -513
- package/src/rich-text/lexical-to-koguma.test.ts +0 -906
- package/src/rich-text/lexical-to-koguma.ts +0 -400
- package/src/rich-text/markdown-to-koguma.ts +0 -164
- package/src/rich-text/plain.test.ts +0 -208
- package/src/rich-text/plain.ts +0 -114
- 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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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('
|
|
64
|
-
const id = boundArgs[
|
|
65
|
-
|
|
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
|
-
//
|
|
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:
|
|
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.
|
|
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('
|
|
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
|
-
//
|
|
152
|
-
|
|
153
|
-
expect(
|
|
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('
|
|
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
|
-
|
|
177
|
-
expect(
|
|
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 —
|
|
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
|
-
//
|
|
232
|
-
expect(
|
|
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
|
|
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(
|
|
248
|
-
expect(
|
|
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
|
-
|
|
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(
|
|
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
|
|
75
|
-
.prepare('SELECT * FROM
|
|
92
|
+
const row = await db
|
|
93
|
+
.prepare('SELECT * FROM assets WHERE id = ?')
|
|
76
94
|
.bind(assetId)
|
|
77
95
|
.first();
|
|
78
|
-
|
|
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
|
|
82
|
-
const raw = entry[fieldId]
|
|
107
|
+
// images stores an array of asset IDs — resolve each
|
|
108
|
+
const raw = entry[fieldId];
|
|
83
109
|
if (raw) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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,
|
|
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,
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/config/define.ts
CHANGED
package/src/config/field.ts
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
-
| '
|
|
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
|
-
/**
|
|
120
|
-
|
|
121
|
-
return new FieldBuilder(
|
|
122
|
-
|
|
123
|
-
|
|
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 */
|
package/src/config/index.ts
CHANGED
|
@@ -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';
|
package/src/config/meta.ts
CHANGED
|
@@ -43,11 +43,11 @@ export const fieldRegistry: FieldTypeMeta[] = [
|
|
|
43
43
|
modifiers: ['required', 'min', 'max', 'default']
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
|
-
type: '
|
|
47
|
-
label: '
|
|
46
|
+
type: 'markdown',
|
|
47
|
+
label: 'Markdown',
|
|
48
48
|
icon: '📝',
|
|
49
49
|
category: 'basics',
|
|
50
|
-
description: '
|
|
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
|
-
//
|
|
209
|
-
body: { type: '
|
|
210
|
-
content: { type: '
|
|
211
|
-
story: { type: '
|
|
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' },
|