@broberg/cms-inline-edit 0.6.2 → 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.
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var server_exports = {};
22
22
  __export(server_exports, {
23
23
  resolveCmsLinks: () => resolveCmsLinks,
24
+ sanitizeCmsHtml: () => sanitizeCmsHtml,
24
25
  saveInlineEditField: () => saveInlineEditField,
25
26
  verifyEditSession: () => verifyEditSession
26
27
  });
@@ -51,15 +52,26 @@ async function verifyEditSession(options) {
51
52
  return body.user ?? null;
52
53
  }
53
54
  var ANCHOR_RE = /<a\b([^>]*\bdata-cms-ref="[^"]*"[^>]*)>([\s\S]*?)<\/a>/gi;
55
+ var ANY_ANCHOR_RE = /<a\b((?:"[^"]*"|'[^']*'|[^>])*)>([\s\S]*?)<\/a>/gi;
56
+ var HREF_RE = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
54
57
  var attr = (tag, name) => {
55
58
  const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`, "i"));
56
59
  return m?.[1] ?? "";
57
60
  };
58
61
  var escapeAttr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
59
62
  var escapeText = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
63
+ function sanitizeCmsHtml(html) {
64
+ if (!html) return html;
65
+ return html.replace(ANY_ANCHOR_RE, (whole, attrs, inner) => {
66
+ const m = attrs.match(HREF_RE);
67
+ const href = m?.[1] ?? m?.[2] ?? m?.[3] ?? "";
68
+ const bare = href.replace(/[\u0000-\u0020]/g, "");
69
+ return /^(?:javascript|data|vbscript):/i.test(bare) ? inner : whole;
70
+ });
71
+ }
60
72
  function resolveCmsLinks(html, lookup) {
61
73
  if (!html) return html;
62
- return html.replace(ANCHOR_RE, (whole, attrs, inner) => {
74
+ return sanitizeCmsHtml(html).replace(ANCHOR_RE, (whole, attrs, inner) => {
63
75
  const ref = attr(attrs, "data-cms-ref");
64
76
  const sep = ref.indexOf(":");
65
77
  if (sep < 1) return whole;
@@ -80,6 +92,7 @@ function resolveCmsLinks(html, lookup) {
80
92
  // Annotate the CommonJS export names for ESM import in node:
81
93
  0 && (module.exports = {
82
94
  resolveCmsLinks,
95
+ sanitizeCmsHtml,
83
96
  saveInlineEditField,
84
97
  verifyEditSession
85
98
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * Optional server-side helpers (Node/Bun) for sites that want a same-origin\n * relay instead of calling the CMS directly from the browser. Not required\n * for the direct-from-browser flow used by initInlineEdit().\n */\n\nexport interface SaveInlineEditFieldOptions {\n cmsBaseUrl: string;\n siteId: string;\n collection: string;\n slug: string;\n field: string;\n value: string;\n sessionToken: string;\n}\n\nexport type SaveInlineEditFieldResult = { ok: true } | { ok: false; error: string };\n\n/** GET the doc, merge the changed field into .data, PATCH the full merged object back. */\nexport async function saveInlineEditField(\n options: SaveInlineEditFieldOptions,\n): Promise<SaveInlineEditFieldResult> {\n const { cmsBaseUrl, siteId, collection, slug, field, value, sessionToken } = options;\n const headers = { Authorization: `Bearer ${sessionToken}` };\n\n const getRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n headers,\n });\n if (!getRes.ok) return { ok: false, error: `GET failed: ${getRes.status}` };\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n method: \"PATCH\",\n headers: { ...headers, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n });\n if (!patchRes.ok) return { ok: false, error: `PATCH failed: ${patchRes.status}` };\n return { ok: true };\n}\n\nexport interface VerifyEditSessionOptions {\n cmsBaseUrl: string;\n token: string;\n}\n\nexport interface EditSessionUser {\n sub: string;\n email: string;\n name: string;\n role: string;\n}\n\n/**\n * Calls the CMS's existing GET /api/auth/me with the token as a Bearer header.\n * That endpoint always returns 200 — {\"user\": null} for anonymous/invalid\n * tokens, never a 401 — so absence of a user is read from the body, not the\n * status code.\n */\nexport async function verifyEditSession(\n options: VerifyEditSessionOptions,\n): Promise<EditSessionUser | null> {\n const res = await fetch(`${options.cmsBaseUrl}/api/auth/me`, {\n headers: { Authorization: `Bearer ${options.token}` },\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { user?: EditSessionUser | null };\n return body.user ?? null;\n}\n\n/* ------------------------------------------------------------------ F164 --\n * Live page references.\n *\n * A link the editor made to a PAGE is stored as inline HTML carrying\n * `data-cms-ref=\"collection:slug\"` next to a real, working href — and\n * optionally `data-cms-ref-label=\"auto\"`, meaning \"show the page's current\n * title\". Call resolveCmsLinks() when you render a richtext field and the link\n * re-points itself after the page moves or is renamed, without anything having\n * to rewrite stored content.\n *\n * Deliberately degrades: a site that never calls this still ships links that\n * work — they just stop following the page. And an unknown reference (deleted\n * page) keeps whatever href it had rather than emitting a dead or empty link.\n * -------------------------------------------------------------------------- */\n\nexport interface CmsLinkTarget {\n /** Current public path or URL of the referenced page. */\n url: string;\n /** Current title, used when the link opted into the auto label. */\n title?: string;\n}\n\n/** Resolve `collection:slug` → its current url + title, or null if it is gone. */\nexport type CmsLinkLookup = (\n collection: string,\n slug: string,\n) => CmsLinkTarget | null | undefined;\n\nconst ANCHOR_RE = /<a\\b([^>]*\\bdata-cms-ref=\"[^\"]*\"[^>]*)>([\\s\\S]*?)<\\/a>/gi;\nconst attr = (tag: string, name: string): string => {\n const m = tag.match(new RegExp(`\\\\b${name}=\"([^\"]*)\"`, \"i\"));\n return m?.[1] ?? \"\";\n};\nconst escapeAttr = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\").replace(/</g, \"&lt;\");\nconst escapeText = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n\nexport function resolveCmsLinks(html: string, lookup: CmsLinkLookup): string {\n if (!html) return html;\n return html.replace(ANCHOR_RE, (whole, attrs: string, inner: string) => {\n const ref = attr(attrs, \"data-cms-ref\");\n const sep = ref.indexOf(\":\");\n if (sep < 1) return whole;\n let target: CmsLinkTarget | null | undefined;\n try {\n target = lookup(ref.slice(0, sep), ref.slice(sep + 1));\n } catch {\n return whole; // a throwing lookup must never take the page down with it\n }\n if (!target?.url) return whole; // page gone → keep the last known href\n\n const auto = attr(attrs, \"data-cms-ref-label\") === \"auto\";\n const label = auto && target.title ? escapeText(target.title) : inner;\n const rebuilt = attrs.replace(/\\bhref=\"[^\"]*\"/i, `href=\"${escapeAttr(target.url)}\"`);\n const withHref = /\\bhref=/i.test(attrs) ? rebuilt : `${attrs} href=\"${escapeAttr(target.url)}\"`;\n return `<a${withHref}>${label}</a>`;\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmBA,eAAsB,oBACpB,SACoC;AACpC,QAAM,EAAE,YAAY,QAAQ,YAAY,MAAM,OAAO,OAAO,aAAa,IAAI;AAC7E,QAAM,UAAU,EAAE,eAAe,UAAU,YAAY,GAAG;AAE1D,QAAM,SAAS,MAAM,MAAM,GAAG,UAAU,YAAY,UAAU,IAAI,IAAI,SAAS,MAAM,IAAI;AAAA,IACvF;AAAA,EACF,CAAC;AACD,MAAI,CAAC,OAAO,GAAI,QAAO,EAAE,IAAI,OAAO,OAAO,eAAe,OAAO,MAAM,GAAG;AAC1E,QAAM,MAAO,MAAM,OAAO,KAAK;AAC/B,QAAM,aAAa,EAAE,GAAG,IAAI,MAAM,CAAC,KAAK,GAAG,MAAM;AAEjD,QAAM,WAAW,MAAM,MAAM,GAAG,UAAU,YAAY,UAAU,IAAI,IAAI,SAAS,MAAM,IAAI;AAAA,IACzF,QAAQ;AAAA,IACR,SAAS,EAAE,GAAG,SAAS,gBAAgB,mBAAmB;AAAA,IAC1D,MAAM,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAAA,EAC3C,CAAC;AACD,MAAI,CAAC,SAAS,GAAI,QAAO,EAAE,IAAI,OAAO,OAAO,iBAAiB,SAAS,MAAM,GAAG;AAChF,SAAO,EAAE,IAAI,KAAK;AACpB;AAoBA,eAAsB,kBACpB,SACiC;AACjC,QAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,UAAU,gBAAgB;AAAA,IAC3D,SAAS,EAAE,eAAe,UAAU,QAAQ,KAAK,GAAG;AAAA,EACtD,CAAC;AACD,MAAI,CAAC,IAAI,GAAI,QAAO;AACpB,QAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,SAAO,KAAK,QAAQ;AACtB;AA8BA,IAAM,YAAY;AAClB,IAAM,OAAO,CAAC,KAAa,SAAyB;AAClD,QAAM,IAAI,IAAI,MAAM,IAAI,OAAO,MAAM,IAAI,cAAc,GAAG,CAAC;AAC3D,SAAO,IAAI,CAAC,KAAK;AACnB;AACA,IAAM,aAAa,CAAC,MAClB,EAAE,QAAQ,MAAM,OAAO,EAAE,QAAQ,MAAM,QAAQ,EAAE,QAAQ,MAAM,MAAM;AACvE,IAAM,aAAa,CAAC,MAClB,EAAE,QAAQ,MAAM,OAAO,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,MAAM,MAAM;AAE9D,SAAS,gBAAgB,MAAc,QAA+B;AAC3E,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,KAAK,QAAQ,WAAW,CAAC,OAAO,OAAe,UAAkB;AACtE,UAAM,MAAM,KAAK,OAAO,cAAc;AACtC,UAAM,MAAM,IAAI,QAAQ,GAAG;AAC3B,QAAI,MAAM,EAAG,QAAO;AACpB,QAAI;AACJ,QAAI;AACF,eAAS,OAAO,IAAI,MAAM,GAAG,GAAG,GAAG,IAAI,MAAM,MAAM,CAAC,CAAC;AAAA,IACvD,QAAQ;AACN,aAAO;AAAA,IACT;AACA,QAAI,CAAC,QAAQ,IAAK,QAAO;AAEzB,UAAM,OAAO,KAAK,OAAO,oBAAoB,MAAM;AACnD,UAAM,QAAQ,QAAQ,OAAO,QAAQ,WAAW,OAAO,KAAK,IAAI;AAChE,UAAM,UAAU,MAAM,QAAQ,mBAAmB,SAAS,WAAW,OAAO,GAAG,CAAC,GAAG;AACnF,UAAM,WAAW,WAAW,KAAK,KAAK,IAAI,UAAU,GAAG,KAAK,UAAU,WAAW,OAAO,GAAG,CAAC;AAC5F,WAAO,KAAK,QAAQ,IAAI,KAAK;AAAA,EAC/B,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * Optional server-side helpers (Node/Bun) for sites that want a same-origin\n * relay instead of calling the CMS directly from the browser. Not required\n * for the direct-from-browser flow used by initInlineEdit().\n */\n\nexport interface SaveInlineEditFieldOptions {\n cmsBaseUrl: string;\n siteId: string;\n collection: string;\n slug: string;\n field: string;\n value: string;\n sessionToken: string;\n}\n\nexport type SaveInlineEditFieldResult = { ok: true } | { ok: false; error: string };\n\n/** GET the doc, merge the changed field into .data, PATCH the full merged object back. */\nexport async function saveInlineEditField(\n options: SaveInlineEditFieldOptions,\n): Promise<SaveInlineEditFieldResult> {\n const { cmsBaseUrl, siteId, collection, slug, field, value, sessionToken } = options;\n const headers = { Authorization: `Bearer ${sessionToken}` };\n\n const getRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n headers,\n });\n if (!getRes.ok) return { ok: false, error: `GET failed: ${getRes.status}` };\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n method: \"PATCH\",\n headers: { ...headers, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n });\n if (!patchRes.ok) return { ok: false, error: `PATCH failed: ${patchRes.status}` };\n return { ok: true };\n}\n\nexport interface VerifyEditSessionOptions {\n cmsBaseUrl: string;\n token: string;\n}\n\nexport interface EditSessionUser {\n sub: string;\n email: string;\n name: string;\n role: string;\n}\n\n/**\n * Calls the CMS's existing GET /api/auth/me with the token as a Bearer header.\n * That endpoint always returns 200 — {\"user\": null} for anonymous/invalid\n * tokens, never a 401 — so absence of a user is read from the body, not the\n * status code.\n */\nexport async function verifyEditSession(\n options: VerifyEditSessionOptions,\n): Promise<EditSessionUser | null> {\n const res = await fetch(`${options.cmsBaseUrl}/api/auth/me`, {\n headers: { Authorization: `Bearer ${options.token}` },\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { user?: EditSessionUser | null };\n return body.user ?? null;\n}\n\n/* ------------------------------------------------------------------ F164 --\n * Live page references.\n *\n * A link the editor made to a PAGE is stored as inline HTML carrying\n * `data-cms-ref=\"collection:slug\"` next to a real, working href — and\n * optionally `data-cms-ref-label=\"auto\"`, meaning \"show the page's current\n * title\". Call resolveCmsLinks() when you render a richtext field and the link\n * re-points itself after the page moves or is renamed, without anything having\n * to rewrite stored content.\n *\n * Deliberately degrades: a site that never calls this still ships links that\n * work — they just stop following the page. And an unknown reference (deleted\n * page) keeps whatever href it had rather than emitting a dead or empty link.\n * -------------------------------------------------------------------------- */\n\nexport interface CmsLinkTarget {\n /** Current public path or URL of the referenced page. */\n url: string;\n /** Current title, used when the link opted into the auto label. */\n title?: string;\n}\n\n/** Resolve `collection:slug` → its current url + title, or null if it is gone. */\nexport type CmsLinkLookup = (\n collection: string,\n slug: string,\n) => CmsLinkTarget | null | undefined;\n\nconst ANCHOR_RE = /<a\\b([^>]*\\bdata-cms-ref=\"[^\"]*\"[^>]*)>([\\s\\S]*?)<\\/a>/gi;\n/**\n * Every anchor, not only the ones carrying a page reference.\n *\n * Quote-aware on purpose. A naive `[^>]*` ends the tag at the first `>` — but a\n * BROWSER does not: inside a quoted attribute value `>` is ordinary text. So\n * `<a href=\"data:text/html,<script>…\">` was read by the regex as a tag ending\n * mid-attribute, escaped the check, and still executed. A sanitiser that\n * tokenises differently from the parser it protects is a bypass, not a guard.\n * Caught by this file's own negative-case test.\n */\nconst ANY_ANCHOR_RE = /<a\\b((?:\"[^\"]*\"|'[^']*'|[^>])*)>([\\s\\S]*?)<\\/a>/gi;\n/** href, quoted or bare — an unquoted attribute is legal HTML5. */\nconst HREF_RE = /\\bhref\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'>]+))/i;\nconst attr = (tag: string, name: string): string => {\n const m = tag.match(new RegExp(`\\\\b${name}=\"([^\"]*)\"`, \"i\"));\n return m?.[1] ?? \"\";\n};\nconst escapeAttr = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\").replace(/</g, \"&lt;\");\nconst escapeText = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n\n/**\n * Neutralise a link whose address would EXECUTE when clicked.\n *\n * The editor refuses these at three points on the way IN, but that only covers\n * text this dialog wrote. Content also arrives from cms-admin's own richtext\n * editor, the REST API, MCP and AI agents, and `marked` renders\n * `[k](javascript:…)` and a raw `<a href=\"javascript:…\">` through untouched —\n * measured with this repo's own version. So the render side needs its own gate.\n *\n * The link's WORDS survive; only the link is removed. Dropping the text as well\n * would silently delete a sentence from a customer's page.\n *\n * Honest limit: this runs only where a site calls it. It is a chokepoint for a\n * consumer that renders through resolveCmsLinks (or sanitizeCmsHtml directly),\n * not a guarantee about every path into every site.\n */\nexport function sanitizeCmsHtml(html: string): string {\n if (!html) return html;\n return html.replace(ANY_ANCHOR_RE, (whole, attrs: string, inner: string) => {\n const m = attrs.match(HREF_RE);\n const href = m?.[1] ?? m?.[2] ?? m?.[3] ?? \"\";\n // Whitespace and control characters are stripped first: browsers ignore\n // them inside a scheme, so `java\\tscript:` and ` javascript:` both run.\n const bare = href.replace(/[\\u0000-\\u0020]/g, \"\");\n return /^(?:javascript|data|vbscript):/i.test(bare) ? inner : whole;\n });\n}\n\nexport function resolveCmsLinks(html: string, lookup: CmsLinkLookup): string {\n if (!html) return html;\n return sanitizeCmsHtml(html).replace(ANCHOR_RE, (whole, attrs: string, inner: string) => {\n const ref = attr(attrs, \"data-cms-ref\");\n const sep = ref.indexOf(\":\");\n if (sep < 1) return whole;\n let target: CmsLinkTarget | null | undefined;\n try {\n target = lookup(ref.slice(0, sep), ref.slice(sep + 1));\n } catch {\n return whole; // a throwing lookup must never take the page down with it\n }\n if (!target?.url) return whole; // page gone → keep the last known href\n\n const auto = attr(attrs, \"data-cms-ref-label\") === \"auto\";\n const label = auto && target.title ? escapeText(target.title) : inner;\n const rebuilt = attrs.replace(/\\bhref=\"[^\"]*\"/i, `href=\"${escapeAttr(target.url)}\"`);\n const withHref = /\\bhref=/i.test(attrs) ? rebuilt : `${attrs} href=\"${escapeAttr(target.url)}\"`;\n return `<a${withHref}>${label}</a>`;\n });\n}\n"],"mappings":"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","names":[]}
@@ -45,6 +45,23 @@ interface CmsLinkTarget {
45
45
  }
