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/media/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media handlers — upload to and serve from R2.
|
|
3
|
+
*
|
|
4
|
+
* Asset metadata is stored as a JSON blob in the `assets` table:
|
|
5
|
+
* { title, url, content_type, width, height, file_size }
|
|
3
6
|
*/
|
|
4
7
|
import type { Context } from 'hono';
|
|
5
8
|
|
|
@@ -56,6 +59,30 @@ function buildKey(id: string, fileName: string): string {
|
|
|
56
59
|
return `${id}${ext ? '.' + ext : ''}`;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
// ── Asset data helpers ──────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
interface AssetData {
|
|
65
|
+
title: string;
|
|
66
|
+
url: string;
|
|
67
|
+
content_type: string;
|
|
68
|
+
width: number | null;
|
|
69
|
+
height: number | null;
|
|
70
|
+
file_size: number | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parse the JSON `data` column from an assets row. */
|
|
74
|
+
function parseAssetData(
|
|
75
|
+
row: Record<string, unknown>
|
|
76
|
+
): AssetData & { id: string; created_at: unknown; updated_at: unknown } {
|
|
77
|
+
const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data;
|
|
78
|
+
return {
|
|
79
|
+
id: row.id as string,
|
|
80
|
+
created_at: row.created_at,
|
|
81
|
+
updated_at: row.updated_at,
|
|
82
|
+
...data
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
60
87
|
|
|
61
88
|
/** Serve a media file from R2 */
|
|
@@ -75,7 +102,7 @@ export async function serveMedia(c: Context): Promise<Response> {
|
|
|
75
102
|
});
|
|
76
103
|
}
|
|
77
104
|
|
|
78
|
-
/** Upload a media file to R2 + register in
|
|
105
|
+
/** Upload a media file to R2 + register in assets table */
|
|
79
106
|
export async function uploadMedia(c: Context): Promise<Response> {
|
|
80
107
|
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
81
108
|
const db = (c.env as Record<string, D1Database>).DB;
|
|
@@ -89,21 +116,21 @@ export async function uploadMedia(c: Context): Promise<Response> {
|
|
|
89
116
|
const { width, height } = await processAndUploadFile(media, file, key);
|
|
90
117
|
|
|
91
118
|
const url = `/api/media/${key}`;
|
|
119
|
+
const assetData: AssetData = {
|
|
120
|
+
title: title || file.name,
|
|
121
|
+
url,
|
|
122
|
+
content_type: file.type,
|
|
123
|
+
width,
|
|
124
|
+
height,
|
|
125
|
+
file_size: file.size
|
|
126
|
+
};
|
|
127
|
+
|
|
92
128
|
await db
|
|
93
|
-
.prepare(
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
.bind(id, title || file.name, url, file.type, file.size, width, height)
|
|
129
|
+
.prepare('INSERT INTO assets (id, data) VALUES (?, ?)')
|
|
130
|
+
.bind(id, JSON.stringify(assetData))
|
|
97
131
|
.run();
|
|
98
132
|
|
|
99
|
-
return c.json({
|
|
100
|
-
id,
|
|
101
|
-
url,
|
|
102
|
-
title: title || file.name,
|
|
103
|
-
contentType: file.type,
|
|
104
|
-
width,
|
|
105
|
-
height
|
|
106
|
-
});
|
|
133
|
+
return c.json({ id, ...assetData });
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
/** List all media assets (lazy-backfills file_size from R2 when null) */
|
|
@@ -111,25 +138,37 @@ export async function listMedia(c: Context): Promise<Response> {
|
|
|
111
138
|
const db = (c.env as Record<string, D1Database>).DB;
|
|
112
139
|
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
113
140
|
const result = await db
|
|
114
|
-
.prepare('SELECT * FROM
|
|
141
|
+
.prepare('SELECT * FROM assets ORDER BY created_at DESC')
|
|
115
142
|
.all();
|
|
116
143
|
|
|
117
|
-
const
|
|
144
|
+
const rows = (result.results ?? []) as Record<string, unknown>[];
|
|
145
|
+
const assets = rows.map(parseAssetData);
|
|
118
146
|
|
|
119
147
|
// Lazy backfill: look up file_size from R2 for any assets missing it
|
|
120
148
|
const needsBackfill = assets.filter(a => a.file_size == null && a.url);
|
|
121
149
|
if (needsBackfill.length > 0) {
|
|
122
150
|
const backfillPromise = Promise.all(
|
|
123
151
|
needsBackfill.map(async asset => {
|
|
124
|
-
const key =
|
|
152
|
+
const key = asset.url.replace('/api/media/', '');
|
|
125
153
|
try {
|
|
126
154
|
const head = await media.head(key);
|
|
127
155
|
if (head) {
|
|
128
156
|
asset.file_size = head.size;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
157
|
+
// Update the JSON blob in DB
|
|
158
|
+
const row = rows.find(r => r.id === asset.id);
|
|
159
|
+
if (row) {
|
|
160
|
+
const data =
|
|
161
|
+
typeof row.data === 'string'
|
|
162
|
+
? JSON.parse(row.data as string)
|
|
163
|
+
: row.data;
|
|
164
|
+
data.file_size = head.size;
|
|
165
|
+
await db
|
|
166
|
+
.prepare(
|
|
167
|
+
"UPDATE assets SET data = ?, updated_at = datetime('now') WHERE id = ?"
|
|
168
|
+
)
|
|
169
|
+
.bind(JSON.stringify(data), asset.id)
|
|
170
|
+
.run();
|
|
171
|
+
}
|
|
133
172
|
}
|
|
134
173
|
} catch {
|
|
135
174
|
// R2 lookup failed — skip, will retry on next list
|
|
@@ -153,30 +192,43 @@ export async function renameMedia(c: Context): Promise<Response> {
|
|
|
153
192
|
return c.json({ error: 'title is required' }, 400);
|
|
154
193
|
}
|
|
155
194
|
|
|
156
|
-
const
|
|
157
|
-
.prepare('
|
|
158
|
-
.bind(
|
|
195
|
+
const row = await db
|
|
196
|
+
.prepare('SELECT * FROM assets WHERE id = ?')
|
|
197
|
+
.bind(id)
|
|
198
|
+
.first();
|
|
199
|
+
if (!row) return c.notFound();
|
|
200
|
+
|
|
201
|
+
const data =
|
|
202
|
+
typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data;
|
|
203
|
+
data.title = title.trim();
|
|
204
|
+
|
|
205
|
+
await db
|
|
206
|
+
.prepare(
|
|
207
|
+
"UPDATE assets SET data = ?, updated_at = datetime('now') WHERE id = ?"
|
|
208
|
+
)
|
|
209
|
+
.bind(JSON.stringify(data), id)
|
|
159
210
|
.run();
|
|
160
211
|
|
|
161
|
-
if (!result.meta.changes) return c.notFound();
|
|
162
212
|
return c.json({ ok: true, id, title: title.trim() });
|
|
163
213
|
}
|
|
164
214
|
|
|
165
|
-
/** Delete a media asset from R2 and
|
|
215
|
+
/** Delete a media asset from R2 and assets table */
|
|
166
216
|
export async function deleteMedia(c: Context): Promise<Response> {
|
|
167
217
|
const media = (c.env as Record<string, R2Bucket>).MEDIA;
|
|
168
218
|
const db = (c.env as Record<string, D1Database>).DB;
|
|
169
219
|
const id = c.req.param('id');
|
|
170
220
|
|
|
171
|
-
const
|
|
172
|
-
.prepare('SELECT * FROM
|
|
221
|
+
const row = await db
|
|
222
|
+
.prepare('SELECT * FROM assets WHERE id = ?')
|
|
173
223
|
.bind(id)
|
|
174
224
|
.first();
|
|
175
|
-
if (!
|
|
225
|
+
if (!row) return c.notFound();
|
|
176
226
|
|
|
177
|
-
const
|
|
227
|
+
const data =
|
|
228
|
+
typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data;
|
|
229
|
+
const key = (data.url as string).replace('/api/media/', '');
|
|
178
230
|
await media.delete(key);
|
|
179
|
-
await db.prepare('DELETE FROM
|
|
231
|
+
await db.prepare('DELETE FROM assets WHERE id = ?').bind(id).run();
|
|
180
232
|
|
|
181
233
|
return c.json({ ok: true });
|
|
182
234
|
}
|
|
@@ -185,12 +237,12 @@ export async function deleteMedia(c: Context): Promise<Response> {
|
|
|
185
237
|
export async function getMedia(c: Context): Promise<Response> {
|
|
186
238
|
const db = (c.env as Record<string, D1Database>).DB;
|
|
187
239
|
const id = c.req.param('id');
|
|
188
|
-
const
|
|
189
|
-
.prepare('SELECT * FROM
|
|
240
|
+
const row = await db
|
|
241
|
+
.prepare('SELECT * FROM assets WHERE id = ?')
|
|
190
242
|
.bind(id)
|
|
191
243
|
.first();
|
|
192
|
-
if (!
|
|
193
|
-
return c.json(
|
|
244
|
+
if (!row) return c.notFound();
|
|
245
|
+
return c.json(parseAssetData(row));
|
|
194
246
|
}
|
|
195
247
|
|
|
196
248
|
/** Replace an existing asset's file (keeps the same DB row ID) */
|
|
@@ -200,7 +252,7 @@ export async function replaceMedia(c: Context): Promise<Response> {
|
|
|
200
252
|
const id = c.req.param('id');
|
|
201
253
|
|
|
202
254
|
const existing = await db
|
|
203
|
-
.prepare('SELECT * FROM
|
|
255
|
+
.prepare('SELECT * FROM assets WHERE id = ?')
|
|
204
256
|
.bind(id)
|
|
205
257
|
.first();
|
|
206
258
|
if (!existing) return c.notFound();
|
|
@@ -209,8 +261,13 @@ export async function replaceMedia(c: Context): Promise<Response> {
|
|
|
209
261
|
if ('error' in parsed) return c.json({ error: parsed.error }, parsed.status);
|
|
210
262
|
const { file } = parsed;
|
|
211
263
|
|
|
264
|
+
const data =
|
|
265
|
+
typeof existing.data === 'string'
|
|
266
|
+
? JSON.parse(existing.data as string)
|
|
267
|
+
: existing.data;
|
|
268
|
+
|
|
212
269
|
// Delete old R2 object
|
|
213
|
-
const oldKey = (
|
|
270
|
+
const oldKey = (data.url as string).replace('/api/media/', '');
|
|
214
271
|
await media.delete(oldKey);
|
|
215
272
|
|
|
216
273
|
// Upload new file
|
|
@@ -218,7 +275,7 @@ export async function replaceMedia(c: Context): Promise<Response> {
|
|
|
218
275
|
const { width, height } = await processAndUploadFile(media, file, newKey);
|
|
219
276
|
|
|
220
277
|
// Update title extension if the file type changed (e.g., photo.jpg → photo.webp)
|
|
221
|
-
const oldTitle =
|
|
278
|
+
const oldTitle = data.title as string;
|
|
222
279
|
const newExt = file.name.split('.').pop();
|
|
223
280
|
const updatedTitle =
|
|
224
281
|
newExt && /\.\w+$/.test(oldTitle)
|
|
@@ -226,20 +283,21 @@ export async function replaceMedia(c: Context): Promise<Response> {
|
|
|
226
283
|
: oldTitle;
|
|
227
284
|
|
|
228
285
|
const newUrl = `/api/media/${newKey}`;
|
|
229
|
-
|
|
230
|
-
.prepare(
|
|
231
|
-
`UPDATE _assets SET title = ?, url = ?, content_type = ?, file_size = ?, width = ?, height = ?, updated_at = datetime('now') WHERE id = ?`
|
|
232
|
-
)
|
|
233
|
-
.bind(updatedTitle, newUrl, file.type, file.size, width, height, id)
|
|
234
|
-
.run();
|
|
235
|
-
|
|
236
|
-
return c.json({
|
|
237
|
-
id,
|
|
286
|
+
const updatedData: AssetData = {
|
|
238
287
|
title: updatedTitle,
|
|
239
288
|
url: newUrl,
|
|
240
289
|
content_type: file.type,
|
|
241
290
|
file_size: file.size,
|
|
242
291
|
width,
|
|
243
292
|
height
|
|
244
|
-
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
await db
|
|
296
|
+
.prepare(
|
|
297
|
+
"UPDATE assets SET data = ?, updated_at = datetime('now') WHERE id = ?"
|
|
298
|
+
)
|
|
299
|
+
.bind(JSON.stringify(updatedData), id)
|
|
300
|
+
.run();
|
|
301
|
+
|
|
302
|
+
return c.json({ id, ...updatedData });
|
|
245
303
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown.test.tsx — component rendering tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the <Markdown> component using @testing-library/react.
|
|
5
|
+
* Environment: happy-dom (configured in bunfig.toml)
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect, afterEach } from 'bun:test';
|
|
8
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
9
|
+
import { Markdown } from './Markdown.tsx';
|
|
10
|
+
|
|
11
|
+
afterEach(() => cleanup());
|
|
12
|
+
|
|
13
|
+
// ── null / empty ──────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe('<Markdown> — null/empty', () => {
|
|
16
|
+
test('renders nothing for null', () => {
|
|
17
|
+
const { container } = render(<Markdown content={null} />);
|
|
18
|
+
expect(container.firstChild).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('renders nothing for undefined', () => {
|
|
22
|
+
const { container } = render(<Markdown content={undefined} />);
|
|
23
|
+
expect(container.firstChild).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('renders nothing for empty string', () => {
|
|
27
|
+
const { container } = render(<Markdown content='' />);
|
|
28
|
+
expect(container.firstChild).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── Wrapper ───────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe('<Markdown> — className', () => {
|
|
35
|
+
test('applies className to wrapper div', () => {
|
|
36
|
+
const { container } = render(
|
|
37
|
+
<Markdown content='Hello' className='prose' />
|
|
38
|
+
);
|
|
39
|
+
expect(container.querySelector('.prose')).not.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── Headings ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe('<Markdown> — headings', () => {
|
|
46
|
+
test('# renders <h1>', () => {
|
|
47
|
+
const { container } = render(<Markdown content='# Hello' />);
|
|
48
|
+
expect(container.querySelector('h1')).not.toBeNull();
|
|
49
|
+
expect(container.querySelector('h1')?.textContent).toBe('Hello');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('## renders <h2>', () => {
|
|
53
|
+
const { container } = render(<Markdown content='## Sub' />);
|
|
54
|
+
expect(container.querySelector('h2')).not.toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Text formatting ───────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
describe('<Markdown> — text formatting', () => {
|
|
61
|
+
test('**bold** renders <strong>', () => {
|
|
62
|
+
const { container } = render(<Markdown content='**bold**' />);
|
|
63
|
+
expect(container.querySelector('strong')).not.toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('*italic* renders <em>', () => {
|
|
67
|
+
const { container } = render(<Markdown content='*italic*' />);
|
|
68
|
+
expect(container.querySelector('em')).not.toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('`code` renders <code>', () => {
|
|
72
|
+
const { container } = render(<Markdown content='`code`' />);
|
|
73
|
+
expect(container.querySelector('code')).not.toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('~~strikethrough~~ renders <del> (GFM)', () => {
|
|
77
|
+
const { container } = render(<Markdown content='~~strike~~' />);
|
|
78
|
+
expect(container.querySelector('del')).not.toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── Links ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe('<Markdown> — links', () => {
|
|
85
|
+
test('[text](url) renders <a>', () => {
|
|
86
|
+
const { container } = render(
|
|
87
|
+
<Markdown content='[Example](https://example.com)' />
|
|
88
|
+
);
|
|
89
|
+
const link = container.querySelector('a');
|
|
90
|
+
expect(link?.getAttribute('href')).toBe('https://example.com');
|
|
91
|
+
expect(link?.textContent).toBe('Example');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Lists ─────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('<Markdown> — lists', () => {
|
|
98
|
+
test('- item renders <ul><li>', () => {
|
|
99
|
+
const { container } = render(
|
|
100
|
+
<Markdown content={`- Item one\n- Item two`} />
|
|
101
|
+
);
|
|
102
|
+
expect(container.querySelector('ul')).not.toBeNull();
|
|
103
|
+
expect(container.querySelectorAll('li').length).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('1. item renders <ol><li>', () => {
|
|
107
|
+
const { container } = render(<Markdown content={`1. First\n2. Second`} />);
|
|
108
|
+
expect(container.querySelector('ol')).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('task list renders checkboxes (GFM)', () => {
|
|
112
|
+
const { container } = render(
|
|
113
|
+
<Markdown content={`- [x] Done\n- [ ] Todo`} />
|
|
114
|
+
);
|
|
115
|
+
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
|
|
116
|
+
expect(checkboxes.length).toBe(2);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── Blockquote ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
describe('<Markdown> — blockquote', () => {
|
|
123
|
+
test('> renders blockquote', () => {
|
|
124
|
+
const { container } = render(<Markdown content='> A wise quote' />);
|
|
125
|
+
expect(container.querySelector('blockquote')).not.toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── Code block ────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
describe('<Markdown> — code block', () => {
|
|
132
|
+
test('fenced code renders <pre><code>', () => {
|
|
133
|
+
const { container } = render(
|
|
134
|
+
<Markdown content={'```ts\nconst x = 1\n```'} />
|
|
135
|
+
);
|
|
136
|
+
expect(container.querySelector('pre')).not.toBeNull();
|
|
137
|
+
expect(container.querySelector('code')).not.toBeNull();
|
|
138
|
+
expect(container.querySelector('code')?.textContent).toContain(
|
|
139
|
+
'const x = 1'
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── Image ─────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('<Markdown> — image', () => {
|
|
147
|
+
test(' renders <img>', () => {
|
|
148
|
+
const { container } = render(
|
|
149
|
+
<Markdown content='' />
|
|
150
|
+
);
|
|
151
|
+
const img = container.querySelector('img');
|
|
152
|
+
expect(img?.getAttribute('src')).toBe('https://example.com/cat.jpg');
|
|
153
|
+
expect(img?.getAttribute('alt')).toBe('A cat');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── HR ────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
describe('<Markdown> — hr', () => {
|
|
160
|
+
test('--- renders <hr>', () => {
|
|
161
|
+
const { container } = render(
|
|
162
|
+
<Markdown content={`Above\n\n---\n\nBelow`} />
|
|
163
|
+
);
|
|
164
|
+
expect(container.querySelector('hr')).not.toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Table (GFM) ──────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe('<Markdown> — table (GFM)', () => {
|
|
171
|
+
test('pipe table renders <table>', () => {
|
|
172
|
+
const md = '| Name | Age |\n| --- | --- |\n| Alice | 30 |';
|
|
173
|
+
const { container } = render(<Markdown content={md} />);
|
|
174
|
+
expect(container.querySelector('table')).not.toBeNull();
|
|
175
|
+
expect(container.querySelector('th')?.textContent).toBe('Name');
|
|
176
|
+
expect(container.querySelector('td')?.textContent).toBe('Alice');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Component overrides ──────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
describe('<Markdown> — component overrides', () => {
|
|
183
|
+
test('custom heading component renders', () => {
|
|
184
|
+
render(
|
|
185
|
+
<Markdown
|
|
186
|
+
content='# Custom'
|
|
187
|
+
components={{
|
|
188
|
+
h1: ({ children }) => <h1 data-testid='custom-h1'>{children}</h1>
|
|
189
|
+
}}
|
|
190
|
+
/>
|
|
191
|
+
);
|
|
192
|
+
expect(screen.getByTestId('custom-h1')).not.toBeNull();
|
|
193
|
+
expect(screen.getByTestId('custom-h1').textContent).toBe('Custom');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <Markdown> — renders a markdown string as semantic HTML using React components.
|
|
3
|
+
*
|
|
4
|
+
* Simple usage:
|
|
5
|
+
* <Markdown content={data.body} className="prose" />
|
|
6
|
+
*
|
|
7
|
+
* Advanced usage with element overrides:
|
|
8
|
+
* <Markdown content={data.body} components={{ h1: ({children}) => <h1 className="text-4xl">{children}</h1> }} />
|
|
9
|
+
*
|
|
10
|
+
* GFM (tables, strikethrough, task lists, autolinks) is enabled by default
|
|
11
|
+
* to match the Milkdown editor's output.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReactNode } from 'react';
|
|
14
|
+
import ReactMarkdown from 'react-markdown';
|
|
15
|
+
import type { Components } from 'react-markdown';
|
|
16
|
+
import remarkGfm from 'remark-gfm';
|
|
17
|
+
|
|
18
|
+
export interface MarkdownProps {
|
|
19
|
+
content: string | null | undefined;
|
|
20
|
+
/** Applied to the wrapper div */
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Per-element component overrides — uses react-markdown's Components type */
|
|
23
|
+
components?: Components;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Markdown({
|
|
27
|
+
content,
|
|
28
|
+
className,
|
|
29
|
+
components
|
|
30
|
+
}: MarkdownProps): ReactNode {
|
|
31
|
+
if (!content) return null;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={className}>
|
|
35
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
|
36
|
+
{content}
|
|
37
|
+
</ReactMarkdown>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -4,31 +4,15 @@
|
|
|
4
4
|
* Hooks:
|
|
5
5
|
* import { useEntry, useEntries } from "koguma/react";
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* import {
|
|
7
|
+
* Markdown renderer:
|
|
8
|
+
* import { Markdown } from "koguma/react";
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
// Data fetching hooks
|
|
12
12
|
export { useEntry, useEntries } from './hooks.ts';
|
|
13
13
|
export type { UseEntryResult, UseEntriesResult } from './hooks.ts';
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
export {
|
|
17
|
-
export type {
|
|
18
|
-
export type {
|
|
19
|
-
RichTextComponents,
|
|
20
|
-
ParagraphProps,
|
|
21
|
-
HeadingProps,
|
|
22
|
-
ListProps,
|
|
23
|
-
ListItemProps,
|
|
24
|
-
QuoteProps,
|
|
25
|
-
CodeBlockProps,
|
|
26
|
-
ImageBlockProps,
|
|
27
|
-
HrProps,
|
|
28
|
-
TableProps,
|
|
29
|
-
LayoutProps,
|
|
30
|
-
CustomBlockProps,
|
|
31
|
-
LinkProps,
|
|
32
|
-
InlineImageProps,
|
|
33
|
-
CustomInlineProps
|
|
34
|
-
} from './types.ts';
|
|
15
|
+
// Markdown renderer
|
|
16
|
+
export { Markdown } from './Markdown.tsx';
|
|
17
|
+
export type { MarkdownProps } from './Markdown.tsx';
|
|
18
|
+
export type { MarkdownComponents } from './types.ts';
|
package/src/react/types.ts
CHANGED
|
@@ -1,114 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* All overrides are optional. Provide only the elements you need to customize.
|
|
5
|
-
* The default renderer produces unstyled semantic HTML for all node types.
|
|
2
|
+
* Re-export react-markdown's Components type for convenience.
|
|
3
|
+
* Users can import this from 'koguma/react' to type their component overrides.
|
|
6
4
|
*/
|
|
7
|
-
|
|
8
|
-
KogumaBlockNode,
|
|
9
|
-
KogumaInlineNode,
|
|
10
|
-
KogumaListItem,
|
|
11
|
-
KogumaTableRow
|
|
12
|
-
} from '../config/types.ts';
|
|
13
|
-
import type { ReactNode } from 'react';
|
|
14
|
-
|
|
15
|
-
// ── Block component props ────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
export interface ParagraphProps {
|
|
18
|
-
node: Extract<KogumaBlockNode, { type: 'paragraph' }>;
|
|
19
|
-
children: ReactNode;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface HeadingProps {
|
|
23
|
-
node: Extract<KogumaBlockNode, { type: 'heading' }>;
|
|
24
|
-
children: ReactNode;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface ListProps {
|
|
28
|
-
node: Extract<KogumaBlockNode, { type: 'list' }>;
|
|
29
|
-
/** Rendered list items */
|
|
30
|
-
children: ReactNode;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ListItemProps {
|
|
34
|
-
item: KogumaListItem;
|
|
35
|
-
/** Rendered inline content of this item */
|
|
36
|
-
children: ReactNode;
|
|
37
|
-
/** Pre-rendered nested list, if any — render it inside your <li> */
|
|
38
|
-
nestedList: ReactNode | null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface QuoteProps {
|
|
42
|
-
node: Extract<KogumaBlockNode, { type: 'quote' }>;
|
|
43
|
-
children: ReactNode;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface CodeBlockProps {
|
|
47
|
-
node: Extract<KogumaBlockNode, { type: 'code' }>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface ImageBlockProps {
|
|
51
|
-
node: Extract<KogumaBlockNode, { type: 'image' }>;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface HrProps {
|
|
55
|
-
node: Extract<KogumaBlockNode, { type: 'hr' }>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface TableProps {
|
|
59
|
-
node: Extract<KogumaBlockNode, { type: 'table' }>;
|
|
60
|
-
rows: KogumaTableRow[];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface LayoutProps {
|
|
64
|
-
node: Extract<KogumaBlockNode, { type: 'layout' }>;
|
|
65
|
-
/** Pre-rendered columns — render each as a column container */
|
|
66
|
-
columns: ReactNode[];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface CustomBlockProps {
|
|
70
|
-
node: Extract<KogumaBlockNode, { type: 'custom' }>;
|
|
71
|
-
name: string;
|
|
72
|
-
data: Record<string, unknown>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ── Inline component props ───────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
export interface LinkProps {
|
|
78
|
-
node: Extract<KogumaInlineNode, { type: 'link' }>;
|
|
79
|
-
children: ReactNode;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface InlineImageProps {
|
|
83
|
-
node: Extract<KogumaInlineNode, { type: 'inline-image' }>;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface CustomInlineProps {
|
|
87
|
-
node: Extract<KogumaInlineNode, { type: 'custom' }>;
|
|
88
|
-
name: string;
|
|
89
|
-
data: Record<string, unknown>;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Component override map ───────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
export interface RichTextComponents {
|
|
95
|
-
// Block overrides
|
|
96
|
-
paragraph?: (props: ParagraphProps) => ReactNode;
|
|
97
|
-
heading?: (props: HeadingProps) => ReactNode;
|
|
98
|
-
list?: (props: ListProps) => ReactNode;
|
|
99
|
-
listItem?: (props: ListItemProps) => ReactNode;
|
|
100
|
-
quote?: (props: QuoteProps) => ReactNode;
|
|
101
|
-
code?: (props: CodeBlockProps) => ReactNode;
|
|
102
|
-
image?: (props: ImageBlockProps) => ReactNode;
|
|
103
|
-
hr?: (props: HrProps) => ReactNode;
|
|
104
|
-
table?: (props: TableProps) => ReactNode;
|
|
105
|
-
layout?: (props: LayoutProps) => ReactNode;
|
|
106
|
-
// Catches any block-level custom node (tweet, youtube, etc.)
|
|
107
|
-
customBlock?: (props: CustomBlockProps) => ReactNode;
|
|
108
|
-
|
|
109
|
-
// Inline overrides
|
|
110
|
-
link?: (props: LinkProps) => ReactNode;
|
|
111
|
-
inlineImage?: (props: InlineImageProps) => ReactNode;
|
|
112
|
-
// Catches any inline custom node (hashtag, mention, etc.)
|
|
113
|
-
customInline?: (props: CustomInlineProps) => ReactNode;
|
|
114
|
-
}
|
|
5
|
+
export type { Components as MarkdownComponents } from 'react-markdown';
|