@broberg/cms-inline-edit 0.6.1 → 0.6.3

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/dist/index.cjs CHANGED
@@ -23,14 +23,20 @@ __export(index_exports, {
23
23
  applyFieldSlice: () => applyFieldSlice,
24
24
  buildConnectUrl: () => buildConnectUrl,
25
25
  disconnect: () => disconnect,
26
+ extractLinkTargets: () => extractLinkTargets,
26
27
  getConnectedToken: () => getConnectedToken,
27
28
  htmlToMarkdown: () => htmlToMarkdown,
28
29
  initInlineEdit: () => initInlineEdit,
30
+ isBareEmail: () => isBareEmail,
31
+ isDangerousUrl: () => isDangerousUrl,
32
+ isExternalHost: () => isExternalHost,
33
+ isSchemeless: () => isSchemeless,
29
34
  positionPopover: () => positionPopover,
30
35
  renderCurrentRefLine: () => renderCurrentRefLine,
31
36
  rescanFields: () => rescanFields,
32
37
  resolveExistingRef: () => resolveExistingRef,
33
- setLinkTarget: () => setLinkTarget
38
+ setLinkTarget: () => setLinkTarget,
39
+ withHttps: () => withHttps
34
40
  });
35
41
  module.exports = __toCommonJS(index_exports);
36
42
 
