koguma 0.4.5 → 0.5.0-rc.1
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/package.json +17 -2
- package/src/config/field.ts +70 -48
- package/src/config/index.ts +13 -1
- package/src/config/types.ts +91 -11
- package/src/db/queries.ts +6 -3
- package/src/react/RichText.tsx +350 -0
- package/src/react/hooks.ts +69 -0
- package/src/react/index.ts +29 -65
- package/src/react/types.ts +114 -0
- package/src/rich-text/index.ts +3 -0
- package/src/rich-text/lexical-to-koguma.test.ts +906 -0
- package/src/rich-text/lexical-to-koguma.ts +400 -0
- package/src/rich-text/plain.test.ts +208 -0
- package/src/rich-text/plain.ts +114 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koguma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-rc.1",
|
|
4
4
|
"description": "🐻 A little CMS with big heart — schema-driven, runs on Cloudflare's free tier",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,12 +47,27 @@
|
|
|
47
47
|
"zod": "^4.3.6"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"hono": "^4.0.0"
|
|
50
|
+
"hono": "^4.0.0",
|
|
51
|
+
"react": ">=18",
|
|
52
|
+
"react-dom": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"react": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"react-dom": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
51
61
|
},
|
|
52
62
|
"devDependencies": {
|
|
53
63
|
"@cloudflare/workers-types": "^4.20260304.0",
|
|
64
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
65
|
+
"@testing-library/react": "^16.3.0",
|
|
54
66
|
"@types/node": "^22.0.0",
|
|
67
|
+
"@types/react": "^19.0.0",
|
|
68
|
+
"@types/react-dom": "^19.0.0",
|
|
55
69
|
"concurrently": "^9.2.1",
|
|
70
|
+
"happy-dom": "^17.4.4",
|
|
56
71
|
"miniflare": "^4.20260301.1",
|
|
57
72
|
"typescript": "^5.7.0",
|
|
58
73
|
"wrangler": "^4.71.0"
|
package/src/config/field.ts
CHANGED
|
@@ -9,23 +9,23 @@
|
|
|
9
9
|
* field.image("Hero Photo")
|
|
10
10
|
* field.refs("featureCard", "Highlights")
|
|
11
11
|
*/
|
|
12
|
-
import { z } from
|
|
13
|
-
import type { KogumaAsset,
|
|
12
|
+
import { z } from 'zod/v4';
|
|
13
|
+
import type { KogumaAsset, KogumaDocument, EntryReference } from './types.ts';
|
|
14
14
|
|
|
15
15
|
// ── Field metadata (extracted by the admin + schema generator) ──────
|
|
16
16
|
|
|
17
17
|
export type FieldType =
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
18
|
+
| 'text'
|
|
19
|
+
| 'longText'
|
|
20
|
+
| 'richText'
|
|
21
|
+
| 'url'
|
|
22
|
+
| 'image'
|
|
23
|
+
| 'boolean'
|
|
24
|
+
| 'number'
|
|
25
|
+
| 'date'
|
|
26
|
+
| 'select'
|
|
27
|
+
| 'reference'
|
|
28
|
+
| 'references';
|
|
29
29
|
|
|
30
30
|
export interface FieldMeta {
|
|
31
31
|
label: string;
|
|
@@ -82,7 +82,10 @@ export class FieldBuilder<T extends z.ZodType = z.ZodType> {
|
|
|
82
82
|
|
|
83
83
|
/** Set a default value */
|
|
84
84
|
default(value: z.infer<T>): FieldBuilder {
|
|
85
|
-
return new FieldBuilder(
|
|
85
|
+
return new FieldBuilder(
|
|
86
|
+
(this._schema as z.ZodType).default(value as never),
|
|
87
|
+
this._meta
|
|
88
|
+
);
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -91,76 +94,85 @@ export class FieldBuilder<T extends z.ZodType = z.ZodType> {
|
|
|
91
94
|
export const field = {
|
|
92
95
|
/** Short text field */
|
|
93
96
|
text(label: string) {
|
|
94
|
-
return new FieldBuilder(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
return new FieldBuilder(z.string().optional().describe(label), {
|
|
98
|
+
label,
|
|
99
|
+
fieldType: 'text',
|
|
100
|
+
required: false
|
|
101
|
+
});
|
|
98
102
|
},
|
|
99
103
|
|
|
100
104
|
/** Multi-line text field */
|
|
101
105
|
longText(label: string) {
|
|
102
|
-
return new FieldBuilder(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
return new FieldBuilder(z.string().optional().describe(label), {
|
|
107
|
+
label,
|
|
108
|
+
fieldType: 'longText',
|
|
109
|
+
required: false
|
|
110
|
+
});
|
|
106
111
|
},
|
|
107
112
|
|
|
108
|
-
/** Rich text field (
|
|
113
|
+
/** Rich text field (Lexical rich text document) */
|
|
109
114
|
richText(label: string) {
|
|
110
115
|
return new FieldBuilder(
|
|
111
|
-
z.custom<
|
|
112
|
-
{ label, fieldType:
|
|
116
|
+
z.custom<KogumaDocument>().optional().describe(label),
|
|
117
|
+
{ label, fieldType: 'richText', required: false }
|
|
113
118
|
);
|
|
114
119
|
},
|
|
115
120
|
|
|
116
121
|
/** URL field with validation */
|
|
117
122
|
url(label: string) {
|
|
118
|
-
return new FieldBuilder(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
return new FieldBuilder(z.string().url().optional().describe(label), {
|
|
124
|
+
label,
|
|
125
|
+
fieldType: 'url',
|
|
126
|
+
required: false
|
|
127
|
+
});
|
|
122
128
|
},
|
|
123
129
|
|
|
124
130
|
/** Image/media field (reference to R2 asset) */
|
|
125
131
|
image(label: string) {
|
|
126
132
|
return new FieldBuilder(
|
|
127
133
|
z.custom<KogumaAsset>().optional().describe(label),
|
|
128
|
-
{ label, fieldType:
|
|
134
|
+
{ label, fieldType: 'image', required: false }
|
|
129
135
|
);
|
|
130
136
|
},
|
|
131
137
|
|
|
132
138
|
/** Boolean toggle */
|
|
133
139
|
boolean(label: string) {
|
|
134
|
-
return new FieldBuilder(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
return new FieldBuilder(z.boolean().optional().describe(label), {
|
|
141
|
+
label,
|
|
142
|
+
fieldType: 'boolean',
|
|
143
|
+
required: false
|
|
144
|
+
});
|
|
138
145
|
},
|
|
139
146
|
|
|
140
147
|
/** Numeric field */
|
|
141
148
|
number(label: string) {
|
|
142
|
-
return new FieldBuilder(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
149
|
+
return new FieldBuilder(z.number().optional().describe(label), {
|
|
150
|
+
label,
|
|
151
|
+
fieldType: 'number',
|
|
152
|
+
required: false
|
|
153
|
+
});
|
|
146
154
|
},
|
|
147
155
|
|
|
148
156
|
/** Date field */
|
|
149
157
|
date(label: string) {
|
|
150
|
-
return new FieldBuilder(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
return new FieldBuilder(z.string().datetime().optional().describe(label), {
|
|
159
|
+
label,
|
|
160
|
+
fieldType: 'date',
|
|
161
|
+
required: false
|
|
162
|
+
});
|
|
154
163
|
},
|
|
155
164
|
|
|
156
165
|
/** Dropdown select */
|
|
157
166
|
select(label: string, opts: { options: string[] }) {
|
|
158
|
-
const enumSchema = z
|
|
167
|
+
const enumSchema = z
|
|
168
|
+
.enum(opts.options as [string, ...string[]])
|
|
169
|
+
.optional()
|
|
170
|
+
.describe(label);
|
|
159
171
|
return new FieldBuilder(enumSchema, {
|
|
160
172
|
label,
|
|
161
|
-
fieldType:
|
|
173
|
+
fieldType: 'select',
|
|
162
174
|
required: false,
|
|
163
|
-
options: opts.options
|
|
175
|
+
options: opts.options
|
|
164
176
|
});
|
|
165
177
|
},
|
|
166
178
|
|
|
@@ -168,7 +180,12 @@ export const field = {
|
|
|
168
180
|
ref(contentType: string, label: string) {
|
|
169
181
|
return new FieldBuilder(
|
|
170
182
|
z.custom<EntryReference>().optional().describe(label),
|
|
171
|
-
{
|
|
183
|
+
{
|
|
184
|
+
label,
|
|
185
|
+
fieldType: 'reference',
|
|
186
|
+
required: false,
|
|
187
|
+
refContentType: contentType
|
|
188
|
+
}
|
|
172
189
|
);
|
|
173
190
|
},
|
|
174
191
|
|
|
@@ -176,7 +193,12 @@ export const field = {
|
|
|
176
193
|
refs(contentType: string, label: string) {
|
|
177
194
|
return new FieldBuilder(
|
|
178
195
|
z.array(z.custom<EntryReference>()).optional().describe(label),
|
|
179
|
-
{
|
|
196
|
+
{
|
|
197
|
+
label,
|
|
198
|
+
fieldType: 'references',
|
|
199
|
+
required: false,
|
|
200
|
+
refContentType: contentType
|
|
201
|
+
}
|
|
180
202
|
);
|
|
181
|
-
}
|
|
203
|
+
}
|
|
182
204
|
};
|
package/src/config/index.ts
CHANGED
|
@@ -20,7 +20,19 @@ export { field } from './field.ts';
|
|
|
20
20
|
export type { FieldBuilder, FieldType, FieldMeta } from './field.ts';
|
|
21
21
|
|
|
22
22
|
// Runtime types
|
|
23
|
-
export type {
|
|
23
|
+
export type {
|
|
24
|
+
KogumaAsset,
|
|
25
|
+
KogumaDocument,
|
|
26
|
+
KogumaBlockNode,
|
|
27
|
+
KogumaInlineNode,
|
|
28
|
+
KogumaListItem,
|
|
29
|
+
KogumaTableRow,
|
|
30
|
+
KogumaTableCell,
|
|
31
|
+
EntryReference
|
|
32
|
+
} from './types.ts';
|
|
33
|
+
|
|
34
|
+
// Rich text utilities
|
|
35
|
+
export { richTextToPlain } from '../rich-text/index.ts';
|
|
24
36
|
|
|
25
37
|
// Meta registry (for dev tools like the Schema Builder)
|
|
26
38
|
export { fieldRegistry, fieldSuggestions } from './meta.ts';
|
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, rich text, and references.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** A media asset stored in R2 */
|
|
@@ -13,20 +13,100 @@ export interface KogumaAsset {
|
|
|
13
13
|
height?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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[];
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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[] };
|
|
28
68
|
}
|
|
29
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
|
+
|
|
30
110
|
/** Reference to another entry */
|
|
31
111
|
export interface EntryReference<T = Record<string, unknown>> {
|
|
32
112
|
id: string;
|
package/src/db/queries.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ContentTypeConfig } from '../config/define.ts';
|
|
8
8
|
import type { FieldMeta } from '../config/field.ts';
|
|
9
|
+
import { lexicalToKoguma } from '../rich-text/index.ts';
|
|
9
10
|
|
|
10
11
|
// ── D1 database interface (Cloudflare Workers binding) ──────────────
|
|
11
12
|
|
|
@@ -60,9 +61,9 @@ export async function getEntry(
|
|
|
60
61
|
for (const [fieldId, meta] of scalarFields(ct)) {
|
|
61
62
|
if (meta.fieldType === 'richText' && typeof entry[fieldId] === 'string') {
|
|
62
63
|
try {
|
|
63
|
-
entry[fieldId] = JSON.parse(entry[fieldId] as string);
|
|
64
|
+
entry[fieldId] = lexicalToKoguma(JSON.parse(entry[fieldId] as string));
|
|
64
65
|
} catch {
|
|
65
|
-
// leave as
|
|
66
|
+
// leave as-is if not valid JSON
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -106,7 +107,9 @@ export async function getEntries(
|
|
|
106
107
|
for (const [fieldId, meta] of scalarFields(ct)) {
|
|
107
108
|
if (meta.fieldType === 'richText' && typeof entry[fieldId] === 'string') {
|
|
108
109
|
try {
|
|
109
|
-
entry[fieldId] =
|
|
110
|
+
entry[fieldId] = lexicalToKoguma(
|
|
111
|
+
JSON.parse(entry[fieldId] as string)
|
|
112
|
+
);
|
|
110
113
|
} catch {
|
|
111
114
|
/* skip */
|
|
112
115
|
}
|