163 lines
6.8 KiB
TypeScript
163 lines
6.8 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { PROJECTS_ROOT, TEMPLATES_ROOT } from "./config";
|
|
import type { ContentProjectRecipe, ProjectMeta } from "./types";
|
|
|
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,48}$/;
|
|
|
|
const DEFAULT_RECIPE: ContentProjectRecipe = {
|
|
positioning: "分享真实、可复用的经验",
|
|
audience: "对这个主题感兴趣的小红书用户",
|
|
tone: "真诚自然",
|
|
contentFormat: "干货教程",
|
|
titleMethod: "突出具体收益,不夸张、不制造焦虑",
|
|
bodyStructure: "痛点开场 → 核心方法 → 分点说明 → 行动建议",
|
|
defaultTags: ["经验分享", "实用干货"],
|
|
};
|
|
|
|
function cleanText(value: unknown, fallback: string) {
|
|
return typeof value === "string" && value.trim() ? value.trim().slice(0, 2000) : fallback;
|
|
}
|
|
|
|
function normalizeRecipe(value: unknown, previous = DEFAULT_RECIPE): ContentProjectRecipe {
|
|
const input = value && typeof value === "object" ? value as Partial<ContentProjectRecipe> : {};
|
|
return {
|
|
positioning: cleanText(input.positioning, previous.positioning),
|
|
audience: cleanText(input.audience, previous.audience),
|
|
tone: cleanText(input.tone, previous.tone),
|
|
contentFormat: cleanText(input.contentFormat, previous.contentFormat),
|
|
titleMethod: cleanText(input.titleMethod, previous.titleMethod),
|
|
bodyStructure: cleanText(input.bodyStructure, previous.bodyStructure),
|
|
defaultTags: Array.isArray(input.defaultTags) ? input.defaultTags.map((item) => cleanText(item, "")).filter(Boolean).slice(0, 20) : previous.defaultTags,
|
|
};
|
|
}
|
|
|
|
export function assertProjectId(id: string) {
|
|
if (!ID_PATTERN.test(id)) throw new Error("Invalid project id");
|
|
}
|
|
|
|
export function projectRoot(id: string) {
|
|
assertProjectId(id);
|
|
return path.join(PROJECTS_ROOT, id);
|
|
}
|
|
|
|
export function workspaceRoot(id: string) {
|
|
return path.join(projectRoot(id), "workspace");
|
|
}
|
|
|
|
export function safeWorkspacePath(id: string, segments: string[]) {
|
|
const root = workspaceRoot(id);
|
|
const target = path.resolve(root, ...segments);
|
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error("Path escapes workspace");
|
|
if (segments.some((part) => part === "node_modules" || part === ".git")) throw new Error("Protected path");
|
|
return target;
|
|
}
|
|
|
|
async function readMetaFile(id: string): Promise<ProjectMeta> {
|
|
return JSON.parse(await fs.readFile(path.join(projectRoot(id), "project.json"), "utf8"));
|
|
}
|
|
|
|
export async function touchProject(id: string) {
|
|
const meta = await readMetaFile(id);
|
|
meta.lastAccessAt = new Date().toISOString();
|
|
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
|
|
return meta;
|
|
}
|
|
|
|
export async function getProject(id: string, touch = true) {
|
|
return touch ? touchProject(id) : readMetaFile(id);
|
|
}
|
|
|
|
export async function listProjects() {
|
|
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
|
|
const entries = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true });
|
|
const results = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
try { return await readMetaFile(entry.name); } catch { return null; }
|
|
}));
|
|
return results.filter((item): item is ProjectMeta => Boolean(item)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
}
|
|
|
|
export async function createProject(input: { name?: string; description?: string; recipe?: Partial<ContentProjectRecipe>; template?: string }) {
|
|
const template = input.template || "basic";
|
|
const templateRoot = path.join(/* turbopackIgnore: true */ TEMPLATES_ROOT, template);
|
|
await fs.access(templateRoot);
|
|
const slug = (input.name || "新内容项目").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "project";
|
|
const id = `${slug}-${crypto.randomBytes(3).toString("hex")}`;
|
|
const root = projectRoot(id);
|
|
await fs.mkdir(root, { recursive: true });
|
|
await fs.cp(templateRoot, workspaceRoot(id), { recursive: true });
|
|
const now = new Date().toISOString();
|
|
const meta: ProjectMeta = {
|
|
id,
|
|
name: cleanText(input.name, "新内容项目").slice(0, 60),
|
|
description: cleanText(input.description, "用同一套方法持续生成一类小红书内容"),
|
|
recipe: normalizeRecipe(input.recipe),
|
|
template,
|
|
revision: 1,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
lastAccessAt: now,
|
|
};
|
|
await fs.writeFile(path.join(root, "project.json"), JSON.stringify(meta, null, 2));
|
|
return meta;
|
|
}
|
|
|
|
export async function updateProject(id: string, input: { name?: string; description?: string; recipe?: Partial<ContentProjectRecipe>; revision?: number }) {
|
|
const meta = await readMetaFile(id);
|
|
if (input.revision !== undefined && input.revision !== meta.revision) {
|
|
const error = new Error(`Revision conflict: expected ${input.revision}, current ${meta.revision}`);
|
|
(error as Error & { status?: number }).status = 409;
|
|
throw error;
|
|
}
|
|
meta.name = cleanText(input.name, meta.name).slice(0, 60);
|
|
meta.description = cleanText(input.description, meta.description);
|
|
meta.recipe = normalizeRecipe(input.recipe, meta.recipe || DEFAULT_RECIPE);
|
|
meta.revision += 1;
|
|
meta.updatedAt = new Date().toISOString();
|
|
meta.lastAccessAt = meta.updatedAt;
|
|
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
|
|
return meta;
|
|
}
|
|
|
|
export async function listFiles(id: string) {
|
|
await touchProject(id);
|
|
const root = workspaceRoot(id);
|
|
const files: string[] = [];
|
|
async function walk(dir: string) {
|
|
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
if (["node_modules", ".git", "dist"].includes(entry.name)) continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) await walk(full);
|
|
else files.push(path.relative(root, full).split(path.sep).join("/"));
|
|
}
|
|
}
|
|
await walk(root);
|
|
return files.sort();
|
|
}
|
|
|
|
export async function readProjectFile(id: string, segments: string[]) {
|
|
await touchProject(id);
|
|
return fs.readFile(safeWorkspacePath(id, segments), "utf8");
|
|
}
|
|
|
|
export async function writeProjectFiles(id: string, changes: Array<{ path: string; content: string }>, expectedRevision?: number) {
|
|
const meta = await readMetaFile(id);
|
|
if (expectedRevision !== undefined && expectedRevision !== meta.revision) {
|
|
const error = new Error(`Revision conflict: expected ${expectedRevision}, current ${meta.revision}`);
|
|
(error as Error & { status?: number }).status = 409;
|
|
throw error;
|
|
}
|
|
for (const change of changes) {
|
|
const target = safeWorkspacePath(id, change.path.split("/"));
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
await fs.writeFile(target, change.content, "utf8");
|
|
}
|
|
const now = new Date().toISOString();
|
|
meta.revision += 1;
|
|
meta.updatedAt = now;
|
|
meta.lastAccessAt = now;
|
|
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
|
|
return meta;
|
|
}
|