create-volt 0.47.0 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.48.0] - 2026-06-29
8
+
9
+ ### Added
10
+ - **WYSIWYG editor in the config.** Manage content embeds the RTEPro rich editor
11
+ (loaded from CDN) instead of a raw textarea — visual editing, shell-gated, no
12
+ public route or auth. Opens markdown rendered to HTML, saves markdown (or HTML
13
+ for complex layouts), with a title field beside the slug.
14
+
7
15
  ## [0.47.0] - 2026-06-29
8
16
 
9
17
  ### Added
@@ -620,6 +628,7 @@ All notable changes to `create-volt` are documented here. The format follows
620
628
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
621
629
  and auto-detects npm / pnpm / yarn / bun for the install step.
622
630
 
631
+ [0.48.0]: https://github.com/MIR-2025/volt/releases/tag/v0.48.0
623
632
  [0.47.0]: https://github.com/MIR-2025/volt/releases/tag/v0.47.0
624
633
  [0.46.0]: https://github.com/MIR-2025/volt/releases/tag/v0.46.0
625
634
  [0.45.1]: https://github.com/MIR-2025/volt/releases/tag/v0.45.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,8 @@
40
40
  });
41
41
  })();
42
42
  </script>
43
+ <script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
44
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
43
45
  <script type="module" src="/setup.js"></script>
44
46
  </body>
45
47
  </html>
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
300
300
  }
301
301
  }
302
302
  const items = signal({ pages: [], posts: [] });
303
- const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
303
+ const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
304
+ let ed = null; // live RTEPro instance for the open editor
304
305
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
306
+ // raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
307
+ function parseDoc(raw) {
308
+ const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
309
+ const front = fm ? fm[1] : "";
310
+ const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
311
+ const body = fm ? raw.slice(fm[0].length) : raw;
312
+ const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
313
+ return { title, bodyHtml };
314
+ }
315
+ function mountEditor(bodyHtml) {
316
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
317
+ ed.setHTML(bodyHtml || "");
318
+ }
305
319
  async function editItem(type, slug) {
306
320
  const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
307
- editing({ type, slug, body: d.body || "", isNew: false });
321
+ const { title, bodyHtml } = parseDoc(d.body || "");
322
+ editing({ type, slug, title, isNew: false });
323
+ queueMicrotask(() => mountEditor(bodyHtml));
308
324
  }
309
325
  function newItem(type) {
310
- const body = type === "post" ? "---\ntitle: New Post\ndate: 2026-01-01\ncategory: \ntags: \n---\n\nWrite your post here.\n" : "---\ntitle: New Page\n---\n\nWrite your page here.\n";
311
- editing({ type, slug: "", body, isNew: true });
326
+ editing({ type, slug: "", title: "", isNew: true });
327
+ queueMicrotask(() => mountEditor(""));
328
+ }
329
+ // markdown can't round-trip complex layouts (columns, inline styles, merged cells,
330
+ // embeds) — save those as HTML so they aren't flattened.
331
+ function isComplex(h) {
332
+ return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
312
333
  }
313
334
  async function saveItem() {
314
335
  const e = editing();
315
336
  const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
316
- const body = document.querySelector("#mg-body").value;
317
337
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
338
+ const title = (document.querySelector("#mg-title").value || "").trim() || slug;
339
+ const htmlOut = ed ? ed.getHTML() : "";
340
+ const complex = isComplex(htmlOut);
341
+ const front = [`title: ${title}`];
342
+ if (complex) front.push("format: html");
343
+ const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
344
+ const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
318
345
  const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
319
346
  if (!r.ok) return status("Error: " + (r.error || "?"));
320
- status("Saved → " + r.file);
347
+ status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
348
+ ed = null;
321
349
  editing(null);
322
350
  loadItems();
323
351
  }
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
339
367
  ${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
340
368
  </div>`;
341
369
  const editorPanel = () => {
342
- const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
343
- return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
344
- <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
345
- <textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px" value=${e.body}></textarea>
370
+ const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
371
+ return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
372
+ <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
373
+ <div id="mg-editor"></div>
346
374
  <div class="mt-2 d-flex gap-2"><button class="btn btn-primary btn-sm" onclick=${saveItem}>Save</button><button class="btn btn-outline-secondary btn-sm" onclick=${() => editing(null)}>Cancel</button></div>
347
375
  </div>`;
348
376
  };
