@broberg/cms-inline-edit 0.4.20 → 0.5.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/README.md CHANGED
@@ -36,3 +36,56 @@ import { saveInlineEditField, verifyEditSession } from "@broberg/cms-inline-edit
36
36
  ```
37
37
 
38
38
  See `docs/features/F157-inline-editing.md` in the [@webhouse/cms](https://github.com/webhousecode/cms) repo for the full design.
39
+
40
+ ## Links to a page (live references)
41
+
42
+ The toolbar's link button offers a **free URL** or **a page on the site**. A page
43
+ link stores a reference next to a real, working href:
44
+
45
+ ```html
46
+ <a href="/da/om-sanne" data-cms-ref="sider:om-sanne" data-cms-ref-label="auto">Om Sanne</a>
47
+ ```
48
+
49
+ Render it through `resolveCmsLinks()` and the link re-points itself when the page
50
+ moves or is renamed — nothing rewrites stored content:
51
+
52
+ ```ts
53
+ import { resolveCmsLinks } from "@broberg/cms-inline-edit/server";
54
+
55
+ const html = resolveCmsLinks(renderMarkdown(doc.body), (collection, slug) => {
56
+ const page = findPage(collection, slug);
57
+ return page ? { url: page.path, title: page.title } : null;
58
+ });
59
+ ```
60
+
61
+ `data-cms-ref-label="auto"` (set when the editor left the link text empty) also
62
+ follows the page's current title. An unknown reference keeps the href it has, so
63
+ a deleted page degrades to the last known link rather than a dead one — and a
64
+ site that never calls the resolver still ships working links.
65
+
66
+ The picker's page list comes from `GET /api/inline-edit/pages?site=<id>`
67
+ (published documents only).
68
+
69
+ ## Markup in a PLAIN field — use `data-cms-token`
70
+
71
+ **Which save path a fix covers matters.** The serializer fixes in 0.4.21
72
+ (orphaned `<li>`) and 0.4.23 (top-level inline formatting, dropped images) live
73
+ in the **rich** path — a field marked `data-cms-richtext="true"`, serialised
74
+ through `htmlToMarkdown`.
75
+
76
+ A **plain** field is different: it saves `el.textContent`, and `textContent` has
77
+ no asterisks. So Markdown markup inside a plain field is lost on save, and
78
+ **0.4.23 does not change that.** If you are carrying a `data-cms-token`
79
+ workaround for markup in a plain field, KEEP IT after upgrading — it is not dead
80
+ code (reported by the sanneandersen session, 2026-08-17).
81
+
82
+ The supported way to keep markup in a plain field is `data-cms-token` on the
83
+ formatted segment, carrying the raw source:
84
+
85
+ ```html
86
+ <span data-cms-token="**Alt fra Blad**"><strong>Alt fra Blad</strong></span>
87
+ ```
88
+
89
+ `serializeTokenSafe()` round-trips those segments verbatim, so the stored value
90
+ keeps its `**…**` while the page shows the rendered form. Use the rich path
91
+ (`data-cms-richtext="true"`) when a field should support formatting generally.
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  buildConnectUrl: () => buildConnectUrl,
25
25
  disconnect: () => disconnect,
26
26
  getConnectedToken: () => getConnectedToken,
27
+ htmlToMarkdown: () => htmlToMarkdown,
27
28
  initInlineEdit: () => initInlineEdit,
28
29
  rescanFields: () => rescanFields
29
30
  });
@@ -39,6 +40,36 @@ function applyFieldSlice(current, original, next) {
39
40
  return current.slice(0, first) + next + current.slice(first + original.length);
40
41
  }
41
42
 
43
+ // src/token-safe.ts
44
+ var TEXT_NODE = 3;
45
+ var ELEMENT_NODE = 1;
46
+ function hasTokenChips(el) {
47
+ return el.querySelector("[data-cms-token]") !== null;
48
+ }
49
+ function serializeTokenSafe(el) {
50
+ let out = "";
51
+ el.childNodes.forEach((node) => {
52
+ if (node.nodeType === TEXT_NODE) {
53
+ out += node.textContent ?? "";
54
+ return;
55
+ }
56
+ if (node.nodeType !== ELEMENT_NODE) return;
57
+ const element = node;
58
+ const token = element.getAttribute("data-cms-token");
59
+ if (token) {
60
+ out += token;
61
+ } else {
62
+ out += serializeTokenSafe(element);
63
+ }
64
+ });
65
+ return out;
66
+ }
67
+ function lockTokenChips(el) {
68
+ el.querySelectorAll("[data-cms-token]").forEach((chip) => {
69
+ chip.setAttribute("contenteditable", "false");
70
+ });
71
+ }
72
+
42
73
  // src/index.ts
43
74
  var DEFAULT_LABELS = {
44
75
  bold: "Fed",
@@ -51,7 +82,27 @@ var DEFAULT_LABELS = {
51
82
  done: "F\xE6rdig",
52
83
  saving: "Gemmer\u2026",
53
84
  saved: "Gemt \u2713",
54
- error: "Fejl \u2014 pr\xF8v igen"
85
+ error: "Fejl \u2014 pr\xF8v igen",
86
+ link: "Link",
87
+ linkTabPage: "Side p\xE5 sitet",
88
+ linkTabUrl: "Fri adresse",
89
+ linkSearch: "S\xF8g efter en side\u2026",
90
+ linkText: "Linktekst",
91
+ linkTextAuto: "F\xF8lger sidens titel",
92
+ linkTextAutoHint: "<b>Tom = f\xF8lger sidens titel.</b> Omd\xF8bes siden, retter teksten sig selv. Skriver du din egen tekst, bliver den st\xE5ende.",
93
+ linkTextOwnHint: "Linket viser <b>din egen tekst</b>. Den bliver st\xE5ende, ogs\xE5 hvis siden omd\xF8bes.",
94
+ linkLiveHint: "Linket peger p\xE5 <b>siden</b> \u2014 ikke p\xE5 en adresse. Flyttes siden, eller f\xE5r den et nyt navn, retter linket sig selv.",
95
+ linkUrlHint: "En fri adresse peger pr\xE6cis d\xE9r, du skriver. Den f\xF8lger <b>ikke</b> med, hvis m\xE5let flytter sig.",
96
+ linkUrl: "Adresse",
97
+ linkInsert: "Inds\xE6t link",
98
+ linkSave: "Gem \xE6ndring",
99
+ linkCancel: "Annull\xE9r",
100
+ linkRemove: "Fjern link",
101
+ linkRemoveConfirm: "Fjern?",
102
+ linkYes: "Ja",
103
+ linkNo: "Nej",
104
+ linkEmpty: "Ingen sider fundet",
105
+ linkLoading: "Henter sider\u2026"
55
106
  };
56
107
  var uiLabels = DEFAULT_LABELS;
57
108
  function resolveOptions(options) {
@@ -153,6 +204,7 @@ function isExpired(token) {
153
204
  var SQUARE_PEN_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>';
154
205
  var UL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>';
155
206
  var OL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>';
207
+ var LINK_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>';
156
208
  function makeIcon() {
157
209
  const icon = document.createElement("span");
158
210
  icon.style.cssText = "display:inline-flex;line-height:0;flex:0 0 auto";
@@ -259,14 +311,16 @@ function wireField(el, token, options) {
259
311
  if (el.getAttribute("contenteditable") === "true") return;
260
312
  e.preventDefault();
261
313
  e.stopPropagation();
262
- el.dataset.cmsOriginalValue = el.textContent ?? "";
314
+ const tokenSafe = hasTokenChips(el);
315
+ el.dataset.cmsOriginalValue = tokenSafe ? serializeTokenSafe(el) : el.textContent ?? "";
263
316
  el.setAttribute("contenteditable", "true");
317
+ if (tokenSafe) lockTokenChips(el);
264
318
  el.focus();
265
319
  });
266
320
  el.addEventListener("blur", () => {
267
321
  el.removeAttribute("contenteditable");
268
322
  const original = el.dataset.cmsOriginalValue ?? "";
269
- const current = el.textContent ?? "";
323
+ const current = hasTokenChips(el) ? serializeTokenSafe(el) : el.textContent ?? "";
270
324
  if (current.trim() === original.trim()) return;
271
325
  void saveField(el, current.trim(), token, options);
272
326
  });
@@ -309,6 +363,7 @@ function deactivateRich() {
309
363
  el.removeAttribute("contenteditable");
310
364
  el.classList.remove("cms-rich-editing");
311
365
  hideRichToolbar();
366
+ hideLinkDialog();
312
367
  if (el.innerHTML !== originalHtml) {
313
368
  const value = mode === "html" ? el.innerHTML : htmlToMarkdown(el.innerHTML);
314
369
  void saveField(el, value, token, options);
@@ -354,6 +409,10 @@ function buildRichToolbar() {
354
409
  t.appendChild(toolbarButton(UL_SVG, uiLabels.unorderedList, () => document.execCommand("insertUnorderedList")));
355
410
  t.appendChild(toolbarButton(OL_SVG, uiLabels.orderedList, () => document.execCommand("insertOrderedList")));
356
411
  t.appendChild(sep());
412
+ const linkBtn = toolbarButton(LINK_SVG, uiLabels.link, () => toggleLinkDialog(linkBtn));
413
+ linkBtn.setAttribute("data-testid", "inline-toolbar-link");
414
+ t.appendChild(linkBtn);
415
+ t.appendChild(sep());
357
416
  const clrLabel = document.createElement("label");
358
417
  clrLabel.style.cssText = "display:flex;align-items:center;gap:5px;color:#9aa4b2;font-size:12px;cursor:pointer;";
359
418
  clrLabel.textContent = uiLabels.color;
@@ -387,6 +446,231 @@ var EMOJIS = "\u{1F600} \u{1F603} \u{1F604} \u{1F601} \u{1F606} \u{1F609} \u{1F6
387
446
  );
388
447
  var emojiPicker = null;
389
448
  var savedRange = null;
449
+ var linkDialog = null;
450
+ var linkPages = null;
451
+ var linkPicked = null;
452
+ var linkEditing = null;
453
+ function hideLinkDialog() {
454
+ if (linkDialog) linkDialog.style.display = "none";
455
+ linkPicked = null;
456
+ linkEditing = null;
457
+ }
458
+ function anchorAtCaret() {
459
+ const sel = window.getSelection();
460
+ if (!sel || sel.rangeCount === 0) return null;
461
+ let node = sel.getRangeAt(0).startContainer;
462
+ while (node && node !== document.body) {
463
+ if (node.nodeType === Node.ELEMENT_NODE && node.tagName === "A") {
464
+ return node;
465
+ }
466
+ node = node.parentNode;
467
+ }
468
+ return null;
469
+ }
470
+ async function fetchLinkablePages() {
471
+ if (linkPages) return linkPages;
472
+ if (!richCtx) return [];
473
+ const { options, token } = richCtx;
474
+ const url = `${options.cmsBaseUrl}/api/inline-edit/pages?site=${encodeURIComponent(options.siteId)}`;
475
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
476
+ if (!res.ok) throw new Error(`pages ${res.status}`);
477
+ const body = await res.json();
478
+ linkPages = body.pages ?? [];
479
+ return linkPages;
480
+ }
481
+ function toggleLinkDialog(anchor) {
482
+ if (linkDialog && linkDialog.style.display === "block") {
483
+ hideLinkDialog();
484
+ return;
485
+ }
486
+ const sel = window.getSelection();
487
+ if (sel && sel.rangeCount > 0) savedRange = sel.getRangeAt(0).cloneRange();
488
+ linkEditing = anchorAtCaret();
489
+ if (!linkDialog) linkDialog = buildLinkDialog();
490
+ renderLinkDialog();
491
+ const rect = anchor.getBoundingClientRect();
492
+ linkDialog.style.top = `${rect.bottom + 8}px`;
493
+ linkDialog.style.left = `${Math.min(Math.max(8, rect.left - 180), window.innerWidth - 400)}px`;
494
+ linkDialog.style.display = "block";
495
+ }
496
+ function buildLinkDialog() {
497
+ const d = document.createElement("div");
498
+ d.setAttribute("data-cms-inline-edit-toolbar", "");
499
+ d.setAttribute("data-testid", "inline-link-dialog");
500
+ d.style.cssText = "position:fixed;z-index:2147483646;background:#1c2027;border:1px solid #3a3f4a;border-radius:10px;width:392px;display:none;box-shadow:0 8px 32px rgba(0,0,0,.5);font-family:system-ui,sans-serif;color:#fff;overflow:hidden;";
501
+ d.addEventListener("mousedown", (e) => {
502
+ const t = e.target;
503
+ if (t.tagName !== "INPUT") e.preventDefault();
504
+ e.stopPropagation();
505
+ });
506
+ document.body.appendChild(d);
507
+ return d;
508
+ }
509
+ function renderLinkDialog() {
510
+ const d = linkDialog;
511
+ if (!d) return;
512
+ const L = uiLabels;
513
+ const editing = !!linkEditing;
514
+ const existingRef = linkEditing?.getAttribute("data-cms-ref") ?? "";
515
+ const onPageTab = !editing || !!existingRef;
516
+ d.innerHTML = `<div style="display:flex;gap:4px;padding:8px 8px 0"><button type="button" data-testid="inline-link-tab-page" data-tab="page" style="${tabCss(onPageTab)}">${L.linkTabPage}</button><button type="button" data-testid="inline-link-tab-url" data-tab="url" style="${tabCss(!onPageTab)}">${L.linkTabUrl}</button></div><div style="padding:10px 12px 12px"><div data-pane="page" style="display:${onPageTab ? "block" : "none"}"><input data-testid="inline-link-search" data-role="search" placeholder="${L.linkSearch}" style="${fieldCss()}"><div data-role="list" data-testid="inline-link-list" style="margin-top:8px;max-height:196px;overflow-y:auto;border:1px solid #3a3f4a;border-radius:8px;background:#141821"></div></div><div data-pane="url" style="display:${onPageTab ? "none" : "block"}"><label style="${labelCss()}">${L.linkUrl}</label><input data-testid="inline-link-url" data-role="url" placeholder="https://\u2026" style="${fieldCss()}"></div><label style="${labelCss()}">${L.linkText}</label><input data-testid="inline-link-text" data-role="text" placeholder="${L.linkTextAuto}" style="${fieldCss()}"><p data-role="hint" style="font-size:11.5px;color:#9aa3b2;margin:6px 0 0;line-height:1.5"></p><div data-role="live" style="display:flex;gap:8px;margin-top:12px;padding:9px 10px;background:rgba(0,178,255,.08);border:1px solid rgba(0,178,255,.28);border-radius:8px"><p style="margin:0;font-size:11.5px;color:#c9d1dd;line-height:1.55">${L.linkLiveHint}</p></div><div style="display:flex;gap:8px;align-items:center;margin-top:14px"><div data-role="remove"></div><div style="flex:1"></div><button type="button" data-testid="inline-link-cancel" data-role="cancel" style="${btnCss(false)}">${L.linkCancel}</button><button type="button" data-testid="inline-link-submit" data-role="submit" style="${btnCss(true)}" disabled>${editing ? L.linkSave : L.linkInsert}</button></div></div>`;
517
+ const q = (role) => d.querySelector(`[data-role="${role}"]`);
518
+ const text = q("text");
519
+ const urlIn = q("url");
520
+ const search = q("search");
521
+ if (editing) {
522
+ text.value = linkEditing?.getAttribute("data-cms-ref-label") === "auto" ? "" : linkEditing?.textContent ?? "";
523
+ if (!existingRef) urlIn.value = linkEditing?.getAttribute("href") ?? "";
524
+ q("remove").appendChild(buildRemoveLink());
525
+ }
526
+ d.querySelectorAll("[data-tab]").forEach((btn) => {
527
+ btn.onclick = () => {
528
+ const page = btn.dataset.tab === "page";
529
+ d.querySelectorAll("[data-tab]").forEach((b) => {
530
+ b.setAttribute("style", tabCss(b.dataset.tab === "page" ? page : !page));
531
+ });
532
+ d.querySelector('[data-pane="page"]').style.display = page ? "block" : "none";
533
+ d.querySelector('[data-pane="url"]').style.display = page ? "none" : "block";
534
+ q("live").style.display = page ? "flex" : "none";
535
+ syncLinkDialog();
536
+ };
537
+ });
538
+ search.oninput = () => renderLinkList(search.value);
539
+ text.oninput = syncLinkDialog;
540
+ urlIn.oninput = syncLinkDialog;
541
+ q("cancel").onclick = hideLinkDialog;
542
+ q("submit").onclick = applyLink;
543
+ renderLinkList("");
544
+ syncLinkDialog();
545
+ }
546
+ function buildRemoveLink() {
547
+ const wrap = document.createElement("div");
548
+ const btn = document.createElement("button");
549
+ btn.type = "button";
550
+ btn.setAttribute("data-testid", "inline-link-remove");
551
+ btn.textContent = uiLabels.linkRemove;
552
+ btn.style.cssText = "background:none;border:none;color:#ff8a8a;font-size:12.5px;cursor:pointer;padding:0 2px;";
553
+ btn.onclick = () => {
554
+ wrap.innerHTML = `<span style="font-size:11.5px;color:#ff8a8a;font-weight:500;padding:0 2px">${uiLabels.linkRemoveConfirm}</span><button type="button" data-testid="inline-link-remove-yes" style="font-size:11px;padding:2px 8px;border-radius:4px;border:none;background:#c0392b;color:#fff;cursor:pointer;line-height:1.4;margin-left:6px">${uiLabels.linkYes}</button><button type="button" data-testid="inline-link-remove-no" style="font-size:11px;padding:2px 8px;border-radius:4px;border:1px solid #3a3f4a;background:none;color:#fff;cursor:pointer;line-height:1.4;margin-left:6px">${uiLabels.linkNo}</button>`;
555
+ wrap.querySelector('[data-testid="inline-link-remove-no"]').onclick = () => {
556
+ wrap.innerHTML = "";
557
+ wrap.appendChild(btn);
558
+ };
559
+ wrap.querySelector('[data-testid="inline-link-remove-yes"]').onclick = () => {
560
+ if (linkEditing) {
561
+ const parent = linkEditing.parentNode;
562
+ while (linkEditing.firstChild) parent?.insertBefore(linkEditing.firstChild, linkEditing);
563
+ parent?.removeChild(linkEditing);
564
+ }
565
+ hideLinkDialog();
566
+ };
567
+ };
568
+ wrap.appendChild(btn);
569
+ return wrap;
570
+ }
571
+ function renderLinkList(filter) {
572
+ const d = linkDialog;
573
+ if (!d) return;
574
+ const list = d.querySelector('[data-role="list"]');
575
+ if (!list) return;
576
+ const paint = (pages) => {
577
+ const f = filter.trim().toLowerCase();
578
+ const shown = pages.filter((p) => !f || `${p.title} ${p.path}`.toLowerCase().includes(f));
579
+ if (!shown.length) {
580
+ list.innerHTML = `<div style="padding:10px;font-size:12.5px;color:#9aa3b2">${uiLabels.linkEmpty}</div>`;
581
+ return;
582
+ }
583
+ list.innerHTML = "";
584
+ shown.forEach((p) => {
585
+ const on = linkPicked?.collection === p.collection && linkPicked?.slug === p.slug;
586
+ const row = document.createElement("div");
587
+ row.setAttribute("data-testid", `inline-link-page-${p.collection}-${p.slug}`);
588
+ row.style.cssText = "display:flex;align-items:center;gap:10px;padding:8px 10px;cursor:pointer;border-bottom:1px solid #232833;" + (on ? "background:rgba(0,178,255,.14);" : "");
589
+ row.innerHTML = `<div style="min-width:0;flex:1"><div style="font-size:13.5px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(p.title)}</div><div style="font-size:11.5px;color:#9aa3b2;font-family:ui-monospace,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(p.path)}</div></div><span style="flex:none;font-size:10px;text-transform:uppercase;color:#9aa3b2;border:1px solid #3a3f4a;border-radius:4px;padding:2px 6px;white-space:nowrap">${escapeHtml(p.label)}</span>`;
590
+ row.onclick = () => {
591
+ linkPicked = p;
592
+ renderLinkList(filter);
593
+ syncLinkDialog();
594
+ };
595
+ list.appendChild(row);
596
+ });
597
+ };
598
+ if (linkPages) {
599
+ paint(linkPages);
600
+ return;
601
+ }
602
+ list.innerHTML = `<div style="padding:10px;font-size:12.5px;color:#9aa3b2">${uiLabels.linkLoading}</div>`;
603
+ fetchLinkablePages().then((pages) => {
604
+ const ref = linkEditing?.getAttribute("data-cms-ref");
605
+ if (ref && !linkPicked) {
606
+ const [c, ...rest] = ref.split(":");
607
+ linkPicked = pages.find((p) => p.collection === c && p.slug === rest.join(":")) ?? null;
608
+ }
609
+ paint(pages);
610
+ syncLinkDialog();
611
+ }).catch(() => {
612
+ list.innerHTML = `<div style="padding:10px;font-size:12.5px;color:#ff8a8a">${uiLabels.error}</div>`;
613
+ });
614
+ }
615
+ function syncLinkDialog() {
616
+ const d = linkDialog;
617
+ if (!d) return;
618
+ const q = (role) => d.querySelector(`[data-role="${role}"]`);
619
+ const onPage = d.querySelector('[data-pane="page"]').style.display !== "none";
620
+ const own = q("text").value.trim();
621
+ q("hint").innerHTML = own ? uiLabels.linkTextOwnHint : onPage ? uiLabels.linkTextAutoHint : uiLabels.linkUrlHint;
622
+ q("live").style.display = onPage ? "flex" : "none";
623
+ q("submit").disabled = onPage ? !linkPicked : !q("url").value.trim();
624
+ }
625
+ function applyLink() {
626
+ const d = linkDialog;
627
+ if (!d || !richCtx) return;
628
+ const q = (role) => d.querySelector(`[data-role="${role}"]`);
629
+ const onPage = d.querySelector('[data-pane="page"]').style.display !== "none";
630
+ const own = q("text").value.trim();
631
+ const href = onPage ? linkPicked?.path ?? "" : q("url").value.trim();
632
+ if (!href) return;
633
+ const ref = onPage && linkPicked ? `${linkPicked.collection}:${linkPicked.slug}` : "";
634
+ const label = own || (onPage ? linkPicked?.title ?? href : href);
635
+ if (linkEditing) {
636
+ linkEditing.setAttribute("href", href);
637
+ if (ref) linkEditing.setAttribute("data-cms-ref", ref);
638
+ else linkEditing.removeAttribute("data-cms-ref");
639
+ if (ref && !own) linkEditing.setAttribute("data-cms-ref-label", "auto");
640
+ else linkEditing.removeAttribute("data-cms-ref-label");
641
+ linkEditing.textContent = label;
642
+ hideLinkDialog();
643
+ return;
644
+ }
645
+ const a = document.createElement("a");
646
+ a.setAttribute("href", href);
647
+ if (ref) {
648
+ a.setAttribute("data-cms-ref", ref);
649
+ if (!own) a.setAttribute("data-cms-ref-label", "auto");
650
+ }
651
+ const sel = window.getSelection();
652
+ if (savedRange && sel) {
653
+ sel.removeAllRanges();
654
+ sel.addRange(savedRange);
655
+ const range = sel.getRangeAt(0);
656
+ a.textContent = own || range.toString() || label;
657
+ range.deleteContents();
658
+ range.insertNode(a);
659
+ sel.removeAllRanges();
660
+ } else {
661
+ a.textContent = label;
662
+ richCtx.el.appendChild(a);
663
+ }
664
+ savedRange = null;
665
+ hideLinkDialog();
666
+ }
667
+ function escapeHtml(v) {
668
+ return v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
669
+ }
670
+ var tabCss = (on) => "flex:1;background:" + (on ? "#2a2f38" : "none") + ";border:1px solid " + (on ? "#3a3f4a" : "transparent") + ";color:" + (on ? "#fff" : "#9aa3b2") + ";height:32px;border-radius:7px;font-size:13px;cursor:pointer;font-weight:500;font-family:inherit;";
671
+ var fieldCss = () => "width:100%;background:#141821;border:1px solid #3a3f4a;color:#fff;height:34px;border-radius:7px;padding:0 10px;font-size:13.5px;outline:none;font-family:inherit;box-sizing:border-box;";
672
+ var labelCss = () => "font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#9aa3b2;margin:12px 0 5px;display:block;";
673
+ var btnCss = (primary) => "height:33px;border-radius:7px;font-size:13px;cursor:pointer;padding:0 14px;font-weight:600;font-family:inherit;" + (primary ? "background:#00b2ff;color:#04121b;border:none;" : "background:none;border:1px solid #3a3f4a;color:#fff;font-weight:500;");
390
674
  function toggleEmojiPicker(anchor) {
391
675
  if (!emojiPicker) emojiPicker = buildEmojiPicker();
392
676
  if (emojiPicker.style.display === "block") {
@@ -470,14 +754,70 @@ function setDeepField(data, path, value) {
470
754
  function htmlToMarkdown(html) {
471
755
  const container = document.createElement("div");
472
756
  container.innerHTML = html;
757
+ reattachOrphanListItems(container);
473
758
  return serializeBlockChildren(container).replace(/\n{3,}/g, "\n\n").trim() + "\n";
474
759
  }
760
+ function isList(el) {
761
+ if (!el) return false;
762
+ const tag = el.tagName.toLowerCase();
763
+ return tag === "ul" || tag === "ol";
764
+ }
765
+ function reattachOrphanListItems(container) {
766
+ const orphans = Array.from(container.children).filter(
767
+ (c) => c.tagName.toLowerCase() === "li"
768
+ );
769
+ for (const li of orphans) {
770
+ const prev = li.previousElementSibling;
771
+ const next = li.nextElementSibling;
772
+ if (isList(prev)) {
773
+ prev.appendChild(li);
774
+ } else if (isList(next)) {
775
+ next.insertBefore(li, next.firstChild);
776
+ } else {
777
+ const list = container.ownerDocument.createElement("ul");
778
+ li.replaceWith(list);
779
+ list.appendChild(li);
780
+ }
781
+ }
782
+ }
783
+ var BLOCK_TAGS = /* @__PURE__ */ new Set([
784
+ "h1",
785
+ "h2",
786
+ "h3",
787
+ "h4",
788
+ "h5",
789
+ "h6",
790
+ "p",
791
+ "div",
792
+ "ul",
793
+ "ol",
794
+ "li",
795
+ "blockquote",
796
+ "pre",
797
+ "hr",
798
+ "table"
799
+ ]);
800
+ function isBlockNode(node) {
801
+ return node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.tagName.toLowerCase());
802
+ }
475
803
  function serializeBlockChildren(parent) {
476
804
  const blocks = [];
477
- parent.childNodes.forEach((node) => {
478
- const s = serializeBlock(node).trim();
805
+ let inlineRun = "";
806
+ const flushInline = () => {
807
+ const s = inlineRun.trim();
479
808
  if (s) blocks.push(s);
809
+ inlineRun = "";
810
+ };
811
+ parent.childNodes.forEach((node) => {
812
+ if (isBlockNode(node)) {
813
+ flushInline();
814
+ const s = serializeBlock(node).trim();
815
+ if (s) blocks.push(s);
816
+ } else {
817
+ inlineRun += serializeInlineNode(node);
818
+ }
480
819
  });
820
+ flushInline();
481
821
  return blocks.join("\n\n");
482
822
  }
483
823
  function serializeBlock(node) {
@@ -505,6 +845,11 @@ function serializeBlock(node) {
505
845
  return serializeList(el, false);
506
846
  case "ol":
507
847
  return serializeList(el, true);
848
+ // Backstop for an orphaned <li> that reattachOrphanListItems could not
849
+ // place (e.g. nested inside another block). Without this it falls through
850
+ // to the inline default and loses its marker — silent content loss.
851
+ case "li":
852
+ return "- " + serializeInline(el).trim();
508
853
  case "blockquote":
509
854
  return serializeBlockChildren(el).split("\n").map((l) => l ? "> " + l : ">").join("\n");
510
855
  case "pre":
@@ -546,14 +891,23 @@ function serializeList(listEl, ordered) {
546
891
  });
547
892
  return items.join("\n");
548
893
  }
894
+ function escapeAttr(value) {
895
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
896
+ }
549
897
  function serializeInline(node) {
550
898
  let out = "";
551
899
  node.childNodes.forEach((child) => {
552
- if (child.nodeType === Node.TEXT_NODE) {
553
- out += (child.textContent ?? "").replace(/\s+/g, " ");
554
- return;
555
- }
556
- if (child.nodeType !== Node.ELEMENT_NODE) return;
900
+ out += serializeInlineNode(child);
901
+ });
902
+ return out;
903
+ }
904
+ function serializeInlineNode(child) {
905
+ if (child.nodeType === Node.TEXT_NODE) {
906
+ return (child.textContent ?? "").replace(/\s+/g, " ");
907
+ }
908
+ if (child.nodeType !== Node.ELEMENT_NODE) return "";
909
+ {
910
+ let out = "";
557
911
  const el = child;
558
912
  const tag = el.tagName.toLowerCase();
559
913
  const inner = serializeInline(el);
@@ -574,7 +928,15 @@ function serializeInline(node) {
574
928
  break;
575
929
  case "a": {
576
930
  const href = el.getAttribute("href") || "";
577
- out += href ? `[${inner}](${href})` : inner;
931
+ const ref = el.getAttribute("data-cms-ref");
932
+ if (ref) {
933
+ const attrs = [`href="${escapeAttr(href)}"`, `data-cms-ref="${escapeAttr(ref)}"`];
934
+ const label = el.getAttribute("data-cms-ref-label");
935
+ if (label) attrs.push(`data-cms-ref-label="${escapeAttr(label)}"`);
936
+ out += `<a ${attrs.join(" ")}>${inner}</a>`;
937
+ } else {
938
+ out += href ? `[${inner}](${href})` : inner;
939
+ }
578
940
  break;
579
941
  }
580
942
  case "img": {
@@ -595,8 +957,8 @@ function serializeInline(node) {
595
957
  default:
596
958
  out += inner;
597
959
  }
598
- });
599
- return out;
960
+ return out;
961
+ }
600
962
  }
601
963
  async function saveField(el, value, token, options) {
602
964
  const collection = el.dataset.cmsCollection;
@@ -704,6 +1066,7 @@ function injectStyles() {
704
1066
  buildConnectUrl,
705
1067
  disconnect,
706
1068
  getConnectedToken,
1069
+ htmlToMarkdown,
707
1070
  initInlineEdit,
708
1071
  rescanFields
709
1072
  });