@orhancodestudio/ocsm-core 0.1.0-alpha.1 → 0.1.0-alpha.2

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog — @orhancodestudio/ocsm-core
2
2
 
3
+ ## 0.1.0-alpha.2
4
+
5
+ ### Added
6
+
7
+ - Visual full-screen **page builder** with section blocks (navbar, hero, heading,
8
+ rich text, image, button, quote, banner, marquee, video, spacer), per-block
9
+ styling (colors, gradients, fonts, spacing), drag-and-drop reorder, visibility
10
+ toggle, device preview, and live themed canvas.
11
+ - Global **header/footer** regions (`LayoutStore`) shared across pages.
12
+ - **Dynamic roles & permissions** (`RoleStore`) with a reusable `Modal`, a redesigned
13
+ Users page, and a tabbed **Settings** page (Roles & Permissions).
14
+ - **Media upload** from device: `MediaStore` + `handleMediaRequest` route helper,
15
+ served live via `/ocsm-media/*` (works before rebuild).
16
+ - Reusable `DataTable`; versioned sessions (auto-invalidate stale cookies).
17
+
18
+ ### Changed
19
+
20
+ - Markdown rendering moved from `next-mdx-remote` to `react-markdown`.
21
+ - Content layer unified on a single `FileBackend` abstraction (fs + GitHub),
22
+ now with binary read/write for media.
23
+
3
24
  ## 0.1.0-alpha.1
4
25
 
5
26
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orhancodestudio/ocsm-core",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
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
+ }
@@ -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 { SaveDocumentAction, SaveLayoutAction } from "../admin.types";
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 block={block} onChange={onContent} />
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 URL)">
643
- <input
644
- className={styles.fsInput}
658
+ <Control label="Logo görseli (opsiyonel)">
659
+ <MediaInput
645
660
  value={block.logoImageUrl}
646
- placeholder="https://… (boşsa metin gösterilir)"
647
- onChange={(e) => onChange({ logoImageUrl: e.target.value })}
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 URL">
781
- <input
782
- className={styles.fsInput}
796
+ <Control label="Görsel">
797
+ <MediaInput
783
798
  value={block.url}
784
- placeholder="https://…"
785
- onChange={(e) => onChange({ url: e.target.value })}
799
+ onChange={(url) => onChange({ url })}
800
+ uploadMedia={uploadMedia}
786
801
  />
787
802
  </Control>
788
803
  <Control label="Alternatif metin (erişilebilirlik)">
@@ -17,4 +17,6 @@ export type {
17
17
  UpdateRoleAction,
18
18
  UpdateUserAction,
19
19
  UpdateUserActionInput,
20
+ UploadMediaAction,
21
+ UploadMediaResult,
20
22
  } from "./admin.types";
@@ -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
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  } from "../content/content.types";
8
8
  import { createContentStore } from "../content/create-content-store";
9
9
  import { LayoutStore } from "../layout/layout-store";
10
+ import { MediaStore } from "../media/media-store";
10
11
  import { RoleStore } from "../roles/role-store";
11
12
  import { createFileBackend } from "../storage/create-file-backend";
12
13
  import { JsonStore } from "../storage/json-store";
@@ -27,6 +28,8 @@ export interface Ocsm {
27
28
  readonly theme: ThemeStore;
28
29
  /** Global header/footer sections shared across every page. */
29
30
  readonly layout: LayoutStore;
31
+ /** Uploaded media (images) storage. */
32
+ readonly media: MediaStore;
30
33
  /** Lists documents in a collection. */
31
34
  listDocuments(collection: string): Promise<ContentDocumentMeta[]>;
32
35
  /** Reads a single document, or `null` if it does not exist. */
@@ -61,6 +64,7 @@ export function createOcsm(config: OcsmConfig): Ocsm {
61
64
  roles: new RoleStore(json),
62
65
  theme: new ThemeStore(json),
63
66
  layout: new LayoutStore(json),
67
+ media: new MediaStore(backend),
64
68
  listDocuments: (collection) => store.list(collection),
65
69
  getDocument: (collection, slug) => store.read(collection, slug),
66
70
  };
@@ -1,7 +1,18 @@
1
1
  export { createOcsm, type Ocsm } 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: Buffer.from(content, "utf8").toString("base64"),
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.1";
8
+ export const OCSM_VERSION = "0.1.0-alpha.2";
9
9
 
10
10
  /** The published npm package name for the OCS Management core. */
11
11
  export const OCSM_PACKAGE_NAME = "@orhancodestudio/ocsm-core";