inlinecms
Version:
Real-time inline CMS for Astro with post management, frontmatter editing, and live preview
1,174 lines (1,168 loc) • 42.8 kB
JavaScript
var __defProp = Object.defineProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
});
};
// src/plugins.ts
var exports_plugins = {};
__export(exports_plugins, {
setupPostManagement: () => setupPostManagement,
setupImagePlugin: () => setupImagePlugin
});
function showToast(type, title, message) {
let container = document.querySelector(".cms-toasts");
if (!container) {
container = document.createElement("div");
container.className = "cms-toasts";
document.body.appendChild(container);
}
const icons = { success: "✅", error: "❌", warning: "⚠️", info: "ℹ️" };
const toast = document.createElement("div");
toast.className = `cms-toast cms-toast-${type}`;
toast.innerHTML = `
<span>${icons[type]}</span>
<div><b>${title}</b><p>${message}</p></div>
<button onclick="this.parentElement.remove()">×</button>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
if (!document.querySelector("#cms-toast-styles")) {
const style = document.createElement("style");
style.id = "cms-toast-styles";
style.textContent = `
.cms-toasts { position: fixed; top: 80px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 10px; }
.cms-toast { display: flex; gap: 12px; align-items: center; background: white; border-radius: 8px; padding: 12px 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); min-width: 300px; animation: slideIn 0.3s; }
.cms-toast span { font-size: 20px; }
.cms-toast div { flex: 1; }
.cms-toast b { font-size: 14px; display: block; margin-bottom: 4px; }
.cms-toast p { font-size: 13px; color: #666; margin: 0; }
.cms-toast button { background: none; border: none; font-size: 20px; cursor: pointer; color: #999; }
.cms-toast-success { border-left: 4px solid #10b981; }
.cms-toast-error { border-left: 4px solid #ef4444; }
slideIn { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
`;
document.head.appendChild(style);
}
}
function ensureModalStyles() {
if (!document.querySelector("#cms-modal-styles")) {
const style = document.createElement("style");
style.id = "cms-modal-styles";
style.textContent = `
.cms-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 10001; animation: fadeIn 0.2s; }
.cms-modal-content { background: white; border-radius: 12px; padding: 24px; max-width: 500px; width: 90%; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); }
.cms-modal-content h3 { margin: 0 0 16px 0; font-size: 20px; }
.cms-modal-body { margin-bottom: 20px; }
.cms-modal-buttons { display: flex; gap: 12px; justify-content: flex-end; }
.cms-btn { padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 14px; font-weight: 500; }
.cms-btn-primary { background: #3b82f6; color: white; }
.cms-btn-secondary { background: #f1f5f9; color: #64748b; }
fadeIn { from { opacity: 0; } to { opacity: 1; } }
`;
document.head.appendChild(style);
}
}
function createModalContainer() {
ensureModalStyles();
const modal = document.createElement("div");
modal.className = "cms-modal";
const s = modal.style;
s.setProperty("position", "fixed", "important");
s.setProperty("inset", "0px", "important");
s.setProperty("background", "rgba(0,0,0,0.5)", "important");
s.setProperty("display", "flex", "important");
s.setProperty("align-items", "center", "important");
s.setProperty("justify-content", "center", "important");
s.setProperty("z-index", "10001", "important");
return modal;
}
function showModal(title, content, onConfirm) {
const modal = createModalContainer();
modal.innerHTML = `
<div class="cms-modal-content">
<h3>${title}</h3>
<div class="cms-modal-body">${content}</div>
<div class="cms-modal-buttons">
<button class="cms-btn cms-btn-secondary" data-action="cancel">Cancel</button>
${onConfirm ? '<button class="cms-btn cms-btn-primary" data-action="confirm">Confirm</button>' : ""}
</div>
</div>
`;
modal.querySelector('[data-action="cancel"]').addEventListener("click", () => modal.remove());
if (onConfirm) {
modal.querySelector('[data-action="confirm"]').addEventListener("click", () => {
onConfirm();
modal.remove();
});
}
document.body.appendChild(modal);
}
class PostAPI {
async list() {
const res = await fetch("/__list");
const data = await res.json();
return data.posts || [];
}
async create(title, slug, frontmatter = {}) {
const res = await fetch("/__create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, slug, frontmatter })
});
return res.json();
}
async delete(path) {
const res = await fetch("/__delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path })
});
return res.json();
}
async getFrontmatter(path) {
const res = await fetch("/__get-frontmatter", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path })
});
const data = await res.json();
return data.frontmatter || {};
}
async updateFrontmatter(path, frontmatter) {
await fetch("/__update-frontmatter", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, frontmatter })
});
}
async getSchema() {
const res = await fetch("/__schema");
const data = await res.json();
return data.schema || {};
}
}
function setupImagePlugin(editor) {
const root = editor.root;
const insertAtPoint = (e, node) => {
const anyDoc = document;
let range = null;
if (anyDoc.caretRangeFromPoint)
range = anyDoc.caretRangeFromPoint(e.clientX, e.clientY);
else if (anyDoc.caretPositionFromPoint) {
const pos = anyDoc.caretPositionFromPoint(e.clientX, e.clientY);
if (pos) {
range = document.createRange();
range.setStart(pos.offsetNode, pos.offset);
}
}
if (range) {
range.collapse(true);
range.insertNode(node);
} else {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const r = sel.getRangeAt(0);
r.deleteContents();
r.insertNode(node);
} else {
root.appendChild(node);
}
}
};
const handleDrop = async (e) => {
e.preventDefault();
root.classList.remove("cms-drag-over");
const files = Array.from(e.dataTransfer?.files || []).filter((f) => f.type.startsWith("image/"));
if (files.length > 0) {
for (const file of files)
await uploadImage(file, file.name, e);
return;
}
const uri = e.dataTransfer?.getData("text/uri-list") || "";
if (uri && /^https?:\/\//.test(uri)) {
try {
const res = await fetch(uri, { mode: "cors" });
const blob = await res.blob();
const name = uri.split("/").pop() || "image";
await uploadImage(blob, name, e);
return;
} catch {
editor.updateStatus({ state: "error", text: "❌ Image fetch failed" });
}
}
};
const uploadImage = async (fileOrBlob, suggestedName, e) => {
editor.updateStatus({ state: "saving", text: "\uD83D\uDCC1 Uploading..." });
const formData = new FormData;
let file;
if (fileOrBlob instanceof File)
file = fileOrBlob;
else
file = new File([fileOrBlob], suggestedName || "image.png", { type: fileOrBlob.type || "image/png" });
formData.append("file", file);
try {
const res = await fetch("/__upload", { method: "POST", body: formData });
const { url } = await res.json();
const img = document.createElement("img");
img.src = url;
img.alt = file.name;
img.style.maxWidth = "100%";
if (e)
insertAtPoint(e, img);
else {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(img);
} else {
root.appendChild(img);
}
}
editor.updateStatus({ state: "saved", text: "✅ Uploaded" });
setTimeout(() => editor.updateStatus({ state: "editing", text: "✏️ Editing" }), 1500);
editor.debouncedSave();
} catch (err) {
editor.updateStatus({ state: "error", text: "❌ Upload failed" });
}
};
root.addEventListener("dragover", (e) => {
e.preventDefault();
root.classList.add("cms-drag-over");
});
root.addEventListener("dragleave", () => root.classList.remove("cms-drag-over"));
root.addEventListener("drop", handleDrop);
root.addEventListener("paste", (e) => {
const item = Array.from(e.clipboardData?.items || []).find((i) => i.type.startsWith("image/"));
if (item) {
e.preventDefault();
const file = item.getAsFile();
if (file)
uploadImage(file);
}
});
}
function setupPostManagement() {
if (document.querySelector(".cms-sidebar"))
return;
const api = new PostAPI;
const sidebar = document.createElement("div");
sidebar.className = "cms-sidebar";
try {
sidebar.setAttribute("transition:persist", "inlinecms-sidebar");
} catch {}
sidebar.innerHTML = `
<div class="cms-sidebar-header">
<div class="cms-brand"><span class="cms-brand-dot"></span>InlineCMS</div>
<div class="cms-grip" aria-hidden>⋮⋮</div>
</div>
<div class="cms-sidebar-group">
<button class="cms-sidebar-btn" data-action="new"><span class="cms-ico">+</span><span>New</span></button>
<button class="cms-sidebar-btn" data-action="list"><span class="cms-ico">☰</span><span>All Posts</span></button>
<button class="cms-sidebar-btn" data-action="frontmatter"><span class="cms-ico">⚙︎</span><span>Settings</span></button>
</div>
<div class="cms-sidebar-sep"></div>
<button class="cms-sidebar-btn cms-btn-danger" data-action="delete"><span class="cms-ico">✕</span><span>Delete</span></button>
`;
document.body.appendChild(sidebar);
try {
const saved = localStorage.getItem("cms-sidebar-pos");
if (saved) {
const { left, top } = JSON.parse(saved);
if (typeof left === "number" && typeof top === "number") {
sidebar.style.left = `${left}px`;
sidebar.style.top = `${top}px`;
sidebar.style.transform = "";
}
}
} catch {}
const handleNew = () => {
const modal = createModalContainer();
modal.innerHTML = `
<div class="cms-modal-content">
<h3>Create New Post</h3>
<div class="cms-modal-body">
<label>Title: <input type="text" id="post-title" style="width: 100%; padding: 8px; margin-top: 4px; border: 1px solid #ddd; border-radius: 4px;"></label>
<label style="margin-top: 12px; display: block;">Slug: <input type="text" id="post-slug" style="width: 100%; padding: 8px; margin-top: 4px; border: 1px solid #ddd; border-radius: 4px;"></label>
</div>
<div class="cms-modal-buttons">
<button class="cms-btn cms-btn-secondary" data-action="cancel">Cancel</button>
<button class="cms-btn cms-btn-primary" data-action="create">Create</button>
</div>
</div>
`;
modal.querySelector('[data-action="cancel"]').addEventListener("click", () => modal.remove());
modal.querySelector('[data-action="create"]').addEventListener("click", async () => {
const title = document.getElementById("post-title").value;
const slug = document.getElementById("post-slug").value;
if (!title || !slug)
return;
const result = await api.create(title, slug);
if (result.success && result.path) {
showToast("success", "Created", "Post created successfully");
window.location.href = result.path;
} else {
showToast("error", "Error", result.error || "Failed to create post");
}
modal.remove();
});
document.body.appendChild(modal);
};
const handleList = async () => {
const posts = await api.list();
const modal = createModalContainer();
modal.innerHTML = `
<div class="cms-modal-content" style="max-width: 700px;">
<h3>All Posts (${posts.length})</h3>
<div class="cms-modal-body" style="max-height: 400px; overflow-y: auto;">
${posts.map((p) => `
<div style="padding: 12px; border-bottom: 1px solid #eee; cursor: pointer; display: flex; justify-content: space-between; align-items: center;" onclick="window.location.href='${p.path}'">
<div>
<b>${p.title}</b>
<div style="font-size: 12px; color: #666;">${p.slug} • ${p.draft ? "Draft" : "Published"}</div>
</div>
<div style="font-size: 12px; color: #999;">${p.date}</div>
</div>
`).join("")}
</div>
<div class="cms-modal-buttons">
<button class="cms-btn cms-btn-secondary" data-action="close">Close</button>
</div>
</div>
`;
modal.querySelector('[data-action="close"]').addEventListener("click", () => modal.remove());
document.body.appendChild(modal);
};
const handleFrontmatter = async () => {
const fm = await api.getFrontmatter(location.pathname);
const schema = await api.getSchema();
const modal = createModalContainer();
const fields = Object.entries(fm).map(([key, val]) => `
<label style="display: block; margin-bottom: 12px;">
${key}:
<input type="${schema[key] === "date" ? "date" : "text"}"
data-key="${key}"
value="${val}"
style="width: 100%; padding: 8px; margin-top: 4px; border: 1px solid #ddd; border-radius: 4px;">
</label>
`).join("");
modal.innerHTML = `
<div class="cms-modal-content">
<h3>Edit Frontmatter</h3>
<div class="cms-modal-body" style="max-height: 400px; overflow-y: auto;">${fields}</div>
<div class="cms-modal-buttons">
<button class="cms-btn cms-btn-secondary" data-action="cancel">Cancel</button>
<button class="cms-btn cms-btn-primary" data-action="save">Save</button>
</div>
</div>
`;
modal.querySelector('[data-action="cancel"]').addEventListener("click", () => modal.remove());
modal.querySelector('[data-action="save"]').addEventListener("click", async () => {
const inputs = modal.querySelectorAll("input[data-key]");
const newFm = {};
inputs.forEach((input) => {
newFm[input.dataset.key] = input.value;
});
await api.updateFrontmatter(location.pathname, newFm);
showToast("success", "Saved", "Frontmatter updated");
modal.remove();
setTimeout(() => location.reload(), 1000);
});
document.body.appendChild(modal);
};
const handleDelete = async () => {
showModal("Delete Post?", "This action cannot be undone.", async () => {
await api.delete(location.pathname);
showToast("success", "Deleted", "Post deleted");
setTimeout(() => window.location.href = "/", 1500);
});
};
sidebar.querySelector('[data-action="new"]').addEventListener("click", handleNew);
sidebar.querySelector('[data-action="list"]').addEventListener("click", handleList);
sidebar.querySelector('[data-action="frontmatter"]').addEventListener("click", handleFrontmatter);
sidebar.querySelector('[data-action="delete"]').addEventListener("click", handleDelete);
const header = sidebar.querySelector(".cms-sidebar-header");
let dragging = false;
let startX = 0, startY = 0;
let startLeft = 0, startTop = 0;
const onPointerDown = (e) => {
if (e.button !== 0 && e.pointerType !== "touch")
return;
const rect = sidebar.getBoundingClientRect();
dragging = true;
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
header.setPointerCapture?.(e.pointerId);
sidebar.classList.add("cms-sidebar-dragging");
document.body.style.userSelect = "none";
};
const onPointerMove = (e) => {
if (!dragging)
return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const rect = sidebar.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = startLeft + dx;
let top = startTop + dy;
left = Math.max(8, Math.min(vw - width - 8, left));
top = Math.max(8, Math.min(vh - height - 8, top));
sidebar.style.left = `${left}px`;
sidebar.style.top = `${top}px`;
sidebar.style.transform = "";
};
const endDrag = (e) => {
if (!dragging)
return;
dragging = false;
try {
e && header.releasePointerCapture?.(e.pointerId);
} catch {}
sidebar.classList.remove("cms-sidebar-dragging");
document.body.style.userSelect = "";
const left = parseFloat(sidebar.style.left || "20");
const top = parseFloat(sidebar.style.top || "0");
try {
localStorage.setItem("cms-sidebar-pos", JSON.stringify({ left, top }));
} catch {}
};
header.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", endDrag);
document.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.key === "n" && !e.shiftKey) {
e.preventDefault();
handleNew();
}
if (e.ctrlKey && e.shiftKey && e.key === "L") {
e.preventDefault();
handleList();
}
if (e.ctrlKey && e.shiftKey && e.key === "F") {
e.preventDefault();
handleFrontmatter();
}
if (e.ctrlKey && e.shiftKey && e.key === "D") {
e.preventDefault();
handleDelete();
}
});
if (!document.querySelector("#cms-sidebar-styles")) {
const style = document.createElement("style");
style.id = "cms-sidebar-styles";
style.textContent = `
:root { --cms-accent: #6366f1; }
.cms-sidebar { position: fixed; left: 20px; top: 50%; transform: translateY(-50%); display: flex; flex-direction: column; gap: 10px; min-width: 200px; z-index: 9998; padding: 14px; border-radius: 16px; background: rgba(255,255,255,0.75); border: 1px solid rgba(100,116,139,0.18); box-shadow: 0 12px 30px rgba(2,6,23,0.12); backdrop-filter: blur(12px) saturate(1.2); }
(prefers-color-scheme: dark) { .cms-sidebar { background: rgba(15,23,42,0.55); border-color: rgba(148,163,184,0.18); box-shadow: 0 12px 34px rgba(0,0,0,0.45); } }
.cms-sidebar:hover { box-shadow: 0 16px 36px rgba(2,6,23,0.16); }
.cms-sidebar-header { display: flex; align-items: center; justify-content: space-between; cursor: grab; user-select: none; }
.cms-sidebar-dragging .cms-sidebar-header { cursor: grabbing; }
.cms-brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 14px; letter-spacing: .2px; }
.cms-brand-dot { width: 8px; height: 8px; border-radius: 50%; background: linear-gradient(135deg, var(--cms-accent), #22d3ee); box-shadow: 0 0 0 3px rgba(99,102,241,0.18); }
.cms-grip { font-size: 14px; color: #94a3b8; letter-spacing: 2px; }
(prefers-color-scheme: dark) { .cms-grip { color: #64748b; } }
.cms-sidebar-group { display: flex; flex-direction: column; gap: 8px; }
.cms-sidebar-sep { height: 1px; background: linear-gradient(90deg, transparent, rgba(148,163,184,0.35), transparent); margin: 2px 2px 0; }
.cms-sidebar-btn { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid transparent; background: linear-gradient(180deg, rgba(241,245,249,0.8), rgba(241,245,249,0.6)); color: #0f172a; font-size: 13px; font-weight: 600; cursor: pointer; transition: transform .08s ease, background .2s ease, border-color .2s ease, box-shadow .2s ease; text-align: left; }
.cms-sidebar-btn:hover { transform: translateY(-1px); background: linear-gradient(180deg, rgba(226,232,240,0.9), rgba(226,232,240,0.7)); border-color: rgba(99,102,241,0.35); box-shadow: 0 6px 14px rgba(2,6,23,0.06); }
.cms-sidebar-btn:active { transform: translateY(0); }
.cms-ico { display:inline-flex; align-items:center; justify-content:center; width: 22px; height: 22px; border-radius: 7px; background: linear-gradient(135deg, rgba(99,102,241,0.18), rgba(99,102,241,0.08)); color: var(--cms-accent); font-size: 12px; }
(prefers-color-scheme: dark) { .cms-sidebar-btn { background: linear-gradient(180deg, rgba(30,41,59,0.9), rgba(30,41,59,0.7)); color: #e2e8f0; } .cms-sidebar-btn:hover { background: linear-gradient(180deg, rgba(51,65,85,0.95), rgba(30,41,59,0.8)); } }
.cms-btn-danger { background: linear-gradient(180deg, rgba(254,226,226,0.9), rgba(254,226,226,0.7)) !important; color: #b91c1c; }
.cms-btn-danger:hover { background: linear-gradient(180deg, rgba(254,202,202,0.95), rgba(254,202,202,0.8)) !important; border-color: rgba(239,68,68,0.35); }
(prefers-color-scheme: dark) { .cms-btn-danger { background: linear-gradient(180deg, rgba(127,29,29,0.65), rgba(69,10,10,0.65)) !important; color: #fecaca; } }
.cms-drag-over { border-color: var(--cms-accent) !important; background: rgba(99,102,241,0.08) !important; }
`;
document.head.appendChild(style);
}
}
// src/inlinecms.ts
function getCurrentElement() {
const sel = window.getSelection();
return sel?.anchorNode ?? null;
}
function insertText(text) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0)
return;
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(document.createTextNode(text));
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
function getCursorPos(el) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0)
return 0;
const range = sel.getRangeAt(0);
const preRange = range.cloneRange();
preRange.selectNodeContents(el);
preRange.setEnd(range.endContainer, range.endOffset);
return preRange.toString().length;
}
function setCursorPos(el, pos) {
const sel = window.getSelection();
if (!sel)
return;
let charCount = 0;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let node;
while (node = walker.nextNode()) {
const len = node.textContent?.length || 0;
if (charCount + len >= pos) {
const range2 = document.createRange();
range2.setStart(node, pos - charCount);
range2.collapse(true);
sel.removeAllRanges();
sel.addRange(range2);
return;
}
charCount += len;
}
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
class InlineCMS {
root;
config;
isEditing = false;
saveTimeout = null;
originalContent = "";
hasUnsavedChanges = false;
history = [];
historyIndex = -1;
historyTimeout = null;
statusEl;
statusText;
MAX_RETRIES = 3;
AUTO_SAVE_DELAY = 2000;
HISTORY_SIZE = 50;
constructor(root, config) {
this.root = root;
this.config = config;
this.statusEl = this.createStatus();
this.statusText = this.statusEl.querySelector(".status-text");
this.init();
}
init() {
this.root.contentEditable = "true";
this.root.style.outline = "none";
this.addStyles();
this.originalContent = this.root.innerHTML;
this.saveHistory();
this.root.addEventListener("focus", () => this.onFocus());
this.root.addEventListener("blur", () => this.onBlur());
this.root.addEventListener("input", () => this.onInput());
this.root.addEventListener("keydown", (e) => this.onKey(e));
this.setupPlugins();
}
onFocus() {
this.isEditing = true;
this.root.classList.add("editing");
this.updateStatus("editing", this.hasUnsavedChanges ? "✏️ Editing (unsaved)" : "✏️ Editing");
}
onBlur() {
this.isEditing = false;
this.root.classList.remove("editing");
this.updateStatus("idle", this.hasUnsavedChanges ? "⚠️ Unsaved" : "");
if (this.hasUnsavedChanges) {
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
this.save();
}
}
onInput() {
this.checkChanges();
this.updateStatus("typing", "✏️ Typing...");
this.saveHistory();
this.debounceSave();
}
onKey(e) {
if (e.ctrlKey || e.metaKey) {
if (e.key === "s") {
e.preventDefault();
this.save();
return true;
}
if (e.key === "z" && !e.shiftKey) {
e.preventDefault();
this.undo();
return true;
}
if (e.key === "z" && e.shiftKey) {
e.preventDefault();
this.redo();
return true;
}
if (e.key === "y") {
e.preventDefault();
this.redo();
return true;
}
if (e.key === "b") {
e.preventDefault();
document.execCommand("bold");
return true;
}
if (e.key === "i") {
e.preventDefault();
document.execCommand("italic");
return true;
}
if (e.key === "k") {
e.preventDefault();
this.createLink();
return true;
}
}
const el = getCurrentElement();
if (e.key === "Backspace" && window.getSelection()?.anchorOffset === 0) {
const h = el?.parentElement?.closest("h1,h2,h3,h4,h5,h6");
if (h) {
e.preventDefault();
const level = parseInt(h.tagName[1]);
const content = h.innerHTML;
const newTag = level === 1 ? "p" : `h${level - 1}`;
const newEl = document.createElement(newTag);
newEl.innerHTML = content;
h.replaceWith(newEl);
if (newEl.firstChild) {
const range = document.createRange();
range.setStart(newEl.firstChild, 0);
range.collapse(true);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
}
this.debounceSave();
return true;
}
}
if (e.key === "Tab") {
const li = this.findLI(el);
if (li) {
e.preventDefault();
e.shiftKey ? this.outdentLI(li) : this.indentLI(li);
this.debounceSave();
return true;
}
}
const code = this.findCode(el);
if (code) {
if (e.key === "Enter") {
e.preventDefault();
insertText(`
`);
return true;
}
if (e.key === "Tab") {
e.preventDefault();
e.shiftKey ? this.unindentCode(code) : insertText(" ");
return true;
}
if (e.key.length === 1 || e.key === "Delete" || e.key === "Backspace") {
setTimeout(() => this.cleanCode(code), 0);
}
}
return false;
}
saveHistory(immediate = false) {
if (this.historyTimeout)
clearTimeout(this.historyTimeout);
const doSave = () => {
const content = this.root.innerHTML;
const cur = this.history[this.historyIndex];
if (cur && cur.content === content)
return;
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push({
content,
timestamp: Date.now(),
cursorPosition: getCursorPos(this.root)
});
this.historyIndex++;
if (this.history.length > this.HISTORY_SIZE) {
this.history.shift();
this.historyIndex--;
}
};
immediate ? doSave() : this.historyTimeout = window.setTimeout(doSave, 300);
}
undo() {
if (this.historyIndex <= 0)
return;
this.historyIndex--;
const entry = this.history[this.historyIndex];
this.root.innerHTML = entry.content;
setCursorPos(this.root, entry.cursorPosition);
this.debounceSave();
this.updateStatus("editing", "↶ Undone");
setTimeout(() => this.updateStatus("editing", "✏️ Editing"), 1500);
}
redo() {
if (this.historyIndex >= this.history.length - 1)
return;
this.historyIndex++;
const entry = this.history[this.historyIndex];
this.root.innerHTML = entry.content;
setCursorPos(this.root, entry.cursorPosition);
this.debounceSave();
this.updateStatus("editing", "↷ Redone");
setTimeout(() => this.updateStatus("editing", "✏️ Editing"), 1500);
}
checkChanges() {
this.hasUnsavedChanges = this.root.innerHTML !== this.originalContent;
if (this.hasUnsavedChanges) {
window.addEventListener("beforeunload", this.beforeUnload);
} else {
window.removeEventListener("beforeunload", this.beforeUnload);
}
}
beforeUnload = (e) => {
if (this.hasUnsavedChanges) {
e.preventDefault();
e.returnValue = "";
return "";
}
};
updateStatus(state, text) {
this.statusText.textContent = text;
const warn = this.hasUnsavedChanges && (state === "idle" || state === "editing");
this.statusEl.className = warn ? "cms-status cms-status-warning" : `cms-status cms-status-${state}`;
this.statusEl.style.display = state === "idle" && !this.hasUnsavedChanges ? "none" : "block";
}
debouncedSave() {
this.debounceSave();
}
debounceSave() {
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
this.saveTimeout = window.setTimeout(() => this.save(), this.AUTO_SAVE_DELAY);
}
async save(retry = 0) {
this.updateStatus("saving", retry > 0 ? `\uD83D\uDCBE Retry ${retry}/${this.MAX_RETRIES}` : "\uD83D\uDCBE Saving...");
try {
const res = await fetch("/__save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: location.pathname, html: this.root.innerHTML })
});
if (res.ok) {
this.originalContent = this.root.innerHTML;
this.hasUnsavedChanges = false;
window.removeEventListener("beforeunload", this.beforeUnload);
this.updateStatus("saved", "✅ Saved");
setTimeout(() => {
const state = this.isEditing ? "editing" : "idle";
const text = this.isEditing ? "✏️ Editing" : "";
this.updateStatus(state, text);
}, 1500);
} else if (retry < this.MAX_RETRIES) {
setTimeout(() => this.save(retry + 1), Math.pow(2, retry) * 1000);
} else {
this.updateStatus("error", "❌ Failed");
this.showRetryDialog();
}
} catch (err) {
if (retry < this.MAX_RETRIES) {
setTimeout(() => this.save(retry + 1), Math.pow(2, retry) * 1000);
} else {
this.updateStatus("error", "❌ Failed");
this.showRetryDialog();
}
}
}
findLI(node) {
while (node) {
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === "LI") {
return node;
}
const li = node.closest?.("li");
if (li)
return li;
node = node.parentNode;
}
return null;
}
indentLI(li) {
const prev = li.previousElementSibling;
if (!prev)
return;
const parent = li.parentElement;
let nested = prev.querySelector(":scope > ul, :scope > ol");
if (!nested) {
nested = document.createElement(parent.tagName.toLowerCase());
prev.appendChild(nested);
}
nested.appendChild(li);
}
outdentLI(li) {
const parent = li.parentElement;
const grandLI = parent.parentElement?.closest("li");
if (!grandLI)
return;
const greatList = grandLI.parentElement;
greatList.insertBefore(li, grandLI.nextSibling);
if (parent.children.length === 0)
parent.remove();
}
findCode(node) {
while (node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node;
if (el.tagName === "CODE" && el.closest("pre"))
return el;
}
node = node.parentNode;
}
return null;
}
cleanCode(code) {
const pos = this.getCursorOffsetIn(code);
const txt = code.textContent || "";
code.innerHTML = "";
code.appendChild(document.createTextNode(txt));
this.setCursorOffsetIn(code, pos);
}
unindentCode(code) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0)
return;
const range = sel.getRangeAt(0);
const text = range.startContainer.textContent || "";
const offset = range.startOffset;
let start = offset;
while (start > 0 && text[start - 1] !== `
`)
start--;
let spaces = 0;
for (let i = start;i < text.length && i < start + 4; i++) {
if (text[i] === " ")
spaces++;
else
break;
}
if (spaces > 0) {
range.startContainer.textContent = text.substring(0, start) + text.substring(start + spaces);
range.setStart(range.startContainer, Math.max(start, offset - spaces));
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}
getCursorOffsetIn(el) {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0)
return 0;
const range = sel.getRangeAt(0);
let offset = 0;
if (range.startContainer.nodeType === Node.TEXT_NODE) {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let node;
while (node = walker.nextNode()) {
if (node === range.startContainer) {
offset += range.startOffset;
break;
}
offset += node.textContent?.length || 0;
}
}
return offset;
}
setCursorOffsetIn(el, offset) {
const sel = window.getSelection();
if (!sel)
return;
const text = el.firstChild;
if (text && text.nodeType === Node.TEXT_NODE) {
const range = document.createRange();
const safe = Math.min(offset, text.textContent?.length || 0);
range.setStart(text, safe);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}
setupPlugins() {
const math = this.root.querySelectorAll(".katex, .MathJax");
math.forEach((m) => {
const el = m;
el.contentEditable = "false";
el.style.cursor = "pointer";
el.onclick = () => this.editMath(el);
});
new MutationObserver((muts) => {
muts.forEach((m) => {
m.addedNodes.forEach((n) => {
if (n.nodeType === Node.ELEMENT_NODE) {
const el = n;
if (el.classList?.contains("katex") || el.classList?.contains("MathJax")) {
el.contentEditable = "false";
el.style.cursor = "pointer";
el.onclick = () => this.editMath(el);
}
}
});
});
}).observe(this.root, { childList: true, subtree: true });
Promise.resolve().then(() => exports_plugins).then(({ setupImagePlugin: setupImagePlugin2, setupPostManagement: setupPostManagement2 }) => {
setupImagePlugin2(this);
setupPostManagement2();
});
}
editMath(el) {
const ann = el.querySelector('annotation[encoding="application/x-tex"]');
const src = ann?.textContent || "";
const newLatex = prompt("Edit LaTeX:", src);
if (newLatex === null || newLatex === src)
return;
const isDisplay = el.closest(".katex-display") !== null;
const delim = isDisplay ? "$$" : "$";
el.replaceWith(document.createTextNode(`${delim}${newLatex}${delim}`));
this.debounceSave();
}
createLink() {
const sel = window.getSelection();
if (!sel)
return;
const text = sel.toString();
const url = prompt("URL:", "https://");
if (!url)
return;
if (text) {
document.execCommand("createLink", false, url);
} else {
const linkText = prompt("Link text:", url);
if (!linkText)
return;
const a = document.createElement("a");
a.href = url;
a.textContent = linkText;
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.insertNode(a);
range.setStartAfter(a);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}
this.debounceSave();
}
createStatus() {
const el = document.createElement("div");
el.className = "cms-status cms-status-idle";
el.style.display = "none";
el.innerHTML = '<div class="status-main"><span class="status-text"></span></div>';
document.body.appendChild(el);
return el;
}
showRetryDialog() {
const dlg = document.createElement("div");
dlg.className = "cms-retry-dialog";
dlg.innerHTML = `
<div class="cms-retry-content">
<h3>⚠️ Save Failed</h3>
<p>Unable to save. Check your connection.</p>
<div class="cms-retry-buttons">
<button class="cms-retry-btn">Retry</button>
<button class="cms-dismiss-btn">Dismiss</button>
</div>
</div>
`;
dlg.querySelector(".cms-retry-btn").addEventListener("click", () => {
dlg.remove();
this.save();
});
dlg.querySelector(".cms-dismiss-btn").addEventListener("click", () => dlg.remove());
document.body.appendChild(dlg);
setTimeout(() => dlg.remove(), 1e4);
}
addStyles() {
const s = document.createElement("style");
s.textContent = `
[data-markdown] {
transition: all 0.2s;
border: 2px dashed transparent;
border-radius: 8px;
padding: 16px;
margin: -16px;
min-height: 50px;
}
[data-markdown]:hover {
border-color: #e2e8f0;
background: rgba(59,130,246,0.02);
}
[data-markdown]:focus, .editing {
outline: none;
border-color: #3b82f6 !important;
background: rgba(59,130,246,0.05) !important;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.cms-status {
position: fixed;
top: 20px;
right: 20px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
z-index: 9999;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.2);
min-width: 100px;
}
.status-main {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 14px;
}
.cms-status-idle { background: #f1f5f9; color: #64748b; opacity: 0.8; }
.cms-status-editing { background: #3b82f6; color: white; }
.cms-status-saving { background: #f59e0b; color: white; }
.cms-status-saved { background: #10b981; color: white; }
.cms-status-typing { background: #6366f1; color: white; }
.cms-status-warning { background: #f59e0b; color: white; animation: pulse 2s infinite; }
.cms-status-error { background: #ef4444; color: white; }
.cms-retry-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.cms-retry-content {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1);
max-width: 400px;
text-align: center;
}
.cms-retry-content h3 { margin: 0 0 12px 0; font-size: 18px; color: #dc2626; }
.cms-retry-content p { margin: 0 0 20px 0; color: #64748b; }
.cms-retry-buttons { display: flex; gap: 12px; justify-content: center; }
.cms-retry-btn {
background: #3b82f6;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.cms-retry-btn:hover { background: #2563eb; }
.cms-dismiss-btn {
background: #f1f5f9;
color: #64748b;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.cms-dismiss-btn:hover { background: #e2e8f0; }
pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
`;
document.head.appendChild(s);
}
destroy() {
if (this.saveTimeout)
clearTimeout(this.saveTimeout);
if (this.historyTimeout)
clearTimeout(this.historyTimeout);
window.removeEventListener("beforeunload", this.beforeUnload);
this.statusEl.remove();
}
}
// src/main.ts
if (typeof window !== "undefined") {
let initTimer = null;
const initAll = async () => {
const roots = document.querySelectorAll("[data-markdown]");
roots.forEach((root) => {
const el = root;
if (el.dataset.inlinecmsInitialized === "true")
return;
el.dataset.inlinecmsInitialized = "true";
new InlineCMS(el, {
contentDir: window.__INLINECMS_CONFIG__?.contentDir || "src/content/blog",
autosaveDelay: window.__INLINECMS_CONFIG__?.autosaveDelay || 2000,
enabled: window.__INLINECMS_CONFIG__?.enabled !== false
});
});
try {
const mod = await Promise.resolve().then(() => exports_plugins);
mod.setupPostManagement?.();
} catch {}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAll);
} else {
initAll();
}
document.addEventListener("astro:page-load", () => initAll());
document.addEventListener("astro:after-swap", () => initAll());
document.addEventListener("astro:before-swap", (ev) => {
const doc = ev?.newDocument;
if (!doc)
return;
if (!doc.querySelector(".cms-sidebar")) {
const placeholder = doc.createElement("div");
placeholder.className = "cms-sidebar";
try {
placeholder.setAttribute("transition:persist", "inlinecms-sidebar");
} catch {}
placeholder.style.display = "none";
doc.body.appendChild(placeholder);
}
});
const mo = new MutationObserver(() => {
if (initTimer)
window.clearTimeout(initTimer);
initTimer = window.setTimeout(() => initAll(), 50);
});
mo.observe(document.documentElement, { childList: true, subtree: true });
const navEvent = () => {
if (initTimer)
window.clearTimeout(initTimer);
initTimer = window.setTimeout(() => initAll(), 0);
};
window.addEventListener("popstate", navEvent);
const _ps = history.pushState;
history.pushState = function(...args) {
const r = _ps.apply(this, args);
navEvent();
return r;
};
const _rs = history.replaceState;
history.replaceState = function(...args) {
const r = _rs.apply(this, args);
navEvent();
return r;
};
}
export {
InlineCMS
};