@@ -48,6 +54,7 @@ function applyFieldSlice(current, original, next) {
48
54
  function isSchemeless(raw) {
49
55
  const v = (raw || "").trim();
50
56
  if (!v) return false;
57
+ if (isBareEmail(v)) return false;
51
58
  if (/^[a-z][a-z0-9+.-]*:/i.test(v)) return false;
52
59
  if (v.startsWith("//")) return false;
53
60
  if (v.startsWith("/")) return false;
@@ -55,6 +62,9 @@ function isSchemeless(raw) {
55
62
  if (v.startsWith("?")) return false;
56
63
  return true;
57
64
  }
65
+ function isBareEmail(raw) {
66
+ return /^[^\s@/:?#]+@[^\s@/:?#]+\.[a-z]{2,}$/i.test((raw || "").trim());
67
+ }
58
68
  function withHttps(raw) {
59
69
  return `https://${(raw || "").trim().replace(/^\/+/, "")}`;
60
70
  }
@@ -67,6 +77,22 @@ function isExternalHost(raw, currentHost) {
67
77
  return false;
68
78
  }
69
79
  }
80
+ function extractLinkTargets(text) {
81
+ const out = [];
82
+ if (typeof text !== "string") return out;
83
+ for (const m of text.matchAll(/<a\b[^>]*?\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/gi)) {
84
+ out.push({ syntax: "html", value: m[2] ?? m[3] ?? m[4] ?? "" });
85
+ }
86
+ for (const m of text.matchAll(/(!)?\[(?:[^\]\[]|\[[^\]]*\])*\]\(([^)\s]+)/g)) {
87
+ if (m[1]) continue;
88
+ out.push({ syntax: "markdown", value: m[2] ?? "" });
89
+ }
90
+ return out;
91
+ }
92
+ function isDangerousUrl(raw) {
93
+ const v = (raw || "").replace(/[\u0000-\u0020]/g, "");
94
+ return /^(?:javascript|data|vbscript):/i.test(v);
95
+ }
70
96
 
71
97
  // src/token-safe.ts
72
98
  var TEXT_NODE = 3;
@@ -125,6 +151,7 @@ var DEFAULT_LABELS = {
125
151
  linkNewTab: "\xC5bn i ny fane",
126
152
  linkSchemeless: "<b>{v}</b> l\xE6ses som en side p\xE5 <b>dette</b> site \u2014 ikke som en adresse ude p\xE5 nettet.",
127
153
  linkSchemelessFix: "Tilf\xF8j https://",
154
+ linkBlocked: "<b>{v}</b> kan ikke bruges som link \u2014 adresser af den type kan k\xF8re kode p\xE5 sitet.",
128
155
  linkInsert: "Inds\xE6t link",
129
156
  linkSave: "Gem \xE6ndring",
130
157
  linkCancel: "Annull\xE9r",
@@ -594,7 +621,7 @@ function renderLinkDialog() {
594
621
  }
595
622
  const existingRef = linkEditing?.getAttribute("data-cms-ref") ?? "";
596
623
  const onPageTab = !editing || !!existingRef;
597
- 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="current" data-testid="inline-link-current" style="display:none;margin-top:8px;padding:7px 10px;background:rgba(0,178,255,.08);border:1px solid rgba(0,178,255,.28);border-radius:8px;font-size:11.5px;color:#c9d1dd;line-height:1.45"></div><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 data-role="schemeless" data-testid="inline-link-schemeless" style="display:none;margin-top:8px;padding:8px 10px;background:rgba(255,176,32,.10);border:1px solid rgba(255,176,32,.34);border-radius:8px"><p data-role="schemeless-text" style="margin:0;font-size:11.5px;color:#e8d6b0;line-height:1.5"></p><button type="button" data-testid="inline-link-add-scheme" data-role="add-scheme" style="${btnCss(false)};margin-top:7px;height:26px;font-size:12px">${L.linkSchemelessFix}</button></div></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><button type="button" data-role="newtab" data-testid="inline-link-newtab" aria-pressed="false" style="display:flex;align-items:center;gap:8px;margin-top:12px;background:none;border:none;padding:0;cursor:pointer;font-family:inherit;font-size:12.5px;color:#c9d1dd"><span data-role="newtab-box" style="${checkboxCss(false)}"></span><span>${L.linkNewTab}</span></button><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>`;
624
+ 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="current" data-testid="inline-link-current" style="display:none;margin-top:8px;padding:7px 10px;background:rgba(0,178,255,.08);border:1px solid rgba(0,178,255,.28);border-radius:8px;font-size:11.5px;color:#c9d1dd;line-height:1.45"></div><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 data-role="schemeless" style="${noticeCss("schemeless")}"><p data-role="schemeless-text" style="margin:0;font-size:11.5px;line-height:1.5"></p><button type="button" data-testid="inline-link-add-scheme" data-role="add-scheme" style="${btnCss(false)};margin-top:7px;height:26px;font-size:12px">${L.linkSchemelessFix}</button></div></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><button type="button" data-role="newtab" data-testid="inline-link-newtab" aria-pressed="false" style="display:flex;align-items:center;gap:8px;margin-top:12px;background:none;border:none;padding:0;cursor:pointer;font-family:inherit;font-size:12.5px;color:#c9d1dd"><span data-role="newtab-box" style="${checkboxCss(false)}"></span><span>${L.linkNewTab}</span></button><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, true)}" disabled>${editing ? L.linkSave : L.linkInsert}</button></div></div>`;
598
625
  const q = (role) => d.querySelector(`[data-role="${role}"]`);
599
626
  const text = q("text");
600
627
  const urlIn = q("url");
@@ -602,7 +629,7 @@ function renderLinkDialog() {
602
629
  if (editing) {
603
630
  text.value = linkEditing?.getAttribute("data-cms-ref-label") === "auto" ? "" : linkEditing?.textContent ?? "";
604
631
  if (!existingRef) urlIn.value = linkEditing?.getAttribute("href") ?? "";
605
- linkNewTab = linkEditing?.getAttribute("target") === "_blank";
632
+ linkNewTab = (linkEditing?.getAttribute("target") ?? "").trim().toLowerCase() === "_blank";
606
633
  linkNewTabTouched = true;
607
634
  q("remove").appendChild(buildRemoveLink());
608
635
  }
@@ -737,30 +764,57 @@ function syncLinkDialog() {
737
764
  const own = q("text").value.trim();
738
765
  q("hint").innerHTML = own ? uiLabels.linkTextOwnHint : onPage ? uiLabels.linkTextAutoHint : uiLabels.linkUrlHint;
739
766
  q("live").style.display = onPage ? "flex" : "none";
740
- q("submit").disabled = onPage ? !linkPicked : !q("url").value.trim();
767
+ const submit = q("submit");
768
+ submit.disabled = onPage ? !linkPicked : !q("url").value.trim();
741
769
  const raw = q("url").value;
742
- const doubtful = !onPage && isSchemeless(raw);
743
- q("schemeless").style.display = doubtful ? "block" : "none";
744
- if (doubtful) {
770
+ const dangerous = !onPage && isDangerousUrl(raw);
771
+ const doubtful = !onPage && !dangerous && isSchemeless(raw);
772
+ const notice = q("schemeless");
773
+ const show = dangerous || doubtful;
774
+ if (dangerous) {
775
+ notice.setAttribute("style", noticeCss("blocked"));
776
+ notice.setAttribute("data-variant", "blocked");
777
+ q("schemeless-text").innerHTML = uiLabels.linkBlocked.replace(
778
+ "{v}",
779
+ () => escapeHtml(raw.trim())
780
+ );
781
+ submit.disabled = true;
782
+ } else if (doubtful) {
783
+ notice.setAttribute("style", noticeCss("schemeless"));
784
+ notice.setAttribute("data-variant", "schemeless");
745
785
  q("schemeless-text").innerHTML = uiLabels.linkSchemeless.replace(
746
786
  "{v}",
747
- escapeHtml(raw.trim())
787
+ () => escapeHtml(raw.trim())
748
788
  );
749
789
  }
750
- if (!onPage && !linkNewTabTouched) {
751
- linkNewTab = isExternalHost(raw, typeof location !== "undefined" ? location.host : "");
790
+ submit.setAttribute("style", btnCss(true, submit.disabled));
791
+ if (!show) {
792
+ notice.removeAttribute("data-variant");
793
+ notice.removeAttribute("data-testid");
794
+ q("schemeless-text").innerHTML = "";
795
+ } else {
796
+ notice.setAttribute("data-testid", dangerous ? "inline-link-blocked" : "inline-link-schemeless");
797
+ }
798
+ notice.style.display = show ? "block" : "none";
799
+ q("schemeless-text").style.color = dangerous ? "#f5c6c6" : "#e8d6b0";
800
+ q("add-scheme").style.display = doubtful ? "" : "none";
801
+ if (!linkNewTabTouched) {
802
+ linkNewTab = onPage ? false : isExternalHost(raw, typeof location !== "undefined" ? location.host : "");
752
803
  }
753
804
  const box = q("newtab-box");
754
805
  if (box) box.setAttribute("style", checkboxCss(linkNewTab));
755
806
  q("newtab").setAttribute("aria-pressed", String(linkNewTab));
756
807
  }
757
808
  function setLinkTarget(el, newTab) {
809
+ const tokens = (el.getAttribute("rel") ?? "").split(/\s+/).filter(Boolean);
810
+ const kept = tokens.filter((t) => t.toLowerCase() !== "noopener");
758
811
  if (newTab) {
759
812
  el.setAttribute("target", "_blank");
760
- el.setAttribute("rel", "noopener");
813
+ el.setAttribute("rel", ["noopener", ...kept].join(" "));
761
814
  } else {
762
815
  el.removeAttribute("target");
763
- el.removeAttribute("rel");
816
+ if (kept.length) el.setAttribute("rel", kept.join(" "));
817
+ else el.removeAttribute("rel");
764
818
  }
765
819
  }
766
820
  function applyLink() {
@@ -771,6 +825,7 @@ function applyLink() {
771
825
  const own = q("text").value.trim();
772
826
  const href = onPage ? linkPicked?.path ?? "" : q("url").value.trim();
773
827
  if (!href) return;
828
+ if (isDangerousUrl(href)) return;
774
829
  const ref = onPage && linkPicked ? `${linkPicked.collection}:${linkPicked.slug}` : "";
775
830
  const label = own || (onPage ? linkPicked?.title ?? href : href);
776
831
  const applyTarget = (el) => setLinkTarget(el, linkNewTab);
@@ -812,10 +867,15 @@ function escapeHtml(v) {
812
867
  return v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
813
868
  }
814
869
  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;";
870
+ var noticeCss = (variant) => "display:none;margin-top:8px;padding:8px 10px;border-radius:8px;background:" + (variant === "blocked" ? "rgba(255,90,90,.10)" : "rgba(255,176,32,.10)") + ";border:1px solid " + (variant === "blocked" ? "rgba(255,90,90,.38)" : "rgba(255,176,32,.34)") + ";";
815
871
  var checkboxCss = (on) => "width:16px;height:16px;flex:0 0 16px;border-radius:4px;box-sizing:border-box;display:inline-block;" + (on ? `background:#00b2ff;border:1px solid #00b2ff;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M4 8.5l2.6 2.6L12 5.8' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/></svg>");background-size:100% 100%;` : "background:#141821;border:1px solid #3a3f4a;");
816
872
  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;";
817
873
  var labelCss = () => "font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#9aa3b2;margin:12px 0 5px;display:block;";
818
- 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;");
874
+ var btnCss = (primary, disabled = false) => "height:33px;border-radius:7px;font-size:13px;padding:0 14px;font-weight:600;font-family:inherit;" + // A disabled button that still looks clickable is the silent state this whole
875
+ // card exists to remove: the editor clicks, nothing happens, and the dialog
876
+ // reads as broken rather than as refusing. It matters more now that `disabled`
877
+ // is what stops a javascript: address being inserted.
878
+ (disabled ? "cursor:not-allowed;opacity:.42;" : "cursor:pointer;") + (primary ? "background:#00b2ff;color:#04121b;border:none;" : "background:none;border:1px solid #3a3f4a;color:#fff;font-weight:500;");
819
879
  function toggleEmojiPicker(anchor) {
820
880
  if (!emojiPicker) emojiPicker = buildEmojiPicker();
821
881
  if (emojiPicker.style.display === "block") {
@@ -1044,7 +1104,7 @@ function preservedLinkAttrs(el) {
1044
1104
  const name = attr.name.toLowerCase();
1045
1105
  if (name === "href") continue;
1046
1106
  if (name.startsWith("on")) continue;
1047
- if (/^javascript:/i.test(attr.value.trim())) continue;
1107
+ if (isDangerousUrl(attr.value)) continue;
1048
1108
  out.push(`${name}="${escapeAttr(attr.value)}"`);
1049
1109
  }
1050
1110
  return out;
@@ -1103,6 +1163,10 @@ function serializeInlineNode(child) {
1103
1163
  break;
1104
1164
  case "a": {
1105
1165
  const href = el.getAttribute("href") || "";
1166
+ if (isDangerousUrl(href)) {
1167
+ out += inner;
1168
+ break;
1169
+ }
1106
1170
  const extras = preservedLinkAttrs(el);
1107
1171
  if (extras.length > 0) {
1108
1172
  out += `<a ${[`href="${escapeAttr(href)}"`, ...extras].join(" ")}>${inner}</a>`;
@@ -1239,13 +1303,19 @@ function injectStyles() {
1239
1303
  applyFieldSlice,
1240
1304
  buildConnectUrl,
1241
1305
  disconnect,
1306
+ extractLinkTargets,
1242
1307
  getConnectedToken,
1243
1308
  htmlToMarkdown,
1244
1309
  initInlineEdit,
1310
+ isBareEmail,
1311
+ isDangerousUrl,
1312
+ isExternalHost,
1313
+ isSchemeless,
1245
1314
  positionPopover,
1246
1315
  renderCurrentRefLine,
1247
1316
  rescanFields,
1248
1317
  resolveExistingRef,
1249
- setLinkTarget
1318
+ setLinkTarget,
1319
+ withHttps
1250
1320
  });
1251
1321
  //# sourceMappingURL=index.cjs.map