@@ -40,6 +40,8 @@
40
40
  });
41
41
  })();
42
42
  </script>
43
+ <script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
44
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
43
45
  <script type="module" src="/setup.js"></script>
44
46
  </body>
45
47
  </html>
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
300
300
  }
301
301
  }
302
302
  const items = signal({ pages: [], posts: [] });
303
- const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
303
+ const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
304
+ let ed = null; // live RTEPro instance for the open editor
304
305
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
306
+ // raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
307
+ function parseDoc(raw) {
308
+ const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
309
+ const front = fm ? fm[1] : "";
310
+ const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
311
+ const body = fm ? raw.slice(fm[0].length) : raw;
312
+ const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
313
+ return { title, bodyHtml };
314
+ }
315
+ function mountEditor(bodyHtml) {
316
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
317
+ ed.setHTML(bodyHtml || "");
318
+ }
305
319
  async function editItem(type, slug) {
306
320
  const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
307
- editing({ type, slug, body: d.body || "", isNew: false });
321
+ const { title, bodyHtml } = parseDoc(d.body || "");
322
+ editing({ type, slug, title, isNew: false });
323
+ queueMicrotask(() => mountEditor(bodyHtml));
308
324
  }
309
325
  function newItem(type) {
310
- const body = type === "post" ? "---\ntitle: New Post\ndate: 2026-01-01\ncategory: \ntags: \n---\n\nWrite your post here.\n" : "---\ntitle: New Page\n---\n\nWrite your page here.\n";
311
- editing({ type, slug: "", body, isNew: true });
326
+ editing({ type, slug: "", title: "", isNew: true });
327
+ queueMicrotask(() => mountEditor(""));
328
+ }
329
+ // markdown can't round-trip complex layouts (columns, inline styles, merged cells,
330
+ // embeds) — save those as HTML so they aren't flattened.
331
+ function isComplex(h) {
332
+ return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
312
333
  }
313
334
  async function saveItem() {
314
335
  const e = editing();
315
336
  const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
316
- const body = document.querySelector("#mg-body").value;
317
337
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
338
+ const title = (document.querySelector("#mg-title").value || "").trim() || slug;
339
+ const htmlOut = ed ? ed.getHTML() : "";
340
+ const complex = isComplex(htmlOut);
341
+ const front = [`title: ${title}`];
342
+ if (complex) front.push("format: html");
343
+ const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
344
+ const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
318
345
  const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
319
346
  if (!r.ok) return status("Error: " + (r.error || "?"));
320
- status("Saved → " + r.file);
347
+ status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
348
+ ed = null;
321
349
  editing(null);
322
350
  loadItems();
323
351
  }
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
339
367
  ${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
340
368
  </div>`;
341
369
  const editorPanel = () => {
342
- const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
343
- return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
344
- <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
345
- <textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px" value=${e.body}></textarea>
370
+ const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
371
+ return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
372
+ <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
373
+ <div id="mg-editor"></div>
346
374
  <div class="mt-2 d-flex gap-2"><button class="btn btn-primary btn-sm" onclick=${saveItem}>Save</button><button class="btn btn-outline-secondary btn-sm" onclick=${() => editing(null)}>Cancel</button></div>
347
375
  </div>`;
348
376
  };
@@ -40,6 +40,8 @@
40
40
  });
41
41
  })();
42
42
  </script>
43
+ <script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
44
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
43
45
  <script type="module" src="/setup.js"></script>
44
46
  </body>
45
47
  </html>
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
300
300
  }
301
301
  }
302
302
  const items = signal({ pages: [], posts: [] });
303
- const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
303
+ const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
304
+ let ed = null; // live RTEPro instance for the open editor
304
305
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
306
+ // raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
307
+ function parseDoc(raw) {
308
+ const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
309
+ const front = fm ? fm[1] : "";
310
+ const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
311
+ const body = fm ? raw.slice(fm[0].length) : raw;
312
+ const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
313
+ return { title, bodyHtml };
314
+ }
315
+ function mountEditor(bodyHtml) {
316
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
317
+ ed.setHTML(bodyHtml || "");
318
+ }
305
319
  async function editItem(type, slug) {
306
320
  const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
307
- editing({ type, slug, body: d.body || "", isNew: false });
321
+ const { title, bodyHtml } = parseDoc(d.body || "");
322
+ editing({ type, slug, title, isNew: false });
323
+ queueMicrotask(() => mountEditor(bodyHtml));
308
324
  }
