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
package/cli/dev-sync.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/dev-sync.ts — Bidirectional sync between content/ files and local D1.
|
|
3
|
+
*
|
|
4
|
+
* Two event-driven sync paths:
|
|
5
|
+
* 1. File watcher: content/ file changes → parse → INSERT OR REPLACE into D1
|
|
6
|
+
* 2. Sync server: router webhook (POST /sync) → write content/ file
|
|
7
|
+
*
|
|
8
|
+
* Loop prevention: a shared cooldown map tracks recent writes by entry ID.
|
|
9
|
+
* When either side writes, it records the ID. The other side skips that ID
|
|
10
|
+
* for a short cooldown window to prevent infinite loops.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { watch, existsSync, statSync } from 'fs';
|
|
14
|
+
import { resolve, relative, join, extname, sep } from 'path';
|
|
15
|
+
import { log, ok, warn, ANSI } from './log.ts';
|
|
16
|
+
import { d1InsertRow, applySchema, type D1Target } from './wrangler.ts';
|
|
17
|
+
import { buildInsertSql } from '../src/db/sql.ts';
|
|
18
|
+
import {
|
|
19
|
+
parseContentFile,
|
|
20
|
+
contentEntryToDbRow,
|
|
21
|
+
dbRowToContentFile,
|
|
22
|
+
writeContentDir,
|
|
23
|
+
type ContentTypeInfo
|
|
24
|
+
} from './content.ts';
|
|
25
|
+
import { CONTENT_DIR, SITE_CONFIG_FILE } from './constants.ts';
|
|
26
|
+
|
|
27
|
+
// ── Constants ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Port for the dev sync HTTP server */
|
|
30
|
+
export const DEV_SYNC_PORT = 4319;
|
|
31
|
+
|
|
32
|
+
/** Env var the router checks to decide whether to fire webhooks */
|
|
33
|
+
export const DEV_SYNC_ENV_VAR = 'KOGUMA_DEV_SYNC';
|
|
34
|
+
|
|
35
|
+
/** Cooldown window (ms) to prevent sync loops */
|
|
36
|
+
const COOLDOWN_MS = 3000;
|
|
37
|
+
|
|
38
|
+
// ── Loop prevention ────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** Tracks recent writes to prevent infinite loops between watchers */
|
|
41
|
+
const recentWrites = new Map<string, number>();
|
|
42
|
+
|
|
43
|
+
function markRecentWrite(entryId: string): void {
|
|
44
|
+
recentWrites.set(entryId, Date.now());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRecentWrite(entryId: string): boolean {
|
|
48
|
+
const ts = recentWrites.get(entryId);
|
|
49
|
+
if (!ts) return false;
|
|
50
|
+
if (Date.now() - ts > COOLDOWN_MS) {
|
|
51
|
+
recentWrites.delete(entryId);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── File watcher: content/ → D1 ───────────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface FileWatcherOptions {
|
|
60
|
+
root: string;
|
|
61
|
+
dbName: string;
|
|
62
|
+
contentTypes: ContentTypeInfo[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function startFileWatcher(opts: FileWatcherOptions): { stop: () => void } {
|
|
66
|
+
const { root, dbName, contentTypes } = opts;
|
|
67
|
+
const contentDir = resolve(root, CONTENT_DIR);
|
|
68
|
+
|
|
69
|
+
if (!existsSync(contentDir)) return { stop: () => {} };
|
|
70
|
+
|
|
71
|
+
const ctMap = new Map(contentTypes.map(ct => [ct.id, ct]));
|
|
72
|
+
|
|
73
|
+
// Debounce to avoid processing the same file multiple times
|
|
74
|
+
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
75
|
+
|
|
76
|
+
const watcher = watch(contentDir, { recursive: true }, (_event, filename) => {
|
|
77
|
+
if (!filename) return;
|
|
78
|
+
const filePath = resolve(contentDir, filename);
|
|
79
|
+
|
|
80
|
+
// Skip non-content files
|
|
81
|
+
const ext = extname(filename);
|
|
82
|
+
if (ext !== '.md' && ext !== '.yml' && ext !== '.yaml') return;
|
|
83
|
+
|
|
84
|
+
// Skip media/ subdirectory
|
|
85
|
+
if (filename.startsWith('media' + sep) || filename.startsWith('media/'))
|
|
86
|
+
return;
|
|
87
|
+
|
|
88
|
+
// Determine content type from directory structure
|
|
89
|
+
const parts = filename.split(sep);
|
|
90
|
+
if (parts.length < 2) return; // Need at least contentType/file.md
|
|
91
|
+
const contentTypeId = parts[0]!;
|
|
92
|
+
|
|
93
|
+
// Debounce — editors often trigger multiple events per save
|
|
94
|
+
const existing = debounceTimers.get(filename);
|
|
95
|
+
if (existing) clearTimeout(existing);
|
|
96
|
+
|
|
97
|
+
debounceTimers.set(
|
|
98
|
+
filename,
|
|
99
|
+
setTimeout(() => {
|
|
100
|
+
debounceTimers.delete(filename);
|
|
101
|
+
|
|
102
|
+
if (!existsSync(filePath)) return; // File was deleted
|
|
103
|
+
|
|
104
|
+
const ctInfo = ctMap.get(contentTypeId);
|
|
105
|
+
if (!ctInfo) return;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const entry = parseContentFile(filePath, contentTypeId);
|
|
109
|
+
|
|
110
|
+
// Derive entry ID for loop prevention
|
|
111
|
+
const slug = entry.slug || (entry.fields.slug as string) || '';
|
|
112
|
+
const entryId = ctInfo.singleton
|
|
113
|
+
? `${contentTypeId}-singleton`
|
|
114
|
+
: `${contentTypeId}-${slug}`;
|
|
115
|
+
|
|
116
|
+
if (isRecentWrite(entryId)) return;
|
|
117
|
+
|
|
118
|
+
// Pack into v2 format: entries(id, content_type, slug, data, status)
|
|
119
|
+
const rowData = contentEntryToDbRow(entry, ctInfo);
|
|
120
|
+
const {
|
|
121
|
+
id, slug: rowSlug, status, publish_at, publishAt,
|
|
122
|
+
created_at: _ca, updated_at: _ua, content_type: _ct,
|
|
123
|
+
...fields
|
|
124
|
+
} = rowData;
|
|
125
|
+
d1InsertRow(root, dbName, '--local', 'entries', {
|
|
126
|
+
id: id as string,
|
|
127
|
+
content_type: contentTypeId,
|
|
128
|
+
slug: (rowSlug as string | undefined) ?? null,
|
|
129
|
+
data: JSON.stringify(fields),
|
|
130
|
+
status: (status as string | undefined) ?? 'published',
|
|
131
|
+
...(publish_at !== undefined ? { publish_at } : {}),
|
|
132
|
+
...(publishAt !== undefined ? { publish_at: publishAt } : {})
|
|
133
|
+
});
|
|
134
|
+
markRecentWrite(entryId);
|
|
135
|
+
|
|
136
|
+
ok(
|
|
137
|
+
`${ANSI.DIM}sync:${ANSI.RESET} ${contentTypeId}/${parts.slice(1).join('/')} → D1`
|
|
138
|
+
);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
warn(`File sync failed for ${filename}: ${e}`);
|
|
141
|
+
}
|
|
142
|
+
}, 300) // 300ms debounce
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
stop: () => {
|
|
148
|
+
watcher.close();
|
|
149
|
+
for (const timer of debounceTimers.values()) clearTimeout(timer);
|
|
150
|
+
debounceTimers.clear();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Sync server: router webhook → content/ files ───────────────────
|
|
156
|
+
|
|
157
|
+
interface SyncServerOptions {
|
|
158
|
+
root: string;
|
|
159
|
+
contentTypes: ContentTypeInfo[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function killStalePortHolder(port: number): void {
|
|
163
|
+
try {
|
|
164
|
+
const { execSync } = require('child_process');
|
|
165
|
+
const out = execSync(`lsof -ti :${port}`, { encoding: 'utf-8' }).trim();
|
|
166
|
+
if (out) {
|
|
167
|
+
for (const pid of out.split('\n')) {
|
|
168
|
+
const n = parseInt(pid, 10);
|
|
169
|
+
if (n > 0 && n !== process.pid) {
|
|
170
|
+
try { process.kill(n, 'SIGTERM'); } catch { /* already gone */ }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Brief pause to let the OS release the socket
|
|
174
|
+
execSync('sleep 0.2');
|
|
175
|
+
}
|
|
176
|
+
} catch { /* lsof not found or no process — fine */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function startSyncServer(opts: SyncServerOptions): {
|
|
180
|
+
stop: () => void;
|
|
181
|
+
port: number;
|
|
182
|
+
} {
|
|
183
|
+
const { root, contentTypes } = opts;
|
|
184
|
+
const contentDir = resolve(root, CONTENT_DIR);
|
|
185
|
+
const ctMap = new Map(contentTypes.map(ct => [ct.id, ct]));
|
|
186
|
+
|
|
187
|
+
killStalePortHolder(DEV_SYNC_PORT);
|
|
188
|
+
|
|
189
|
+
const server = Bun.serve({
|
|
190
|
+
port: DEV_SYNC_PORT,
|
|
191
|
+
reusePort: true,
|
|
192
|
+
fetch: async (req: Request) => {
|
|
193
|
+
const url = new URL(req.url);
|
|
194
|
+
|
|
195
|
+
if (req.method === 'POST' && url.pathname === '/sync') {
|
|
196
|
+
try {
|
|
197
|
+
const payload = (await req.json()) as {
|
|
198
|
+
action: 'create' | 'update' | 'delete';
|
|
199
|
+
contentType: string;
|
|
200
|
+
entry?: Record<string, unknown>;
|
|
201
|
+
entryId?: string;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const ctInfo = ctMap.get(payload.contentType);
|
|
205
|
+
if (!ctInfo) {
|
|
206
|
+
return new Response('Unknown content type', { status: 400 });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (payload.action === 'delete') {
|
|
210
|
+
// For delete, we'd need to remove the file — for now, just log
|
|
211
|
+
ok(
|
|
212
|
+
`${ANSI.DIM}sync:${ANSI.RESET} D1 → deleted ${payload.contentType}/${payload.entryId}`
|
|
213
|
+
);
|
|
214
|
+
return new Response('ok');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!payload.entry) {
|
|
218
|
+
return new Response('Missing entry data', { status: 400 });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Derive entry ID for loop prevention
|
|
222
|
+
const slug =
|
|
223
|
+
(payload.entry.slug as string) ||
|
|
224
|
+
(payload.entry.id as string) ||
|
|
225
|
+
'';
|
|
226
|
+
const entryId = ctInfo.singleton
|
|
227
|
+
? `${payload.contentType}-singleton`
|
|
228
|
+
: `${payload.contentType}-${slug}`;
|
|
229
|
+
|
|
230
|
+
if (isRecentWrite(entryId)) {
|
|
231
|
+
return new Response('ok (cooldown)');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Write the entry to a content/ file
|
|
235
|
+
const count = writeContentDir(contentDir, [payload.entry], [ctInfo]);
|
|
236
|
+
|
|
237
|
+
if (count > 0) {
|
|
238
|
+
markRecentWrite(entryId);
|
|
239
|
+
ok(
|
|
240
|
+
`${ANSI.DIM}sync:${ANSI.RESET} D1 → ${payload.contentType}/${slug || 'index'}`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return new Response('ok');
|
|
245
|
+
} catch (e) {
|
|
246
|
+
warn(`Sync server error: ${e}`);
|
|
247
|
+
return new Response('error', { status: 500 });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return new Response('not found', { status: 404 });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
port: server.port,
|
|
257
|
+
stop: () => server.stop()
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export interface DevSyncHandle {
|
|
264
|
+
stop: () => void;
|
|
265
|
+
syncUrl: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Start bidirectional dev sync.
|
|
270
|
+
*
|
|
271
|
+
* Returns a handle with:
|
|
272
|
+
* - `stop()` to clean up watchers and server
|
|
273
|
+
* - `syncUrl` to pass as KOGUMA_DEV_SYNC env var to wrangler dev
|
|
274
|
+
*/
|
|
275
|
+
export function startDevSync(
|
|
276
|
+
root: string,
|
|
277
|
+
dbName: string,
|
|
278
|
+
contentTypes: ContentTypeInfo[],
|
|
279
|
+
opts?: { silent?: boolean }
|
|
280
|
+
): DevSyncHandle {
|
|
281
|
+
const contentDir = resolve(root, CONTENT_DIR);
|
|
282
|
+
|
|
283
|
+
// Ensure content/ directory exists
|
|
284
|
+
if (!existsSync(contentDir)) {
|
|
285
|
+
const { mkdirSync } = require('fs');
|
|
286
|
+
mkdirSync(contentDir, { recursive: true });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Start both sync paths
|
|
290
|
+
const fileWatcher = startFileWatcher({ root, dbName, contentTypes });
|
|
291
|
+
const syncServer = startSyncServer({ root, contentTypes });
|
|
292
|
+
|
|
293
|
+
if (!opts?.silent) {
|
|
294
|
+
ok(`Dev sync active — file watcher + sync server on :${syncServer.port}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
syncUrl: `http://localhost:${syncServer.port}`,
|
|
299
|
+
stop: () => {
|
|
300
|
+
fileWatcher.stop();
|
|
301
|
+
syncServer.stop();
|
|
302
|
+
recentWrites.clear();
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
package/cli/exec.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/exec.ts — Shell execution helpers.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrappers around child_process for CLI command execution.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync, exec } from 'child_process';
|
|
8
|
+
|
|
9
|
+
export interface ExecOptions {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
silent?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run a shell command synchronously. Throws on failure unless `silent` is true.
|
|
16
|
+
* When silent, returns stdout even on non-zero exit.
|
|
17
|
+
*/
|
|
18
|
+
export function run(cmd: string, opts?: ExecOptions): string {
|
|
19
|
+
try {
|
|
20
|
+
return execSync(cmd, {
|
|
21
|
+
cwd: opts?.cwd,
|
|
22
|
+
encoding: 'utf-8',
|
|
23
|
+
stdio: opts?.silent ? 'pipe' : 'inherit'
|
|
24
|
+
}) as string;
|
|
25
|
+
} catch (e: unknown) {
|
|
26
|
+
const error = e as { stdout?: string; stderr?: string; status?: number };
|
|
27
|
+
if (opts?.silent) return error.stdout ?? '';
|
|
28
|
+
throw e;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run a shell command silently and return trimmed stdout.
|
|
34
|
+
*/
|
|
35
|
+
export function runCapture(cmd: string, cwd?: string): string {
|
|
36
|
+
return run(cmd, { cwd, silent: true }).trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run a shell command asynchronously (non-blocking).
|
|
41
|
+
* This keeps the event loop free so spinners can animate.
|
|
42
|
+
*/
|
|
43
|
+
export function runAsync(cmd: string, opts?: ExecOptions): Promise<string> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
exec(
|
|
46
|
+
cmd,
|
|
47
|
+
{ cwd: opts?.cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 },
|
|
48
|
+
(error, stdout, stderr) => {
|
|
49
|
+
if (error) {
|
|
50
|
+
if (opts?.silent) {
|
|
51
|
+
resolve(stdout ?? '');
|
|
52
|
+
} else {
|
|
53
|
+
reject(error);
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
resolve(stdout);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|