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
@@ -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 _assets table */
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
- `INSERT INTO _assets (id, title, url, content_type, file_size, width, height) VALUES (?, ?, ?, ?, ?, ?, ?)`
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 _assets ORDER BY created_at DESC')
141
+ .prepare('SELECT * FROM assets ORDER BY created_at DESC')
115
142
  .all();
116
143
 
117
- const assets = (result.results ?? []) as Record<string, unknown>[];
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 = (asset.url as string).replace('/api/media/', '');
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
- await db
130
- .prepare('UPDATE _assets SET file_size = ? WHERE id = ?')
131
- .bind(head.size, asset.id)
132
- .run();
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 result = await db
157
- .prepare('UPDATE _assets SET title = ? WHERE id = ?')
158
- .bind(title.trim(), id)
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 _assets table */
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 asset = await db
172
- .prepare('SELECT * FROM _assets WHERE id = ?')
221
+ const row = await db
222
+ .prepare('SELECT * FROM assets WHERE id = ?')
173
223
  .bind(id)
174
224
  .first();
175
- if (!asset) return c.notFound();
225
+ if (!row) return c.notFound();
176
226
 
177
- const key = (asset.url as string).replace('/api/media/', '');
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 _assets WHERE id = ?').bind(id).run();
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 asset = await db
189
- .prepare('SELECT * FROM _assets WHERE id = ?')
240
+ const row = await db
241
+ .prepare('SELECT * FROM assets WHERE id = ?')
190
242
  .bind(id)
191
243
  .first();
192
- if (!asset) return c.notFound();
193
- return c.json(asset);
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 _assets WHERE id = ?')
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 = (existing.url as string).replace('/api/media/', '');
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 = existing.title as string;
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
- await db
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('![alt](url) renders <img>', () => {
148
+ const { container } = render(
149
+ <Markdown content='![A cat](https://example.com/cat.jpg)' />
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
+ }
@@ -4,31 +4,15 @@
4
4
  * Hooks:
5
5
  * import { useEntry, useEntries } from "koguma/react";
6
6
  *
7
- * Rich text renderer:
8
- * import { RichText } from "koguma/react";
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
- // Rich text renderer
16
- export { RichText } from './RichText.tsx';
17
- export type { RichTextProps } from './RichText.tsx';
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';
@@ -1,114 +1,5 @@
1
1
  /**
2
- * RichTextComponents — per-element override types for <RichText>.
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
- import type {
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';