46
46
  /** Resolve `collection:slug` → its current url + title, or null if it is gone. */
47
47
  type CmsLinkLookup = (collection: string, slug: string) => CmsLinkTarget | null | undefined;
48
+ /**
49
+ * Neutralise a link whose address would EXECUTE when clicked.
50
+ *
51
+ * The editor refuses these at three points on the way IN, but that only covers
52
+ * text this dialog wrote. Content also arrives from cms-admin's own richtext
53
+ * editor, the REST API, MCP and AI agents, and `marked` renders
54
+ * `[k](javascript:…)` and a raw `<a href="javascript:…">` through untouched —
55
+ * measured with this repo's own version. So the render side needs its own gate.
56
+ *
57
+ * The link's WORDS survive; only the link is removed. Dropping the text as well
58
+ * would silently delete a sentence from a customer's page.
59
+ *
60
+ * Honest limit: this runs only where a site calls it. It is a chokepoint for a
61
+ * consumer that renders through resolveCmsLinks (or sanitizeCmsHtml directly),
62
+ * not a guarantee about every path into every site.
63
+ */
64
+ declare function sanitizeCmsHtml(html: string): string;
48
65
  declare function resolveCmsLinks(html: string, lookup: CmsLinkLookup): string;
49
66
 
50
- export { type CmsLinkLookup, type CmsLinkTarget, type EditSessionUser, type SaveInlineEditFieldOptions, type SaveInlineEditFieldResult, type VerifyEditSessionOptions, resolveCmsLinks, saveInlineEditField, verifyEditSession };
67
+ export { type CmsLinkLookup, type CmsLinkTarget, type EditSessionUser, type SaveInlineEditFieldOptions, type SaveInlineEditFieldResult, type VerifyEditSessionOptions, resolveCmsLinks, sanitizeCmsHtml, saveInlineEditField, verifyEditSession };
@@ -45,6 +45,23 @@ interface CmsLinkTarget {
45
45
  }
