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/src/config/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Core CMS types used across Koguma.
|
|
3
|
-
* These are the runtime types for assets
|
|
3
|
+
* These are the runtime types for assets and references.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** A media asset stored in R2 */
|
|
@@ -13,100 +13,6 @@ export interface KogumaAsset {
|
|
|
13
13
|
height?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// ── Rich Text ─────────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* A koguma-normalized rich text document.
|
|
20
|
-
* Editor-agnostic — does not expose Lexical internals.
|
|
21
|
-
* Produced server-side from Lexical's SerializedEditorState.
|
|
22
|
-
*/
|
|
23
|
-
export interface KogumaDocument {
|
|
24
|
-
nodes: KogumaBlockNode[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type KogumaBlockNode =
|
|
28
|
-
| {
|
|
29
|
-
type: 'paragraph';
|
|
30
|
-
key?: string;
|
|
31
|
-
align?: 'left' | 'center' | 'right' | 'justify';
|
|
32
|
-
children: KogumaInlineNode[];
|
|
33
|
-
}
|
|
34
|
-
| {
|
|
35
|
-
type: 'heading';
|
|
36
|
-
key?: string;
|
|
37
|
-
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
38
|
-
children: KogumaInlineNode[];
|
|
39
|
-
}
|
|
40
|
-
| { type: 'list'; key?: string; ordered: boolean; items: KogumaListItem[] }
|
|
41
|
-
| { type: 'quote'; key?: string; children: KogumaInlineNode[] }
|
|
42
|
-
| { type: 'code'; key?: string; language?: string; text: string }
|
|
43
|
-
| {
|
|
44
|
-
type: 'image';
|
|
45
|
-
key?: string;
|
|
46
|
-
url: string;
|
|
47
|
-
alt?: string;
|
|
48
|
-
width?: number;
|
|
49
|
-
height?: number;
|
|
50
|
-
}
|
|
51
|
-
| { type: 'hr'; key?: string }
|
|
52
|
-
| { type: 'table'; key?: string; rows: KogumaTableRow[] }
|
|
53
|
-
| { type: 'layout'; key?: string; columns: KogumaBlockNode[][] }
|
|
54
|
-
| {
|
|
55
|
-
type: 'custom';
|
|
56
|
-
key?: string;
|
|
57
|
-
name: string;
|
|
58
|
-
data: Record<string, unknown>;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/** A list item — supports nesting and checklists */
|
|
62
|
-
export interface KogumaListItem {
|
|
63
|
-
key?: string;
|
|
64
|
-
children: KogumaInlineNode[];
|
|
65
|
-
/** Defined = checklist item. true = checked, false = unchecked */
|
|
66
|
-
checked?: boolean;
|
|
67
|
-
nestedList?: { ordered: boolean; items: KogumaListItem[] };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface KogumaTableRow {
|
|
71
|
-
key?: string;
|
|
72
|
-
isHeader?: boolean;
|
|
73
|
-
cells: KogumaTableCell[];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface KogumaTableCell {
|
|
77
|
-
key?: string;
|
|
78
|
-
children: KogumaInlineNode[];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export type KogumaInlineNode =
|
|
82
|
-
| {
|
|
83
|
-
type: 'text';
|
|
84
|
-
key?: string;
|
|
85
|
-
text: string;
|
|
86
|
-
bold?: boolean;
|
|
87
|
-
italic?: boolean;
|
|
88
|
-
underline?: boolean;
|
|
89
|
-
code?: boolean;
|
|
90
|
-
strikethrough?: boolean;
|
|
91
|
-
superscript?: boolean;
|
|
92
|
-
subscript?: boolean;
|
|
93
|
-
}
|
|
94
|
-
| {
|
|
95
|
-
type: 'link';
|
|
96
|
-
key?: string;
|
|
97
|
-
url: string;
|
|
98
|
-
newTab?: boolean;
|
|
99
|
-
children: KogumaInlineNode[];
|
|
100
|
-
}
|
|
101
|
-
| { type: 'inline-image'; key?: string; url: string; alt?: string }
|
|
102
|
-
| { type: 'line-break'; key?: string }
|
|
103
|
-
| {
|
|
104
|
-
type: 'custom';
|
|
105
|
-
key?: string;
|
|
106
|
-
name: string;
|
|
107
|
-
data: Record<string, unknown>;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
16
|
/** Reference to another entry */
|
|
111
17
|
export interface EntryReference<T = Record<string, unknown>> {
|
|
112
18
|
id: string;
|
package/src/db/init.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database initialization — hardcoded 2-table schema for the JSON document store.
|
|
3
|
+
*
|
|
4
|
+
* Two tables, forever. No migrations.
|
|
5
|
+
*
|
|
6
|
+
* entries — all content, keyed by content_type. Field data stored as JSON blob.
|
|
7
|
+
* assets — media metadata as JSON blob. Files live in R2.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ── Schema SQL ──────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export const INIT_SQL = `
|
|
13
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
content_type TEXT NOT NULL,
|
|
16
|
+
slug TEXT,
|
|
17
|
+
data TEXT NOT NULL,
|
|
18
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
19
|
+
publish_at TEXT,
|
|
20
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
21
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(content_type);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_entries_slug ON entries(content_type, slug);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS assets (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
data TEXT NOT NULL,
|
|
29
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
30
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
31
|
+
);
|
|
32
|
+
`.trim();
|
|
33
|
+
|
|
34
|
+
// ── D1 database interface ───────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
interface D1Database {
|
|
37
|
+
prepare(query: string): D1PreparedStatement;
|
|
38
|
+
batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
|
|
39
|
+
exec(sql: string): Promise<D1ExecResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface D1PreparedStatement {
|
|
43
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
44
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
45
|
+
all<T = Record<string, unknown>>(): Promise<D1Result<T>>;
|
|
46
|
+
run(): Promise<D1Result>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface D1Result<T = unknown> {
|
|
50
|
+
results?: T[];
|
|
51
|
+
success: boolean;
|
|
52
|
+
meta: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface D1ExecResult {
|
|
56
|
+
count: number;
|
|
57
|
+
duration: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Init function ───────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Initialize the database with the 2-table schema.
|
|
64
|
+
* Safe to call multiple times (uses IF NOT EXISTS).
|
|
65
|
+
*/
|
|
66
|
+
export async function initDatabase(db: D1Database): Promise<void> {
|
|
67
|
+
await db.exec(INIT_SQL);
|
|
68
|
+
}
|
package/src/db/queries.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Generic CRUD queries
|
|
2
|
+
* Generic CRUD queries for the JSON document store.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* All content is stored in a single `entries` table with a `data` JSON blob.
|
|
5
|
+
* No per-content-type tables, no join tables, no schema generation.
|
|
6
6
|
*/
|
|
7
|
-
import type { ContentTypeConfig } from '../config/define.ts';
|
|
8
|
-
import type { FieldMeta } from '../config/field.ts';
|
|
9
7
|
|
|
10
8
|
// ── D1 database interface (Cloudflare Workers binding) ──────────────
|
|
11
9
|
|
|
@@ -29,271 +27,182 @@ interface D1Result<T = unknown> {
|
|
|
29
27
|
|
|
30
28
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
/** Unpack a DB row into a flat entry: system fields + spread data blob. */
|
|
31
|
+
function unpackRow(row: Record<string, unknown>): Record<string, unknown> {
|
|
32
|
+
const {
|
|
33
|
+
id,
|
|
34
|
+
content_type,
|
|
35
|
+
slug,
|
|
36
|
+
data,
|
|
37
|
+
status,
|
|
38
|
+
publish_at,
|
|
39
|
+
created_at,
|
|
40
|
+
updated_at,
|
|
41
|
+
...rest
|
|
42
|
+
} = row;
|
|
43
|
+
const parsed = typeof data === 'string' ? JSON.parse(data) : (data ?? {});
|
|
44
|
+
return {
|
|
45
|
+
id,
|
|
46
|
+
content_type,
|
|
47
|
+
slug,
|
|
48
|
+
status,
|
|
49
|
+
publish_at,
|
|
50
|
+
created_at,
|
|
51
|
+
updated_at,
|
|
52
|
+
...parsed,
|
|
53
|
+
...rest
|
|
54
|
+
};
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
/** Pack a flat entry into system columns + data JSON blob. */
|
|
58
|
+
function packData(data: Record<string, unknown>): string {
|
|
59
|
+
// Strip system fields — they live in columns, not the blob
|
|
60
|
+
const {
|
|
61
|
+
id,
|
|
62
|
+
content_type,
|
|
63
|
+
slug,
|
|
64
|
+
status,
|
|
65
|
+
publish_at,
|
|
66
|
+
publishAt,
|
|
67
|
+
created_at,
|
|
68
|
+
updated_at,
|
|
69
|
+
...fields
|
|
70
|
+
} = data;
|
|
71
|
+
return JSON.stringify(fields);
|
|
42
72
|
}
|
|
43
73
|
|
|
44
74
|
// ── GET ONE ─────────────────────────────────────────────────────────
|
|
45
75
|
|
|
46
76
|
export async function getEntry(
|
|
47
77
|
db: D1Database,
|
|
48
|
-
|
|
78
|
+
contentType: string,
|
|
49
79
|
id: string
|
|
50
80
|
): Promise<Record<string, unknown> | null> {
|
|
51
81
|
const row = await db
|
|
52
|
-
.prepare(
|
|
53
|
-
.bind(id)
|
|
82
|
+
.prepare('SELECT * FROM entries WHERE content_type = ? AND id = ?')
|
|
83
|
+
.bind(contentType, id)
|
|
54
84
|
.first();
|
|
55
85
|
|
|
56
86
|
if (!row) return null;
|
|
57
|
-
|
|
58
|
-
// Parse JSON fields (richText and images stored as JSON strings)
|
|
59
|
-
const entry = { ...row } as Record<string, unknown>;
|
|
60
|
-
for (const [fieldId, meta] of scalarFields(ct)) {
|
|
61
|
-
if (
|
|
62
|
-
(meta.fieldType === 'richText' || meta.fieldType === 'images') &&
|
|
63
|
-
typeof entry[fieldId] === 'string'
|
|
64
|
-
) {
|
|
65
|
-
try {
|
|
66
|
-
entry[fieldId] = JSON.parse(entry[fieldId] as string);
|
|
67
|
-
} catch {
|
|
68
|
-
// leave as-is if not valid JSON
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Load references arrays
|
|
74
|
-
for (const [fieldId] of refArrayFields(ct)) {
|
|
75
|
-
const joinTable = `${ct.id}__${fieldId}`;
|
|
76
|
-
const refs = await db
|
|
77
|
-
.prepare(
|
|
78
|
-
`SELECT target_id FROM ${joinTable} WHERE source_id = ? ORDER BY sort_order`
|
|
79
|
-
)
|
|
80
|
-
.bind(id)
|
|
81
|
-
.all();
|
|
82
|
-
entry[fieldId] =
|
|
83
|
-
refs.results?.map((r: Record<string, unknown>) => r.target_id) ?? [];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return entry;
|
|
87
|
+
return unpackRow(row);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
// ── GET ALL ─────────────────────────────────────────────────────────
|
|
90
91
|
|
|
91
92
|
export async function getEntries(
|
|
92
93
|
db: D1Database,
|
|
93
|
-
|
|
94
|
+
contentType: string,
|
|
94
95
|
opts?: { publishedOnly?: boolean }
|
|
95
96
|
): Promise<Record<string, unknown>[]> {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.prepare(`SELECT * FROM ${ct.id}${whereClause} ORDER BY created_at DESC`)
|
|
101
|
-
.all();
|
|
102
|
-
|
|
103
|
-
const rows = (result.results ?? []) as Record<string, unknown>[];
|
|
104
|
-
|
|
105
|
-
// Parse JSON + load refs for each row
|
|
106
|
-
const entries: Record<string, unknown>[] = [];
|
|
107
|
-
for (const row of rows) {
|
|
108
|
-
const entry = { ...row };
|
|
109
|
-
for (const [fieldId, meta] of scalarFields(ct)) {
|
|
110
|
-
if (
|
|
111
|
-
(meta.fieldType === 'richText' || meta.fieldType === 'images') &&
|
|
112
|
-
typeof entry[fieldId] === 'string'
|
|
113
|
-
) {
|
|
114
|
-
try {
|
|
115
|
-
entry[fieldId] = JSON.parse(entry[fieldId] as string);
|
|
116
|
-
} catch {
|
|
117
|
-
/* skip */
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (const [fieldId] of refArrayFields(ct)) {
|
|
123
|
-
const joinTable = `${ct.id}__${fieldId}`;
|
|
124
|
-
const refs = await db
|
|
125
|
-
.prepare(
|
|
126
|
-
`SELECT target_id FROM ${joinTable} WHERE source_id = ? ORDER BY sort_order`
|
|
127
|
-
)
|
|
128
|
-
.bind(entry.id)
|
|
129
|
-
.all();
|
|
130
|
-
entry[fieldId] =
|
|
131
|
-
refs.results?.map((r: Record<string, unknown>) => r.target_id) ?? [];
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
entries.push(entry);
|
|
97
|
+
let query = 'SELECT * FROM entries WHERE content_type = ?';
|
|
98
|
+
if (opts?.publishedOnly) {
|
|
99
|
+
query +=
|
|
100
|
+
" AND status = 'published' AND (publish_at IS NULL OR publish_at <= datetime('now'))";
|
|
135
101
|
}
|
|
102
|
+
query += ' ORDER BY created_at DESC';
|
|
136
103
|
|
|
137
|
-
|
|
104
|
+
const result = await db.prepare(query).bind(contentType).all();
|
|
105
|
+
return (result.results ?? []).map(unpackRow);
|
|
138
106
|
}
|
|
139
107
|
|
|
140
108
|
// ── CREATE ──────────────────────────────────────────────────────────
|
|
141
109
|
|
|
142
110
|
export async function createEntry(
|
|
143
111
|
db: D1Database,
|
|
144
|
-
|
|
112
|
+
contentType: string,
|
|
145
113
|
data: Record<string, unknown>
|
|
146
114
|
): Promise<Record<string, unknown>> {
|
|
147
115
|
const id = (data.id as string) ?? crypto.randomUUID();
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
typeof val === 'object'
|
|
163
|
-
) {
|
|
164
|
-
return JSON.stringify(val);
|
|
165
|
-
}
|
|
166
|
-
return val ?? null;
|
|
167
|
-
})
|
|
168
|
-
];
|
|
169
|
-
|
|
170
|
-
const statements: D1PreparedStatement[] = [
|
|
171
|
-
db
|
|
172
|
-
.prepare(
|
|
173
|
-
`INSERT INTO ${ct.id} (${columns.join(', ')}) VALUES (${placeholders})`
|
|
174
|
-
)
|
|
175
|
-
.bind(...values)
|
|
176
|
-
];
|
|
177
|
-
|
|
178
|
-
// Insert join table entries for refs
|
|
179
|
-
for (const [fieldId] of refFields) {
|
|
180
|
-
const refs = data[fieldId] as string[] | undefined;
|
|
181
|
-
if (refs?.length) {
|
|
182
|
-
const joinTable = `${ct.id}__${fieldId}`;
|
|
183
|
-
for (let i = 0; i < refs.length; i++) {
|
|
184
|
-
statements.push(
|
|
185
|
-
db
|
|
186
|
-
.prepare(
|
|
187
|
-
`INSERT INTO ${joinTable} (source_id, target_id, sort_order) VALUES (?, ?, ?)`
|
|
188
|
-
)
|
|
189
|
-
.bind(id, refs[i], i)
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
await db.batch(statements);
|
|
196
|
-
return (await getEntry(db, ct, id))!;
|
|
116
|
+
const slug = (data.slug as string) ?? null;
|
|
117
|
+
const status = (data.status as string) ?? 'draft';
|
|
118
|
+
const publishAt =
|
|
119
|
+
(data.publishAt as string) ?? (data.publish_at as string) ?? null;
|
|
120
|
+
const blob = packData(data);
|
|
121
|
+
|
|
122
|
+
await db
|
|
123
|
+
.prepare(
|
|
124
|
+
`INSERT INTO entries (id, content_type, slug, data, status, publish_at) VALUES (?, ?, ?, ?, ?, ?)`
|
|
125
|
+
)
|
|
126
|
+
.bind(id, contentType, slug, blob, status, publishAt)
|
|
127
|
+
.run();
|
|
128
|
+
|
|
129
|
+
return (await getEntry(db, contentType, id))!;
|
|
197
130
|
}
|
|
198
131
|
|
|
199
132
|
// ── UPDATE ──────────────────────────────────────────────────────────
|
|
200
133
|
|
|
201
134
|
export async function updateEntry(
|
|
202
135
|
db: D1Database,
|
|
203
|
-
|
|
136
|
+
contentType: string,
|
|
204
137
|
id: string,
|
|
205
138
|
data: Record<string, unknown>
|
|
206
139
|
): Promise<Record<string, unknown> | null> {
|
|
207
|
-
|
|
208
|
-
const
|
|
140
|
+
// Read existing entry to merge data blobs
|
|
141
|
+
const existing = await db
|
|
142
|
+
.prepare('SELECT * FROM entries WHERE content_type = ? AND id = ?')
|
|
143
|
+
.bind(contentType, id)
|
|
144
|
+
.first();
|
|
209
145
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
146
|
+
if (!existing) return null;
|
|
147
|
+
|
|
148
|
+
const existingData =
|
|
149
|
+
typeof existing.data === 'string'
|
|
150
|
+
? JSON.parse(existing.data as string)
|
|
151
|
+
: {};
|
|
152
|
+
|
|
153
|
+
// Merge new fields into existing data blob
|
|
154
|
+
const {
|
|
155
|
+
id: _,
|
|
156
|
+
content_type: __,
|
|
157
|
+
slug: newSlug,
|
|
158
|
+
status: newStatus,
|
|
159
|
+
publishAt,
|
|
160
|
+
publish_at,
|
|
161
|
+
created_at,
|
|
162
|
+
updated_at,
|
|
163
|
+
...newFields
|
|
164
|
+
} = data;
|
|
165
|
+
const mergedBlob = JSON.stringify({ ...existingData, ...newFields });
|
|
166
|
+
|
|
167
|
+
// Build SET clause for system columns that were provided
|
|
168
|
+
const sets: string[] = ['data = ?', "updated_at = datetime('now')"];
|
|
169
|
+
const values: unknown[] = [mergedBlob];
|
|
213
170
|
|
|
214
|
-
// Handle status updates
|
|
215
171
|
if ('status' in data) {
|
|
216
|
-
|
|
172
|
+
sets.push('status = ?');
|
|
217
173
|
values.push(data.status);
|
|
218
174
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
updates.push('publishAt = ?');
|
|
223
|
-
values.push(data.publishAt);
|
|
175
|
+
if ('publishAt' in data || 'publish_at' in data) {
|
|
176
|
+
sets.push('publish_at = ?');
|
|
177
|
+
values.push(publishAt ?? publish_at ?? null);
|
|
224
178
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
updates.push(`${fieldId} = ?`);
|
|
229
|
-
const val = data[fieldId];
|
|
230
|
-
if (
|
|
231
|
-
(meta.fieldType === 'richText' || meta.fieldType === 'images') &&
|
|
232
|
-
val != null &&
|
|
233
|
-
typeof val === 'object'
|
|
234
|
-
) {
|
|
235
|
-
values.push(JSON.stringify(val));
|
|
236
|
-
} else {
|
|
237
|
-
values.push(val ?? null);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
179
|
+
if ('slug' in data) {
|
|
180
|
+
sets.push('slug = ?');
|
|
181
|
+
values.push(data.slug);
|
|
240
182
|
}
|
|
241
183
|
|
|
242
|
-
values.push(id);
|
|
184
|
+
values.push(contentType, id);
|
|
243
185
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
186
|
+
await db
|
|
187
|
+
.prepare(
|
|
188
|
+
`UPDATE entries SET ${sets.join(', ')} WHERE content_type = ? AND id = ?`
|
|
189
|
+
)
|
|
190
|
+
.bind(...values)
|
|
191
|
+
.run();
|
|
249
192
|
|
|
250
|
-
|
|
251
|
-
for (const [fieldId] of refFields) {
|
|
252
|
-
if (fieldId in data) {
|
|
253
|
-
const joinTable = `${ct.id}__${fieldId}`;
|
|
254
|
-
statements.push(
|
|
255
|
-
db.prepare(`DELETE FROM ${joinTable} WHERE source_id = ?`).bind(id)
|
|
256
|
-
);
|
|
257
|
-
const refs = data[fieldId] as string[] | undefined;
|
|
258
|
-
if (refs?.length) {
|
|
259
|
-
for (let i = 0; i < refs.length; i++) {
|
|
260
|
-
statements.push(
|
|
261
|
-
db
|
|
262
|
-
.prepare(
|
|
263
|
-
`INSERT INTO ${joinTable} (source_id, target_id, sort_order) VALUES (?, ?, ?)`
|
|
264
|
-
)
|
|
265
|
-
.bind(id, refs[i], i)
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
await db.batch(statements);
|
|
273
|
-
return getEntry(db, ct, id);
|
|
193
|
+
return getEntry(db, contentType, id);
|
|
274
194
|
}
|
|
275
195
|
|
|
276
196
|
// ── DELETE ───────────────────────────────────────────────────────────
|
|
277
197
|
|
|
278
198
|
export async function deleteEntry(
|
|
279
199
|
db: D1Database,
|
|
280
|
-
|
|
200
|
+
contentType: string,
|
|
281
201
|
id: string
|
|
282
202
|
): Promise<boolean> {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
];
|
|
288
|
-
|
|
289
|
-
// Clean up join tables
|
|
290
|
-
for (const [fieldId] of refFields) {
|
|
291
|
-
const joinTable = `${ct.id}__${fieldId}`;
|
|
292
|
-
statements.push(
|
|
293
|
-
db.prepare(`DELETE FROM ${joinTable} WHERE source_id = ?`).bind(id)
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
await db.batch(statements);
|
|
203
|
+
await db
|
|
204
|
+
.prepare('DELETE FROM entries WHERE content_type = ? AND id = ?')
|
|
205
|
+
.bind(contentType, id)
|
|
206
|
+
.run();
|
|
298
207
|
return true;
|
|
299
208
|
}
|
package/src/db/sql.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface AssetIndex {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Build an asset index from raw
|
|
53
|
+
* Build an asset index from raw assets rows.
|
|
54
54
|
* Maps both full title and title-without-extension to asset IDs.
|
|
55
55
|
*/
|
|
56
56
|
export function buildAssetIndex(
|
|
@@ -90,18 +90,15 @@ export interface FieldMeta {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Process a seed entry by resolving image references
|
|
94
|
-
*
|
|
93
|
+
* Process a seed entry by resolving image references.
|
|
94
|
+
* Markdown fields are left as-is (already markdown strings).
|
|
95
95
|
*
|
|
96
96
|
* Returns a new processed entry (does not mutate the input).
|
|
97
97
|
*/
|
|
98
98
|
export function processSeedEntry(
|
|
99
99
|
entry: Record<string, unknown>,
|
|
100
100
|
fieldMeta: Record<string, FieldMeta>,
|
|
101
|
-
assetIndex: AssetIndex
|
|
102
|
-
markdownToKoguma: (md: string) => { nodes: unknown[] },
|
|
103
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
|
-
kogumaToLexical: (doc: any) => Record<string, unknown>
|
|
101
|
+
assetIndex: AssetIndex
|
|
105
102
|
): { processed: Record<string, unknown>; resolutions: string[] } {
|
|
106
103
|
const processed: Record<string, unknown> = {
|
|
107
104
|
id: (entry.id as string) ?? crypto.randomUUID(),
|
|
@@ -121,11 +118,6 @@ export function processSeedEntry(
|
|
|
121
118
|
processed[fieldId] = result.id;
|
|
122
119
|
resolutions.push(`${fieldId}: "${value}" → ${result.id}`);
|
|
123
120
|
}
|
|
124
|
-
} else if (meta.fieldType === 'richText' && typeof value === 'string') {
|
|
125
|
-
const doc = markdownToKoguma(value);
|
|
126
|
-
const lexical = kogumaToLexical(doc);
|
|
127
|
-
processed[fieldId] = JSON.stringify(lexical);
|
|
128
|
-
resolutions.push(`${fieldId}: markdown → Lexical JSON`);
|
|
129
121
|
} else if (meta.fieldType === 'images' && Array.isArray(value)) {
|
|
130
122
|
const ids = value.map((v: unknown) => {
|
|
131
123
|
if (typeof v !== 'string') return v;
|
|
@@ -136,8 +128,9 @@ export function processSeedEntry(
|
|
|
136
128
|
}
|
|
137
129
|
return v;
|
|
138
130
|
});
|
|
139
|
-
processed[fieldId] =
|
|
131
|
+
processed[fieldId] = ids;
|
|
140
132
|
}
|
|
133
|
+
// markdown: left as-is (markdown string) — no conversion needed
|
|
141
134
|
}
|
|
142
135
|
|
|
143
136
|
return { processed, resolutions };
|
|
@@ -147,23 +140,15 @@ export function processSeedEntry(
|
|
|
147
140
|
|
|
148
141
|
/**
|
|
149
142
|
* Generate all INSERT OR REPLACE statements for importing a set of entries
|
|
150
|
-
* into a table
|
|
143
|
+
* into a table.
|
|
151
144
|
*/
|
|
152
145
|
export function buildImportSql(
|
|
153
|
-
|
|
154
|
-
entries: Record<string, unknown>[]
|
|
155
|
-
joinTables?: Record<string, Record<string, unknown>[]>
|
|
146
|
+
table: string,
|
|
147
|
+
entries: Record<string, unknown>[]
|
|
156
148
|
): string[] {
|
|
157
149
|
const statements: string[] = [];
|
|
158
150
|
for (const entry of entries) {
|
|
159
|
-
statements.push(buildInsertSql(
|
|
160
|
-
}
|
|
161
|
-
if (joinTables) {
|
|
162
|
-
for (const [jtName, rows] of Object.entries(joinTables)) {
|
|
163
|
-
for (const row of rows) {
|
|
164
|
-
statements.push(buildInsertSql(jtName, row));
|
|
165
|
-
}
|
|
166
|
-
}
|
|
151
|
+
statements.push(buildInsertSql(table, entry));
|
|
167
152
|
}
|
|
168
153
|
return statements;
|
|
169
154
|
}
|