inlinecms
Version:
Real-time inline CMS for Astro with post management, frontmatter editing, and live preview
381 lines (379 loc) • 16.5 kB
JavaScript
import { createRequire } from "node:module";
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __toESM = (mod, isNodeMode, target) => {
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: () => mod[key],
enumerable: true
});
return to;
};
var __require = /* @__PURE__ */ createRequire(import.meta.url);
// index.ts
import { readFileSync } from "node:fs";
async function getModules() {
const fs = await import("node:fs/promises");
const { join } = await import("node:path");
const { pathToFileURL } = await import("url");
const { createRequire: createRequire2 } = await import("module");
const require2 = createRequire2(pathToFileURL(process.cwd() + "/index.js"));
return { fs, join, matter: require2("gray-matter"), yaml: require2("js-yaml"), require: require2 };
}
async function inferSchema(contentDir) {
try {
const { fs, join, matter } = await getModules();
const files = await fs.readdir(join(process.cwd(), contentDir));
const schema = {};
for (const file of files.filter((f) => f.endsWith(".md")).slice(0, 5)) {
const content = await fs.readFile(join(process.cwd(), contentDir, file), "utf-8");
const parsed = matter(content);
for (const [key, val] of Object.entries(parsed.data)) {
if (!schema[key])
schema[key] = new Set;
if (val instanceof Date || typeof val === "string" && /^\d{4}-\d{2}-\d{2}/.test(val)) {
schema[key].add("date");
} else {
schema[key].add(typeof val === "boolean" ? "boolean" : typeof val === "number" ? "number" : Array.isArray(val) ? "array" : "string");
}
}
}
const result = {};
for (const [key, types] of Object.entries(schema)) {
const arr = Array.from(types);
result[key] = arr.includes("date") ? "date" : arr[0] || "string";
}
return result;
} catch {
return {};
}
}
function processWithSchema(fm, schema) {
const result = {};
for (const [key, val] of Object.entries(fm)) {
if (val == null) {
result[key] = val;
continue;
}
const type = schema[key];
if (type === "date") {
result[key] = val instanceof Date ? new Date(val.toISOString().split("T")[0]) : new Date(new Date(val).toISOString().split("T")[0]);
} else if (type === "boolean") {
result[key] = typeof val === "string" ? val.toLowerCase() === "true" : Boolean(val);
} else if (type === "number") {
result[key] = typeof val === "string" ? isNaN(+val) ? val : +val : val;
} else if (type === "array") {
result[key] = Array.isArray(val) ? val : typeof val === "string" ? val.split(",").map((s) => s.trim()) : [val];
} else {
result[key] = val;
}
}
return result;
}
function readBody(req) {
return new Promise((resolve) => {
let body = "";
req.on("data", (chunk) => body += chunk);
req.on("end", () => resolve(body));
});
}
function inlineCMS(configOrContentDir) {
const config = typeof configOrContentDir === "string" ? { contentDir: configOrContentDir } : configOrContentDir;
const { contentDir, urlPattern = "/posts/{slug}/", enabled = true } = config;
return {
name: "inlinecms",
hooks: {
"astro:config:setup": ({ injectScript, command }) => {
if (command === "dev" && enabled) {
const clientScript = readFileSync(new URL("./client.js", import.meta.url), "utf8");
injectScript("page", `window.__INLINECMS_CONFIG__=${JSON.stringify({ urlPattern })};`);
injectScript("page", clientScript);
}
},
"astro:server:setup": ({ server }) => {
server.middlewares.use("/__save", async (req, res, next) => {
if (req.method !== "POST")
return next();
try {
const { path, html } = JSON.parse(await readBody(req));
const { fs, join, matter, require: require2 } = await getModules();
const TurndownService = require2("turndown");
const slug = path.split("/").filter(Boolean).at(-1);
const filePath = join(process.cwd(), contentDir, `${slug}.md`);
const parsed = matter(await fs.readFile(filePath, "utf-8"));
const td = new TurndownService({ headingStyle: "atx", bulletListMarker: "-" });
td.escape = (s) => s;
td.keep(["figure", "svg", "video"]);
td.addRule("astroImage", {
filter: (n) => n.nodeName === "IMG" && n.getAttribute("src")?.includes("/_image"),
replacement: (_, n) => {
let src = n.getAttribute("src") || "";
const alt = n.getAttribute("alt") || "";
if (src.includes("/_image?href=")) {
const href = new URL(src, "http://localhost").searchParams.get("href");
if (href) {
const match = decodeURIComponent(href).match(/\/@fs\/.*?\/src\/(.+?)(?:\?|$)/);
if (match?.[1])
src = `../../${match[1]}`;
}
}
return ``;
}
});
td.addRule("katexDisplay", {
filter: (n) => n.nodeName === "DIV" && n.classList?.contains("katex-display"),
replacement: (_, n) => {
const latex = n.querySelector('annotation[encoding="application/x-tex"]')?.textContent?.trim();
return latex ? `
$$
${latex}
$$
` : n.textContent;
}
});
td.addRule("katexInline", {
filter: (n) => n.nodeName === "SPAN" && n.classList?.contains("katex") && !n.classList?.contains("katex-display"),
replacement: (_, n) => {
const latex = n.querySelector('annotation[encoding="application/x-tex"]')?.textContent;
return latex ? `$${latex}$` : n.textContent;
}
});
td.addRule("code", {
filter: (n) => n.nodeName === "PRE" && n.firstChild?.nodeName === "CODE",
replacement: (_, n) => {
const code = n.firstChild.textContent?.replace(/\n$/, "") ?? "";
const lang = n.getAttribute("data-language") || n.firstChild.getAttribute("class")?.match(/language-(\w+)/)?.[1] || "";
return `
\`\`\`${lang}
${code}
\`\`\`
`;
}
});
const newContent = td.turndown(html).trim();
await fs.writeFile(filePath, matter.stringify(newContent, parsed.data), "utf-8");
res.writeHead(200);
res.end("OK");
} catch (err) {
console.error("[save error]", err);
res.writeHead(500);
res.end("error");
}
});
server.middlewares.use("/__upload", async (req, res, next) => {
if (req.method !== "POST")
return next();
try {
const crypto = await import("node:crypto");
const fs = await import("node:fs/promises");
const path = await import("node:path");
const uploadsDir = path.join(process.cwd(), "public", "uploads");
await fs.mkdir(uploadsDir, { recursive: true });
const buffer = Buffer.concat(await new Promise((resolve) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(chunks));
}));
const boundary = req.headers["content-type"]?.split("boundary=")[1];
if (!boundary) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "No boundary" }));
}
const parts = buffer.toString("binary").split(`--${boundary}`);
let fileBuffer = null;
let filename = "";
for (const part of parts) {
if (part.includes("filename=")) {
const headers = part.split(`\r
\r
`)[0];
const match = headers.match(/filename="([^"]+)"/);
filename = match?.[1] || "upload";
const dataStart = part.indexOf(`\r
\r
`) + 4;
const dataEnd = part.lastIndexOf(`\r
`);
fileBuffer = Buffer.from(part.slice(dataStart, dataEnd), "binary");
break;
}
}
if (!fileBuffer) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "No file" }));
}
const ext = path.extname(filename).toLowerCase();
const allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
if (!allowed.includes(ext)) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Invalid type" }));
}
if (fileBuffer.length > 10485760) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Too large" }));
}
const hash = crypto.createHash("md5").update(fileBuffer).digest("hex");
const slug = req.headers.referer?.split("/").filter(Boolean).at(-1) || "post";
const finalFilename = `${slug}-${hash}${ext}`;
await fs.writeFile(path.join(uploadsDir, finalFilename), fileBuffer);
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ url: `/uploads/${finalFilename}` }));
} catch (err) {
console.error("[upload error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "failed" }));
}
});
server.middlewares.use("/__create", async (req, res, next) => {
if (req.method !== "POST")
return next();
try {
const { title, slug, frontmatter } = JSON.parse(await readBody(req));
if (!title?.trim() || !slug?.trim()) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Title and slug required" }));
}
if (!/^[a-z0-9-]+$/.test(slug)) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "Invalid slug" }));
}
const { fs, join, yaml } = await getModules();
const filePath = join(process.cwd(), contentDir, `${slug}.md`);
try {
await fs.access(filePath);
res.writeHead(409);
return res.end(JSON.stringify({ error: "Post exists" }));
} catch {}
const schema = await inferSchema(contentDir);
const now = new Date;
const fm = processWithSchema({ title: title.trim(), date: new Date(now.toISOString().split("T")[0]), draft: false, ...frontmatter }, schema);
const content = `---
${yaml.dump(fm, { quotingType: '"', forceQuotes: false })}---
`;
await fs.writeFile(filePath, content, "utf-8");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true, path: urlPattern.replace("{slug}", slug) }));
} catch (err) {
console.error("[create error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed" }));
}
});
server.middlewares.use("/__delete", async (req, res, next) => {
if (req.method !== "POST")
return next();
try {
const { path } = JSON.parse(await readBody(req));
const { fs, join } = await getModules();
const slug = path.split("/").filter(Boolean).at(-1);
const filePath = join(process.cwd(), contentDir, `${slug}.md`);
try {
await fs.access(filePath);
} catch {
res.writeHead(404);
return res.end(JSON.stringify({ error: "Not found" }));
}
await fs.unlink(filePath);
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
} catch (err) {
console.error("[delete error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed" }));
}
});
server.middlewares.use("/__list", async (req, res, next) => {
if (req.method !== "GET")
return next();
try {
const { fs, join, matter } = await getModules();
const contentPath = join(process.cwd(), contentDir);
const files = await fs.readdir(contentPath);
const posts = await Promise.all(files.filter((f) => f.endsWith(".md")).map(async (file) => {
const parsed = matter(await fs.readFile(join(contentPath, file), "utf-8"));
const slug = file.replace(".md", "");
return { slug, title: parsed.data.title || slug, date: parsed.data.date || "", draft: parsed.data.draft || false, path: urlPattern.replace("{slug}", slug), frontmatter: parsed.data };
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ posts }));
} catch (err) {
console.error("[list error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed" }));
}
});
server.middlewares.use("/__schema", async (req, res, next) => {
if (req.method !== "GET")
return next();
try {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ schema: await inferSchema(contentDir) }));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed" }));
}
});
server.middlewares.use("/__update-frontmatter", async (req, res, next) => {
if (req.method !== "POST")
return next();
try {
const { path, frontmatter } = JSON.parse(await readBody(req));
const { fs, join, matter, yaml } = await getModules();
const slug = path.split("/").filter(Boolean).at(-1);
const filePath = join(process.cwd(), contentDir, `${slug}.md`);
try {
await fs.access(filePath);
} catch {
res.writeHead(404);
return res.end(JSON.stringify({ error: "Not found" }));
}
const parsed = matter(await fs.readFile(filePath, "utf-8"));
const schema = await inferSchema(contentDir);
const fm = processWithSchema(frontmatter, schema);
const newContent = `---
${yaml.dump(fm, { quotingType: '"', forceQuotes: false })}---
${parsed.content}`;
await fs.writeFile(filePath, newContent, "utf-8");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
} catch (err) {
console.error("[update error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed" }));
}
});
server.middlewares.use("/__get-frontmatter", async (req, res, next) => {
if (req.method !== "POST")
return next();
try {
const { path } = JSON.parse(await readBody(req));
const { fs, join, matter } = await getModules();
const slug = path.split("/").filter(Boolean).at(-1);
const filePath = join(process.cwd(), contentDir, `${slug}.md`);
try {
await fs.access(filePath);
} catch {
res.writeHead(404);
return res.end(JSON.stringify({ error: "Not found" }));
}
const parsed = matter(await fs.readFile(filePath, "utf-8"));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true, frontmatter: parsed.data }));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed" }));
}
});
}
}
};
}
export {
inlineCMS as default
};