46
46
  /** Resolve `collection:slug` → its current url + title, or null if it is gone. */
47
47
  type CmsLinkLookup = (collection: string, slug: string) => CmsLinkTarget | null | undefined;
48
+ /**
49
+ * Neutralise a link whose address would EXECUTE when clicked.
50
+ *
51
+ * The editor refuses these at three points on the way IN, but that only covers
52
+ * text this dialog wrote. Content also arrives from cms-admin's own richtext
53
+ * editor, the REST API, MCP and AI agents, and `marked` renders
54
+ * `[k](javascript:…)` and a raw `<a href="javascript:…">` through untouched —
55
+ * measured with this repo's own version. So the render side needs its own gate.
56
+ *
57
+ * The link's WORDS survive; only the link is removed. Dropping the text as well
58
+ * would silently delete a sentence from a customer's page.
59
+ *
60
+ * Honest limit: this runs only where a site calls it. It is a chokepoint for a
61
+ * consumer that renders through resolveCmsLinks (or sanitizeCmsHtml directly),
62
+ * not a guarantee about every path into every site.
63
+ */
64
+ declare function sanitizeCmsHtml(html: string): string;
48
65
  declare function resolveCmsLinks(html: string, lookup: CmsLinkLookup): string;
49
66
 
50
- export { type CmsLinkLookup, type CmsLinkTarget, type EditSessionUser, type SaveInlineEditFieldOptions, type SaveInlineEditFieldResult, type VerifyEditSessionOptions, resolveCmsLinks, saveInlineEditField, verifyEditSession };
67
+ export { type CmsLinkLookup, type CmsLinkTarget, type EditSessionUser, type SaveInlineEditFieldOptions, type SaveInlineEditFieldResult, type VerifyEditSessionOptions, resolveCmsLinks, sanitizeCmsHtml, saveInlineEditField, verifyEditSession };
@@ -25,15 +25,26 @@ async function verifyEditSession(options) {
25
25
  return body.user ?? null;
26
26
  }
