@orhancodestudio/ocsm-core 0.1.0-alpha.1 → 0.1.0-alpha.3
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 +31 -0
- package/package.json +1 -1
- package/src/admin/admin.module.css +21 -0
- package/src/admin/admin.types.ts +10 -0
- package/src/admin/components/media-input.tsx +92 -0
- package/src/admin/components/page-builder.tsx +27 -12
- package/src/admin/index.ts +2 -0
- package/src/admin/ocsm-admin.tsx +2 -0
- package/src/media/index.ts +15 -0
- package/src/media/media-store.ts +95 -0
- package/src/media/mime.ts +41 -0
- package/src/server/create-ocsm.ts +51 -9
- package/src/server/index.ts +12 -1
- package/src/server/media-route.ts +32 -0
- package/src/storage/file-backend.ts +4 -0
- package/src/storage/fs-file-backend.ts +15 -0
- package/src/storage/github-file-backend.ts +33 -1
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog — @orhancodestudio/ocsm-core
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.3
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `ocsm.public` — a filesystem-only reader (`listDocuments`, `getDocument`,
|
|
8
|
+
`getLayout`, `getTheme`) for the public site. Decouples the production build
|
|
9
|
+
from GitHub: public pages read the committed checkout instead of the API
|
|
10
|
+
(faster, no rate limits, build never depends on GitHub availability). The live
|
|
11
|
+
GitHub backend still powers the editor's reads, writes, and media serving.
|
|
12
|
+
|
|
13
|
+
## 0.1.0-alpha.2
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Visual full-screen **page builder** with section blocks (navbar, hero, heading,
|
|
18
|
+
rich text, image, button, quote, banner, marquee, video, spacer), per-block
|
|
19
|
+
styling (colors, gradients, fonts, spacing), drag-and-drop reorder, visibility
|
|
20
|
+
toggle, device preview, and live themed canvas.
|
|
21
|
+
- Global **header/footer** regions (`LayoutStore`) shared across pages.
|
|
22
|
+
- **Dynamic roles & permissions** (`RoleStore`) with a reusable `Modal`, a redesigned
|
|
23
|
+
Users page, and a tabbed **Settings** page (Roles & Permissions).
|
|
24
|
+
- **Media upload** from device: `MediaStore` + `handleMediaRequest` route helper,
|
|
25
|
+
served live via `/ocsm-media/*` (works before rebuild).
|
|
26
|
+
- Reusable `DataTable`; versioned sessions (auto-invalidate stale cookies).
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Markdown rendering moved from `next-mdx-remote` to `react-markdown`.
|
|
31
|
+
- Content layer unified on a single `FileBackend` abstraction (fs + GitHub),
|
|
32
|
+
now with binary read/write for media.
|
|
33
|
+
|
|
3
34
|
## 0.1.0-alpha.1
|
|
4
35
|
|
|
5
36
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orhancodestudio/ocsm-core",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
4
|
"description": "OCS Management — the git-based CMS core for Next.js. Built by Orhan Code Studio.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Orhan Code Studio (https://orhancodestudio.com)",
|
|
@@ -1310,3 +1310,24 @@ a.card:hover {
|
|
|
1310
1310
|
color: var(--p-muted);
|
|
1311
1311
|
margin-top: 2px;
|
|
1312
1312
|
}
|
|
1313
|
+
|
|
1314
|
+
/* ---------- Media input ---------- */
|
|
1315
|
+
.mediaRow {
|
|
1316
|
+
display: flex;
|
|
1317
|
+
gap: 8px;
|
|
1318
|
+
align-items: center;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
.mediaRow .fsInput {
|
|
1322
|
+
flex: 1;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
.mediaPreview {
|
|
1326
|
+
margin-top: 8px;
|
|
1327
|
+
max-width: 100%;
|
|
1328
|
+
max-height: 120px;
|
|
1329
|
+
border-radius: 8px;
|
|
1330
|
+
border: 1px solid #e2e8f0;
|
|
1331
|
+
display: block;
|
|
1332
|
+
object-fit: contain;
|
|
1333
|
+
}
|
package/src/admin/admin.types.ts
CHANGED
|
@@ -60,6 +60,15 @@ export type UpdateRoleAction = (
|
|
|
60
60
|
) => Promise<ActionResult>;
|
|
61
61
|
export type DeleteRoleAction = (id: string) => Promise<ActionResult>;
|
|
62
62
|
|
|
63
|
+
// --- Media ---
|
|
64
|
+
export interface UploadMediaResult {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
url?: string;
|
|
67
|
+
message?: string;
|
|
68
|
+
}
|
|
69
|
+
/** Uploads a file (FormData field `file`) and returns its public URL. */
|
|
70
|
+
export type UploadMediaAction = (form: FormData) => Promise<UploadMediaResult>;
|
|
71
|
+
|
|
63
72
|
export type SaveThemeAction = (theme: OcsmTheme) => Promise<ActionResult>;
|
|
64
73
|
|
|
65
74
|
export type SaveLayoutAction = (
|
|
@@ -79,6 +88,7 @@ export interface OcsmAdminActions {
|
|
|
79
88
|
createRole: CreateRoleAction;
|
|
80
89
|
updateRole: UpdateRoleAction;
|
|
81
90
|
deleteRole: DeleteRoleAction;
|
|
91
|
+
uploadMedia: UploadMediaAction;
|
|
82
92
|
saveTheme: SaveThemeAction;
|
|
83
93
|
saveLayout: SaveLayoutAction;
|
|
84
94
|
signOut: SignOutAction;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
ACCEPTED_UPLOAD_HINT,
|
|
6
|
+
UPLOAD_ACCEPT_ATTR,
|
|
7
|
+
} from "../../media/mime";
|
|
8
|
+
import type { UploadMediaAction } from "../admin.types";
|
|
9
|
+
import styles from "../admin.module.css";
|
|
10
|
+
|
|
11
|
+
export interface MediaInputProps {
|
|
12
|
+
value: string;
|
|
13
|
+
onChange: (url: string) => void;
|
|
14
|
+
uploadMedia: UploadMediaAction;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Image field with both a URL text input and a "device upload" button. Uploads
|
|
20
|
+
* go through the host's {@link UploadMediaAction} (stored via the CMS backend)
|
|
21
|
+
* and the returned URL is written back into the field.
|
|
22
|
+
*/
|
|
23
|
+
export function MediaInput({
|
|
24
|
+
value,
|
|
25
|
+
onChange,
|
|
26
|
+
uploadMedia,
|
|
27
|
+
placeholder,
|
|
28
|
+
}: MediaInputProps) {
|
|
29
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const [busy, setBusy] = useState(false);
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
async function onFile(file: File) {
|
|
34
|
+
setError(null);
|
|
35
|
+
setBusy(true);
|
|
36
|
+
try {
|
|
37
|
+
const form = new FormData();
|
|
38
|
+
form.append("file", file);
|
|
39
|
+
const result = await uploadMedia(form);
|
|
40
|
+
if (result.ok && result.url) {
|
|
41
|
+
onChange(result.url);
|
|
42
|
+
} else {
|
|
43
|
+
setError(result.message ?? "Yükleme başarısız");
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
setError("Yükleme sırasında hata oluştu");
|
|
47
|
+
} finally {
|
|
48
|
+
setBusy(false);
|
|
49
|
+
if (fileRef.current) fileRef.current.value = "";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div>
|
|
55
|
+
<div className={styles.mediaRow}>
|
|
56
|
+
<input
|
|
57
|
+
className={styles.fsInput}
|
|
58
|
+
value={value}
|
|
59
|
+
placeholder={placeholder ?? "https://… veya cihazdan yükle"}
|
|
60
|
+
onChange={(event) => onChange(event.target.value)}
|
|
61
|
+
/>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
className={`${styles.btn} ${styles.btnSm}`}
|
|
65
|
+
disabled={busy}
|
|
66
|
+
onClick={() => fileRef.current?.click()}
|
|
67
|
+
>
|
|
68
|
+
{busy ? "Yükleniyor…" : "Yükle"}
|
|
69
|
+
</button>
|
|
70
|
+
<input
|
|
71
|
+
ref={fileRef}
|
|
72
|
+
type="file"
|
|
73
|
+
accept={UPLOAD_ACCEPT_ATTR}
|
|
74
|
+
hidden
|
|
75
|
+
onChange={(event) => {
|
|
76
|
+
const file = event.target.files?.[0];
|
|
77
|
+
if (file) void onFile(file);
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
{value ? (
|
|
82
|
+
// biome-ignore lint/a11y/useAltText: decorative thumbnail preview
|
|
83
|
+
<img src={value} alt="" className={styles.mediaPreview} />
|
|
84
|
+
) : null}
|
|
85
|
+
{error ? (
|
|
86
|
+
<p className={styles.errorText}>{error}</p>
|
|
87
|
+
) : (
|
|
88
|
+
<p className={styles.help}>{ACCEPTED_UPLOAD_HINT}</p>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
import { BlockItem } from "../../blocks/block-renderer";
|
|
19
19
|
import { themeToCssVariables } from "../../theme/css";
|
|
20
20
|
import type { OcsmTheme } from "../../theme/theme.types";
|
|
21
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
SaveDocumentAction,
|
|
23
|
+
SaveLayoutAction,
|
|
24
|
+
UploadMediaAction,
|
|
25
|
+
} from "../admin.types";
|
|
22
26
|
import styles from "../admin.module.css";
|
|
23
27
|
import {
|
|
24
28
|
IconEye,
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
IconPhone,
|
|
30
34
|
IconTrash,
|
|
31
35
|
} from "./icons";
|
|
36
|
+
import { MediaInput } from "./media-input";
|
|
32
37
|
|
|
33
38
|
export interface PageBuilderProps {
|
|
34
39
|
collection: string;
|
|
@@ -36,6 +41,7 @@ export interface PageBuilderProps {
|
|
|
36
41
|
backHref: string;
|
|
37
42
|
saveAction: SaveDocumentAction;
|
|
38
43
|
saveLayout: SaveLayoutAction;
|
|
44
|
+
uploadMedia: UploadMediaAction;
|
|
39
45
|
theme: OcsmTheme;
|
|
40
46
|
initialSlug?: string;
|
|
41
47
|
initialTitle?: string;
|
|
@@ -62,6 +68,7 @@ export function PageBuilder({
|
|
|
62
68
|
backHref,
|
|
63
69
|
saveAction,
|
|
64
70
|
saveLayout,
|
|
71
|
+
uploadMedia,
|
|
65
72
|
theme,
|
|
66
73
|
initialSlug,
|
|
67
74
|
initialTitle,
|
|
@@ -365,6 +372,7 @@ export function PageBuilder({
|
|
|
365
372
|
block={selectedBlock}
|
|
366
373
|
onContent={patchContent}
|
|
367
374
|
onStyle={patchStyle}
|
|
375
|
+
uploadMedia={uploadMedia}
|
|
368
376
|
/>
|
|
369
377
|
</div>
|
|
370
378
|
</div>
|
|
@@ -589,17 +597,23 @@ function Inspector({
|
|
|
589
597
|
block,
|
|
590
598
|
onContent,
|
|
591
599
|
onStyle,
|
|
600
|
+
uploadMedia,
|
|
592
601
|
}: {
|
|
593
602
|
block: Block;
|
|
594
603
|
onContent: (patch: Record<string, unknown>) => void;
|
|
595
604
|
onStyle: (patch: Partial<BlockStyle>) => void;
|
|
605
|
+
uploadMedia: UploadMediaAction;
|
|
596
606
|
}) {
|
|
597
607
|
const noAlign = block.type === "marquee" || block.type === "navbar";
|
|
598
608
|
return (
|
|
599
609
|
<>
|
|
600
610
|
<div className={styles.fsGroup}>
|
|
601
611
|
<h3 className={styles.fsGroupTitle}>İçerik</h3>
|
|
602
|
-
<ContentFields
|
|
612
|
+
<ContentFields
|
|
613
|
+
block={block}
|
|
614
|
+
onChange={onContent}
|
|
615
|
+
uploadMedia={uploadMedia}
|
|
616
|
+
/>
|
|
603
617
|
</div>
|
|
604
618
|
{block.type === "spacer" ? (
|
|
605
619
|
<div className={styles.fsGroup}>
|
|
@@ -624,9 +638,11 @@ function Inspector({
|
|
|
624
638
|
function ContentFields({
|
|
625
639
|
block,
|
|
626
640
|
onChange,
|
|
641
|
+
uploadMedia,
|
|
627
642
|
}: {
|
|
628
643
|
block: Block;
|
|
629
644
|
onChange: (patch: Record<string, unknown>) => void;
|
|
645
|
+
uploadMedia: UploadMediaAction;
|
|
630
646
|
}) {
|
|
631
647
|
switch (block.type) {
|
|
632
648
|
case "navbar":
|
|
@@ -639,12 +655,12 @@ function ContentFields({
|
|
|
639
655
|
onChange={(e) => onChange({ logoText: e.target.value })}
|
|
640
656
|
/>
|
|
641
657
|
</Control>
|
|
642
|
-
<Control label="Logo görseli (opsiyonel
|
|
643
|
-
<
|
|
644
|
-
className={styles.fsInput}
|
|
658
|
+
<Control label="Logo görseli (opsiyonel)">
|
|
659
|
+
<MediaInput
|
|
645
660
|
value={block.logoImageUrl}
|
|
646
|
-
|
|
647
|
-
|
|
661
|
+
onChange={(url) => onChange({ logoImageUrl: url })}
|
|
662
|
+
uploadMedia={uploadMedia}
|
|
663
|
+
placeholder="Boşsa logo metni gösterilir"
|
|
648
664
|
/>
|
|
649
665
|
</Control>
|
|
650
666
|
<Control label="Logo bağlantısı">
|
|
@@ -777,12 +793,11 @@ function ContentFields({
|
|
|
777
793
|
case "image":
|
|
778
794
|
return (
|
|
779
795
|
<>
|
|
780
|
-
<Control label="Görsel
|
|
781
|
-
<
|
|
782
|
-
className={styles.fsInput}
|
|
796
|
+
<Control label="Görsel">
|
|
797
|
+
<MediaInput
|
|
783
798
|
value={block.url}
|
|
784
|
-
|
|
785
|
-
|
|
799
|
+
onChange={(url) => onChange({ url })}
|
|
800
|
+
uploadMedia={uploadMedia}
|
|
786
801
|
/>
|
|
787
802
|
</Control>
|
|
788
803
|
<Control label="Alternatif metin (erişilebilirlik)">
|
package/src/admin/index.ts
CHANGED
package/src/admin/ocsm-admin.tsx
CHANGED
|
@@ -147,6 +147,7 @@ export async function OcsmAdmin({
|
|
|
147
147
|
backHref={`/ocsm-admin/${collection.name}`}
|
|
148
148
|
saveAction={actions.saveDocument}
|
|
149
149
|
saveLayout={actions.saveLayout}
|
|
150
|
+
uploadMedia={actions.uploadMedia}
|
|
150
151
|
theme={theme}
|
|
151
152
|
initialBlocks={[]}
|
|
152
153
|
initialHeader={headerBlocks}
|
|
@@ -169,6 +170,7 @@ export async function OcsmAdmin({
|
|
|
169
170
|
backHref={`/ocsm-admin/${collection.name}`}
|
|
170
171
|
saveAction={actions.saveDocument}
|
|
171
172
|
saveLayout={actions.saveLayout}
|
|
173
|
+
uploadMedia={actions.uploadMedia}
|
|
172
174
|
theme={theme}
|
|
173
175
|
initialSlug={seg1}
|
|
174
176
|
initialTitle={asString(document?.frontmatter.title)}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
MEDIA_DIR,
|
|
3
|
+
MEDIA_URL_PREFIX,
|
|
4
|
+
type MediaContent,
|
|
5
|
+
MediaStore,
|
|
6
|
+
type StoredMedia,
|
|
7
|
+
} from "./media-store";
|
|
8
|
+
export {
|
|
9
|
+
ACCEPTED_UPLOAD_HINT,
|
|
10
|
+
extForMime,
|
|
11
|
+
isAllowedUploadMime,
|
|
12
|
+
MAX_UPLOAD_BYTES,
|
|
13
|
+
mimeForExt,
|
|
14
|
+
UPLOAD_ACCEPT_ATTR,
|
|
15
|
+
} from "./mime";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import type { FileBackend } from "../storage/file-backend";
|
|
4
|
+
import {
|
|
5
|
+
extForMime,
|
|
6
|
+
isAllowedUploadMime,
|
|
7
|
+
MAX_UPLOAD_BYTES,
|
|
8
|
+
mimeForExt,
|
|
9
|
+
} from "./mime";
|
|
10
|
+
|
|
11
|
+
/** Directory (within the repo) where uploaded media is stored. */
|
|
12
|
+
export const MEDIA_DIR = "ocsm/media";
|
|
13
|
+
|
|
14
|
+
/** Public URL prefix the serving route is mounted at. */
|
|
15
|
+
export const MEDIA_URL_PREFIX = "/ocsm-media";
|
|
16
|
+
|
|
17
|
+
export interface StoredMedia {
|
|
18
|
+
/** File name within the media directory (also the URL segment). */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Public URL to reference the file (served by the media route). */
|
|
21
|
+
url: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MediaContent {
|
|
25
|
+
data: Buffer;
|
|
26
|
+
contentType: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stores and retrieves uploaded media through a {@link FileBackend}. Files live
|
|
31
|
+
* under {@link MEDIA_DIR} and are served via {@link MEDIA_URL_PREFIX} (a route
|
|
32
|
+
* handler reading from the backend), so uploads are available immediately —
|
|
33
|
+
* even in production, before any rebuild.
|
|
34
|
+
*/
|
|
35
|
+
export class MediaStore {
|
|
36
|
+
constructor(private readonly backend: FileBackend) {}
|
|
37
|
+
|
|
38
|
+
/** Validates and stores an uploaded file, returning its public URL. */
|
|
39
|
+
async save(input: {
|
|
40
|
+
data: Buffer;
|
|
41
|
+
mime: string;
|
|
42
|
+
originalName?: string;
|
|
43
|
+
}): Promise<StoredMedia> {
|
|
44
|
+
if (!isAllowedUploadMime(input.mime)) {
|
|
45
|
+
throw new Error("OCSM: desteklenmeyen dosya türü");
|
|
46
|
+
}
|
|
47
|
+
if (input.data.byteLength > MAX_UPLOAD_BYTES) {
|
|
48
|
+
throw new Error("OCSM: dosya çok büyük (en fazla 5 MB)");
|
|
49
|
+
}
|
|
50
|
+
const ext = extForMime(input.mime);
|
|
51
|
+
if (!ext) throw new Error("OCSM: desteklenmeyen dosya türü");
|
|
52
|
+
|
|
53
|
+
const name = `${baseName(input.originalName)}-${randomBytes(4).toString("hex")}.${ext}`;
|
|
54
|
+
await this.backend.writeBinary(
|
|
55
|
+
`${MEDIA_DIR}/${name}`,
|
|
56
|
+
input.data,
|
|
57
|
+
`ocsm: upload media ${name}`,
|
|
58
|
+
);
|
|
59
|
+
return { name, url: `${MEDIA_URL_PREFIX}/${name}` };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reads a stored file by name, inferring its content type. */
|
|
63
|
+
async read(name: string): Promise<MediaContent | null> {
|
|
64
|
+
if (!isSafeName(name)) return null;
|
|
65
|
+
const data = await this.backend.readBinary(`${MEDIA_DIR}/${name}`);
|
|
66
|
+
if (!data) return null;
|
|
67
|
+
return { data, contentType: mimeForExt(extension(name)) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Lists stored media file names. */
|
|
71
|
+
async list(): Promise<string[]> {
|
|
72
|
+
return this.backend.listFiles(MEDIA_DIR);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Slugified base of the original name (without extension), capped in length. */
|
|
77
|
+
function baseName(original?: string): string {
|
|
78
|
+
const stem = (original ?? "dosya").replace(/\.[^.]+$/, "");
|
|
79
|
+
const slug = stem
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
82
|
+
.replace(/^-+|-+$/g, "")
|
|
83
|
+
.slice(0, 40);
|
|
84
|
+
return slug || "dosya";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rejects names with path separators or traversal. */
|
|
88
|
+
function isSafeName(name: string): boolean {
|
|
89
|
+
return /^[a-zA-Z0-9._-]+$/.test(name) && !name.includes("..");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extension(name: string): string {
|
|
93
|
+
const match = name.match(/\.([^.]+)$/);
|
|
94
|
+
return match?.[1] ?? "";
|
|
95
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Allowed image upload types, mapped to their canonical file extension. */
|
|
2
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
3
|
+
"image/png": "png",
|
|
4
|
+
"image/jpeg": "jpg",
|
|
5
|
+
"image/gif": "gif",
|
|
6
|
+
"image/webp": "webp",
|
|
7
|
+
"image/avif": "avif",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const EXT_TO_MIME: Record<string, string> = {
|
|
11
|
+
png: "image/png",
|
|
12
|
+
jpg: "image/jpeg",
|
|
13
|
+
jpeg: "image/jpeg",
|
|
14
|
+
gif: "image/gif",
|
|
15
|
+
webp: "image/webp",
|
|
16
|
+
avif: "image/avif",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Largest accepted upload, in bytes (5 MB). */
|
|
20
|
+
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Human-readable list of accepted formats, for UI hints. */
|
|
23
|
+
export const ACCEPTED_UPLOAD_HINT = "PNG, JPG, GIF, WEBP, AVIF · en fazla 5 MB";
|
|
24
|
+
|
|
25
|
+
/** The `accept` attribute value for a file input. */
|
|
26
|
+
export const UPLOAD_ACCEPT_ATTR = Object.keys(MIME_TO_EXT).join(",");
|
|
27
|
+
|
|
28
|
+
/** Returns the canonical extension for an upload MIME type, or `null` if not allowed. */
|
|
29
|
+
export function extForMime(mime: string): string | null {
|
|
30
|
+
return MIME_TO_EXT[mime.toLowerCase()] ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns the MIME type for a file extension, or a generic fallback. */
|
|
34
|
+
export function mimeForExt(ext: string): string {
|
|
35
|
+
return EXT_TO_MIME[ext.toLowerCase()] ?? "application/octet-stream";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether a MIME type is an accepted upload format. */
|
|
39
|
+
export function isAllowedUploadMime(mime: string): boolean {
|
|
40
|
+
return mime.toLowerCase() in MIME_TO_EXT;
|
|
41
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Block } from "../blocks/block.types";
|
|
1
2
|
import type { OcsmConfig, ResolvedOcsmConfig } from "../config/config.types";
|
|
2
3
|
import { resolveOcsmConfig } from "../config/resolve-config";
|
|
3
4
|
import type { ContentStore } from "../content/content-store.interface";
|
|
@@ -6,38 +7,63 @@ import type {
|
|
|
6
7
|
ContentDocumentMeta,
|
|
7
8
|
} from "../content/content.types";
|
|
8
9
|
import { createContentStore } from "../content/create-content-store";
|
|
9
|
-
import { LayoutStore } from "../layout/layout-store";
|
|
10
|
+
import { type LayoutRegion, LayoutStore } from "../layout/layout-store";
|
|
11
|
+
import { MediaStore } from "../media/media-store";
|
|
10
12
|
import { RoleStore } from "../roles/role-store";
|
|
11
13
|
import { createFileBackend } from "../storage/create-file-backend";
|
|
14
|
+
import { FsFileBackend } from "../storage/fs-file-backend";
|
|
12
15
|
import { JsonStore } from "../storage/json-store";
|
|
13
16
|
import { ThemeStore } from "../theme/theme-store";
|
|
17
|
+
import type { OcsmTheme } from "../theme/theme.types";
|
|
14
18
|
import { UserStore } from "../users/user-store";
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Read-only content access that always reads from the **local filesystem** (the
|
|
22
|
+
* repository checkout), regardless of the GitHub env. Use this for the public
|
|
23
|
+
* site so the production build/render never depends on the GitHub API — content
|
|
24
|
+
* is baked from the committed files and refreshed on each rebuild.
|
|
25
|
+
*/
|
|
26
|
+
export interface PublicReader {
|
|
27
|
+
listDocuments(collection: string): Promise<ContentDocumentMeta[]>;
|
|
28
|
+
getDocument(collection: string, slug: string): Promise<ContentDocument | null>;
|
|
29
|
+
getLayout(region: LayoutRegion): Promise<Block[]>;
|
|
30
|
+
getTheme(): Promise<OcsmTheme>;
|
|
31
|
+
}
|
|
32
|
+
|
|
16
33
|
/** A configured OCS Management instance, bound to a config and storage backend. */
|
|
17
34
|
export interface Ocsm {
|
|
18
35
|
/** The fully-resolved configuration. */
|
|
19
36
|
readonly config: ResolvedOcsmConfig;
|
|
20
|
-
/**
|
|
37
|
+
/** Live content access (GitHub in production) — for the editor and writes. */
|
|
21
38
|
readonly store: ContentStore;
|
|
22
39
|
/** User management. */
|
|
23
40
|
readonly users: UserStore;
|
|
24
41
|
/** Role & permission management. */
|
|
25
42
|
readonly roles: RoleStore;
|
|
26
|
-
/** Public-site theme. */
|
|
43
|
+
/** Public-site theme (live backend). */
|
|
27
44
|
readonly theme: ThemeStore;
|
|
28
|
-
/** Global header/footer sections shared across every page. */
|
|
45
|
+
/** Global header/footer sections shared across every page (live backend). */
|
|
29
46
|
readonly layout: LayoutStore;
|
|
30
|
-
/**
|
|
47
|
+
/** Uploaded media (images) storage (live backend). */
|
|
48
|
+
readonly media: MediaStore;
|
|
49
|
+
/**
|
|
50
|
+
* Filesystem-only reader for the public site. Decouples the build from GitHub:
|
|
51
|
+
* public pages read the checked-out files, not the API.
|
|
52
|
+
*/
|
|
53
|
+
readonly public: PublicReader;
|
|
54
|
+
/** Lists documents in a collection (live backend). */
|
|
31
55
|
listDocuments(collection: string): Promise<ContentDocumentMeta[]>;
|
|
32
|
-
/** Reads a single document, or `null` if it does not exist. */
|
|
56
|
+
/** Reads a single document, or `null` if it does not exist (live backend). */
|
|
33
57
|
getDocument(collection: string, slug: string): Promise<ContentDocument | null>;
|
|
34
58
|
}
|
|
35
59
|
|
|
36
60
|
/**
|
|
37
61
|
* Creates an {@link Ocsm} instance from an author-supplied config. Call once in a
|
|
38
|
-
* server-only module and reuse the returned instance across the app.
|
|
39
|
-
*
|
|
40
|
-
* GitHub in production)
|
|
62
|
+
* server-only module and reuse the returned instance across the app.
|
|
63
|
+
*
|
|
64
|
+
* The **live backend** (filesystem in dev, GitHub in production) powers the
|
|
65
|
+
* editor's live reads and all writes. A separate **filesystem reader**
|
|
66
|
+
* ({@link Ocsm.public}) serves the public site so the build never calls GitHub.
|
|
41
67
|
*
|
|
42
68
|
* @example
|
|
43
69
|
* ```ts
|
|
@@ -50,10 +76,19 @@ export interface Ocsm {
|
|
|
50
76
|
*/
|
|
51
77
|
export function createOcsm(config: OcsmConfig): Ocsm {
|
|
52
78
|
const resolved = resolveOcsmConfig(config);
|
|
79
|
+
|
|
80
|
+
// Live backend: GitHub in production, filesystem in dev.
|
|
53
81
|
const backend = createFileBackend();
|
|
54
82
|
const store = createContentStore(resolved, backend);
|
|
55
83
|
const json = new JsonStore(backend);
|
|
56
84
|
|
|
85
|
+
// Public reader: always the local filesystem (the repo checkout).
|
|
86
|
+
const fsBackend = new FsFileBackend();
|
|
87
|
+
const publicStore = createContentStore(resolved, fsBackend);
|
|
88
|
+
const publicJson = new JsonStore(fsBackend);
|
|
89
|
+
const publicLayout = new LayoutStore(publicJson);
|
|
90
|
+
const publicTheme = new ThemeStore(publicJson);
|
|
91
|
+
|
|
57
92
|
return {
|
|
58
93
|
config: resolved,
|
|
59
94
|
store,
|
|
@@ -61,6 +96,13 @@ export function createOcsm(config: OcsmConfig): Ocsm {
|
|
|
61
96
|
roles: new RoleStore(json),
|
|
62
97
|
theme: new ThemeStore(json),
|
|
63
98
|
layout: new LayoutStore(json),
|
|
99
|
+
media: new MediaStore(backend),
|
|
100
|
+
public: {
|
|
101
|
+
listDocuments: (collection) => publicStore.list(collection),
|
|
102
|
+
getDocument: (collection, slug) => publicStore.read(collection, slug),
|
|
103
|
+
getLayout: (region) => publicLayout.get(region),
|
|
104
|
+
getTheme: () => publicTheme.get(),
|
|
105
|
+
},
|
|
64
106
|
listDocuments: (collection) => store.list(collection),
|
|
65
107
|
getDocument: (collection, slug) => store.read(collection, slug),
|
|
66
108
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
export { createOcsm, type Ocsm } from "./create-ocsm";
|
|
1
|
+
export { createOcsm, type Ocsm, type PublicReader } from "./create-ocsm";
|
|
2
2
|
export { deleteDocument, writeDocument } from "./documents";
|
|
3
|
+
export { handleMediaRequest } from "./media-route";
|
|
3
4
|
export { OcsmContent, type OcsmContentProps } from "./render-mdx";
|
|
4
5
|
|
|
6
|
+
// Media
|
|
7
|
+
export {
|
|
8
|
+
ACCEPTED_UPLOAD_HINT,
|
|
9
|
+
MAX_UPLOAD_BYTES,
|
|
10
|
+
type MediaContent,
|
|
11
|
+
MediaStore,
|
|
12
|
+
type StoredMedia,
|
|
13
|
+
UPLOAD_ACCEPT_ATTR,
|
|
14
|
+
} from "../media";
|
|
15
|
+
|
|
5
16
|
// Auth, users & roles
|
|
6
17
|
export { authenticate, setupFirstAdmin } from "../auth/authenticate";
|
|
7
18
|
export { hashPassword, verifyPassword } from "../auth/password";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Ocsm } from "./create-ocsm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serves an uploaded media file as a web `Response`. Mount this in a Route
|
|
5
|
+
* Handler at `app/ocsm-media/[...path]/route.ts`:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { handleMediaRequest } from "@orhancodestudio/ocsm-core/server";
|
|
9
|
+
* import { ocsm } from "@/lib/ocsm";
|
|
10
|
+
*
|
|
11
|
+
* export async function GET(_req: Request, ctx: { params: Promise<{ path: string[] }> }) {
|
|
12
|
+
* const { path } = await ctx.params;
|
|
13
|
+
* return handleMediaRequest(ocsm, path.join("/"));
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export async function handleMediaRequest(
|
|
18
|
+
ocsm: Ocsm,
|
|
19
|
+
name: string,
|
|
20
|
+
): Promise<Response> {
|
|
21
|
+
const file = await ocsm.media.read(name);
|
|
22
|
+
if (!file) {
|
|
23
|
+
return new Response("Not found", { status: 404 });
|
|
24
|
+
}
|
|
25
|
+
return new Response(new Uint8Array(file.data), {
|
|
26
|
+
headers: {
|
|
27
|
+
"content-type": file.contentType,
|
|
28
|
+
// Filenames are unique per upload, so they are safe to cache forever.
|
|
29
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -8,6 +8,10 @@ export interface FileBackend {
|
|
|
8
8
|
read(path: string): Promise<string | null>;
|
|
9
9
|
/** Creates or overwrites a file. `message` is used as the commit message by git backends. */
|
|
10
10
|
write(path: string, content: string, message?: string): Promise<void>;
|
|
11
|
+
/** Reads a binary file, or returns `null` if it does not exist. */
|
|
12
|
+
readBinary(path: string): Promise<Buffer | null>;
|
|
13
|
+
/** Creates or overwrites a binary file. */
|
|
14
|
+
writeBinary(path: string, data: Buffer, message?: string): Promise<void>;
|
|
11
15
|
/** Deletes a file. No-op if it does not exist. */
|
|
12
16
|
remove(path: string, message?: string): Promise<void>;
|
|
13
17
|
/** Lists file names (not directories) directly inside `dir`. Returns `[]` if missing. */
|
|
@@ -21,6 +21,21 @@ export class FsFileBackend implements FileBackend {
|
|
|
21
21
|
await fs.writeFile(absolute, content, "utf8");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
async readBinary(filePath: string): Promise<Buffer | null> {
|
|
25
|
+
try {
|
|
26
|
+
return await fs.readFile(this.resolve(filePath));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (isNotFoundError(error)) return null;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async writeBinary(filePath: string, data: Buffer): Promise<void> {
|
|
34
|
+
const absolute = this.resolve(filePath);
|
|
35
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
36
|
+
await fs.writeFile(absolute, data);
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
async remove(filePath: string): Promise<void> {
|
|
25
40
|
await fs.rm(this.resolve(filePath), { force: true });
|
|
26
41
|
}
|
|
@@ -37,12 +37,44 @@ export class GitHubFileBackend implements FileBackend {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async write(filePath: string, content: string, message?: string): Promise<void> {
|
|
40
|
+
await this.writeBinary(filePath, Buffer.from(content, "utf8"), message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async readBinary(filePath: string): Promise<Buffer | null> {
|
|
44
|
+
try {
|
|
45
|
+
const { data } = await this.octokit.repos.getContent({
|
|
46
|
+
...this.base(),
|
|
47
|
+
ref: this.options.branch,
|
|
48
|
+
path: filePath,
|
|
49
|
+
});
|
|
50
|
+
if (Array.isArray(data) || data.type !== "file") return null;
|
|
51
|
+
if (data.content) {
|
|
52
|
+
return Buffer.from(data.content, "base64");
|
|
53
|
+
}
|
|
54
|
+
// Files larger than ~1MB return empty content; fetch the raw blob instead.
|
|
55
|
+
if (data.download_url) {
|
|
56
|
+
const res = await fetch(data.download_url);
|
|
57
|
+
if (!res.ok) return null;
|
|
58
|
+
return Buffer.from(await res.arrayBuffer());
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isNotFoundError(error)) return null;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async writeBinary(
|
|
68
|
+
filePath: string,
|
|
69
|
+
data: Buffer,
|
|
70
|
+
message?: string,
|
|
71
|
+
): Promise<void> {
|
|
40
72
|
await this.octokit.repos.createOrUpdateFileContents({
|
|
41
73
|
...this.base(),
|
|
42
74
|
branch: this.options.branch,
|
|
43
75
|
path: filePath,
|
|
44
76
|
message: message ?? `ocsm: update ${filePath}`,
|
|
45
|
-
content:
|
|
77
|
+
content: data.toString("base64"),
|
|
46
78
|
sha: await this.shaFor(filePath),
|
|
47
79
|
});
|
|
48
80
|
}
|
package/src/version.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* update checker ({@link ./update/check-for-updates}) and the admin "update available"
|
|
6
6
|
* notice.
|
|
7
7
|
*/
|
|
8
|
-
export const OCSM_VERSION = "0.1.0-alpha.
|
|
8
|
+
export const OCSM_VERSION = "0.1.0-alpha.3";
|
|
9
9
|
|
|
10
10
|
/** The published npm package name for the OCS Management core. */
|
|
11
11
|
export const OCSM_PACKAGE_NAME = "@orhancodestudio/ocsm-core";
|