@alstar/studio 0.0.0-beta.1 → 0.0.0-beta.10
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 +73 -0
- package/api/backup.ts +38 -0
- package/api/block.ts +96 -30
- package/api/index.ts +12 -1
- package/api/mcp.ts +53 -0
- package/components/AdminPanel.ts +74 -0
- package/components/Backup.ts +10 -0
- package/components/BlockFieldRenderer.ts +121 -0
- package/components/BlockRenderer.ts +22 -0
- package/components/Entries.ts +19 -11
- package/components/Entry.ts +13 -21
- package/components/FieldRenderer.ts +35 -0
- package/components/Render.ts +46 -0
- package/components/Settings.ts +101 -0
- package/components/{layout.ts → SiteLayout.ts} +22 -17
- 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 +100 -7
- package/index.ts +66 -34
- package/package.json +8 -8
- package/pages/entry/[id].ts +17 -0
- package/{components → pages}/index.ts +7 -4
- package/pages/settings.ts +10 -0
- package/public/studio/css/admin-panel.css +103 -0
- package/public/studio/css/blocks-field.css +53 -0
- package/public/studio/css/settings.css +24 -0
- package/public/studio/js/markdown-editor.js +34 -0
- package/public/studio/js/sortable-list.js +50 -0
- package/public/studio/main.css +161 -0
- package/public/studio/main.js +9 -0
- package/queries/block-with-children.ts +74 -0
- package/queries/block.ts +289 -0
- 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 +2 -98
- package/queries/structure-types.ts +97 -0
- package/readme.md +205 -0
- package/schemas.ts +15 -54
- package/types.ts +133 -5
- package/utils/buildBlocksTree.ts +4 -4
- package/utils/define.ts +41 -0
- package/utils/file-based-router.ts +11 -2
- package/utils/get-config.ts +8 -9
- package/utils/get-or-create-row.ts +41 -0
- package/utils/html.ts +247 -0
- package/utils/startup-log.ts +10 -0
- package/components/AdminPanel/AdminPanel.css +0 -59
- package/components/AdminPanel/AdminPanel.ts +0 -57
- package/components/Block.ts +0 -116
- package/components/Entry.css +0 -7
- package/components/Field.ts +0 -164
- package/components/Fields.ts +0 -43
- package/public/main.css +0 -92
- package/public/main.js +0 -43
- /package/public/{favicon.svg → studio/favicon.svg} +0 -0
|
@@ -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
|
@@ -1,98 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { type DBBlock } from '../types.ts'
|
|
4
|
-
import { buildBlockTree } from '../utils/buildBlocksTree.ts'
|
|
5
|
-
|
|
6
|
-
export const blocks = (options: {
|
|
7
|
-
parent: string | number | null
|
|
8
|
-
}): DBBlock[] | null => {
|
|
9
|
-
const q =
|
|
10
|
-
options.parent === null
|
|
11
|
-
? sql`
|
|
12
|
-
select
|
|
13
|
-
*
|
|
14
|
-
from
|
|
15
|
-
blocks
|
|
16
|
-
where
|
|
17
|
-
parent_block_id is null;
|
|
18
|
-
`
|
|
19
|
-
: sql`
|
|
20
|
-
select
|
|
21
|
-
*
|
|
22
|
-
from
|
|
23
|
-
blocks
|
|
24
|
-
where
|
|
25
|
-
parent_block_id = ?;
|
|
26
|
-
`
|
|
27
|
-
|
|
28
|
-
const transaction = db.database.prepare(q)
|
|
29
|
-
|
|
30
|
-
if (options.parent === null) {
|
|
31
|
-
return transaction.all() as unknown as DBBlock[]
|
|
32
|
-
} else {
|
|
33
|
-
return transaction.all(options.parent) as unknown as DBBlock[]
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export const block = (
|
|
38
|
-
query: Record<string, string | null>,
|
|
39
|
-
options?: { recursive: boolean },
|
|
40
|
-
): DBBlock | null => {
|
|
41
|
-
const str = Object.keys(query)
|
|
42
|
-
.map((key) => `${key.replace('parent', 'parent_block_id')} = ?`)
|
|
43
|
-
.join(' AND ')
|
|
44
|
-
|
|
45
|
-
const q = options?.recursive
|
|
46
|
-
? sql`
|
|
47
|
-
with recursive
|
|
48
|
-
block_hierarchy as (
|
|
49
|
-
-- Anchor member: the root block, depth = 0
|
|
50
|
-
select
|
|
51
|
-
b.*,
|
|
52
|
-
0 as depth
|
|
53
|
-
from
|
|
54
|
-
blocks b
|
|
55
|
-
where
|
|
56
|
-
b.id = ? -- Replace 5 with your root block ID
|
|
57
|
-
union all
|
|
58
|
-
-- Recursive member: find children and increment depth
|
|
59
|
-
select
|
|
60
|
-
b.*,
|
|
61
|
-
bh.depth + 1 as depth
|
|
62
|
-
from
|
|
63
|
-
blocks b
|
|
64
|
-
inner join block_hierarchy bh on b.parent_block_id = bh.id
|
|
65
|
-
)
|
|
66
|
-
select
|
|
67
|
-
*
|
|
68
|
-
from
|
|
69
|
-
block_hierarchy
|
|
70
|
-
order by
|
|
71
|
-
sort_order;
|
|
72
|
-
`
|
|
73
|
-
: sql`
|
|
74
|
-
select
|
|
75
|
-
*
|
|
76
|
-
from
|
|
77
|
-
blocks
|
|
78
|
-
where
|
|
79
|
-
${str};
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
const transaction = db.database.prepare(q)
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
if (options?.recursive) {
|
|
86
|
-
const res = transaction.all(query.id) as unknown as any[]
|
|
87
|
-
return (buildBlockTree(res) as any) || null
|
|
88
|
-
}
|
|
89
|
-
return (
|
|
90
|
-
(transaction.get(...Object.values(query)) as unknown as DBBlock) || null
|
|
91
|
-
)
|
|
92
|
-
} catch (error) {
|
|
93
|
-
console.log('error')
|
|
94
|
-
return null
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export const query = { blocks, block }
|
|
1
|
+
export * as query from './block.ts'
|
|
2
|
+
export { getBlockTrees } from './getBlockTrees.ts'
|
|
@@ -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/readme.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Alstar Studio
|
|
2
|
+
|
|
3
|
+
Alstar Studio is a **fullstack framework** for building CMS-driven applications with **native Node.js** and **Hono**.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Create a new project:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm create @alstar
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Follow the CLI prompts to set up a starter project in your chosen folder.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
## Development
|
|
18
|
+
|
|
19
|
+
Start the dev server:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This runs a **Hono server**.
|
|
26
|
+
|
|
27
|
+
The core app is created via `createStudio(structure)`, which returns the Hono app. This makes it possible to extend the server with plugins or custom settings:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const app = await createStudio(structure)
|
|
31
|
+
// app.use(...) custom middleware
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
## Routing
|
|
36
|
+
|
|
37
|
+
Pages are defined in the `/pages` directory.
|
|
38
|
+
|
|
39
|
+
* Each `.ts` file becomes a route.
|
|
40
|
+
* Dynamic routes are created with square brackets, e.g. `/pages/[slug].ts`.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## CMS
|
|
44
|
+
|
|
45
|
+
Access the CMS at:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/admin
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Defining Content Structure
|
|
52
|
+
|
|
53
|
+
Pass a `Structure` object to `createStudio(structure)` to define the schema.
|
|
54
|
+
|
|
55
|
+
Use the helpers:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import {
|
|
59
|
+
defineBlock,
|
|
60
|
+
defineField,
|
|
61
|
+
defineStructure,
|
|
62
|
+
defineBlockField
|
|
63
|
+
} from '@alstar/studio'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Example: Schema Definition
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const titleField = defineField({
|
|
70
|
+
label: 'Title',
|
|
71
|
+
type: 'text' | 'image' | 'markdown' | 'slug',
|
|
72
|
+
description: 'Page title'
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const pageBuilder = defineBlockField({
|
|
76
|
+
label: 'Sections',
|
|
77
|
+
type: 'blocks',
|
|
78
|
+
children: {
|
|
79
|
+
hero: defineBlock({
|
|
80
|
+
label: 'Hero',
|
|
81
|
+
type: 'hero',
|
|
82
|
+
fields: { /* fields */ },
|
|
83
|
+
}),
|
|
84
|
+
gallery: defineBlock({
|
|
85
|
+
label: 'Gallery',
|
|
86
|
+
type: 'gallery',
|
|
87
|
+
fields: { /* fields */ },
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const entryBlock = defineBlock({
|
|
93
|
+
label: 'Entry',
|
|
94
|
+
type: 'entry',
|
|
95
|
+
fields: {
|
|
96
|
+
title: titleField,
|
|
97
|
+
builder: pageBuilder
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
export default defineStructure({
|
|
102
|
+
entry: entryBlock
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Concepts
|
|
107
|
+
|
|
108
|
+
* **Blocks** contain **fields**.
|
|
109
|
+
* **Block fields** (`type: 'blocks'`) can nest multiple block types under `children`.
|
|
110
|
+
* This enables **page builders** and reusable structures.
|
|
111
|
+
|
|
112
|
+
All content is stored in a **SQLite database** (`studio.db`) and can be queried in the templates with the `query` module.
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
## Frontend
|
|
116
|
+
|
|
117
|
+
The framework encourages **server-side rendering** with Hono’s HTML helper (re-exported by the `@alstar/studio` package):
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { defineEntry, html } from '@alstar/studio'
|
|
121
|
+
|
|
122
|
+
export default defineEntry((c) => {
|
|
123
|
+
const slug = c.req.param('slug')
|
|
124
|
+
|
|
125
|
+
return html`
|
|
126
|
+
<h1>Hello World</h1>
|
|
127
|
+
<p>This page is: ${slug}</p>
|
|
128
|
+
`
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Interactivity
|
|
133
|
+
|
|
134
|
+
Even though the framework allows for having any library and tool for creating client-side behavior, it's recommended to use lightweight libraries such as:
|
|
135
|
+
|
|
136
|
+
* [Datastar](https://data-star.dev/) (used internally by the Studio)
|
|
137
|
+
* [Alpine.js](https://alpinejs.dev/)
|
|
138
|
+
|
|
139
|
+
## Quickstart Example Project
|
|
140
|
+
|
|
141
|
+
This example shows how to define a simple **page schema** and render it on the frontend.
|
|
142
|
+
|
|
143
|
+
### 1. Define the CMS Schema (`./index.ts`)
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import { createStudio, defineBlock, defineField, defineStructure } from '@alstar/studio'
|
|
147
|
+
|
|
148
|
+
const page = defineBlock({
|
|
149
|
+
label: 'Page',
|
|
150
|
+
type: 'page',
|
|
151
|
+
fields: {
|
|
152
|
+
title: defineField({
|
|
153
|
+
label: 'Title',
|
|
154
|
+
type: 'text',
|
|
155
|
+
}),
|
|
156
|
+
slug: defineField({
|
|
157
|
+
label: 'Slug',
|
|
158
|
+
type: 'slug',
|
|
159
|
+
}),
|
|
160
|
+
body: defineField({
|
|
161
|
+
label: 'Body',
|
|
162
|
+
type: 'markdown',
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const structure = defineStructure({
|
|
168
|
+
page,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
await createStudio(structure)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 2. Create a Frontend Route (`/pages/[slug].ts`)
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { defineEntry, html, query } from '@alstar/studio'
|
|
178
|
+
|
|
179
|
+
export default defineEntry(c) => {
|
|
180
|
+
const slug = c.req.param('slug')
|
|
181
|
+
const page = query.root({ type: 'slug', value: slug })
|
|
182
|
+
|
|
183
|
+
if (!page) return c.notFound()
|
|
184
|
+
|
|
185
|
+
return html`
|
|
186
|
+
<main>
|
|
187
|
+
<h1>${page.fields.title.value}</h1>
|
|
188
|
+
<article>${page.fields.body.value}</article>
|
|
189
|
+
</main>
|
|
190
|
+
`
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 3. Run the Project
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
pnpm run dev
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Visit:
|
|
201
|
+
|
|
202
|
+
* **CMS admin**: `http://localhost:3000/admin`
|
|
203
|
+
* **Frontend page**: `http://localhost:3000/my-first-page`
|
|
204
|
+
|
|
205
|
+
Create a new page in the CMS, set its slug field to `my-first-page`, and the frontend will render it automatically.
|
package/schemas.ts
CHANGED
|
@@ -1,56 +1,5 @@
|
|
|
1
1
|
import { sql } from './utils/sql.ts'
|
|
2
2
|
|
|
3
|
-
// export const entriesTable = {
|
|
4
|
-
// tableName: 'entries',
|
|
5
|
-
// columns: sql`
|
|
6
|
-
// title TEXT not null, -- Title of the page
|
|
7
|
-
// slug TEXT not null unique, -- URL slug for the page
|
|
8
|
-
// meta_description TEXT -- Optional meta description for SEO
|
|
9
|
-
// `,
|
|
10
|
-
// }
|
|
11
|
-
|
|
12
|
-
// export const fieldTable = {
|
|
13
|
-
// tableName: 'fields',
|
|
14
|
-
// columns: sql`
|
|
15
|
-
// name TEXT not null, -- Name of the field (e.g., "content", "header", "image")
|
|
16
|
-
// type TEXT not null, -- Field type (e.g., "text", "image", "video")
|
|
17
|
-
// label TEXT not null, -- Field label (e.g., "Text", "Image", "Video")
|
|
18
|
-
// options TEXT -- Additional options or settings (can be a JSON string if needed)
|
|
19
|
-
// `,
|
|
20
|
-
// }
|
|
21
|
-
|
|
22
|
-
// export const entriesFieldsTable = {
|
|
23
|
-
// tableName: 'entry_fields',
|
|
24
|
-
// columns: sql`
|
|
25
|
-
// entry_id INTEGER not null, -- Foreign key to pages
|
|
26
|
-
// field_id INTEGER not null, -- Foreign key to fields
|
|
27
|
-
// position INTEGER, -- Optional: order of the field on the page
|
|
28
|
-
// content TEXT, -- Content of the field (e.g., text, image URL, etc.)
|
|
29
|
-
// foreign key (entry_id) references entries (id),
|
|
30
|
-
// foreign key (field_id) references fields (id)
|
|
31
|
-
// `,
|
|
32
|
-
// }
|
|
33
|
-
|
|
34
|
-
// export const entryTypeTable = {
|
|
35
|
-
// tableName: 'entry_types',
|
|
36
|
-
// columns: sql`
|
|
37
|
-
// name TEXT not null, -- Name of the field (e.g., "content", "header", "image")
|
|
38
|
-
// type TEXT not null, -- Field type (e.g., "text", "image", "video")
|
|
39
|
-
// label TEXT not null, -- Field label (e.g., "Text", "Image", "Video")
|
|
40
|
-
// options TEXT -- Additional options or settings (can be a JSON string if needed)
|
|
41
|
-
// `,
|
|
42
|
-
// }
|
|
43
|
-
|
|
44
|
-
// export const entryEntryTypeTable = {
|
|
45
|
-
// tableName: 'entry_entry_types',
|
|
46
|
-
// columns: sql`
|
|
47
|
-
// entry_id INTEGER not null, -- Foreign key to pages
|
|
48
|
-
// entry_type_id INTEGER not null, -- Foreign key to fields
|
|
49
|
-
// foreign key (entry_id) references entries (id),
|
|
50
|
-
// foreign key (entry_type_id) references entry_types (id)
|
|
51
|
-
// `,
|
|
52
|
-
// }
|
|
53
|
-
|
|
54
3
|
// -- Blocks
|
|
55
4
|
export const blocksTable = {
|
|
56
5
|
tableName: 'blocks',
|
|
@@ -58,10 +7,22 @@ export const blocksTable = {
|
|
|
58
7
|
name TEXT not null,
|
|
59
8
|
label TEXT not null,
|
|
60
9
|
type TEXT not null,
|
|
61
|
-
sort_order INTEGER not null default 0,
|
|
62
10
|
value TEXT,
|
|
63
11
|
options JSON,
|
|
64
|
-
|
|
65
|
-
|
|
12
|
+
status TEXT default 'enabled',
|
|
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
|
|
66
27
|
`,
|
|
67
28
|
}
|