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
@@ -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
+ }