@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { defineOcsmConfig } from "./config/define-config";
|
|
2
|
+
export { resolveOcsmConfig } from "./config/resolve-config";
|
|
3
|
+
export type {
|
|
4
|
+
OcsmBrandConfig,
|
|
5
|
+
OcsmConfig,
|
|
6
|
+
OcsmContentCollectionConfig,
|
|
7
|
+
ResolvedOcsmConfig,
|
|
8
|
+
} from "./config/config.types";
|
|
9
|
+
export { checkForUpdates, type UpdateStatus } from "./update/check-for-updates";
|
|
10
|
+
export { OCSM_PACKAGE_NAME, OCSM_VERSION } from "./version";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type LayoutRegion, LayoutStore } from "./layout-store";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Block, normalizeBlocks } from "../blocks/block.types";
|
|
2
|
+
import type { JsonStore } from "../storage/json-store";
|
|
3
|
+
|
|
4
|
+
/** Global layout regions shared across every page. */
|
|
5
|
+
export type LayoutRegion = "header" | "footer";
|
|
6
|
+
|
|
7
|
+
const PATHS: Record<LayoutRegion, string> = {
|
|
8
|
+
header: "ocsm/header.json",
|
|
9
|
+
footer: "ocsm/footer.json",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Stores the global **header** and **footer** section blocks (Shopify-style),
|
|
14
|
+
* which render on every page. The per-page body lives in each document instead.
|
|
15
|
+
*/
|
|
16
|
+
export class LayoutStore {
|
|
17
|
+
constructor(private readonly json: JsonStore) {}
|
|
18
|
+
|
|
19
|
+
async get(region: LayoutRegion): Promise<Block[]> {
|
|
20
|
+
const data = await this.json.read<{ blocks: unknown }>(PATHS[region]);
|
|
21
|
+
return normalizeBlocks(data?.blocks);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async save(region: LayoutRegion, blocks: Block[]): Promise<void> {
|
|
25
|
+
await this.json.write(PATHS[region], { blocks }, `ocsm: update ${region}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { JsonStore } from "../storage/json-store";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_ROLES,
|
|
5
|
+
type OcsmPermission,
|
|
6
|
+
type OcsmRole,
|
|
7
|
+
type RolesFile,
|
|
8
|
+
sanitizePermissions,
|
|
9
|
+
SYSTEM_ADMIN_ROLE_ID,
|
|
10
|
+
} from "./role.types";
|
|
11
|
+
|
|
12
|
+
const ROLES_PATH = "ocsm/roles.json";
|
|
13
|
+
|
|
14
|
+
export interface RoleInput {
|
|
15
|
+
name: string;
|
|
16
|
+
permissions: OcsmPermission[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Manages roles persisted to `ocsm/roles.json`, seeded with defaults. */
|
|
20
|
+
export class RoleStore {
|
|
21
|
+
constructor(private readonly json: JsonStore) {}
|
|
22
|
+
|
|
23
|
+
async list(): Promise<OcsmRole[]> {
|
|
24
|
+
const data = await this.json.read<RolesFile>(ROLES_PATH);
|
|
25
|
+
if (!data || !Array.isArray(data.roles) || data.roles.length === 0) {
|
|
26
|
+
return DEFAULT_ROLES.map((role) => ({ ...role }));
|
|
27
|
+
}
|
|
28
|
+
return data.roles;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async get(id: string): Promise<OcsmRole | null> {
|
|
32
|
+
return (await this.list()).find((role) => role.id === id) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolves a role id to its name + permissions, with safe fallbacks. */
|
|
36
|
+
async resolve(id: string): Promise<{ name: string; permissions: OcsmPermission[] }> {
|
|
37
|
+
const role = await this.get(id);
|
|
38
|
+
if (role) return { name: role.name, permissions: role.permissions };
|
|
39
|
+
return { name: id, permissions: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async create(input: RoleInput): Promise<OcsmRole> {
|
|
43
|
+
const roles = await this.list();
|
|
44
|
+
const name = input.name.trim();
|
|
45
|
+
if (!name) throw new Error("OCSM: rol adı boş olamaz");
|
|
46
|
+
if (roles.some((r) => r.name.toLowerCase() === name.toLowerCase())) {
|
|
47
|
+
throw new Error(`OCSM: "${name}" adlı bir rol zaten var`);
|
|
48
|
+
}
|
|
49
|
+
const role: OcsmRole = {
|
|
50
|
+
id: randomUUID(),
|
|
51
|
+
name,
|
|
52
|
+
permissions: sanitizePermissions(input.permissions),
|
|
53
|
+
};
|
|
54
|
+
await this.save([...roles, role]);
|
|
55
|
+
return role;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async update(id: string, input: RoleInput): Promise<void> {
|
|
59
|
+
const roles = await this.list();
|
|
60
|
+
const target = roles.find((r) => r.id === id);
|
|
61
|
+
if (!target) throw new Error("OCSM: rol bulunamadı");
|
|
62
|
+
|
|
63
|
+
const name = input.name.trim();
|
|
64
|
+
if (!name) throw new Error("OCSM: rol adı boş olamaz");
|
|
65
|
+
if (
|
|
66
|
+
roles.some(
|
|
67
|
+
(r) => r.id !== id && r.name.toLowerCase() === name.toLowerCase(),
|
|
68
|
+
)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error(`OCSM: "${name}" adlı bir rol zaten var`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
target.name = name;
|
|
74
|
+
// The system admin role always keeps every permission (prevents lockout).
|
|
75
|
+
if (id !== SYSTEM_ADMIN_ROLE_ID) {
|
|
76
|
+
target.permissions = sanitizePermissions(input.permissions);
|
|
77
|
+
}
|
|
78
|
+
await this.save(roles);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async remove(id: string, assignedRoleIds: string[] = []): Promise<void> {
|
|
82
|
+
const roles = await this.list();
|
|
83
|
+
const target = roles.find((r) => r.id === id);
|
|
84
|
+
if (!target) return;
|
|
85
|
+
if (target.system) throw new Error("OCSM: sistem rolü silinemez");
|
|
86
|
+
if (assignedRoleIds.includes(id)) {
|
|
87
|
+
throw new Error("OCSM: bu role atanmış kullanıcılar var; önce onları taşıyın");
|
|
88
|
+
}
|
|
89
|
+
await this.save(roles.filter((r) => r.id !== id));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async save(roles: OcsmRole[]): Promise<void> {
|
|
93
|
+
await this.json.write<RolesFile>(ROLES_PATH, { roles }, "ocsm: update roles");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** The fixed set of capabilities a role can grant. */
|
|
2
|
+
export type OcsmPermission =
|
|
3
|
+
| "content:read"
|
|
4
|
+
| "content:write"
|
|
5
|
+
| "content:delete"
|
|
6
|
+
| "users:manage"
|
|
7
|
+
| "settings:manage";
|
|
8
|
+
|
|
9
|
+
/** A role: a named set of permissions. Custom roles are user-defined. */
|
|
10
|
+
export interface OcsmRole {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
permissions: OcsmPermission[];
|
|
14
|
+
/** System roles cannot be deleted and (for admin) have locked permissions. */
|
|
15
|
+
system?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** On-disk shape of `ocsm/roles.json`. */
|
|
19
|
+
export interface RolesFile {
|
|
20
|
+
roles: OcsmRole[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Every permission, with a human-readable label, for the role editor UI. */
|
|
24
|
+
export const ALL_PERMISSIONS: ReadonlyArray<{
|
|
25
|
+
key: OcsmPermission;
|
|
26
|
+
label: string;
|
|
27
|
+
description: string;
|
|
28
|
+
}> = [
|
|
29
|
+
{
|
|
30
|
+
key: "content:read",
|
|
31
|
+
label: "İçerikleri görüntüle",
|
|
32
|
+
description: "Panelde içerikleri listeleyip görebilir.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: "content:write",
|
|
36
|
+
label: "İçerik oluştur / düzenle",
|
|
37
|
+
description: "Sayfa ve yazıları oluşturup düzenleyebilir.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: "content:delete",
|
|
41
|
+
label: "İçerik sil",
|
|
42
|
+
description: "Sayfa ve yazıları silebilir.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "users:manage",
|
|
46
|
+
label: "Kullanıcıları yönet",
|
|
47
|
+
description: "Kullanıcı ekleyip düzenleyip silebilir.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: "settings:manage",
|
|
51
|
+
label: "Yapılandırmayı yönet",
|
|
52
|
+
description: "Rolleri ve sistem ayarlarını yönetebilir.",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const ALL_PERMISSION_KEYS: OcsmPermission[] = ALL_PERMISSIONS.map((p) => p.key);
|
|
57
|
+
|
|
58
|
+
/** Default seed roles, used when `ocsm/roles.json` does not exist yet. */
|
|
59
|
+
export const DEFAULT_ROLES: OcsmRole[] = [
|
|
60
|
+
{
|
|
61
|
+
id: "admin",
|
|
62
|
+
name: "Yönetici",
|
|
63
|
+
permissions: [...ALL_PERMISSION_KEYS],
|
|
64
|
+
system: true,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "editor",
|
|
68
|
+
name: "Editör",
|
|
69
|
+
permissions: ["content:read", "content:write", "content:delete"],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** The role id always granted every permission and never deletable. */
|
|
74
|
+
export const SYSTEM_ADMIN_ROLE_ID = "admin";
|
|
75
|
+
|
|
76
|
+
/** Returns a deduplicated, valid permission list. */
|
|
77
|
+
export function sanitizePermissions(input: unknown): OcsmPermission[] {
|
|
78
|
+
if (!Array.isArray(input)) return [];
|
|
79
|
+
const set = new Set<OcsmPermission>();
|
|
80
|
+
for (const value of input) {
|
|
81
|
+
if (ALL_PERMISSION_KEYS.includes(value as OcsmPermission)) {
|
|
82
|
+
set.add(value as OcsmPermission);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return [...set];
|
|
86
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { OcsmConfig, ResolvedOcsmConfig } from "../config/config.types";
|
|
2
|
+
import { resolveOcsmConfig } from "../config/resolve-config";
|
|
3
|
+
import type { ContentStore } from "../content/content-store.interface";
|
|
4
|
+
import type {
|
|
5
|
+
ContentDocument,
|
|
6
|
+
ContentDocumentMeta,
|
|
7
|
+
} from "../content/content.types";
|
|
8
|
+
import { createContentStore } from "../content/create-content-store";
|
|
9
|
+
import { LayoutStore } from "../layout/layout-store";
|
|
10
|
+
import { RoleStore } from "../roles/role-store";
|
|
11
|
+
import { createFileBackend } from "../storage/create-file-backend";
|
|
12
|
+
import { JsonStore } from "../storage/json-store";
|
|
13
|
+
import { ThemeStore } from "../theme/theme-store";
|
|
14
|
+
import { UserStore } from "../users/user-store";
|
|
15
|
+
|
|
16
|
+
/** A configured OCS Management instance, bound to a config and storage backend. */
|
|
17
|
+
export interface Ocsm {
|
|
18
|
+
/** The fully-resolved configuration. */
|
|
19
|
+
readonly config: ResolvedOcsmConfig;
|
|
20
|
+
/** Content access (collections of MDX documents). */
|
|
21
|
+
readonly store: ContentStore;
|
|
22
|
+
/** User management. */
|
|
23
|
+
readonly users: UserStore;
|
|
24
|
+
/** Role & permission management. */
|
|
25
|
+
readonly roles: RoleStore;
|
|
26
|
+
/** Public-site theme. */
|
|
27
|
+
readonly theme: ThemeStore;
|
|
28
|
+
/** Global header/footer sections shared across every page. */
|
|
29
|
+
readonly layout: LayoutStore;
|
|
30
|
+
/** Lists documents in a collection. */
|
|
31
|
+
listDocuments(collection: string): Promise<ContentDocumentMeta[]>;
|
|
32
|
+
/** Reads a single document, or `null` if it does not exist. */
|
|
33
|
+
getDocument(collection: string, slug: string): Promise<ContentDocument | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 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. Content,
|
|
39
|
+
* users, and theme all share a single storage backend (filesystem in dev,
|
|
40
|
+
* GitHub in production).
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* // lib/ocsm.ts
|
|
45
|
+
* import { createOcsm } from "@orhancodestudio/ocsm-core/server";
|
|
46
|
+
* import config from "@/ocsm.config";
|
|
47
|
+
*
|
|
48
|
+
* export const ocsm = createOcsm(config);
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function createOcsm(config: OcsmConfig): Ocsm {
|
|
52
|
+
const resolved = resolveOcsmConfig(config);
|
|
53
|
+
const backend = createFileBackend();
|
|
54
|
+
const store = createContentStore(resolved, backend);
|
|
55
|
+
const json = new JsonStore(backend);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
config: resolved,
|
|
59
|
+
store,
|
|
60
|
+
users: new UserStore(json),
|
|
61
|
+
roles: new RoleStore(json),
|
|
62
|
+
theme: new ThemeStore(json),
|
|
63
|
+
layout: new LayoutStore(json),
|
|
64
|
+
listDocuments: (collection) => store.list(collection),
|
|
65
|
+
getDocument: (collection, slug) => store.read(collection, slug),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SaveDocumentInput } from "../admin/admin.types";
|
|
2
|
+
import type { Ocsm } from "./create-ocsm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persists a document from the page builder. Section blocks are stored in the
|
|
6
|
+
* document's frontmatter. Wrap this in a `"use server"` action in your app.
|
|
7
|
+
*/
|
|
8
|
+
export async function writeDocument(
|
|
9
|
+
ocsm: Ocsm,
|
|
10
|
+
input: SaveDocumentInput,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
await ocsm.store.write({
|
|
13
|
+
collection: input.collection,
|
|
14
|
+
slug: input.slug,
|
|
15
|
+
frontmatter: { title: input.title, blocks: input.blocks },
|
|
16
|
+
body: "",
|
|
17
|
+
message: `ocsm: save ${input.collection}/${input.slug}`,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Deletes a document. Wrap this in a `"use server"` action in your app. */
|
|
22
|
+
export async function deleteDocument(
|
|
23
|
+
ocsm: Ocsm,
|
|
24
|
+
collection: string,
|
|
25
|
+
slug: string,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
await ocsm.store.delete(collection, slug);
|
|
28
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export { createOcsm, type Ocsm } from "./create-ocsm";
|
|
2
|
+
export { deleteDocument, writeDocument } from "./documents";
|
|
3
|
+
export { OcsmContent, type OcsmContentProps } from "./render-mdx";
|
|
4
|
+
|
|
5
|
+
// Auth, users & roles
|
|
6
|
+
export { authenticate, setupFirstAdmin } from "../auth/authenticate";
|
|
7
|
+
export { hashPassword, verifyPassword } from "../auth/password";
|
|
8
|
+
export { requirePermission, userHasPermission } from "../auth/permissions";
|
|
9
|
+
export {
|
|
10
|
+
createSession,
|
|
11
|
+
destroySession,
|
|
12
|
+
getSessionUser,
|
|
13
|
+
type SessionUser,
|
|
14
|
+
} from "../auth/session";
|
|
15
|
+
export {
|
|
16
|
+
type CreateUserInput,
|
|
17
|
+
type PublicUser,
|
|
18
|
+
type UpdateUserInput,
|
|
19
|
+
UserStore,
|
|
20
|
+
} from "../users";
|
|
21
|
+
export {
|
|
22
|
+
ALL_PERMISSIONS,
|
|
23
|
+
DEFAULT_ROLES,
|
|
24
|
+
type OcsmPermission,
|
|
25
|
+
type OcsmRole,
|
|
26
|
+
type RoleInput,
|
|
27
|
+
RoleStore,
|
|
28
|
+
SYSTEM_ADMIN_ROLE_ID,
|
|
29
|
+
} from "../roles";
|
|
30
|
+
|
|
31
|
+
// Theme
|
|
32
|
+
export {
|
|
33
|
+
DEFAULT_THEME,
|
|
34
|
+
type OcsmTheme,
|
|
35
|
+
themeToCssString,
|
|
36
|
+
themeToCssVariables,
|
|
37
|
+
THEME_FIELDS,
|
|
38
|
+
ThemeStore,
|
|
39
|
+
} from "../theme";
|
|
40
|
+
|
|
41
|
+
// Layout (global header/footer)
|
|
42
|
+
export { type LayoutRegion, LayoutStore } from "../layout";
|
|
43
|
+
|
|
44
|
+
// Blocks & rendering
|
|
45
|
+
export {
|
|
46
|
+
type Block,
|
|
47
|
+
BLOCK_TYPES,
|
|
48
|
+
BlockRenderer,
|
|
49
|
+
type BlockType,
|
|
50
|
+
createBlock,
|
|
51
|
+
normalizeBlocks,
|
|
52
|
+
OcsmMarkdown,
|
|
53
|
+
} from "../blocks";
|
|
54
|
+
|
|
55
|
+
// Content types
|
|
56
|
+
export type {
|
|
57
|
+
ContentDocument,
|
|
58
|
+
ContentDocumentMeta,
|
|
59
|
+
} from "../content/content.types";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { OcsmMarkdown } from "../blocks/markdown";
|
|
2
|
+
|
|
3
|
+
export interface OcsmContentProps {
|
|
4
|
+
/** Raw Markdown body to render. */
|
|
5
|
+
source: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders a Markdown body. Kept for backward compatibility with documents that
|
|
10
|
+
* store a plain Markdown body instead of section blocks.
|
|
11
|
+
*/
|
|
12
|
+
export function OcsmContent({ source }: OcsmContentProps) {
|
|
13
|
+
return <OcsmMarkdown>{source}</OcsmMarkdown>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FileBackend } from "./file-backend";
|
|
2
|
+
import { FsFileBackend } from "./fs-file-backend";
|
|
3
|
+
import { GitHubFileBackend } from "./github-file-backend";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Selects a {@link FileBackend} from the environment:
|
|
7
|
+
*
|
|
8
|
+
* - `OCSM_GITHUB_TOKEN` + `OCSM_GITHUB_REPO` set → {@link GitHubFileBackend}
|
|
9
|
+
* (serverless production).
|
|
10
|
+
* - otherwise → {@link FsFileBackend} (local development).
|
|
11
|
+
*/
|
|
12
|
+
export function createFileBackend(): FileBackend {
|
|
13
|
+
const token = process.env.OCSM_GITHUB_TOKEN;
|
|
14
|
+
const repo = process.env.OCSM_GITHUB_REPO;
|
|
15
|
+
const branch = process.env.OCSM_GITHUB_BRANCH ?? "main";
|
|
16
|
+
|
|
17
|
+
if (token && repo) {
|
|
18
|
+
const [owner, name] = repo.split("/");
|
|
19
|
+
if (!owner || !name) {
|
|
20
|
+
throw new Error('OCSM: OCSM_GITHUB_REPO must use the "owner/repo" format');
|
|
21
|
+
}
|
|
22
|
+
return new GitHubFileBackend({ token, owner, repo: name, branch });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new FsFileBackend();
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level storage abstraction shared by the content layer and the JSON data
|
|
3
|
+
* stores (users, theme, settings). Implementations: {@link FsFileBackend} for
|
|
4
|
+
* local development and {@link GitHubFileBackend} for serverless production.
|
|
5
|
+
*/
|
|
6
|
+
export interface FileBackend {
|
|
7
|
+
/** Reads a UTF-8 file, or returns `null` if it does not exist. */
|
|
8
|
+
read(path: string): Promise<string | null>;
|
|
9
|
+
/** Creates or overwrites a file. `message` is used as the commit message by git backends. */
|
|
10
|
+
write(path: string, content: string, message?: string): Promise<void>;
|
|
11
|
+
/** Deletes a file. No-op if it does not exist. */
|
|
12
|
+
remove(path: string, message?: string): Promise<void>;
|
|
13
|
+
/** Lists file names (not directories) directly inside `dir`. Returns `[]` if missing. */
|
|
14
|
+
listFiles(dir: string): Promise<string[]>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Narrows an unknown error to a "not found" condition (filesystem or HTTP 404). */
|
|
18
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
19
|
+
if ((error as NodeJS.ErrnoException | null)?.code === "ENOENT") return true;
|
|
20
|
+
return (
|
|
21
|
+
typeof error === "object" &&
|
|
22
|
+
error !== null &&
|
|
23
|
+
"status" in error &&
|
|
24
|
+
(error as { status: number }).status === 404
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { type FileBackend, isNotFoundError } from "./file-backend";
|
|
4
|
+
|
|
5
|
+
/** Reads and writes files on the local filesystem, relative to a working directory. */
|
|
6
|
+
export class FsFileBackend implements FileBackend {
|
|
7
|
+
constructor(private readonly cwd: string = process.cwd()) {}
|
|
8
|
+
|
|
9
|
+
async read(filePath: string): Promise<string | null> {
|
|
10
|
+
try {
|
|
11
|
+
return await fs.readFile(this.resolve(filePath), "utf8");
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (isNotFoundError(error)) return null;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async write(filePath: string, content: string): Promise<void> {
|
|
19
|
+
const absolute = this.resolve(filePath);
|
|
20
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
|
21
|
+
await fs.writeFile(absolute, content, "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async remove(filePath: string): Promise<void> {
|
|
25
|
+
await fs.rm(this.resolve(filePath), { force: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async listFiles(dir: string): Promise<string[]> {
|
|
29
|
+
try {
|
|
30
|
+
const entries = await fs.readdir(this.resolve(dir), {
|
|
31
|
+
withFileTypes: true,
|
|
32
|
+
});
|
|
33
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (isNotFoundError(error)) return [];
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private resolve(filePath: string): string {
|
|
41
|
+
return path.join(this.cwd, filePath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { Octokit } from "@octokit/rest";
|
|
3
|
+
import { type FileBackend, isNotFoundError } from "./file-backend";
|
|
4
|
+
|
|
5
|
+
export interface GitHubFileBackendOptions {
|
|
6
|
+
/** GitHub access token with `contents:write` on the target repo. */
|
|
7
|
+
token: string;
|
|
8
|
+
owner: string;
|
|
9
|
+
repo: string;
|
|
10
|
+
branch: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reads files via the GitHub Contents API and writes them back as commits.
|
|
15
|
+
* Used in production (e.g. on Vercel) where the runtime filesystem is ephemeral.
|
|
16
|
+
*/
|
|
17
|
+
export class GitHubFileBackend implements FileBackend {
|
|
18
|
+
private readonly octokit: Octokit;
|
|
19
|
+
|
|
20
|
+
constructor(private readonly options: GitHubFileBackendOptions) {
|
|
21
|
+
this.octokit = new Octokit({ auth: options.token });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async read(filePath: string): Promise<string | null> {
|
|
25
|
+
try {
|
|
26
|
+
const { data } = await this.octokit.repos.getContent({
|
|
27
|
+
...this.base(),
|
|
28
|
+
ref: this.options.branch,
|
|
29
|
+
path: filePath,
|
|
30
|
+
});
|
|
31
|
+
if (Array.isArray(data) || data.type !== "file") return null;
|
|
32
|
+
return Buffer.from(data.content, "base64").toString("utf8");
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (isNotFoundError(error)) return null;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async write(filePath: string, content: string, message?: string): Promise<void> {
|
|
40
|
+
await this.octokit.repos.createOrUpdateFileContents({
|
|
41
|
+
...this.base(),
|
|
42
|
+
branch: this.options.branch,
|
|
43
|
+
path: filePath,
|
|
44
|
+
message: message ?? `ocsm: update ${filePath}`,
|
|
45
|
+
content: Buffer.from(content, "utf8").toString("base64"),
|
|
46
|
+
sha: await this.shaFor(filePath),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async remove(filePath: string, message?: string): Promise<void> {
|
|
51
|
+
const sha = await this.shaFor(filePath);
|
|
52
|
+
if (!sha) return;
|
|
53
|
+
await this.octokit.repos.deleteFile({
|
|
54
|
+
...this.base(),
|
|
55
|
+
branch: this.options.branch,
|
|
56
|
+
path: filePath,
|
|
57
|
+
message: message ?? `ocsm: delete ${filePath}`,
|
|
58
|
+
sha,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async listFiles(dir: string): Promise<string[]> {
|
|
63
|
+
try {
|
|
64
|
+
const { data } = await this.octokit.repos.getContent({
|
|
65
|
+
...this.base(),
|
|
66
|
+
ref: this.options.branch,
|
|
67
|
+
path: dir,
|
|
68
|
+
});
|
|
69
|
+
if (!Array.isArray(data)) return [];
|
|
70
|
+
return data
|
|
71
|
+
.filter((entry) => entry.type === "file")
|
|
72
|
+
.map((entry) => entry.name);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (isNotFoundError(error)) return [];
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private base(): { owner: string; repo: string } {
|
|
80
|
+
return { owner: this.options.owner, repo: this.options.repo };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async shaFor(filePath: string): Promise<string | undefined> {
|
|
84
|
+
try {
|
|
85
|
+
const { data } = await this.octokit.repos.getContent({
|
|
86
|
+
...this.base(),
|
|
87
|
+
ref: this.options.branch,
|
|
88
|
+
path: filePath,
|
|
89
|
+
});
|
|
90
|
+
if (Array.isArray(data) || data.type !== "file") return undefined;
|
|
91
|
+
return data.sha;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isNotFoundError(error)) return undefined;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { type FileBackend, isNotFoundError } from "./file-backend";
|
|
2
|
+
export { FsFileBackend } from "./fs-file-backend";
|
|
3
|
+
export {
|
|
4
|
+
GitHubFileBackend,
|
|
5
|
+
type GitHubFileBackendOptions,
|
|
6
|
+
} from "./github-file-backend";
|
|
7
|
+
export { createFileBackend } from "./create-file-backend";
|
|
8
|
+
export { JsonStore } from "./json-store";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FileBackend } from "./file-backend";
|
|
2
|
+
|
|
3
|
+
/** Reads and writes JSON documents through a {@link FileBackend}. */
|
|
4
|
+
export class JsonStore {
|
|
5
|
+
constructor(private readonly backend: FileBackend) {}
|
|
6
|
+
|
|
7
|
+
/** Reads and parses a JSON file, or returns `null` if it does not exist. */
|
|
8
|
+
async read<T>(path: string): Promise<T | null> {
|
|
9
|
+
const raw = await this.backend.read(path);
|
|
10
|
+
if (raw === null) return null;
|
|
11
|
+
return JSON.parse(raw) as T;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Reads a JSON file, falling back to `fallback` when it does not exist. */
|
|
15
|
+
async readOr<T>(path: string, fallback: T): Promise<T> {
|
|
16
|
+
return (await this.read<T>(path)) ?? fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Serializes and writes a JSON file (pretty-printed). */
|
|
20
|
+
async write<T>(path: string, data: T, message?: string): Promise<void> {
|
|
21
|
+
await this.backend.write(path, `${JSON.stringify(data, null, 2)}\n`, message);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/theme/css.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { OcsmTheme } from "./theme.types";
|
|
2
|
+
|
|
3
|
+
/** Maps a theme to its CSS custom properties. */
|
|
4
|
+
export function themeToCssVariables(theme: OcsmTheme): Record<string, string> {
|
|
5
|
+
return {
|
|
6
|
+
"--ocsm-primary": theme.primaryColor,
|
|
7
|
+
"--ocsm-accent": theme.accentColor,
|
|
8
|
+
"--ocsm-bg": theme.backgroundColor,
|
|
9
|
+
"--ocsm-surface": theme.surfaceColor,
|
|
10
|
+
"--ocsm-text": theme.textColor,
|
|
11
|
+
"--ocsm-muted": theme.mutedColor,
|
|
12
|
+
"--ocsm-font": theme.fontFamily,
|
|
13
|
+
"--ocsm-font-heading": theme.headingFontFamily,
|
|
14
|
+
"--ocsm-radius": theme.radius,
|
|
15
|
+
"--ocsm-max-width": theme.maxWidth,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Renders a theme as a `:root { ... }` CSS string. Inject the result into a
|
|
21
|
+
* `<style>` tag in your site layout so pages can use `var(--ocsm-*)`.
|
|
22
|
+
*/
|
|
23
|
+
export function themeToCssString(theme: OcsmTheme): string {
|
|
24
|
+
const declarations = Object.entries(themeToCssVariables(theme))
|
|
25
|
+
.map(([name, value]) => `${name}: ${value};`)
|
|
26
|
+
.join(" ");
|
|
27
|
+
return `:root { ${declarations} }`;
|
|
28
|
+
}
|