inlinecms
Version:
Real-time inline CMS for Astro with post management, frontmatter editing, and live preview
642 lines (640 loc) • 27.1 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 inferFrontmatterSchema(contentDir) {
try {
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"));
const matter = require2("gray-matter");
const contentPath = join(process.cwd(), contentDir);
const files = await fs.readdir(contentPath);
const mdFiles = files.filter((file) => file.endsWith(".md")).slice(0, 5);
const schema = {};
for (const file of mdFiles) {
const filePath = join(contentPath, file);
const content = await fs.readFile(filePath, "utf-8");
const parsed = matter(content);
for (const [key, value] of Object.entries(parsed.data)) {
if (!schema[key]) {
schema[key] = new Set;
}
if (value instanceof Date) {
schema[key].add("date");
} else if (typeof value === "boolean") {
schema[key].add("boolean");
} else if (typeof value === "number") {
schema[key].add("number");
} else if (Array.isArray(value)) {
schema[key].add("array");
} else if (typeof value === "string") {
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
schema[key].add("date");
} else {
schema[key].add("string");
}
} else {
schema[key].add("object");
}
}
}
const finalSchema = {};
for (const [key, types] of Object.entries(schema)) {
const typeArray = Array.from(types);
if (typeArray.includes("date")) {
finalSchema[key] = "date";
} else if (typeArray.length === 1) {
finalSchema[key] = typeArray[0];
} else {
finalSchema[key] = "string";
}
}
return finalSchema;
} catch (error) {
console.warn("[schema inference error]", error);
return {};
}
}
function processFrontmatterWithSchema(frontmatter, schema) {
const processed = {};
for (const [key, value] of Object.entries(frontmatter)) {
if (value === undefined || value === null) {
processed[key] = value;
continue;
}
const expectedType = schema[key];
switch (expectedType) {
case "date":
if (value instanceof Date) {
processed[key] = new Date(value.toISOString().split("T")[0]);
} else if (typeof value === "string") {
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
const dateString = date.toISOString().split("T")[0];
processed[key] = new Date(dateString);
} else {
processed[key] = value;
}
} catch {
processed[key] = value;
}
} else {
processed[key] = value;
}
break;
case "boolean":
if (typeof value === "string") {
processed[key] = value.toLowerCase() === "true";
} else {
processed[key] = Boolean(value);
}
break;
case "number":
if (typeof value === "string") {
const num = Number(value);
processed[key] = isNaN(num) ? value : num;
} else {
processed[key] = value;
}
break;
case "array":
if (!Array.isArray(value)) {
if (typeof value === "string") {
try {
processed[key] = value.split(",").map((item) => item.trim());
} catch {
processed[key] = [value];
}
} else {
processed[key] = [value];
}
} else {
processed[key] = value;
}
break;
default:
processed[key] = value;
}
}
return processed;
}
function editableIntegration(configOrContentDir) {
const config = typeof configOrContentDir === "string" ? { contentDir: configOrContentDir } : configOrContentDir;
const {
contentDir,
urlPattern = "/posts/{slug}/",
enabled = true
} = config;
return {
name: "astro-editable-dev",
hooks: {
"astro:config:setup": ({ injectScript, command }) => {
if (command === "dev" && enabled) {
const clientScript = readFileSync(new URL("./client.js", import.meta.url), "utf8");
const configScript = `
window.__INLINECMS_CONFIG__ = ${JSON.stringify({ urlPattern })};
`;
injectScript("page", configScript);
injectScript("page", clientScript);
}
},
"astro:server:setup": ({ server }) => {
server.middlewares.use("/__save", async (req, res, next) => {
if (req.method !== "POST")
return next();
let body = "";
req.on("data", (chunk) => body += chunk);
req.on("end", async () => {
try {
const { path, html } = JSON.parse(body);
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"));
const TurndownService = require2("turndown");
const matter = require2("gray-matter");
const slug = path.split("/").filter(Boolean).at(-1);
const filePath = join(process.cwd(), contentDir, `${slug}.md`);
const md = await fs.readFile(filePath, "utf-8");
const parsed = matter(md);
const td = new TurndownService({
headingStyle: "atx",
bulletListMarker: "-"
});
td.escape = (s) => s;
td.addRule("astroImage", {
filter: (node) => {
return node.nodeName === "IMG" && node.getAttribute("src") && (node.getAttribute("src").includes("/_image?href=") || node.getAttribute("src").includes("/@fs/"));
},
replacement: (_, node) => {
const src = node.getAttribute("src") || "";
const alt = node.getAttribute("alt") || "";
let originalPath = src;
if (src.includes("/_image?href=")) {
try {
const url = new URL(src, "http://localhost");
const href = url.searchParams.get("href");
if (href) {
const decodedHref = decodeURIComponent(href);
if (decodedHref.includes("/@fs/")) {
const fsMatch = decodedHref.match(/\/@fs\/.*?\/src\/(.+?)(?:\?|$)/);
if (fsMatch && fsMatch[1]) {
const relativePath = fsMatch[1];
originalPath = `../../${relativePath}`;
}
}
}
} catch (e) {}
} else if (src.includes("/@fs/")) {
const fsMatch = src.match(/\/@fs\/.*?\/src\/(.+?)(?:\?|$)/);
if (fsMatch && fsMatch[1]) {
const relativePath = fsMatch[1];
originalPath = `../../${relativePath}`;
}
}
return ``;
}
});
td.addRule("fencedCodeBlock", {
filter: (node) => node.nodeName === "PRE" && node.firstChild?.nodeName === "CODE",
replacement: (_, node) => {
const codeNode = node.firstChild;
const code = codeNode.textContent?.replace(/\n$/, "") ?? "";
let lang = node.getAttribute("data-language") || "";
if (!lang) {
const className = codeNode.getAttribute("class") || "";
const match = className.match(/language-(\w+)/);
lang = match ? match[1] : "";
}
return `
\`\`\`${lang}
${code}
\`\`\`
`;
}
});
const newContent = td.turndown(html).trim();
const updated = matter.stringify(newContent, parsed.data);
await fs.writeFile(filePath, updated, "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 chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
const boundary = req.headers["content-type"]?.split("boundary=")[1];
if (!boundary) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "No boundary found" }));
}
const boundaryBuffer = Buffer.from(`--${boundary}`);
let fileBuffer = null;
let filename = "";
let startIndex = buffer.indexOf(boundaryBuffer);
while (startIndex !== -1) {
const nextBoundaryIndex = buffer.indexOf(boundaryBuffer, startIndex + boundaryBuffer.length);
const partBuffer = nextBoundaryIndex !== -1 ? buffer.subarray(startIndex, nextBoundaryIndex) : buffer.subarray(startIndex);
const headersEndIndex = partBuffer.indexOf(Buffer.from(`\r
\r
`));
if (headersEndIndex !== -1) {
const headers = partBuffer.subarray(0, headersEndIndex).toString();
if (headers.includes("Content-Disposition: form-data") && headers.includes("filename=")) {
const lines = headers.split(`\r
`);
const dispositionLine = lines.find((line) => line.includes("filename="));
if (dispositionLine) {
const match = dispositionLine.match(/filename="([^"]+)"/);
filename = match ? match[1] : "upload";
}
const binaryDataStart = headersEndIndex + 4;
let binaryData = partBuffer.subarray(binaryDataStart);
if (binaryData.length >= 2 && binaryData[binaryData.length - 2] === 13 && binaryData[binaryData.length - 1] === 10) {
binaryData = binaryData.subarray(0, -2);
}
fileBuffer = binaryData;
break;
}
}
startIndex = nextBoundaryIndex;
}
if (!fileBuffer) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "No file found" }));
}
const ext = path.extname(filename).toLowerCase();
const allowedTypes = [
".jpg",
".jpeg",
".png",
".gif",
".webp",
".svg"
];
const maxSize = 10485760;
if (!allowedTypes.includes(ext)) {
res.writeHead(400);
return res.end(JSON.stringify({
error: "File type not allowed. Use: " + allowedTypes.join(", ")
}));
}
if (fileBuffer.length > maxSize) {
res.writeHead(400);
return res.end(JSON.stringify({ error: "File too large. Max size: 10MB" }));
}
const hash = crypto.createHash("md5").update(fileBuffer).digest("hex");
const fileExt = ext || ".jpg";
const referer = req.headers.referer || "";
const slug = referer.split("/").filter(Boolean).at(-1) || "post";
const finalFilename = `${slug}-${hash}${fileExt}`;
const dest = path.join(uploadsDir, finalFilename);
await fs.writeFile(dest, fileBuffer);
const url = `/uploads/${finalFilename}`;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ url }));
} catch (error) {
console.error("[upload processing error]", error);
res.writeHead(500);
res.end(JSON.stringify({ error: "Processing failed" }));
}
});
} catch (err) {
console.error("[upload error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "upload failed" }));
}
});
server.middlewares.use("/__create", async (req, res, next) => {
if (req.method !== "POST")
return next();
let body = "";
req.on("data", (chunk) => body += chunk);
req.on("end", async () => {
try {
let requestData;
try {
requestData = JSON.parse(body);
} catch (parseError) {
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON in request body" }));
return;
}
const { title, slug, frontmatter } = requestData;
if (!title || typeof title !== "string" || !title.trim()) {
res.writeHead(400);
res.end(JSON.stringify({
error: "Title is required and must be a non-empty string"
}));
return;
}
if (!slug || typeof slug !== "string" || !slug.trim()) {
res.writeHead(400);
res.end(JSON.stringify({
error: "Slug is required and must be a non-empty string"
}));
return;
}
const cleanSlug = slug.trim();
if (!/^[a-z0-9-]+$/.test(cleanSlug)) {
res.writeHead(400);
res.end(JSON.stringify({
error: "Slug can only contain lowercase letters, numbers, and hyphens"
}));
return;
}
if (frontmatter && (typeof frontmatter !== "object" || Array.isArray(frontmatter))) {
res.writeHead(400);
res.end(JSON.stringify({
error: "Frontmatter must be a valid object"
}));
return;
}
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"));
const matter = require2("gray-matter");
const filePath = join(process.cwd(), contentDir, `${cleanSlug}.md`);
try {
await fs.access(filePath);
res.writeHead(409);
res.end(JSON.stringify({
error: `A post with slug "${cleanSlug}" already exists. Please choose a different slug.`
}));
return;
} catch {}
const contentPath = join(process.cwd(), contentDir);
try {
await fs.access(contentPath);
} catch {
res.writeHead(500);
res.end(JSON.stringify({
error: `Content directory "${contentDir}" does not exist. Please check your configuration.`
}));
return;
}
const schema = await inferFrontmatterSchema(contentDir);
const now = new Date;
const defaultFrontmatter = {
title: title.trim(),
date: new Date(now.toISOString().split("T")[0]),
draft: false,
...frontmatter
};
const processedFrontmatter = processFrontmatterWithSchema(defaultFrontmatter, schema);
try {
JSON.stringify(processedFrontmatter);
} catch (stringifyError) {
res.writeHead(400);
res.end(JSON.stringify({
error: "Invalid frontmatter: contains non-serializable values"
}));
return;
}
const yaml = require2("js-yaml");
const yamlContent = yaml.dump(processedFrontmatter, {
quotingType: '"',
forceQuotes: false,
schema: yaml.DEFAULT_SCHEMA
});
const content = `---
${yamlContent}---
`;
await fs.writeFile(filePath, content, "utf-8");
const postUrl = urlPattern.replace("{slug}", cleanSlug);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: true,
path: postUrl,
filePath: filePath.replace(process.cwd(), ""),
message: "Post created successfully"
}));
} catch (err) {
console.error("[create error]", err);
let errorMessage = "Failed to create post";
if (err.code === "EACCES") {
errorMessage = "Permission denied: Cannot write to content directory";
} else if (err.code === "ENOSPC") {
errorMessage = "Insufficient disk space to create post";
} else if (err.code === "EMFILE" || err.code === "ENFILE") {
errorMessage = "Too many open files: Please try again";
} else if (err instanceof SyntaxError) {
errorMessage = "Invalid content format: Please check your frontmatter";
}
res.writeHead(500);
res.end(JSON.stringify({
error: errorMessage,
details: err.message
}));
}
});
});
server.middlewares.use("/__delete", async (req, res, next) => {
if (req.method !== "POST")
return next();
let body = "";
req.on("data", (chunk) => body += chunk);
req.on("end", async () => {
try {
const { path } = JSON.parse(body);
const fs = await import("node:fs/promises");
const { join } = await import("node:path");
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);
res.end(JSON.stringify({ error: "Post not found" }));
return;
}
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 to delete post" }));
}
});
});
server.middlewares.use("/__list", async (req, res, next) => {
if (req.method !== "GET")
return next();
try {
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"));
const matter = require2("gray-matter");
const contentPath = join(process.cwd(), contentDir);
const files = await fs.readdir(contentPath);
const mdFiles = files.filter((file) => file.endsWith(".md"));
const posts = await Promise.all(mdFiles.map(async (file) => {
const filePath = join(contentPath, file);
const content = await fs.readFile(filePath, "utf-8");
const parsed = matter(content);
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 to list posts" }));
}
});
server.middlewares.use("/__schema", async (req, res, next) => {
if (req.method !== "GET")
return next();
try {
const schema = await inferFrontmatterSchema(contentDir);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ schema }));
} catch (err) {
console.error("[schema error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed to infer schema" }));
}
});
server.middlewares.use("/__update-frontmatter", async (req, res, next) => {
if (req.method !== "POST")
return next();
let body = "";
req.on("data", (chunk) => body += chunk);
req.on("end", async () => {
try {
const { path, frontmatter } = JSON.parse(body);
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"));
const matter = require2("gray-matter");
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);
res.end(JSON.stringify({ error: "Post not found" }));
return;
}
const currentContent = await fs.readFile(filePath, "utf-8");
const parsed = matter(currentContent);
const schema = await inferFrontmatterSchema(contentDir);
const processedFrontmatter = processFrontmatterWithSchema(frontmatter, schema);
const yaml = require2("js-yaml");
const yamlContent = yaml.dump(processedFrontmatter, {
quotingType: '"',
forceQuotes: false,
schema: yaml.DEFAULT_SCHEMA
});
const newContent = `---
${yamlContent}---
${parsed.content}`;
await fs.writeFile(filePath, newContent, "utf-8");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: true,
message: "Frontmatter updated successfully"
}));
} catch (err) {
console.error("[frontmatter update error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed to update frontmatter" }));
}
});
});
server.middlewares.use("/__get-frontmatter", async (req, res, next) => {
if (req.method !== "POST")
return next();
let body = "";
req.on("data", (chunk) => body += chunk);
req.on("end", async () => {
try {
const { path } = JSON.parse(body);
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"));
const matter = require2("gray-matter");
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);
res.end(JSON.stringify({ error: "Post not found" }));
return;
}
const content = await fs.readFile(filePath, "utf-8");
const parsed = matter(content);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
success: true,
frontmatter: parsed.data
}));
} catch (err) {
console.error("[get frontmatter error]", err);
res.writeHead(500);
res.end(JSON.stringify({ error: "Failed to get frontmatter" }));
}
});
});
}
}
};
}
export {
editableIntegration as default
};