UNPKG

inlinecms

Version:

Real-time inline CMS for Astro with post management, frontmatter editing, and live preview

642 lines (640 loc) 27.1 kB
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 `![${alt}](${originalPath})`; } }); 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 };