27
27
  var ANCHOR_RE = /<a\b([^>]*\bdata-cms-ref="[^"]*"[^>]*)>([\s\S]*?)<\/a>/gi;
28
+ var ANY_ANCHOR_RE = /<a\b((?:"[^"]*"|'[^']*'|[^>])*)>([\s\S]*?)<\/a>/gi;
29
+ var HREF_RE = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
28
30
  var attr = (tag, name) => {
29
31
  const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`, "i"));
30
32
  return m?.[1] ?? "";
31
33
  };
32
34
  var escapeAttr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
33
35
  var escapeText = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
36
+ function sanitizeCmsHtml(html) {
37
+ if (!html) return html;
38
+ return html.replace(ANY_ANCHOR_RE, (whole, attrs, inner) => {
39
+ const m = attrs.match(HREF_RE);
40
+ const href = m?.[1] ?? m?.[2] ?? m?.[3] ?? "";
41
+ const bare = href.replace(/[\u0000-\u0020]/g, "");
42
+ return /^(?:javascript|data|vbscript):/i.test(bare) ? inner : whole;
43
+ });
44
+ }
34
45
  function resolveCmsLinks(html, lookup) {
35
46
  if (!html) return html;
36
- return html.replace(ANCHOR_RE, (whole, attrs, inner) => {
47
+ return sanitizeCmsHtml(html).replace(ANCHOR_RE, (whole, attrs, inner) => {
37
48
  const ref = attr(attrs, "data-cms-ref");
38
49
  const sep = ref.indexOf(":");
39
50
  if (sep < 1) return whole;
@@ -53,6 +64,7 @@ function resolveCmsLinks(html, lookup) {
53
64
  }
54
65
  export {
55
66
  resolveCmsLinks,
67
+ sanitizeCmsHtml,
56
68
  saveInlineEditField,
57
69
  verifyEditSession
58
70
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * Optional server-side helpers (Node/Bun) for sites that want a same-origin\n * relay instead of calling the CMS directly from the browser. Not required\n * for the direct-from-browser flow used by initInlineEdit().\n */\n\nexport interface SaveInlineEditFieldOptions {\n cmsBaseUrl: string;\n siteId: string;\n collection: string;\n slug: string;\n field: string;\n value: string;\n sessionToken: string;\n}\n\nexport type SaveInlineEditFieldResult = { ok: true } | { ok: false; error: string };\n\n/** GET the doc, merge the changed field into .data, PATCH the full merged object back. */\nexport async function saveInlineEditField(\n options: SaveInlineEditFieldOptions,\n): Promise<SaveInlineEditFieldResult> {\n const { cmsBaseUrl, siteId, collection, slug, field, value, sessionToken } = options;\n const headers = { Authorization: `Bearer ${sessionToken}` };\n\n const getRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n headers,\n });\n if (!getRes.ok) return { ok: false, error: `GET failed: ${getRes.status}` };\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n method: \"PATCH\",\n headers: { ...headers, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n });\n if (!patchRes.ok) return { ok: false, error: `PATCH failed: ${patchRes.status}` };\n return { ok: true };\n}\n\nexport interface VerifyEditSessionOptions {\n cmsBaseUrl: string;\n token: string;\n}\n\nexport interface EditSessionUser {\n sub: string;\n email: string;\n name: string;\n role: string;\n}\n\n/**\n * Calls the CMS's existing GET /api/auth/me with the token as a Bearer header.\n * That endpoint always returns 200 — {\"user\": null} for anonymous/invalid\n * tokens, never a 401 — so absence of a user is read from the body, not the\n * status code.\n */\nexport async function verifyEditSession(\n options: VerifyEditSessionOptions,\n): Promise<EditSessionUser | null> {\n const res = await fetch(`${options.cmsBaseUrl}/api/auth/me`, {\n headers: { Authorization: `Bearer ${options.token}` },\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { user?: EditSessionUser | null };\n return body.user ?? null;\n}\n\n/* ------------------------------------------------------------------ F164 --\n * Live page references.\n *\n * A link the editor made to a PAGE is stored as inline HTML carrying\n * `data-cms-ref=\"collection:slug\"` next to a real, working href — and\n * optionally `data-cms-ref-label=\"auto\"`, meaning \"show the page's current\n * title\". Call resolveCmsLinks() when you render a richtext field and the link\n * re-points itself after the page moves or is renamed, without anything having\n * to rewrite stored content.\n *\n * Deliberately degrades: a site that never calls this still ships links that\n * work — they just stop following the page. And an unknown reference (deleted\n * page) keeps whatever href it had rather than emitting a dead or empty link.\n * -------------------------------------------------------------------------- */\n\nexport interface CmsLinkTarget {\n /** Current public path or URL of the referenced page. */\n url: string;\n /** Current title, used when the link opted into the auto label. */\n title?: string;\n}\n\n/** Resolve `collection:slug` → its current url + title, or null if it is gone. */\nexport type CmsLinkLookup = (\n collection: string,\n slug: string,\n) => CmsLinkTarget | null | undefined;\n\nconst ANCHOR_RE = /<a\\b([^>]*\\bdata-cms-ref=\"[^\"]*\"[^>]*)>([\\s\\S]*?)<\\/a>/gi;\nconst attr = (tag: string, name: string): string => {\n const m = tag.match(new RegExp(`\\\\b${name}=\"([^\"]*)\"`, \"i\"));\n return m?.[1] ?? \"\";\n};\nconst escapeAttr = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\").replace(/</g, \"&lt;\");\nconst escapeText = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n\nexport function resolveCmsLinks(html: string, lookup: CmsLinkLookup): string {\n if (!html) return html;\n return html.replace(ANCHOR_RE, (whole, attrs: string, inner: string) => {\n const ref = attr(attrs, \"data-cms-ref\");\n const sep = ref.indexOf(\":\");\n if (sep < 1) return whole;\n let target: CmsLinkTarget | null | undefined;\n try {\n target = lookup(ref.slice(0, sep), ref.slice(sep + 1));\n } catch {\n return whole; // a throwing lookup must never take the page down with it\n }\n if (!target?.url) return whole; // page gone → keep the last known href\n\n const auto = attr(attrs, \"data-cms-ref-label\") === \"auto\";\n const label = auto && target.title ? escapeText(target.title) : inner;\n const rebuilt = attrs.replace(/\\bhref=\"[^\"]*\"/i, `href=\"${escapeAttr(target.url)}\"`);\n const withHref = /\\bhref=/i.test(attrs) ? rebuilt : `${attrs} href=\"${escapeAttr(target.url)}\"`;\n return `<a${withHref}>${label}</a>`;\n });\n}\n"],"mappings":";AAmBA,eAAsB,oBACpB,SACoC;AACpC,QAAM,EAAE,YAAY,QAAQ,YAAY,MAAM,OAAO,OAAO,aAAa,IAAI;AAC7E,QAAM,UAAU,EAAE,eAAe,UAAU,YAAY,GAAG;AAE1D,QAAM,SAAS,MAAM,MAAM,GAAG,UAAU,YAAY,UAAU,IAAI,IAAI,SAAS,MAAM,IAAI;AAAA,IACvF;AAAA,EACF,CAAC;AACD,MAAI,CAAC,OAAO,GAAI,QAAO,EAAE,IAAI,OAAO,OAAO,eAAe,OAAO,MAAM,GAAG;AAC1E,QAAM,MAAO,MAAM,OAAO,KAAK;AAC/B,QAAM,aAAa,EAAE,GAAG,IAAI,MAAM,CAAC,KAAK,GAAG,MAAM;AAEjD,QAAM,WAAW,MAAM,MAAM,GAAG,UAAU,YAAY,UAAU,IAAI,IAAI,SAAS,MAAM,IAAI;AAAA,IACzF,QAAQ;AAAA,IACR,SAAS,EAAE,GAAG,SAAS,gBAAgB,mBAAmB;AAAA,IAC1D,MAAM,KAAK,UAAU,EAAE,MAAM,WAAW,CAAC;AAAA,EAC3C,CAAC;AACD,MAAI,CAAC,SAAS,GAAI,QAAO,EAAE,IAAI,OAAO,OAAO,iBAAiB,SAAS,MAAM,GAAG;AAChF,SAAO,EAAE,IAAI,KAAK;AACpB;AAoBA,eAAsB,kBACpB,SACiC;AACjC,QAAM,MAAM,MAAM,MAAM,GAAG,QAAQ,UAAU,gBAAgB;AAAA,IAC3D,SAAS,EAAE,eAAe,UAAU,QAAQ,KAAK,GAAG;AAAA,EACtD,CAAC;AACD,MAAI,CAAC,IAAI,GAAI,QAAO;AACpB,QAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,SAAO,KAAK,QAAQ;AACtB;AA8BA,IAAM,YAAY;AAClB,IAAM,OAAO,CAAC,KAAa,SAAyB;AAClD,QAAM,IAAI,IAAI,MAAM,IAAI,OAAO,MAAM,IAAI,cAAc,GAAG,CAAC;AAC3D,SAAO,IAAI,CAAC,KAAK;AACnB;AACA,IAAM,aAAa,CAAC,MAClB,EAAE,QAAQ,MAAM,OAAO,EAAE,QAAQ,MAAM,QAAQ,EAAE,QAAQ,MAAM,MAAM;AACvE,IAAM,aAAa,CAAC,MAClB,EAAE,QAAQ,MAAM,OAAO,EAAE,QAAQ,MAAM,MAAM,EAAE,QAAQ,MAAM,MAAM;AAE9D,SAAS,gBAAgB,MAAc,QAA+B;AAC3E,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,KAAK,QAAQ,WAAW,CAAC,OAAO,OAAe,UAAkB;AACtE,UAAM,MAAM,KAAK,OAAO,cAAc;AACtC,UAAM,MAAM,IAAI,QAAQ,GAAG;AAC3B,QAAI,MAAM,EAAG,QAAO;AACpB,QAAI;AACJ,QAAI;AACF,eAAS,OAAO,IAAI,MAAM,GAAG,GAAG,GAAG,IAAI,MAAM,MAAM,CAAC,CAAC;AAAA,IACvD,QAAQ;AACN,aAAO;AAAA,IACT;AACA,QAAI,CAAC,QAAQ,IAAK,QAAO;AAEzB,UAAM,OAAO,KAAK,OAAO,oBAAoB,MAAM;AACnD,UAAM,QAAQ,QAAQ,OAAO,QAAQ,WAAW,OAAO,KAAK,IAAI;AAChE,UAAM,UAAU,MAAM,QAAQ,mBAAmB,SAAS,WAAW,OAAO,GAAG,CAAC,GAAG;AACnF,UAAM,WAAW,WAAW,KAAK,KAAK,IAAI,UAAU,GAAG,KAAK,UAAU,WAAW,OAAO,GAAG,CAAC;AAC5F,WAAO,KAAK,QAAQ,IAAI,KAAK;AAAA,EAC/B,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../src/server/index.ts"],"sourcesContent":["/**\n * Optional server-side helpers (Node/Bun) for sites that want a same-origin\n * relay instead of calling the CMS directly from the browser. Not required\n * for the direct-from-browser flow used by initInlineEdit().\n */\n\nexport interface SaveInlineEditFieldOptions {\n cmsBaseUrl: string;\n siteId: string;\n collection: string;\n slug: string;\n field: string;\n value: string;\n sessionToken: string;\n}\n\nexport type SaveInlineEditFieldResult = { ok: true } | { ok: false; error: string };\n\n/** GET the doc, merge the changed field into .data, PATCH the full merged object back. */\nexport async function saveInlineEditField(\n options: SaveInlineEditFieldOptions,\n): Promise<SaveInlineEditFieldResult> {\n const { cmsBaseUrl, siteId, collection, slug, field, value, sessionToken } = options;\n const headers = { Authorization: `Bearer ${sessionToken}` };\n\n const getRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n headers,\n });\n if (!getRes.ok) return { ok: false, error: `GET failed: ${getRes.status}` };\n const doc = (await getRes.json()) as { data?: Record<string, unknown> };\n const mergedData = { ...doc.data, [field]: value };\n\n const patchRes = await fetch(`${cmsBaseUrl}/api/cms/${collection}/${slug}?site=${siteId}`, {\n method: \"PATCH\",\n headers: { ...headers, \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ data: mergedData }),\n });\n if (!patchRes.ok) return { ok: false, error: `PATCH failed: ${patchRes.status}` };\n return { ok: true };\n}\n\nexport interface VerifyEditSessionOptions {\n cmsBaseUrl: string;\n token: string;\n}\n\nexport interface EditSessionUser {\n sub: string;\n email: string;\n name: string;\n role: string;\n}\n\n/**\n * Calls the CMS's existing GET /api/auth/me with the token as a Bearer header.\n * That endpoint always returns 200 — {\"user\": null} for anonymous/invalid\n * tokens, never a 401 — so absence of a user is read from the body, not the\n * status code.\n */\nexport async function verifyEditSession(\n options: VerifyEditSessionOptions,\n): Promise<EditSessionUser | null> {\n const res = await fetch(`${options.cmsBaseUrl}/api/auth/me`, {\n headers: { Authorization: `Bearer ${options.token}` },\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { user?: EditSessionUser | null };\n return body.user ?? null;\n}\n\n/* ------------------------------------------------------------------ F164 --\n * Live page references.\n *\n * A link the editor made to a PAGE is stored as inline HTML carrying\n * `data-cms-ref=\"collection:slug\"` next to a real, working href — and\n * optionally `data-cms-ref-label=\"auto\"`, meaning \"show the page's current\n * title\". Call resolveCmsLinks() when you render a richtext field and the link\n * re-points itself after the page moves or is renamed, without anything having\n * to rewrite stored content.\n *\n * Deliberately degrades: a site that never calls this still ships links that\n * work — they just stop following the page. And an unknown reference (deleted\n * page) keeps whatever href it had rather than emitting a dead or empty link.\n * -------------------------------------------------------------------------- */\n\nexport interface CmsLinkTarget {\n /** Current public path or URL of the referenced page. */\n url: string;\n /** Current title, used when the link opted into the auto label. */\n title?: string;\n}\n\n/** Resolve `collection:slug` → its current url + title, or null if it is gone. */\nexport type CmsLinkLookup = (\n collection: string,\n slug: string,\n) => CmsLinkTarget | null | undefined;\n\nconst ANCHOR_RE = /<a\\b([^>]*\\bdata-cms-ref=\"[^\"]*\"[^>]*)>([\\s\\S]*?)<\\/a>/gi;\n/**\n * Every anchor, not only the ones carrying a page reference.\n *\n * Quote-aware on purpose. A naive `[^>]*` ends the tag at the first `>` — but a\n * BROWSER does not: inside a quoted attribute value `>` is ordinary text. So\n * `<a href=\"data:text/html,<script>…\">` was read by the regex as a tag ending\n * mid-attribute, escaped the check, and still executed. A sanitiser that\n * tokenises differently from the parser it protects is a bypass, not a guard.\n * Caught by this file's own negative-case test.\n */\nconst ANY_ANCHOR_RE = /<a\\b((?:\"[^\"]*\"|'[^']*'|[^>])*)>([\\s\\S]*?)<\\/a>/gi;\n/** href, quoted or bare — an unquoted attribute is legal HTML5. */\nconst HREF_RE = /\\bhref\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s\"'>]+))/i;\nconst attr = (tag: string, name: string): string => {\n const m = tag.match(new RegExp(`\\\\b${name}=\"([^\"]*)\"`, \"i\"));\n return m?.[1] ?? \"\";\n};\nconst escapeAttr = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\").replace(/</g, \"&lt;\");\nconst escapeText = (v: string): string =>\n v.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n\n/**\n * Neutralise a link whose address would EXECUTE when clicked.\n *\n * The editor refuses these at three points on the way IN, but that only covers\n * text this dialog wrote. Content also arrives from cms-admin's own richtext\n * editor, the REST API, MCP and AI agents, and `marked` renders\n * `[k](javascript:…)` and a raw `<a href=\"javascript:…\">` through untouched —\n * measured with this repo's own version. So the render side needs its own gate.\n *\n * The link's WORDS survive; only the link is removed. Dropping the text as well\n * would silently delete a sentence from a customer's page.\n *\n * Honest limit: this runs only where a site calls it. It is a chokepoint for a\n * consumer that renders through resolveCmsLinks (or sanitizeCmsHtml directly),\n * not a guarantee about every path into every site.\n */\nexport function sanitizeCmsHtml(html: string): string {\n if (!html) return html;\n return html.replace(ANY_ANCHOR_RE, (whole, attrs: string, inner: string) => {\n const m = attrs.match(HREF_RE);\n const href = m?.[1] ?? m?.[2] ?? m?.[3] ?? \"\";\n // Whitespace and control characters are stripped first: browsers ignore\n // them inside a scheme, so `java\\tscript:` and ` javascript:` both run.\n const bare = href.replace(/[\\u0000-\\u0020]/g, \"\");\n return /^(?:javascript|data|vbscript):/i.test(bare) ? inner : whole;\n });\n}\n\nexport function resolveCmsLinks(html: string, lookup: CmsLinkLookup): string {\n if (!html) return html;\n return sanitizeCmsHtml(html).replace(ANCHOR_RE, (whole, attrs: string, inner: string) => {\n const ref = attr(attrs, \"data-cms-ref\");\n const sep = ref.indexOf(\":\");\n if (sep < 1) return whole;\n let target: CmsLinkTarget | null | undefined;\n try {\n target = lookup(ref.slice(0, sep), ref.slice(sep + 1));\n } catch {\n return whole; // a throwing lookup must never take the page down with it\n }\n if (!target?.url) return whole; // page gone → keep the last known href\n\n const auto = attr(attrs, \"data-cms-ref-label\") === \"auto\";\n const label = auto && target.title ? escapeText(target.title) : inner;\n const rebuilt = attrs.replace(/\\bhref=\"[^\"]*\"/i, `href=\"${escapeAttr(target.url)}\"`);\n const withHref = /\\bhref=/i.test(attrs) ? rebuilt : `${attrs} href=\"${escapeAttr(target.url)}\"`;\n return `<a${withHref}>${label}</a>`;\n });\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/cms-inline-edit",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Click-to-edit inline editing for live @webhouse/cms-powered sites — copy-owned, framework-agnostic",
5
5
  "type": "module",
6
6
  "license": "MIT",