309
325
  function newItem(type) {
310
- const body = type === "post" ? "---\ntitle: New Post\ndate: 2026-01-01\ncategory: \ntags: \n---\n\nWrite your post here.\n" : "---\ntitle: New Page\n---\n\nWrite your page here.\n";
311
- editing({ type, slug: "", body, isNew: true });
326
+ editing({ type, slug: "", title: "", isNew: true });
327
+ queueMicrotask(() => mountEditor(""));
328
+ }
329
+ // markdown can't round-trip complex layouts (columns, inline styles, merged cells,
330
+ // embeds) — save those as HTML so they aren't flattened.
331
+ function isComplex(h) {
332
+ return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
312
333
  }
313
334
  async function saveItem() {
314
335
  const e = editing();
315
336
  const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
316
- const body = document.querySelector("#mg-body").value;
317
337
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
338
+ const title = (document.querySelector("#mg-title").value || "").trim() || slug;
339
+ const htmlOut = ed ? ed.getHTML() : "";
340
+ const complex = isComplex(htmlOut);
341
+ const front = [`title: ${title}`];
342
+ if (complex) front.push("format: html");
343
+ const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
344
+ const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
318
345
  const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
319
346
  if (!r.ok) return status("Error: " + (r.error || "?"));
320
- status("Saved → " + r.file);
347
+ status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
348
+ ed = null;
321
349
  editing(null);
322
350
  loadItems();
323
351
  }
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
339
367
  ${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
340
368
  </div>`;
341
369
  const editorPanel = () => {
342
- const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
343
- return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
344
- <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
345
- <textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px" value=${e.body}></textarea>
370
+ const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
371
+ return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
372
+ <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
373
+ <div id="mg-editor"></div>
346
374
  <div class="mt-2 d-flex gap-2"><button class="btn btn-primary btn-sm" onclick=${saveItem}>Save</button><button class="btn btn-outline-secondary btn-sm" onclick=${() => editing(null)}>Cancel</button></div>
347
375
  </div>`;
348
376
  };
@@ -40,6 +40,8 @@
40
40
  });
41
41
  })();
42
42
  </script>
43
+ <script src="https://cdn.jsdelivr.net/npm/rte-rich-text-editor-pro@1.0.22/rte-pro.js"></script>
44
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
43
45
  <script type="module" src="/setup.js"></script>
44
46
  </body>
45
47
  </html>
@@ -300,24 +300,52 @@ async function buyCredits(amountUsd) {
300
300
  }
301
301
  }
302
302
  const items = signal({ pages: [], posts: [] });
303
- const editing = signal(null); // { type, slug, body, isNew } — set only on open/save/close, so typing doesn't re-render
303
+ const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
304
+ let ed = null; // live RTEPro instance for the open editor
304
305
  const loadItems = async () => items(await (await fetch("/setup/content")).json());
306
+ // raw .md → { title, bodyHtml } for the WYSIWYG (markdown rendered to HTML)
307
+ function parseDoc(raw) {
308
+ const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
309
+ const front = fm ? fm[1] : "";
310
+ const title = ((front.match(/^title:\s*(.+)$/m) || [])[1] || "").trim();
311
+ const body = fm ? raw.slice(fm[0].length) : raw;
312
+ const bodyHtml = /^format:\s*html\s*$/m.test(front) ? body : window.marked.parse(body);
313
+ return { title, bodyHtml };
314
+ }
315
+ function mountEditor(bodyHtml) {
316
+ ed = window.RTEPro.init("#mg-editor", { height: "60vh", placeholder: "Write…" });
317
+ ed.setHTML(bodyHtml || "");
318
+ }
305
319
  async function editItem(type, slug) {
306
320
  const d = await (await fetch(`/setup/content/raw?type=${type}&slug=${encodeURIComponent(slug)}`)).json();
307
- editing({ type, slug, body: d.body || "", isNew: false });
321
+ const { title, bodyHtml } = parseDoc(d.body || "");
322
+ editing({ type, slug, title, isNew: false });
323
+ queueMicrotask(() => mountEditor(bodyHtml));
308
324
  }
