@alstar/studio 0.0.0-beta.5 → 0.0.0-beta.7
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/api/api-key.ts +74 -0
- package/api/block.ts +82 -42
- package/api/index.ts +9 -1
- package/api/mcp.ts +53 -0
- package/components/AdminPanel.ts +74 -0
- package/components/BlockFieldRenderer.ts +121 -0
- package/components/BlockRenderer.ts +22 -0
- package/components/Entries.ts +5 -4
- package/components/Entry.ts +13 -21
- package/components/FieldRenderer.ts +35 -0
- package/components/Render.ts +46 -0
- package/components/Settings.ts +98 -0
- package/components/{layout.ts → SiteLayout.ts} +9 -13
- package/components/fields/Markdown.ts +44 -0
- package/components/fields/Text.ts +42 -0
- package/components/fields/index.ts +4 -0
- package/components/icons.ts +59 -0
- package/index.ts +52 -30
- package/package.json +3 -2
- package/pages/entry/[id].ts +17 -0
- package/{components → pages}/index.ts +7 -4
- package/pages/settings.ts +10 -0
- package/public/studio/admin-panel.css +103 -0
- package/public/studio/blocks.css +53 -0
- package/public/studio/main.css +162 -0
- package/public/studio/main.js +10 -0
- package/public/studio/markdown-editor.js +34 -0
- package/public/studio/settings.css +24 -0
- package/public/studio/sortable-list.js +40 -0
- package/queries/block-with-children.ts +74 -0
- package/queries/block.ts +31 -40
- package/queries/db-types.ts +15 -0
- package/queries/getBlockTrees-2.ts +0 -0
- package/queries/getBlockTrees.ts +316 -0
- package/queries/getBlocks.ts +214 -0
- package/queries/index.ts +1 -0
- package/queries/structure-types.ts +97 -0
- package/schemas.ts +14 -3
- package/types.ts +123 -6
- package/utils/buildBlocksTree.ts +4 -4
- package/utils/define.ts +31 -5
- package/utils/file-based-router.ts +1 -0
- package/utils/get-or-create-row.ts +41 -0
- package/utils/startup-log.ts +9 -0
- package/components/AdminPanel/AdminPanel.css +0 -78
- package/components/AdminPanel/AdminPanel.ts +0 -57
- package/components/Block.ts +0 -116
- package/components/Field.ts +0 -168
- package/components/Fields.ts +0 -43
- package/public/main.css +0 -95
- package/public/main.js +0 -44
- /package/{components/Entry.css → public/studio/entry.css} +0 -0
- /package/public/{favicon.svg → studio/favicon.svg} +0 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// types-and-runtime.ts
|
|
2
|
+
// import Database from "better-sqlite3";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { sql } from "../utils/sql.ts";
|
|
5
|
+
|
|
6
|
+
/* =========================
|
|
7
|
+
Runtime helpers (preserve literal types)
|
|
8
|
+
========================= */
|
|
9
|
+
export type FieldType = "text" | "slug" | "markdown" | "image" | "blocks";
|
|
10
|
+
|
|
11
|
+
export function defineField<const T extends { name: string; label: string; type: FieldType; fields?: readonly any[] }>(def: T) {
|
|
12
|
+
return def;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function defineBlock<const T extends { name: string; label: string; type: string; fields?: readonly any[] }>(def: T) {
|
|
16
|
+
return def;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function defineStructure<const T extends readonly any[]>(blocks: T) {
|
|
20
|
+
return blocks;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* =========================
|
|
24
|
+
DB base type (every row shape)
|
|
25
|
+
========================= */
|
|
26
|
+
export type DBBase = {
|
|
27
|
+
id: number;
|
|
28
|
+
created_at: string;
|
|
29
|
+
updated_at: string;
|
|
30
|
+
name: string;
|
|
31
|
+
label: string;
|
|
32
|
+
type: string;
|
|
33
|
+
sort_order: number;
|
|
34
|
+
value: string | null;
|
|
35
|
+
options: any | null;
|
|
36
|
+
status: string;
|
|
37
|
+
parent_id: number | null;
|
|
38
|
+
depth: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/* =========================
|
|
42
|
+
Type-level mapping: from structure literal -> runtime result types
|
|
43
|
+
========================= */
|
|
44
|
+
|
|
45
|
+
type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
|
|
46
|
+
|
|
47
|
+
/** PrimitiveLeaf: non-'blocks' field instance */
|
|
48
|
+
type PrimitiveLeafNode<Name extends string, TypeStr extends string> = DBBase & {
|
|
49
|
+
readonly name: Name;
|
|
50
|
+
readonly type: TypeStr;
|
|
51
|
+
readonly children?: []; // leaf has no children array
|
|
52
|
+
readonly fields?: {}; // leaf has empty fields
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Blocks wrapper node (a field with type 'blocks') */
|
|
56
|
+
type BlocksWrapperNode<Name extends string, SubBlocks extends readonly any[]> = DBBase & {
|
|
57
|
+
readonly name: Name;
|
|
58
|
+
readonly type: "blocks";
|
|
59
|
+
// children are the instances of BlockNodeFromDefs for nested block defs
|
|
60
|
+
readonly children: BlockNodeFromDefs<SubBlocks>[];
|
|
61
|
+
readonly fields: {}; // wrapper itself doesn't define extra fields in DSL
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** For a FieldDef literal F, produce its runtime node type */
|
|
65
|
+
type FieldNodeFromLiteral<F> =
|
|
66
|
+
F extends { name: infer N extends string; type: infer FT extends string }
|
|
67
|
+
? FT extends "blocks"
|
|
68
|
+
? F extends { fields: infer SB extends readonly any[] }
|
|
69
|
+
? BlocksWrapperNode<N, SB>
|
|
70
|
+
: PrimitiveLeafNode<N, FT>
|
|
71
|
+
: PrimitiveLeafNode<N, FT>
|
|
72
|
+
: never;
|
|
73
|
+
|
|
74
|
+
/** Given an array of FieldDefs, produce a mapping name -> appropriate Node */
|
|
75
|
+
type FieldsFromFieldDefs<TDefs> =
|
|
76
|
+
TDefs extends readonly any[]
|
|
77
|
+
? {
|
|
78
|
+
[P in ArrayElement<TDefs> as P extends { name: infer N extends string } ? N : never]:
|
|
79
|
+
FieldNodeFromLiteral<P>;
|
|
80
|
+
}
|
|
81
|
+
: {};
|
|
82
|
+
|
|
83
|
+
/** A Block node (for a BlockDef literal B) */
|
|
84
|
+
type BlockNodeFromLiteral<B> =
|
|
85
|
+
B extends { name: infer BN extends string; type: infer BT extends string; fields?: infer BF extends readonly any[] }
|
|
86
|
+
? DBBase & {
|
|
87
|
+
readonly name: BN;
|
|
88
|
+
readonly type: BT;
|
|
89
|
+
// The block's fields mapping is built from B.fields (may include blocks wrappers)
|
|
90
|
+
readonly fields: FieldsFromFieldDefs<BF>;
|
|
91
|
+
// Block instances themselves do not keep a top-level children array (their nested content is in fields),
|
|
92
|
+
// except for "blocks" wrapper nodes (which are FieldNodes, not BlockNodes).
|
|
93
|
+
readonly children?: []; // mainly unused for normal block instances
|
|
94
|
+
}
|
|
95
|
+
: never;
|
|
96
|
+
|
|
97
|
+
/** Build union type for an array of BlockDefs */
|
|
98
|
+
type BlockNodeFromDefs<TDefs extends readonly any[]> =
|
|
99
|
+
ArrayElement<TDefs> extends infer B ? (B extends any ? BlockNodeFromLiteral<B> : never) : never;
|
|
100
|
+
|
|
101
|
+
/** Final result type returned by getBlockTrees for a given structure */
|
|
102
|
+
export type BlockTreeFromStructure<TStructure extends readonly any[]> =
|
|
103
|
+
BlockNodeFromDefs<TStructure>[];
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
/* =========================
|
|
107
|
+
RUNTIME: fetch + build + transform
|
|
108
|
+
========================= */
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds adjacency (map of id->node) and attaches children arrays.
|
|
112
|
+
* Then transforms nodes so that:
|
|
113
|
+
* - each node gets .fields where immediate children are mapped by child.name
|
|
114
|
+
* - if node.type === "blocks" -> keep node.children as the array of nested blocks
|
|
115
|
+
* - otherwise -> clear node.children (we represent children via fields on the parent)
|
|
116
|
+
*/
|
|
117
|
+
function buildForestRows(rows: (DBBase)[]) {
|
|
118
|
+
const map = new Map<number, any>();
|
|
119
|
+
for (const r of rows) {
|
|
120
|
+
// copy DBBase row into mutable node
|
|
121
|
+
map.set(r.id, {
|
|
122
|
+
...r,
|
|
123
|
+
children: [],
|
|
124
|
+
fields: {}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const roots: any[] = [];
|
|
129
|
+
for (const r of rows) {
|
|
130
|
+
const node = map.get(r.id)!;
|
|
131
|
+
if (r.parent_id === null) {
|
|
132
|
+
roots.push(node);
|
|
133
|
+
} else {
|
|
134
|
+
const parent = map.get(r.parent_id);
|
|
135
|
+
if (parent) parent.children.push(node);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Recursively transform nodes bottom-up
|
|
140
|
+
function transformNode(node: any) {
|
|
141
|
+
// first transform children
|
|
142
|
+
for (const child of node.children) {
|
|
143
|
+
transformNode(child);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// build fields map from immediate children keyed by child.name
|
|
147
|
+
const fields: Record<string, any> = {};
|
|
148
|
+
for (const child of node.children) {
|
|
149
|
+
// assign the transformed child as field value
|
|
150
|
+
fields[child.name] = child;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
node.fields = fields;
|
|
154
|
+
|
|
155
|
+
// Important: keep the children array only for 'blocks' wrapper nodes,
|
|
156
|
+
// because wrappers need to hold their nested block instances under children.
|
|
157
|
+
// For all other nodes we clear children (they are exposed via node.fields)
|
|
158
|
+
if (node.type !== "blocks") {
|
|
159
|
+
node.children = [];
|
|
160
|
+
}
|
|
161
|
+
return node;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return roots.map(transformNode);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function rootQuery(filterSql: string, depthLimit?: number) {
|
|
168
|
+
const depthLimitClause =
|
|
169
|
+
depthLimit !== undefined ? `WHERE d.depth + 1 <= ${depthLimit}` : ''
|
|
170
|
+
|
|
171
|
+
return sql`
|
|
172
|
+
with recursive
|
|
173
|
+
ancestors as (
|
|
174
|
+
select
|
|
175
|
+
id,
|
|
176
|
+
created_at,
|
|
177
|
+
updated_at,
|
|
178
|
+
name,
|
|
179
|
+
label,
|
|
180
|
+
type,
|
|
181
|
+
sort_order,
|
|
182
|
+
value,
|
|
183
|
+
options,
|
|
184
|
+
status,
|
|
185
|
+
parent_id,
|
|
186
|
+
0 as depth
|
|
187
|
+
from
|
|
188
|
+
blocks
|
|
189
|
+
where
|
|
190
|
+
${filterSql}
|
|
191
|
+
union all
|
|
192
|
+
select
|
|
193
|
+
b.id,
|
|
194
|
+
b.created_at,
|
|
195
|
+
b.updated_at,
|
|
196
|
+
b.name,
|
|
197
|
+
b.label,
|
|
198
|
+
b.type,
|
|
199
|
+
b.sort_order,
|
|
200
|
+
b.value,
|
|
201
|
+
b.options,
|
|
202
|
+
b.status,
|
|
203
|
+
b.parent_id,
|
|
204
|
+
a.depth + 1
|
|
205
|
+
from
|
|
206
|
+
blocks b
|
|
207
|
+
inner join ancestors a on b.id = a.parent_id
|
|
208
|
+
),
|
|
209
|
+
roots as (
|
|
210
|
+
select
|
|
211
|
+
id,
|
|
212
|
+
created_at,
|
|
213
|
+
updated_at,
|
|
214
|
+
name,
|
|
215
|
+
label,
|
|
216
|
+
type,
|
|
217
|
+
sort_order,
|
|
218
|
+
value,
|
|
219
|
+
options,
|
|
220
|
+
status,
|
|
221
|
+
parent_id,
|
|
222
|
+
0 as depth
|
|
223
|
+
from
|
|
224
|
+
ancestors
|
|
225
|
+
where
|
|
226
|
+
parent_id is null
|
|
227
|
+
),
|
|
228
|
+
descendants as (
|
|
229
|
+
select
|
|
230
|
+
id,
|
|
231
|
+
created_at,
|
|
232
|
+
updated_at,
|
|
233
|
+
name,
|
|
234
|
+
label,
|
|
235
|
+
type,
|
|
236
|
+
sort_order,
|
|
237
|
+
value,
|
|
238
|
+
options,
|
|
239
|
+
status,
|
|
240
|
+
parent_id,
|
|
241
|
+
depth
|
|
242
|
+
from
|
|
243
|
+
roots
|
|
244
|
+
union all
|
|
245
|
+
select
|
|
246
|
+
b.id,
|
|
247
|
+
b.created_at,
|
|
248
|
+
b.updated_at,
|
|
249
|
+
b.name,
|
|
250
|
+
b.label,
|
|
251
|
+
b.type,
|
|
252
|
+
b.sort_order,
|
|
253
|
+
b.value,
|
|
254
|
+
b.options,
|
|
255
|
+
b.status,
|
|
256
|
+
b.parent_id,
|
|
257
|
+
d.depth + 1
|
|
258
|
+
from
|
|
259
|
+
blocks b
|
|
260
|
+
inner join descendants d on b.parent_id = d.id ${depthLimitClause}
|
|
261
|
+
)
|
|
262
|
+
select
|
|
263
|
+
*
|
|
264
|
+
from
|
|
265
|
+
descendants
|
|
266
|
+
order by
|
|
267
|
+
parent_id,
|
|
268
|
+
sort_order;
|
|
269
|
+
`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildFilterSql(params: Record<string, any>) {
|
|
273
|
+
const entries = Object.entries(params)
|
|
274
|
+
const filterSql = entries
|
|
275
|
+
.map(([key, value]) =>
|
|
276
|
+
value === null ? `${key} is null` : `${key} = :${key}`,
|
|
277
|
+
)
|
|
278
|
+
.join(' and ')
|
|
279
|
+
|
|
280
|
+
let sqlParams: Record<keyof typeof params, any> = {}
|
|
281
|
+
|
|
282
|
+
for (const param in params) {
|
|
283
|
+
if (params[param] !== null) {
|
|
284
|
+
sqlParams[param] = params[param]
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { filterSql, sqlParams }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Generic typed getBlockTrees:
|
|
293
|
+
* - TStructure: the structure literal you pass
|
|
294
|
+
* - returns: Array of typed block nodes derived from that structure
|
|
295
|
+
*
|
|
296
|
+
* NOTE: the runtime SQL must return rows for the correct tree (use your
|
|
297
|
+
* previous recursive CTE with explicit columns + depth reset for roots).
|
|
298
|
+
*/
|
|
299
|
+
export function getBlockTrees<
|
|
300
|
+
TStructure extends readonly any[]
|
|
301
|
+
>(
|
|
302
|
+
db: DatabaseSync,
|
|
303
|
+
_structure: TStructure, // used for typing; at runtime it's not strictly needed
|
|
304
|
+
// sql: string, // the recursive SQL with explicit columns (see earlier messages)
|
|
305
|
+
params: Record<string, any> = {}
|
|
306
|
+
): BlockTreeFromStructure<TStructure> {
|
|
307
|
+
const { filterSql, sqlParams } = buildFilterSql(params)
|
|
308
|
+
// run SQL (you must provide the SQL string matching your earlier working CTE)
|
|
309
|
+
const rows = db.prepare(rootQuery(filterSql)).all(sqlParams) as DBBase[]; // depth included in DBBase.depth
|
|
310
|
+
|
|
311
|
+
// build + transform
|
|
312
|
+
const forest = buildForestRows(rows);
|
|
313
|
+
|
|
314
|
+
// cast to compile-time derived type (we ensure runtime shape matches the type)
|
|
315
|
+
return forest as unknown as BlockTreeFromStructure<TStructure>;
|
|
316
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { sql } from "../utils/sql.ts";
|
|
3
|
+
|
|
4
|
+
// --- Field & block definitions ---
|
|
5
|
+
type FieldType = 'text' | 'slug' | 'markdown' | 'blocks' | 'image';
|
|
6
|
+
|
|
7
|
+
interface BaseField {
|
|
8
|
+
label: string;
|
|
9
|
+
type: FieldType;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TextField extends BaseField {
|
|
13
|
+
type: 'text' | 'slug' | 'markdown';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ImageField extends BaseField {
|
|
17
|
+
type: 'image';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface BlocksField extends BaseField {
|
|
21
|
+
type: 'blocks';
|
|
22
|
+
children: Record<string, BlockDef>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type FieldDef = TextField | ImageField | BlocksField;
|
|
26
|
+
|
|
27
|
+
interface BlockDef {
|
|
28
|
+
label: string;
|
|
29
|
+
type: string;
|
|
30
|
+
fields: Record<string, FieldDef>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Identity helpers (preserve literal types) ---
|
|
34
|
+
export function defineField<T extends FieldDef>(field: T) {
|
|
35
|
+
return field;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function defineBlock<T extends BlockDef>(block: T) {
|
|
39
|
+
return block;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function defineStructure<T extends Record<string, BlockDef>>(structure: T) {
|
|
43
|
+
return structure;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Type mapping from structure to data ---
|
|
47
|
+
type FieldToType<F extends FieldDef> =
|
|
48
|
+
F['type'] extends 'text' | 'slug' | 'markdown' ? string :
|
|
49
|
+
F['type'] extends 'image' ? string :
|
|
50
|
+
F['type'] extends 'blocks'
|
|
51
|
+
? {
|
|
52
|
+
[K in keyof F['children']]: {
|
|
53
|
+
type: K;
|
|
54
|
+
data: BlockData<F['children'][K]>;
|
|
55
|
+
}
|
|
56
|
+
}[keyof F['children']][]
|
|
57
|
+
: never;
|
|
58
|
+
|
|
59
|
+
type BlockData<B extends BlockDef> = {
|
|
60
|
+
[K in keyof B['fields']]: FieldToType<B['fields'][K]>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type StructureData<S extends Record<string, BlockDef>> = {
|
|
64
|
+
[K in keyof S]: BlockData<S[K]>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// This will be inferred after you define your structure
|
|
68
|
+
export type CMSData<S extends Record<string, BlockDef>> = StructureData<S>;
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
interface DBBase {
|
|
72
|
+
id: number;
|
|
73
|
+
created_at: string;
|
|
74
|
+
updated_at: string;
|
|
75
|
+
name: string;
|
|
76
|
+
label: string;
|
|
77
|
+
type: string;
|
|
78
|
+
sort_order: number;
|
|
79
|
+
value: string | null;
|
|
80
|
+
options: any;
|
|
81
|
+
status: string;
|
|
82
|
+
parent_id: number | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildTypedForest<
|
|
86
|
+
S extends Record<string, BlockDef>,
|
|
87
|
+
K extends keyof S
|
|
88
|
+
>(
|
|
89
|
+
rows: DBBase[],
|
|
90
|
+
rootDef: S[K]
|
|
91
|
+
): CMSData<S>[K][] {
|
|
92
|
+
const map = new Map<number, DBBase & { children: DBBase[] }>();
|
|
93
|
+
|
|
94
|
+
// Initialize with children arrays
|
|
95
|
+
for (const r of rows) {
|
|
96
|
+
map.set(r.id, { ...r, children: [] });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Link children to parents
|
|
100
|
+
const roots: (DBBase & { children: DBBase[] })[] = [];
|
|
101
|
+
for (const r of rows) {
|
|
102
|
+
const node = map.get(r.id)!;
|
|
103
|
+
if (r.parent_id === null) {
|
|
104
|
+
roots.push(node);
|
|
105
|
+
} else {
|
|
106
|
+
const parent = map.get(r.parent_id);
|
|
107
|
+
if (parent) parent.children.push(node);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Recursive transformer: maps a DB row into typed data
|
|
112
|
+
function transformNode<D extends FieldDef | BlockDef>(
|
|
113
|
+
node: DBBase & DBBase['type'] extends 'blocks' ? { children: DBBase[] } : {},
|
|
114
|
+
def: D
|
|
115
|
+
): any {
|
|
116
|
+
if ('fields' in def) {
|
|
117
|
+
// It's a BlockDef
|
|
118
|
+
const result: any = {};
|
|
119
|
+
for (const key in def.fields) {
|
|
120
|
+
const fieldDef = def.fields[key];
|
|
121
|
+
const childNode = node.children.find(c => c.name === key);
|
|
122
|
+
result[key] = childNode
|
|
123
|
+
? transformNode(childNode, fieldDef)
|
|
124
|
+
: getDefaultValue(fieldDef);
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
} else {
|
|
128
|
+
// It's a FieldDef
|
|
129
|
+
if (def.type === 'text' || def.type === 'slug' || def.type === 'markdown') {
|
|
130
|
+
return node.value ?? '';
|
|
131
|
+
}
|
|
132
|
+
if (def.type === 'image') {
|
|
133
|
+
return node.value ?? '';
|
|
134
|
+
}
|
|
135
|
+
if (def.type === 'blocks') {
|
|
136
|
+
return node.children.map(child => {
|
|
137
|
+
const childDef = def.children[child.name as keyof typeof def.children];
|
|
138
|
+
return {
|
|
139
|
+
type: child.name as keyof typeof def.children,
|
|
140
|
+
data: transformNode(child, childDef)
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Provide safe defaults for missing fields
|
|
148
|
+
function getDefaultValue(fieldDef: FieldDef): any {
|
|
149
|
+
if (fieldDef.type === 'blocks') return [];
|
|
150
|
+
return '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Map all root nodes into typed data
|
|
154
|
+
return roots.map(root => transformNode(root, rootDef));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
function rootQuery(filterSql: string, depthLimit?: number) {
|
|
159
|
+
const depthLimitClause =
|
|
160
|
+
depthLimit !== undefined ? `WHERE d.depth + 1 <= ${depthLimit}` : '';
|
|
161
|
+
|
|
162
|
+
return sql`
|
|
163
|
+
with recursive
|
|
164
|
+
ancestors as (
|
|
165
|
+
select
|
|
166
|
+
id, created_at, updated_at, name, label, type, sort_order, value, options, status, parent_id,
|
|
167
|
+
0 as depth
|
|
168
|
+
from blocks
|
|
169
|
+
where ${filterSql}
|
|
170
|
+
union all
|
|
171
|
+
select
|
|
172
|
+
b.id, b.created_at, b.updated_at, b.name, b.label, b.type, b.sort_order, b.value, b.options, b.status, b.parent_id,
|
|
173
|
+
a.depth + 1
|
|
174
|
+
from blocks b
|
|
175
|
+
inner join ancestors a on b.id = a.parent_id
|
|
176
|
+
),
|
|
177
|
+
roots as (
|
|
178
|
+
select * from ancestors where parent_id is null
|
|
179
|
+
),
|
|
180
|
+
descendants as (
|
|
181
|
+
select * from roots
|
|
182
|
+
union all
|
|
183
|
+
select
|
|
184
|
+
b.id, b.created_at, b.updated_at, b.name, b.label, b.type, b.sort_order, b.value, b.options, b.status, b.parent_id,
|
|
185
|
+
d.depth + 1
|
|
186
|
+
from blocks b
|
|
187
|
+
inner join descendants d on b.parent_id = d.id ${depthLimitClause}
|
|
188
|
+
)
|
|
189
|
+
select * from descendants
|
|
190
|
+
order by parent_id, sort_order
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function queryTypedRoot<
|
|
195
|
+
S extends Record<string, BlockDef>,
|
|
196
|
+
K extends keyof S
|
|
197
|
+
>(
|
|
198
|
+
db: DatabaseSync,
|
|
199
|
+
structure: S,
|
|
200
|
+
blockType: K,
|
|
201
|
+
opts: { id?: number; depthLimit?: number }
|
|
202
|
+
): CMSData<S>[K][] {
|
|
203
|
+
let filterSql: string;
|
|
204
|
+
if (opts.id !== undefined) {
|
|
205
|
+
filterSql = `id = ${opts.id}`;
|
|
206
|
+
} else {
|
|
207
|
+
filterSql = `type = '${String(structure[blockType].type)}'`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sql = rootQuery(filterSql, opts.depthLimit);
|
|
211
|
+
const rows = db.prepare(sql).all() as unknown as DBBase[];
|
|
212
|
+
|
|
213
|
+
return buildTypedForest(rows, structure[blockType]);
|
|
214
|
+
}
|
package/queries/index.ts
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// structure-types.ts
|
|
2
|
+
import { type DBBase } from "./db-types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract helpers
|
|
6
|
+
*/
|
|
7
|
+
type ArrayElement<T> = T extends readonly (infer U)[] ? U : never;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Field definition shape inferred from defineField(...) (the runtime helper)
|
|
11
|
+
* We keep it generic as "any" shape but with the important properties present
|
|
12
|
+
*/
|
|
13
|
+
type FieldDef = {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly type: string;
|
|
16
|
+
readonly fields?: readonly any[]; // only present when type === 'blocks'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Block definition shape inferred from defineBlock(...) (the runtime helper)
|
|
21
|
+
*/
|
|
22
|
+
type BlockDef = {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly type: string;
|
|
25
|
+
readonly fields?: readonly FieldDef[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Primitive field node (non-'blocks'): DBBase + kept fields but no children
|
|
30
|
+
*/
|
|
31
|
+
type PrimitiveFieldNode<TField extends FieldDef> =
|
|
32
|
+
DBBase & {
|
|
33
|
+
readonly name: TField["name"];
|
|
34
|
+
readonly type: TField["type"];
|
|
35
|
+
// no children (leaf), no nested fields
|
|
36
|
+
readonly children?: [];
|
|
37
|
+
readonly fields?: {}; // empty object for leaf
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* For 'blocks' typed field, we need:
|
|
42
|
+
* - the block node representing the 'blocks' wrapper (has DBBase props)
|
|
43
|
+
* - its 'children' are an array of BlockNodes corresponding to nested block defs supplied in the field's 'fields' array
|
|
44
|
+
* - its 'fields' property is the mapping of its own child-field names (can be empty)
|
|
45
|
+
*/
|
|
46
|
+
type BlocksFieldNode<
|
|
47
|
+
TField extends FieldDef,
|
|
48
|
+
TFieldDefs extends readonly BlockDef[]
|
|
49
|
+
> = DBBase & {
|
|
50
|
+
readonly name: TField["name"]; // e.g. "blocks" or "images"
|
|
51
|
+
readonly type: "blocks"; // literally 'blocks'
|
|
52
|
+
readonly children: BlockNodeFromBlockDefs<TFieldDefs>[]; // children are instances of the nested blocks
|
|
53
|
+
readonly fields: FieldsFromFieldDefs<TFieldDefs[number]["fields"]>; // the blocks-wrapper's own fields mapping (if any)
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the 'fields' object for a set of FieldDef[].
|
|
58
|
+
* Maps each field name -> either PrimitiveFieldNode or BlocksFieldNode recursively.
|
|
59
|
+
*/
|
|
60
|
+
type FieldsFromFieldDefs<TDefs> =
|
|
61
|
+
// If no fields
|
|
62
|
+
TDefs extends readonly any[]
|
|
63
|
+
? {
|
|
64
|
+
// For each field F in TDefs, map F['name'] -> node type
|
|
65
|
+
[F in ArrayElement<TDefs> as F extends { name: infer N extends string } ? N : never]:
|
|
66
|
+
F extends { type: "blocks"; fields: readonly BlockDef[] }
|
|
67
|
+
? BlocksFieldNode<F, F["fields"]>
|
|
68
|
+
: PrimitiveFieldNode<F>;
|
|
69
|
+
}
|
|
70
|
+
: {};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A Block node type for a particular BlockDef.
|
|
74
|
+
* - fields: mapping derived from the block's declared fields
|
|
75
|
+
* - children: by default [], because in our final shape all immediate children are placed under 'fields' of the parent.
|
|
76
|
+
* BUT for nodes that are themselves 'blocks' wrappers (i.e. appear as a Block instance of a nested block def),
|
|
77
|
+
* their 'children' will contain actual child blocks (these are handled via BlocksFieldNode above).
|
|
78
|
+
*/
|
|
79
|
+
export type BlockNode<T extends BlockDef> = DBBase & {
|
|
80
|
+
readonly name: T["name"];
|
|
81
|
+
readonly type: T["type"];
|
|
82
|
+
readonly fields: FieldsFromFieldDefs<T["fields"]>;
|
|
83
|
+
// for regular block nodes, children will usually be [] (top-level parent's children moved into fields)
|
|
84
|
+
readonly children: [];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Construct BlockNode unions for a set of block defs (used when blocks field has multiple block subdefs)
|
|
89
|
+
*/
|
|
90
|
+
type BlockNodeFromBlockDefs<TDefs extends readonly BlockDef[]> =
|
|
91
|
+
ArrayElement<TDefs> extends infer B ? (B extends BlockDef ? BlockNode<B> : never) : never;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The top-level forest return type when you pass a structure: it's an array of BlockNode of any top-level BlockDef
|
|
95
|
+
*/
|
|
96
|
+
export type BlockTreeFromStructure<TStructure extends readonly BlockDef[]> =
|
|
97
|
+
BlockNodeFromBlockDefs<TStructure>;
|
package/schemas.ts
CHANGED
|
@@ -7,11 +7,22 @@ export const blocksTable = {
|
|
|
7
7
|
name TEXT not null,
|
|
8
8
|
label TEXT not null,
|
|
9
9
|
type TEXT not null,
|
|
10
|
-
sort_order INTEGER not null default 0,
|
|
11
10
|
value TEXT,
|
|
12
11
|
options JSON,
|
|
13
12
|
status TEXT default 'enabled',
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
sort_order INTEGER not null default 0,
|
|
14
|
+
_depth INTEGER,
|
|
15
|
+
parent_id INTEGER,
|
|
16
|
+
foreign key (parent_id) references blocks (id)
|
|
17
|
+
`,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// -- API keys
|
|
21
|
+
export const apiKeysTable = {
|
|
22
|
+
tableName: 'api_keys',
|
|
23
|
+
columns: sql`
|
|
24
|
+
name TEXT not null,
|
|
25
|
+
value TEXT,
|
|
26
|
+
hint TEXT
|
|
16
27
|
`,
|
|
17
28
|
}
|