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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koguma",
3
- "version": "0.4.5",
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"
@@ -9,23 +9,23 @@
9
9
  * field.image("Hero Photo")
10
10
  * field.refs("featureCard", "Highlights")
11
11
  */
12
- import { z } from "zod/v4";
13
- import type { KogumaAsset, RichTextDocument, EntryReference } from "./types.ts";
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
- | "text"
19
- | "longText"
20
- | "richText"
21
- | "url"
22
- | "image"
23
- | "boolean"
24
- | "number"
25
- | "date"
26
- | "select"
27
- | "reference"
28
- | "references";
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((this._schema as z.ZodType).default(value as never), this._meta);
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
- z.string().optional().describe(label),
96
- { label, fieldType: "text", required: false },
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
- z.string().optional().describe(label),
104
- { label, fieldType: "longText", required: false },
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 (Tiptap JSON document) */
113
+ /** Rich text field (Lexical rich text document) */
109
114
  richText(label: string) {
110
115
  return new FieldBuilder(
111
- z.custom<RichTextDocument>().optional().describe(label),
112
- { label, fieldType: "richText", required: false },
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
- z.string().url().optional().describe(label),
120
- { label, fieldType: "url", required: false },
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: "image", required: false },
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
- z.boolean().optional().describe(label),
136
- { label, fieldType: "boolean", required: false },
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
- z.number().optional().describe(label),
144
- { label, fieldType: "number", required: false },
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
- z.string().datetime().optional().describe(label),
152
- { label, fieldType: "date", required: false },
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.enum(opts.options as [string, ...string[]]).optional().describe(label);
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: "select",
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
- { label, fieldType: "reference", required: false, refContentType: contentType },
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
- { label, fieldType: "references", required: false, refContentType: contentType },
196
+ {
197
+ label,
198
+ fieldType: 'references',
199
+ required: false,
200
+ refContentType: contentType
201
+ }
180
202
  );
181
- },
203
+ }
182
204
  };
@@ -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 { KogumaAsset, RichTextDocument, EntryReference } from './types.ts';
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';
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Core CMS types used across Koguma.
3
- * These are the runtime types for assets and rich text not Zod schemas.
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
- /** A Tiptap/ProseMirror-compatible rich text document */
17
- export interface RichTextDocument {
18
- type: "doc";
19
- content: RichTextNode[];
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 interface RichTextNode {
23
- type: string;
24
- content?: RichTextNode[];
25
- text?: string;
26
- marks?: { type: string; attrs?: Record<string, unknown> }[];
27
- attrs?: Record<string, unknown>;
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 string if not valid JSON
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] = JSON.parse(entry[fieldId] as string);
110
+ entry[fieldId] = lexicalToKoguma(
111
+ JSON.parse(entry[fieldId] as string)
112
+ );
110
113
  } catch {
111
114
  /* skip */
112
115
  }