feat(cms): add storage based website content management
This commit is contained in:
parent
7d5689cfa4
commit
12d31f5468
31 changed files with 1347 additions and 158 deletions
153
lib/content/service.ts
Normal file
153
lib/content/service.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { existsSync } from "fs";
|
||||
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { storageDirectory } from "@/lib/runtime/config";
|
||||
import { listMediaFiles } from "@/lib/admin/media";
|
||||
import { contentFileNames, defaultContent } from "./defaults";
|
||||
import type { ContentDocuments, ContentKey, ContentSummary } from "./types";
|
||||
|
||||
export const contentDirectory = path.join(storageDirectory, "content");
|
||||
export const contentBackupDirectory = path.join(contentDirectory, "backups");
|
||||
export const contentVersion = "0.4.0";
|
||||
|
||||
const contentKeys = Object.keys(contentFileNames) as ContentKey[];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function sanitizeString(value: string) {
|
||||
return value
|
||||
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/javascript:/gi, "")
|
||||
.replace(/[<>]/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") return sanitizeString(value);
|
||||
if (Array.isArray(value)) return value.map(sanitizeValue);
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, sanitizeValue(entry)]));
|
||||
}
|
||||
|
||||
function mergeContent<T>(defaults: T, incoming: unknown): T {
|
||||
if (Array.isArray(defaults)) return Array.isArray(incoming) ? sanitizeValue(incoming) as T : defaults;
|
||||
if (!isRecord(defaults)) return sanitizeValue(incoming ?? defaults) as T;
|
||||
if (!isRecord(incoming)) return defaults;
|
||||
|
||||
const merged: Record<string, unknown> = { ...defaults };
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
merged[key] = mergeContent(value, incoming[key]);
|
||||
}
|
||||
return sanitizeValue(merged) as T;
|
||||
}
|
||||
|
||||
function filePathFor(key: ContentKey) {
|
||||
return path.join(contentDirectory, contentFileNames[key]);
|
||||
}
|
||||
|
||||
function backupStamp() {
|
||||
const date = new Date();
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function pruneBackups(key: ContentKey) {
|
||||
const prefix = `${key}.`;
|
||||
const entries = await readdir(contentBackupDirectory).catch(() => []);
|
||||
const backups = await Promise.all(entries
|
||||
.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))
|
||||
.map(async (entry) => {
|
||||
const fullPath = path.join(contentBackupDirectory, entry);
|
||||
const fileStat = await stat(fullPath);
|
||||
return { entry, fullPath, modified: fileStat.mtimeMs };
|
||||
}));
|
||||
|
||||
const obsolete = backups.sort((left, right) => right.modified - left.modified).slice(20);
|
||||
await Promise.all(obsolete.map((entry) => unlink(entry.fullPath).catch(() => undefined)));
|
||||
}
|
||||
|
||||
async function ensureContentDocument<K extends ContentKey>(key: K) {
|
||||
await mkdir(contentDirectory, { recursive: true });
|
||||
await mkdir(contentBackupDirectory, { recursive: true });
|
||||
|
||||
const target = filePathFor(key);
|
||||
if (!existsSync(target)) {
|
||||
await writeFile(target, `${JSON.stringify(defaultContent[key], null, 2)}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureContentStorage() {
|
||||
await mkdir(contentDirectory, { recursive: true });
|
||||
await mkdir(contentBackupDirectory, { recursive: true });
|
||||
await Promise.all(contentKeys.map((key) => ensureContentDocument(key)));
|
||||
}
|
||||
|
||||
export async function getContent<K extends ContentKey>(key: K): Promise<ContentDocuments[K]> {
|
||||
await ensureContentDocument(key);
|
||||
|
||||
try {
|
||||
const raw = await readFile(filePathFor(key), "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return mergeContent(defaultContent[key], parsed);
|
||||
} catch {
|
||||
return defaultContent[key];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllContent(): Promise<ContentDocuments> {
|
||||
const entries = await Promise.all(contentKeys.map(async (key) => [key, await getContent(key)] as const));
|
||||
return Object.fromEntries(entries) as ContentDocuments;
|
||||
}
|
||||
|
||||
export async function saveContent<K extends ContentKey>(key: K, content: ContentDocuments[K]) {
|
||||
await ensureContentDocument(key);
|
||||
const current = filePathFor(key);
|
||||
const backup = path.join(contentBackupDirectory, `${key}.${backupStamp()}.json`);
|
||||
|
||||
if (existsSync(current)) await rename(current, backup);
|
||||
|
||||
const next = mergeContent(defaultContent[key], {
|
||||
...content,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await writeFile(current, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
||||
await pruneBackups(key);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function getContentSummary(): Promise<ContentSummary> {
|
||||
await ensureContentStorage();
|
||||
const documents = await Promise.all(contentKeys.map(async (key) => {
|
||||
const content = await getContent(key);
|
||||
return {
|
||||
key,
|
||||
fileName: contentFileNames[key],
|
||||
title: "title" in content ? content.title : content.siteName,
|
||||
updatedAt: content.updatedAt,
|
||||
};
|
||||
}));
|
||||
const lastModified = documents
|
||||
.map((document) => document.updatedAt)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
return {
|
||||
version: contentVersion,
|
||||
lastModified,
|
||||
pageCount: documents.length,
|
||||
documents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getContentSystemStatus() {
|
||||
const [summary, media] = await Promise.all([getContentSummary(), listMediaFiles()]);
|
||||
return {
|
||||
...summary,
|
||||
mediaCount: media.length,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue