@orhancodestudio/ocsm-core 0.1.0-alpha.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/CHANGELOG.md +13 -0
- package/README.md +47 -0
- package/package.json +53 -0
- package/src/admin/admin.module.css +1312 -0
- package/src/admin/admin.types.ts +85 -0
- package/src/admin/components/access-denied.tsx +12 -0
- package/src/admin/components/admin-shell.tsx +168 -0
- package/src/admin/components/content-list-view.tsx +83 -0
- package/src/admin/components/dashboard-view.tsx +113 -0
- package/src/admin/components/data-table.tsx +80 -0
- package/src/admin/components/document-delete-button.tsx +44 -0
- package/src/admin/components/icons.tsx +150 -0
- package/src/admin/components/modal.tsx +78 -0
- package/src/admin/components/page-builder.tsx +1334 -0
- package/src/admin/components/settings-view.tsx +334 -0
- package/src/admin/components/sign-out-button.tsx +22 -0
- package/src/admin/components/system-view.tsx +77 -0
- package/src/admin/components/users-panel.tsx +321 -0
- package/src/admin/index.ts +20 -0
- package/src/admin/ocsm-admin.tsx +259 -0
- package/src/auth/authenticate.ts +76 -0
- package/src/auth/index.ts +9 -0
- package/src/auth/password.ts +22 -0
- package/src/auth/permissions.ts +27 -0
- package/src/auth/session.ts +103 -0
- package/src/blocks/block-renderer.tsx +428 -0
- package/src/blocks/block.types.ts +401 -0
- package/src/blocks/index.ts +15 -0
- package/src/blocks/markdown.tsx +11 -0
- package/src/config/config.schema.ts +28 -0
- package/src/config/config.types.ts +16 -0
- package/src/config/define-config.ts +19 -0
- package/src/config/index.ts +13 -0
- package/src/config/resolve-config.ts +10 -0
- package/src/content/content-repository.ts +66 -0
- package/src/content/content-store.interface.ts +23 -0
- package/src/content/content.types.ts +25 -0
- package/src/content/create-content-store.ts +18 -0
- package/src/content/frontmatter.ts +25 -0
- package/src/content/index.ts +12 -0
- package/src/index.ts +10 -0
- package/src/layout/index.ts +1 -0
- package/src/layout/layout-store.ts +27 -0
- package/src/roles/index.ts +10 -0
- package/src/roles/role-store.ts +95 -0
- package/src/roles/role.types.ts +86 -0
- package/src/server/create-ocsm.ts +67 -0
- package/src/server/documents.ts +28 -0
- package/src/server/index.ts +59 -0
- package/src/server/render-mdx.tsx +14 -0
- package/src/storage/create-file-backend.ts +26 -0
- package/src/storage/file-backend.ts +26 -0
- package/src/storage/fs-file-backend.ts +43 -0
- package/src/storage/github-file-backend.ts +97 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/json-store.ts +23 -0
- package/src/theme/css.ts +28 -0
- package/src/theme/index.ts +8 -0
- package/src/theme/theme-store.ts +19 -0
- package/src/theme/theme.types.ts +53 -0
- package/src/types/css-modules.d.ts +4 -0
- package/src/update/check-for-updates.ts +50 -0
- package/src/update/index.ts +1 -0
- package/src/users/index.ts +6 -0
- package/src/users/user-store.ts +120 -0
- package/src/users/user.types.ts +18 -0
- package/src/version.ts +11 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/** The kinds of section blocks a page can contain. */
|
|
2
|
+
export type BlockType =
|
|
3
|
+
| "navbar"
|
|
4
|
+
| "hero"
|
|
5
|
+
| "heading"
|
|
6
|
+
| "richText"
|
|
7
|
+
| "image"
|
|
8
|
+
| "button"
|
|
9
|
+
| "quote"
|
|
10
|
+
| "banner"
|
|
11
|
+
| "marquee"
|
|
12
|
+
| "video"
|
|
13
|
+
| "spacer";
|
|
14
|
+
|
|
15
|
+
export type TextAlign = "left" | "center" | "right";
|
|
16
|
+
|
|
17
|
+
export type BackgroundType = "solid" | "gradient";
|
|
18
|
+
|
|
19
|
+
/** Shared visual style applied to a section. Empty string = inherit/default. */
|
|
20
|
+
export interface BlockStyle {
|
|
21
|
+
bgType: BackgroundType;
|
|
22
|
+
background: string;
|
|
23
|
+
gradientFrom: string;
|
|
24
|
+
gradientTo: string;
|
|
25
|
+
gradientAngle: string;
|
|
26
|
+
textColor: string;
|
|
27
|
+
textAlign: TextAlign;
|
|
28
|
+
fontFamily: string;
|
|
29
|
+
fontSize: string;
|
|
30
|
+
fontWeight: string;
|
|
31
|
+
paddingY: string;
|
|
32
|
+
maxWidth: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface WithStyle {
|
|
36
|
+
id: string;
|
|
37
|
+
style: BlockStyle;
|
|
38
|
+
/** When true, the block is hidden on the public site. */
|
|
39
|
+
hidden?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NavLink {
|
|
43
|
+
label: string;
|
|
44
|
+
url: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface NavbarBlock extends WithStyle {
|
|
48
|
+
type: "navbar";
|
|
49
|
+
logoText: string;
|
|
50
|
+
logoImageUrl: string;
|
|
51
|
+
logoHref: string;
|
|
52
|
+
logoAlign: TextAlign;
|
|
53
|
+
links: NavLink[];
|
|
54
|
+
linksAlign: TextAlign;
|
|
55
|
+
/** When true, the header sticks to the top while scrolling (CSS `position: sticky`). */
|
|
56
|
+
sticky: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface HeroBlock extends WithStyle {
|
|
60
|
+
type: "hero";
|
|
61
|
+
heading: string;
|
|
62
|
+
subheading: string;
|
|
63
|
+
buttonLabel: string;
|
|
64
|
+
buttonUrl: string;
|
|
65
|
+
buttonColor: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface HeadingBlock extends WithStyle {
|
|
69
|
+
type: "heading";
|
|
70
|
+
text: string;
|
|
71
|
+
level: 1 | 2 | 3;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RichTextBlock extends WithStyle {
|
|
75
|
+
type: "richText";
|
|
76
|
+
markdown: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ImageBlock extends WithStyle {
|
|
80
|
+
type: "image";
|
|
81
|
+
url: string;
|
|
82
|
+
alt: string;
|
|
83
|
+
caption: string;
|
|
84
|
+
width: string;
|
|
85
|
+
height: string;
|
|
86
|
+
radius: string;
|
|
87
|
+
fit: "cover" | "contain" | "fill";
|
|
88
|
+
link: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ButtonBlock extends WithStyle {
|
|
92
|
+
type: "button";
|
|
93
|
+
label: string;
|
|
94
|
+
url: string;
|
|
95
|
+
buttonColor: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface QuoteBlock extends WithStyle {
|
|
99
|
+
type: "quote";
|
|
100
|
+
text: string;
|
|
101
|
+
author: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface BannerBlock extends WithStyle {
|
|
105
|
+
type: "banner";
|
|
106
|
+
text: string;
|
|
107
|
+
url: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface MarqueeBlock extends WithStyle {
|
|
111
|
+
type: "marquee";
|
|
112
|
+
text: string;
|
|
113
|
+
speed: string;
|
|
114
|
+
direction: "left" | "right";
|
|
115
|
+
repeat: string;
|
|
116
|
+
gap: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface VideoBlock extends WithStyle {
|
|
120
|
+
type: "video";
|
|
121
|
+
url: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SpacerBlock extends WithStyle {
|
|
125
|
+
type: "spacer";
|
|
126
|
+
height: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type Block =
|
|
130
|
+
| NavbarBlock
|
|
131
|
+
| HeroBlock
|
|
132
|
+
| HeadingBlock
|
|
133
|
+
| RichTextBlock
|
|
134
|
+
| ImageBlock
|
|
135
|
+
| ButtonBlock
|
|
136
|
+
| QuoteBlock
|
|
137
|
+
| BannerBlock
|
|
138
|
+
| MarqueeBlock
|
|
139
|
+
| VideoBlock
|
|
140
|
+
| SpacerBlock;
|
|
141
|
+
|
|
142
|
+
/** Block types in the order they appear in the "add section" menu. */
|
|
143
|
+
export const BLOCK_TYPES: ReadonlyArray<{
|
|
144
|
+
type: BlockType;
|
|
145
|
+
label: string;
|
|
146
|
+
icon: string;
|
|
147
|
+
/** Only offered in the global header region. */
|
|
148
|
+
headerOnly?: boolean;
|
|
149
|
+
}> = [
|
|
150
|
+
{ type: "navbar", label: "Üst Bilgi", icon: "▤", headerOnly: true },
|
|
151
|
+
{ type: "hero", label: "Hero", icon: "★" },
|
|
152
|
+
{ type: "heading", label: "Başlık", icon: "H" },
|
|
153
|
+
{ type: "richText", label: "Metin", icon: "¶" },
|
|
154
|
+
{ type: "image", label: "Görsel", icon: "▣" },
|
|
155
|
+
{ type: "button", label: "Buton", icon: "◉" },
|
|
156
|
+
{ type: "quote", label: "Alıntı", icon: "❝" },
|
|
157
|
+
{ type: "banner", label: "Banner", icon: "▭" },
|
|
158
|
+
{ type: "marquee", label: "Kayan Yazı", icon: "↔" },
|
|
159
|
+
{ type: "video", label: "Video", icon: "▶" },
|
|
160
|
+
{ type: "spacer", label: "Boşluk", icon: "↕" },
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
export const FONT_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [
|
|
164
|
+
{ label: "Tema (varsayılan)", value: "" },
|
|
165
|
+
{ label: "Sistem", value: "system-ui, -apple-system, sans-serif" },
|
|
166
|
+
{ label: "Inter / Sans", value: "Inter, system-ui, sans-serif" },
|
|
167
|
+
{ label: "Georgia (serif)", value: "Georgia, 'Times New Roman', serif" },
|
|
168
|
+
{ label: "Times (serif)", value: "'Times New Roman', Times, serif" },
|
|
169
|
+
{ label: "Arial", value: "Arial, Helvetica, sans-serif" },
|
|
170
|
+
{ label: "Courier (mono)", value: "'Courier New', ui-monospace, monospace" },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
export const FONT_WEIGHT_OPTIONS: ReadonlyArray<{ label: string; value: string }> =
|
|
174
|
+
[
|
|
175
|
+
{ label: "Varsayılan", value: "" },
|
|
176
|
+
{ label: "İnce (300)", value: "300" },
|
|
177
|
+
{ label: "Normal (400)", value: "400" },
|
|
178
|
+
{ label: "Orta (500)", value: "500" },
|
|
179
|
+
{ label: "Yarı kalın (600)", value: "600" },
|
|
180
|
+
{ label: "Kalın (700)", value: "700" },
|
|
181
|
+
{ label: "Çok kalın (800)", value: "800" },
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
/** Curated gradient presets offered in the inspector. */
|
|
185
|
+
export const GRADIENT_PRESETS: ReadonlyArray<{
|
|
186
|
+
label: string;
|
|
187
|
+
from: string;
|
|
188
|
+
to: string;
|
|
189
|
+
angle: string;
|
|
190
|
+
}> = [
|
|
191
|
+
{ label: "Gün batımı", from: "#ff6a00", to: "#ee0979", angle: "135" },
|
|
192
|
+
{ label: "Okyanus", from: "#2193b0", to: "#6dd5ed", angle: "135" },
|
|
193
|
+
{ label: "Mor sis", from: "#7c3aed", to: "#2563eb", angle: "135" },
|
|
194
|
+
{ label: "Orman", from: "#11998e", to: "#38ef7d", angle: "135" },
|
|
195
|
+
{ label: "Gece", from: "#0f2027", to: "#2c5364", angle: "160" },
|
|
196
|
+
{ label: "Şeftali", from: "#ffecd2", to: "#fcb69f", angle: "120" },
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
const BLOCK_TYPE_SET = new Set<BlockType>([
|
|
200
|
+
"navbar",
|
|
201
|
+
"hero",
|
|
202
|
+
"heading",
|
|
203
|
+
"richText",
|
|
204
|
+
"image",
|
|
205
|
+
"button",
|
|
206
|
+
"quote",
|
|
207
|
+
"banner",
|
|
208
|
+
"marquee",
|
|
209
|
+
"video",
|
|
210
|
+
"spacer",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
export function isBlockType(value: string): value is BlockType {
|
|
214
|
+
return BLOCK_TYPE_SET.has(value as BlockType);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function baseStyle(overrides: Partial<BlockStyle> = {}): BlockStyle {
|
|
218
|
+
return {
|
|
219
|
+
bgType: "solid",
|
|
220
|
+
background: "",
|
|
221
|
+
gradientFrom: "#7c3aed",
|
|
222
|
+
gradientTo: "#2563eb",
|
|
223
|
+
gradientAngle: "135",
|
|
224
|
+
textColor: "",
|
|
225
|
+
textAlign: "left",
|
|
226
|
+
fontFamily: "",
|
|
227
|
+
fontSize: "",
|
|
228
|
+
fontWeight: "",
|
|
229
|
+
paddingY: "32",
|
|
230
|
+
maxWidth: "720",
|
|
231
|
+
...overrides,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Creates a new block of the given type with sensible defaults and a fresh id. */
|
|
236
|
+
export function createBlock(type: BlockType): Block {
|
|
237
|
+
const id = newId();
|
|
238
|
+
switch (type) {
|
|
239
|
+
case "navbar":
|
|
240
|
+
return {
|
|
241
|
+
id,
|
|
242
|
+
type,
|
|
243
|
+
logoText: "ACME",
|
|
244
|
+
logoImageUrl: "",
|
|
245
|
+
logoHref: "/",
|
|
246
|
+
logoAlign: "left",
|
|
247
|
+
links: [
|
|
248
|
+
{ label: "Anasayfa", url: "/" },
|
|
249
|
+
{ label: "Hizmetler", url: "/p/hizmetler" },
|
|
250
|
+
{ label: "İletişim", url: "/p/iletisim" },
|
|
251
|
+
],
|
|
252
|
+
linksAlign: "right",
|
|
253
|
+
sticky: false,
|
|
254
|
+
style: baseStyle({
|
|
255
|
+
background: "#ffffff",
|
|
256
|
+
textColor: "#0f172a",
|
|
257
|
+
paddingY: "16",
|
|
258
|
+
maxWidth: "1080",
|
|
259
|
+
}),
|
|
260
|
+
};
|
|
261
|
+
case "hero":
|
|
262
|
+
return {
|
|
263
|
+
id,
|
|
264
|
+
type,
|
|
265
|
+
heading: "Etkileyici bir başlık",
|
|
266
|
+
subheading: "Kısa bir açıklama metni buraya gelir.",
|
|
267
|
+
buttonLabel: "Başla",
|
|
268
|
+
buttonUrl: "#",
|
|
269
|
+
buttonColor: "#2563eb",
|
|
270
|
+
style: baseStyle({
|
|
271
|
+
background: "#0f172a",
|
|
272
|
+
textColor: "#ffffff",
|
|
273
|
+
textAlign: "center",
|
|
274
|
+
paddingY: "88",
|
|
275
|
+
}),
|
|
276
|
+
};
|
|
277
|
+
case "heading":
|
|
278
|
+
return {
|
|
279
|
+
id,
|
|
280
|
+
type,
|
|
281
|
+
text: "Bölüm başlığı",
|
|
282
|
+
level: 2,
|
|
283
|
+
style: baseStyle({ paddingY: "10", fontWeight: "700" }),
|
|
284
|
+
};
|
|
285
|
+
case "richText":
|
|
286
|
+
return {
|
|
287
|
+
id,
|
|
288
|
+
type,
|
|
289
|
+
markdown: "Buraya **zengin** metin yazın.",
|
|
290
|
+
style: baseStyle({ paddingY: "16" }),
|
|
291
|
+
};
|
|
292
|
+
case "image":
|
|
293
|
+
return {
|
|
294
|
+
id,
|
|
295
|
+
type,
|
|
296
|
+
url: "",
|
|
297
|
+
alt: "",
|
|
298
|
+
caption: "",
|
|
299
|
+
width: "100",
|
|
300
|
+
height: "",
|
|
301
|
+
radius: "12",
|
|
302
|
+
fit: "cover",
|
|
303
|
+
link: "",
|
|
304
|
+
style: baseStyle({ textAlign: "center", paddingY: "16" }),
|
|
305
|
+
};
|
|
306
|
+
case "button":
|
|
307
|
+
return {
|
|
308
|
+
id,
|
|
309
|
+
type,
|
|
310
|
+
label: "Tıkla",
|
|
311
|
+
url: "#",
|
|
312
|
+
buttonColor: "#2563eb",
|
|
313
|
+
style: baseStyle({ textAlign: "center", paddingY: "16" }),
|
|
314
|
+
};
|
|
315
|
+
case "quote":
|
|
316
|
+
return {
|
|
317
|
+
id,
|
|
318
|
+
type,
|
|
319
|
+
text: "İlham veren bir alıntı buraya gelir.",
|
|
320
|
+
author: "Yazar",
|
|
321
|
+
style: baseStyle({ paddingY: "24" }),
|
|
322
|
+
};
|
|
323
|
+
case "banner":
|
|
324
|
+
return {
|
|
325
|
+
id,
|
|
326
|
+
type,
|
|
327
|
+
text: "Duyuru: yeni bir şeyler var!",
|
|
328
|
+
url: "",
|
|
329
|
+
style: baseStyle({
|
|
330
|
+
background: "#2563eb",
|
|
331
|
+
textColor: "#ffffff",
|
|
332
|
+
textAlign: "center",
|
|
333
|
+
paddingY: "14",
|
|
334
|
+
maxWidth: "full",
|
|
335
|
+
}),
|
|
336
|
+
};
|
|
337
|
+
case "marquee":
|
|
338
|
+
return {
|
|
339
|
+
id,
|
|
340
|
+
type,
|
|
341
|
+
text: "Kayan duyuru metni",
|
|
342
|
+
speed: "16",
|
|
343
|
+
direction: "left",
|
|
344
|
+
repeat: "4",
|
|
345
|
+
gap: "60",
|
|
346
|
+
style: baseStyle({
|
|
347
|
+
background: "#0f172a",
|
|
348
|
+
textColor: "#ffffff",
|
|
349
|
+
paddingY: "12",
|
|
350
|
+
maxWidth: "full",
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
case "video":
|
|
354
|
+
return {
|
|
355
|
+
id,
|
|
356
|
+
type,
|
|
357
|
+
url: "",
|
|
358
|
+
style: baseStyle({ paddingY: "24", maxWidth: "820" }),
|
|
359
|
+
};
|
|
360
|
+
case "spacer":
|
|
361
|
+
return {
|
|
362
|
+
id,
|
|
363
|
+
type,
|
|
364
|
+
height: "48",
|
|
365
|
+
style: baseStyle({ paddingY: "0" }),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Leniently parses untrusted data (e.g. frontmatter) into a typed block list. */
|
|
371
|
+
export function normalizeBlocks(value: unknown): Block[] {
|
|
372
|
+
if (!Array.isArray(value)) return [];
|
|
373
|
+
const blocks: Block[] = [];
|
|
374
|
+
for (const raw of value) {
|
|
375
|
+
if (!raw || typeof raw !== "object") continue;
|
|
376
|
+
const record = raw as Record<string, unknown>;
|
|
377
|
+
const type = record.type;
|
|
378
|
+
if (typeof type !== "string" || !isBlockType(type)) continue;
|
|
379
|
+
const base = createBlock(type);
|
|
380
|
+
const style = isObject(record.style)
|
|
381
|
+
? { ...base.style, ...(record.style as Partial<BlockStyle>) }
|
|
382
|
+
: base.style;
|
|
383
|
+
blocks.push({
|
|
384
|
+
...base,
|
|
385
|
+
...record,
|
|
386
|
+
type,
|
|
387
|
+
id: typeof record.id === "string" ? record.id : base.id,
|
|
388
|
+
style,
|
|
389
|
+
} as Block);
|
|
390
|
+
}
|
|
391
|
+
return blocks;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
395
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function newId(): string {
|
|
399
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
400
|
+
return uuid ?? `b-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
401
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type BannerBlock,
|
|
3
|
+
type Block,
|
|
4
|
+
type BlockType,
|
|
5
|
+
BLOCK_TYPES,
|
|
6
|
+
createBlock,
|
|
7
|
+
type HeadingBlock,
|
|
8
|
+
type HeroBlock,
|
|
9
|
+
type ImageBlock,
|
|
10
|
+
isBlockType,
|
|
11
|
+
normalizeBlocks,
|
|
12
|
+
type RichTextBlock,
|
|
13
|
+
} from "./block.types";
|
|
14
|
+
export { BlockRenderer } from "./block-renderer";
|
|
15
|
+
export { OcsmMarkdown } from "./markdown";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Markdown from "react-markdown";
|
|
2
|
+
import remarkGfm from "remark-gfm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a Markdown string. Isomorphic — safe in both Server and Client
|
|
6
|
+
* Components. Replaces MDX rendering (which is incompatible with the current
|
|
7
|
+
* React/Next versions).
|
|
8
|
+
*/
|
|
9
|
+
export function OcsmMarkdown({ children }: { children: string }) {
|
|
10
|
+
return <Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Branding shown in the admin panel and (optionally) the public site. */
|
|
4
|
+
export const ocsmBrandSchema = z.object({
|
|
5
|
+
name: z.string().min(1),
|
|
6
|
+
description: z.string().optional(),
|
|
7
|
+
logoUrl: z.string().url().optional(),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/** A single content collection (e.g. posts, pages). */
|
|
11
|
+
export const ocsmCollectionSchema = z.object({
|
|
12
|
+
/** Unique key, also referenced by content helpers and admin routes. */
|
|
13
|
+
name: z.string().min(1),
|
|
14
|
+
/** Human-readable label shown in the admin panel. */
|
|
15
|
+
label: z.string().min(1),
|
|
16
|
+
/** Directory (relative to the content root) holding this collection's files. */
|
|
17
|
+
directory: z.string().min(1),
|
|
18
|
+
/** URL path prefix for public routes of this collection (e.g. "blog"). */
|
|
19
|
+
basePath: z.string().optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Top-level OCS Management configuration. */
|
|
23
|
+
export const ocsmConfigSchema = z.object({
|
|
24
|
+
brand: ocsmBrandSchema,
|
|
25
|
+
/** Root directory (relative to the project) where content files live. */
|
|
26
|
+
contentRoot: z.string().default("content"),
|
|
27
|
+
collections: z.array(ocsmCollectionSchema).min(1),
|
|
28
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
ocsmBrandSchema,
|
|
4
|
+
ocsmCollectionSchema,
|
|
5
|
+
ocsmConfigSchema,
|
|
6
|
+
} from "./config.schema";
|
|
7
|
+
|
|
8
|
+
/** Author-facing config shape (before defaults are applied). */
|
|
9
|
+
export type OcsmConfig = z.input<typeof ocsmConfigSchema>;
|
|
10
|
+
|
|
11
|
+
/** Fully-resolved config with defaults applied. */
|
|
12
|
+
export type ResolvedOcsmConfig = z.output<typeof ocsmConfigSchema>;
|
|
13
|
+
|
|
14
|
+
export type OcsmBrandConfig = z.input<typeof ocsmBrandSchema>;
|
|
15
|
+
|
|
16
|
+
export type OcsmContentCollectionConfig = z.input<typeof ocsmCollectionSchema>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OcsmConfig } from "./config.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identity helper that gives full type-checking and editor autocomplete when
|
|
5
|
+
* authoring an `ocsm.config.ts` file.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { defineOcsmConfig } from "@orhancodestudio/ocsm-core/config";
|
|
10
|
+
*
|
|
11
|
+
* export default defineOcsmConfig({
|
|
12
|
+
* brand: { name: "My Site" },
|
|
13
|
+
* collections: [{ name: "posts", label: "Posts", directory: "posts", basePath: "blog" }],
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function defineOcsmConfig(config: OcsmConfig): OcsmConfig {
|
|
18
|
+
return config;
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { defineOcsmConfig } from "./define-config";
|
|
2
|
+
export { resolveOcsmConfig } from "./resolve-config";
|
|
3
|
+
export {
|
|
4
|
+
ocsmBrandSchema,
|
|
5
|
+
ocsmCollectionSchema,
|
|
6
|
+
ocsmConfigSchema,
|
|
7
|
+
} from "./config.schema";
|
|
8
|
+
export type {
|
|
9
|
+
OcsmConfig,
|
|
10
|
+
OcsmBrandConfig,
|
|
11
|
+
OcsmContentCollectionConfig,
|
|
12
|
+
ResolvedOcsmConfig,
|
|
13
|
+
} from "./config.types";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ocsmConfigSchema } from "./config.schema";
|
|
2
|
+
import type { OcsmConfig, ResolvedOcsmConfig } from "./config.types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates an author-supplied config and applies defaults, returning a
|
|
6
|
+
* fully-resolved config. Throws a `ZodError` if the config is invalid.
|
|
7
|
+
*/
|
|
8
|
+
export function resolveOcsmConfig(config: OcsmConfig): ResolvedOcsmConfig {
|
|
9
|
+
return ocsmConfigSchema.parse(config);
|
|
10
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ResolvedOcsmConfig } from "../config/config.types";
|
|
2
|
+
import type { FileBackend } from "../storage/file-backend";
|
|
3
|
+
import {
|
|
4
|
+
CONTENT_FILE_EXTENSION,
|
|
5
|
+
type ContentStore,
|
|
6
|
+
} from "./content-store.interface";
|
|
7
|
+
import type {
|
|
8
|
+
ContentDocument,
|
|
9
|
+
ContentDocumentMeta,
|
|
10
|
+
WriteContentInput,
|
|
11
|
+
} from "./content.types";
|
|
12
|
+
import { parseDocument, serializeDocument } from "./frontmatter";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Content access backed by a {@link FileBackend}. Maps collections to directories
|
|
16
|
+
* under the configured content root and (de)serializes MDX frontmatter.
|
|
17
|
+
*/
|
|
18
|
+
export class ContentRepository implements ContentStore {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly config: ResolvedOcsmConfig,
|
|
21
|
+
private readonly backend: FileBackend,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async list(collection: string): Promise<ContentDocumentMeta[]> {
|
|
25
|
+
const names = await this.backend.listFiles(this.directoryFor(collection));
|
|
26
|
+
const documents: ContentDocumentMeta[] = [];
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
if (!name.endsWith(CONTENT_FILE_EXTENSION)) continue;
|
|
29
|
+
const slug = name.slice(0, -CONTENT_FILE_EXTENSION.length);
|
|
30
|
+
const document = await this.read(collection, slug);
|
|
31
|
+
if (document) documents.push(document);
|
|
32
|
+
}
|
|
33
|
+
return documents;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async read(collection: string, slug: string): Promise<ContentDocument | null> {
|
|
37
|
+
const raw = await this.backend.read(this.fileFor(collection, slug));
|
|
38
|
+
if (raw === null) return null;
|
|
39
|
+
return parseDocument({ collection, slug, raw });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async write(input: WriteContentInput): Promise<void> {
|
|
43
|
+
await this.backend.write(
|
|
44
|
+
this.fileFor(input.collection, input.slug),
|
|
45
|
+
serializeDocument(input),
|
|
46
|
+
input.message ?? `ocsm: save ${input.collection}/${input.slug}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async delete(collection: string, slug: string): Promise<void> {
|
|
51
|
+
await this.backend.remove(
|
|
52
|
+
this.fileFor(collection, slug),
|
|
53
|
+
`ocsm: delete ${collection}/${slug}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private directoryFor(collection: string): string {
|
|
58
|
+
const found = this.config.collections.find((c) => c.name === collection);
|
|
59
|
+
if (!found) throw new Error(`OCSM: unknown collection "${collection}"`);
|
|
60
|
+
return `${this.config.contentRoot}/${found.directory}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private fileFor(collection: string, slug: string): string {
|
|
64
|
+
return `${this.directoryFor(collection)}/${slug}${CONTENT_FILE_EXTENSION}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContentDocument,
|
|
3
|
+
ContentDocumentMeta,
|
|
4
|
+
WriteContentInput,
|
|
5
|
+
} from "./content.types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Backend-agnostic content access. Implementations: {@link FileSystemContentStore}
|
|
9
|
+
* (local dev) and {@link GitHubContentStore} (production / serverless).
|
|
10
|
+
*/
|
|
11
|
+
export interface ContentStore {
|
|
12
|
+
/** Lists every document in a collection. Returns `[]` if the collection is empty. */
|
|
13
|
+
list(collection: string): Promise<ContentDocumentMeta[]>;
|
|
14
|
+
/** Reads a single document, or `null` if it does not exist. */
|
|
15
|
+
read(collection: string, slug: string): Promise<ContentDocument | null>;
|
|
16
|
+
/** Creates or updates a document. */
|
|
17
|
+
write(input: WriteContentInput): Promise<void>;
|
|
18
|
+
/** Deletes a document. No-op if it does not exist. */
|
|
19
|
+
delete(collection: string, slug: string): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shared file extension for content documents. */
|
|
23
|
+
export const CONTENT_FILE_EXTENSION = ".mdx";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Metadata describing a stored content document (no body). */
|
|
2
|
+
export interface ContentDocumentMeta {
|
|
3
|
+
/** Slug derived from the file name (without extension). */
|
|
4
|
+
slug: string;
|
|
5
|
+
/** Collection this document belongs to. */
|
|
6
|
+
collection: string;
|
|
7
|
+
/** Parsed frontmatter. */
|
|
8
|
+
frontmatter: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A full content document, including its MDX/Markdown body. */
|
|
12
|
+
export interface ContentDocument extends ContentDocumentMeta {
|
|
13
|
+
/** Raw MDX/Markdown body (frontmatter stripped). */
|
|
14
|
+
body: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Input for creating or updating a document. */
|
|
18
|
+
export interface WriteContentInput {
|
|
19
|
+
collection: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
frontmatter: Record<string, unknown>;
|
|
22
|
+
body: string;
|
|
23
|
+
/** Commit message used by git-backed stores. */
|
|
24
|
+
message?: string;
|
|
25
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResolvedOcsmConfig } from "../config/config.types";
|
|
2
|
+
import { createFileBackend } from "../storage/create-file-backend";
|
|
3
|
+
import type { FileBackend } from "../storage/file-backend";
|
|
4
|
+
import { ContentRepository } from "./content-repository";
|
|
5
|
+
import type { ContentStore } from "./content-store.interface";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a {@link ContentStore} bound to the environment's storage backend
|
|
9
|
+
* (filesystem in development, GitHub in production).
|
|
10
|
+
*
|
|
11
|
+
* @param backend - Optional explicit backend, mainly for testing.
|
|
12
|
+
*/
|
|
13
|
+
export function createContentStore(
|
|
14
|
+
config: ResolvedOcsmConfig,
|
|
15
|
+
backend: FileBackend = createFileBackend(),
|
|
16
|
+
): ContentStore {
|
|
17
|
+
return new ContentRepository(config, backend);
|
|
18
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import matter from "gray-matter";
|
|
2
|
+
import type { ContentDocument } from "./content.types";
|
|
3
|
+
|
|
4
|
+
/** Parses a raw MDX/Markdown string (with frontmatter) into a document. */
|
|
5
|
+
export function parseDocument(params: {
|
|
6
|
+
collection: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
raw: string;
|
|
9
|
+
}): ContentDocument {
|
|
10
|
+
const parsed = matter(params.raw);
|
|
11
|
+
return {
|
|
12
|
+
collection: params.collection,
|
|
13
|
+
slug: params.slug,
|
|
14
|
+
frontmatter: parsed.data,
|
|
15
|
+
body: parsed.content.trim(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Serializes frontmatter + body back into an MDX/Markdown string. */
|
|
20
|
+
export function serializeDocument(input: {
|
|
21
|
+
frontmatter: Record<string, unknown>;
|
|
22
|
+
body: string;
|
|
23
|
+
}): string {
|
|
24
|
+
return matter.stringify(`${input.body}\n`, input.frontmatter);
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CONTENT_FILE_EXTENSION,
|
|
3
|
+
type ContentStore,
|
|
4
|
+
} from "./content-store.interface";
|
|
5
|
+
export type {
|
|
6
|
+
ContentDocument,
|
|
7
|
+
ContentDocumentMeta,
|
|
8
|
+
WriteContentInput,
|
|
9
|
+
} from "./content.types";
|
|
10
|
+
export { ContentRepository } from "./content-repository";
|
|
11
|
+
export { createContentStore } from "./create-content-store";
|
|
12
|
+
export { parseDocument, serializeDocument } from "./frontmatter";
|