309
325
  function newItem(type) {
310
- const body = type === "post" ? "---\ntitle: New Post\ndate: 2026-01-01\ncategory: \ntags: \n---\n\nWrite your post here.\n" : "---\ntitle: New Page\n---\n\nWrite your page here.\n";
311
- editing({ type, slug: "", body, isNew: true });
326
+ editing({ type, slug: "", title: "", isNew: true });
327
+ queueMicrotask(() => mountEditor(""));
328
+ }
329
+ // markdown can't round-trip complex layouts (columns, inline styles, merged cells,
330
+ // embeds) — save those as HTML so they aren't flattened.
331
+ function isComplex(h) {
332
+ return /\bstyle\s*=\s*["'][^"']*(text-align|column|float|grid|flex|width|height|color|background|font|margin|padding)/i.test(h) || /\b(colspan|rowspan)\b/i.test(h) || /<(u|font|mark|sub|sup|iframe|video|audio|figure)\b/i.test(h) || /class\s*=\s*["'][^"']*(col|grid|row|flex|layout)/i.test(h);
312
333
  }
313
334
  async function saveItem() {
314
335
  const e = editing();
315
336
  const slug = (document.querySelector("#mg-slug").value || "").trim().toLowerCase();
316
- const body = document.querySelector("#mg-body").value;
317
337
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return status("Slug must be lowercase letters, numbers, hyphens.");
338
+ const title = (document.querySelector("#mg-title").value || "").trim() || slug;
339
+ const htmlOut = ed ? ed.getHTML() : "";
340
+ const complex = isComplex(htmlOut);
341
+ const front = [`title: ${title}`];
342
+ if (complex) front.push("format: html");
343
+ const docBody = complex ? htmlOut : ed ? ed.getMarkdown() : "";
344
+ const body = `---\n${front.join("\n")}\n---\n\n${docBody}\n`;
318
345
  const r = await (await fetch("/setup/content/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: e.type, slug, body }) })).json();
319
346
  if (!r.ok) return status("Error: " + (r.error || "?"));
320
- status("Saved → " + r.file);
347
+ status("Saved → " + r.file + (complex ? " (HTML — complex layout)" : ""));
348
+ ed = null;
321
349
  editing(null);
322
350
  loadItems();
323
351
  }
@@ -339,10 +367,10 @@ const section = (label, type, key) =>
339
367
  ${() => (items()[key].length ? html`<ul class="list-group">${items()[key].map(itemRow)}</ul>` : html`<div class="small text-muted">No ${key} yet.</div>`)}
340
368
  </div>`;
341
369
  const editorPanel = () => {
342
- const e = editing(); // inputs are uncontrolled (read on Save) so typing never re-renders
343
- return html`<div class="p-3 mb-2" style="border:1px solid #232a36;border-radius:10px">
344
- <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
345
- <textarea id="mg-body" class="form-control" rows="16" style="font-family:ui-monospace,monospace;font-size:13px" value=${e.body}></textarea>
370
+ const e = editing(); // inputs uncontrolled (read on Save); RTEPro mounts into #mg-editor
371
+ return html`<div class="p-3 mb-2" style="border:1px solid var(--border,#232a36);border-radius:10px">
372
+ <div class="d-flex gap-2 mb-2"><input id="mg-slug" class="form-control" placeholder="slug" value=${e.slug} readonly=${!e.isNew} style="max-width:200px" /><input id="mg-title" class="form-control" placeholder="Title" value=${e.title || ""} /><span class="align-self-center small text-muted">${e.type === "post" ? "posts/" : "pages/"}</span></div>
373
+ <div id="mg-editor"></div>
346
374
  <div class="mt-2 d-flex gap-2"><button class="btn btn-primary btn-sm" onclick=${saveItem}>Save</button><button class="btn btn-outline-secondary btn-sm" onclick=${() => editing(null)}>Cancel</button></div>
347
375
  </div>`;
348
376
  };