@rebuy/rebuy 3.23.0 → 3.24.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 +1 -0
- package/dist/cab/asLayout.d.ts +12 -0
- package/dist/cab/contentText.d.ts +8 -0
- package/dist/cab/decoratedOrderId.d.ts +2 -0
- package/dist/cab/gid.d.ts +2 -0
- package/dist/cab/index.cjs +177 -0
- package/dist/cab/index.d.ts +9 -0
- package/dist/cab/index.mjs +154 -0
- package/dist/cab/interpolateAndSanitize.d.ts +8 -0
- package/dist/cab/listOf.d.ts +9 -0
- package/dist/cab/selectLanguage.d.ts +8 -0
- package/dist/cab/tiptapPlain.d.ts +10 -0
- package/dist/cab/tiptapRuns.d.ts +40 -0
- package/dist/cab/toCents.d.ts +2 -0
- package/dist/index.cjs +2 -0
- package/dist/index.mjs +2 -0
- package/dist/schema/checkout-and-beyond/index.cjs +2 -0
- package/dist/schema/checkout-and-beyond/index.mjs +2 -0
- package/dist/schema/widgets/checkout-and-beyond/regex.d.ts +23 -1
- package/dist/server/index.cjs +21 -9
- package/dist/server/index.mjs +21 -9
- package/dist/transforms/htmlToTiptap/linkSafety.d.ts +19 -0
- package/dist/transforms/index.cjs +21 -9
- package/dist/transforms/index.mjs +21 -9
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ Node `^22.14.0 || ^24.10.0 || ^26.0.0` for development; the published artifacts
|
|
|
17
17
|
| Subpath | Contents |
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| `@rebuy/rebuy` | Root barrel: schemas, client helpers, utilities |
|
|
20
|
+
| `@rebuy/rebuy/cab` | zod-free CAB renderer helpers shared by every renderer (`contentText`, `tiptapPlainText`, `tiptapRuns`, `asLayout`, `idFromGid`, `toCents`, `decoratedOrderId`) |
|
|
20
21
|
| `@rebuy/rebuy/client` | `RebuyClient` API client |
|
|
21
22
|
| `@rebuy/rebuy/gwp` | Gift-with-purchase validation helpers |
|
|
22
23
|
| `@rebuy/rebuy/pricing` | Variant pricing/discount forecast (`priceVariant`, `currencyDecimals`) |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CABLayoutSection } from '../schema/widgets/checkout-and-beyond/layout';
|
|
2
|
+
/**
|
|
3
|
+
* Render a layout-derived section (cart-line, offers, carousel, banner) as a plain layout; the variable
|
|
4
|
+
* fields are dropped — the scope was already mounted and the visibility binding already evaluated at
|
|
5
|
+
* dispatch, and `mutators` already has its host (a re-dispatch must not double any of them).
|
|
6
|
+
*
|
|
7
|
+
* The literal (checked against `SectionType`) rather than the runtime enum: `common.ts` is not tree-shakeable,
|
|
8
|
+
* so importing one constant would drag every CAB enum into each renderer that bundles this subpath.
|
|
9
|
+
*/
|
|
10
|
+
export declare const asLayout: <T extends {
|
|
11
|
+
sectionType: string;
|
|
12
|
+
}>(section: T) => CABLayoutSection;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Variables } from '../cab/interpolateAndSanitize';
|
|
2
|
+
import type { CABTextSection } from '../schema/widgets/checkout-and-beyond/text';
|
|
3
|
+
/**
|
|
4
|
+
* A text section's buyer-language copy as ONE interpolated, sanitized plain string — rich content flattens
|
|
5
|
+
* to bare text (marks and structure drop). The shared reading behind button label slots and the `content`
|
|
6
|
+
* mutator trigger, so "what does this copy say" has a single answer.
|
|
7
|
+
*/
|
|
8
|
+
export declare const contentText: (content: CABTextSection["content"], language?: string, variables?: Variables) => string;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/cab/index.ts
|
|
21
|
+
var cab_exports = {};
|
|
22
|
+
__export(cab_exports, {
|
|
23
|
+
asLayout: () => asLayout,
|
|
24
|
+
contentText: () => contentText,
|
|
25
|
+
decoratedOrderId: () => decoratedOrderId,
|
|
26
|
+
idFromGid: () => idFromGid,
|
|
27
|
+
interpolateAndSanitize: () => interpolateAndSanitize,
|
|
28
|
+
isTiptapDoc: () => isTiptapDoc,
|
|
29
|
+
selectLanguage: () => selectLanguage,
|
|
30
|
+
tiptapPlainText: () => tiptapPlainText,
|
|
31
|
+
tiptapRuns: () => tiptapRuns,
|
|
32
|
+
toCents: () => toCents
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(cab_exports);
|
|
35
|
+
|
|
36
|
+
// src/cab/asLayout.ts
|
|
37
|
+
var asLayout = (section) => ({
|
|
38
|
+
...section,
|
|
39
|
+
mutators: void 0,
|
|
40
|
+
sectionType: "layout",
|
|
41
|
+
variables: void 0,
|
|
42
|
+
visibility: void 0
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// src/schema/widgets/checkout-and-beyond/regex.ts
|
|
46
|
+
var DYNAMIC_TOKEN_REGEX = /^\{\{\s*[A-Za-z]+\s*\}\}$/;
|
|
47
|
+
var DYNAMIC_TOKEN_ANYWHERE_REGEX = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/;
|
|
48
|
+
var HTML_TAGS_REGEX = /<\/?[a-zA-Z][^>]*>/g;
|
|
49
|
+
|
|
50
|
+
// src/cab/interpolateAndSanitize.ts
|
|
51
|
+
var TOKEN_REGEX = /{{1,2}\s*([^{}\s]+)\s*}{1,2}/g;
|
|
52
|
+
var toCamelCase = (key) => key.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase());
|
|
53
|
+
var resolveKey = (key, variables) => {
|
|
54
|
+
if (Object.hasOwn(variables, key)) return key;
|
|
55
|
+
const camel = toCamelCase(key);
|
|
56
|
+
if (Object.hasOwn(variables, camel)) return camel;
|
|
57
|
+
const lower = camel.toLowerCase();
|
|
58
|
+
return Object.keys(variables).find((name) => name.toLowerCase() === lower);
|
|
59
|
+
};
|
|
60
|
+
var interpolateAndSanitize = (input, variables) => (input ?? "").replace(TOKEN_REGEX, (match, key) => {
|
|
61
|
+
if (!variables) return match;
|
|
62
|
+
const resolved = resolveKey(key, variables);
|
|
63
|
+
return resolved === void 0 ? match : String(variables[resolved] ?? "");
|
|
64
|
+
}).replace(HTML_TAGS_REGEX, "");
|
|
65
|
+
|
|
66
|
+
// src/cab/selectLanguage.ts
|
|
67
|
+
var FALLBACK_LANGUAGE = "en";
|
|
68
|
+
var selectLanguage = (content, language) => {
|
|
69
|
+
if (!content) return void 0;
|
|
70
|
+
if (language && Object.hasOwn(content, language)) return content[language];
|
|
71
|
+
if (Object.hasOwn(content, FALLBACK_LANGUAGE)) return content[FALLBACK_LANGUAGE];
|
|
72
|
+
return Object.values(content)[0];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/cab/listOf.ts
|
|
76
|
+
var listOf = (value) => Array.isArray(value) ? value.filter((item) => !!item) : [];
|
|
77
|
+
|
|
78
|
+
// src/cab/tiptapPlain.ts
|
|
79
|
+
var isTiptapDoc = (value) => !!value && typeof value === "object" && value.type === "doc";
|
|
80
|
+
var tiptapPlainText = (doc) => listOf(doc?.content).map(
|
|
81
|
+
(paragraph) => listOf(paragraph.content).map((node) => node.text ?? "").join("")
|
|
82
|
+
).join(" ").trim();
|
|
83
|
+
|
|
84
|
+
// src/cab/contentText.ts
|
|
85
|
+
var contentText = (content, language, variables) => {
|
|
86
|
+
const selected = selectLanguage(content, language);
|
|
87
|
+
const text = typeof selected === "string" ? selected : isTiptapDoc(selected) ? tiptapPlainText(selected) : "";
|
|
88
|
+
return interpolateAndSanitize(text, variables);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// src/cab/decoratedOrderId.ts
|
|
92
|
+
var decoratedOrderId = (orderId) => orderId?.match(/\/Order(?:Identity)?\/(\d+)$/)?.[1] ?? orderId;
|
|
93
|
+
|
|
94
|
+
// src/cab/gid.ts
|
|
95
|
+
var idFromGid = (gid) => gid.slice(gid.lastIndexOf("/") + 1);
|
|
96
|
+
|
|
97
|
+
// src/transforms/htmlToTiptap/isValidHref.ts
|
|
98
|
+
var RELATIVE_BASE = "https://x.invalid";
|
|
99
|
+
var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
100
|
+
var isValidHref = (href) => {
|
|
101
|
+
if (!href) return false;
|
|
102
|
+
if (href === "#") return true;
|
|
103
|
+
if (DYNAMIC_TOKEN_REGEX.test(href)) return true;
|
|
104
|
+
if (href.startsWith("/")) {
|
|
105
|
+
try {
|
|
106
|
+
return new URL(href, RELATIVE_BASE).origin === RELATIVE_BASE;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(href);
|
|
113
|
+
return ALLOWED_PROTOCOLS.has(url.protocol);
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// src/transforms/htmlToTiptap/linkSafety.ts
|
|
120
|
+
var DEFAULT_LINK_TARGET = "_blank";
|
|
121
|
+
var DEFAULT_LINK_REL = "noopener noreferrer nofollow";
|
|
122
|
+
var ensureSafeRel = (rel, target) => {
|
|
123
|
+
if (target !== DEFAULT_LINK_TARGET) return rel;
|
|
124
|
+
const sourceTokens = (rel ?? "").split(/\s+/).filter(Boolean);
|
|
125
|
+
if (sourceTokens.length === 0) return DEFAULT_LINK_REL;
|
|
126
|
+
const tokens = new Set(sourceTokens.filter((token) => token.toLowerCase() !== "opener"));
|
|
127
|
+
tokens.add("noopener");
|
|
128
|
+
tokens.add("noreferrer");
|
|
129
|
+
return Array.from(tokens).join(" ");
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// src/cab/tiptapRuns.ts
|
|
133
|
+
var HEADING = "heading";
|
|
134
|
+
var isHeadingMark = (mark) => mark.type === "textStyle" && mark.attrs?.fontSize === HEADING;
|
|
135
|
+
var isRenderableHref = (href) => isValidHref(href) && !DYNAMIC_TOKEN_ANYWHERE_REGEX.test(href);
|
|
136
|
+
var toRun = (node, variables) => {
|
|
137
|
+
const run = {
|
|
138
|
+
bold: false,
|
|
139
|
+
italic: false,
|
|
140
|
+
strike: false,
|
|
141
|
+
text: interpolateAndSanitize(node.text ?? "", variables)
|
|
142
|
+
};
|
|
143
|
+
let link;
|
|
144
|
+
let textStyle;
|
|
145
|
+
for (const mark of listOf(node.marks)) {
|
|
146
|
+
if (mark.type === "bold") run.bold = true;
|
|
147
|
+
else if (mark.type === "italic") run.italic = true;
|
|
148
|
+
else if (mark.type === "strike") run.strike = true;
|
|
149
|
+
else if (mark.type === "link") link = mark;
|
|
150
|
+
else if (mark.type === "textStyle") textStyle = mark;
|
|
151
|
+
}
|
|
152
|
+
const color = textStyle?.attrs?.color;
|
|
153
|
+
const fontSize = textStyle?.attrs?.fontSize;
|
|
154
|
+
if (color) run.color = color;
|
|
155
|
+
if (fontSize && fontSize !== HEADING) run.fontSize = fontSize;
|
|
156
|
+
if (link) {
|
|
157
|
+
const href = interpolateAndSanitize(link.attrs?.href ?? "", variables);
|
|
158
|
+
if (isRenderableHref(href)) {
|
|
159
|
+
const target = link.attrs?.target ?? DEFAULT_LINK_TARGET;
|
|
160
|
+
const rel = ensureSafeRel(link.attrs?.rel, target);
|
|
161
|
+
run.href = href;
|
|
162
|
+
if (rel) run.rel = rel;
|
|
163
|
+
if (target === DEFAULT_LINK_TARGET) run.target = DEFAULT_LINK_TARGET;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return run;
|
|
167
|
+
};
|
|
168
|
+
var tiptapRuns = (paragraph, variables) => {
|
|
169
|
+
const content = listOf(paragraph?.content);
|
|
170
|
+
return {
|
|
171
|
+
heading: content.some((node) => listOf(node.marks).some(isHeadingMark)),
|
|
172
|
+
runs: content.map((node) => toRun(node, variables))
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// src/cab/toCents.ts
|
|
177
|
+
var toCents = (amount) => Math.round(Number(amount ?? 0) * 100);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from '../cab/asLayout';
|
|
2
|
+
export * from '../cab/contentText';
|
|
3
|
+
export * from '../cab/decoratedOrderId';
|
|
4
|
+
export * from '../cab/gid';
|
|
5
|
+
export * from '../cab/interpolateAndSanitize';
|
|
6
|
+
export * from '../cab/selectLanguage';
|
|
7
|
+
export * from '../cab/tiptapPlain';
|
|
8
|
+
export * from '../cab/tiptapRuns';
|
|
9
|
+
export * from '../cab/toCents';
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// src/cab/asLayout.ts
|
|
2
|
+
var asLayout = (section) => ({
|
|
3
|
+
...section,
|
|
4
|
+
mutators: void 0,
|
|
5
|
+
sectionType: "layout",
|
|
6
|
+
variables: void 0,
|
|
7
|
+
visibility: void 0
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// src/schema/widgets/checkout-and-beyond/regex.ts
|
|
11
|
+
var DYNAMIC_TOKEN_REGEX = /^\{\{\s*[A-Za-z]+\s*\}\}$/;
|
|
12
|
+
var DYNAMIC_TOKEN_ANYWHERE_REGEX = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/;
|
|
13
|
+
var HTML_TAGS_REGEX = /<\/?[a-zA-Z][^>]*>/g;
|
|
14
|
+
|
|
15
|
+
// src/cab/interpolateAndSanitize.ts
|
|
16
|
+
var TOKEN_REGEX = /{{1,2}\s*([^{}\s]+)\s*}{1,2}/g;
|
|
17
|
+
var toCamelCase = (key) => key.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase());
|
|
18
|
+
var resolveKey = (key, variables) => {
|
|
19
|
+
if (Object.hasOwn(variables, key)) return key;
|
|
20
|
+
const camel = toCamelCase(key);
|
|
21
|
+
if (Object.hasOwn(variables, camel)) return camel;
|
|
22
|
+
const lower = camel.toLowerCase();
|
|
23
|
+
return Object.keys(variables).find((name) => name.toLowerCase() === lower);
|
|
24
|
+
};
|
|
25
|
+
var interpolateAndSanitize = (input, variables) => (input ?? "").replace(TOKEN_REGEX, (match, key) => {
|
|
26
|
+
if (!variables) return match;
|
|
27
|
+
const resolved = resolveKey(key, variables);
|
|
28
|
+
return resolved === void 0 ? match : String(variables[resolved] ?? "");
|
|
29
|
+
}).replace(HTML_TAGS_REGEX, "");
|
|
30
|
+
|
|
31
|
+
// src/cab/selectLanguage.ts
|
|
32
|
+
var FALLBACK_LANGUAGE = "en";
|
|
33
|
+
var selectLanguage = (content, language) => {
|
|
34
|
+
if (!content) return void 0;
|
|
35
|
+
if (language && Object.hasOwn(content, language)) return content[language];
|
|
36
|
+
if (Object.hasOwn(content, FALLBACK_LANGUAGE)) return content[FALLBACK_LANGUAGE];
|
|
37
|
+
return Object.values(content)[0];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// src/cab/listOf.ts
|
|
41
|
+
var listOf = (value) => Array.isArray(value) ? value.filter((item) => !!item) : [];
|
|
42
|
+
|
|
43
|
+
// src/cab/tiptapPlain.ts
|
|
44
|
+
var isTiptapDoc = (value) => !!value && typeof value === "object" && value.type === "doc";
|
|
45
|
+
var tiptapPlainText = (doc) => listOf(doc?.content).map(
|
|
46
|
+
(paragraph) => listOf(paragraph.content).map((node) => node.text ?? "").join("")
|
|
47
|
+
).join(" ").trim();
|
|
48
|
+
|
|
49
|
+
// src/cab/contentText.ts
|
|
50
|
+
var contentText = (content, language, variables) => {
|
|
51
|
+
const selected = selectLanguage(content, language);
|
|
52
|
+
const text = typeof selected === "string" ? selected : isTiptapDoc(selected) ? tiptapPlainText(selected) : "";
|
|
53
|
+
return interpolateAndSanitize(text, variables);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// src/cab/decoratedOrderId.ts
|
|
57
|
+
var decoratedOrderId = (orderId) => orderId?.match(/\/Order(?:Identity)?\/(\d+)$/)?.[1] ?? orderId;
|
|
58
|
+
|
|
59
|
+
// src/cab/gid.ts
|
|
60
|
+
var idFromGid = (gid) => gid.slice(gid.lastIndexOf("/") + 1);
|
|
61
|
+
|
|
62
|
+
// src/transforms/htmlToTiptap/isValidHref.ts
|
|
63
|
+
var RELATIVE_BASE = "https://x.invalid";
|
|
64
|
+
var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
65
|
+
var isValidHref = (href) => {
|
|
66
|
+
if (!href) return false;
|
|
67
|
+
if (href === "#") return true;
|
|
68
|
+
if (DYNAMIC_TOKEN_REGEX.test(href)) return true;
|
|
69
|
+
if (href.startsWith("/")) {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(href, RELATIVE_BASE).origin === RELATIVE_BASE;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const url = new URL(href);
|
|
78
|
+
return ALLOWED_PROTOCOLS.has(url.protocol);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// src/transforms/htmlToTiptap/linkSafety.ts
|
|
85
|
+
var DEFAULT_LINK_TARGET = "_blank";
|
|
86
|
+
var DEFAULT_LINK_REL = "noopener noreferrer nofollow";
|
|
87
|
+
var ensureSafeRel = (rel, target) => {
|
|
88
|
+
if (target !== DEFAULT_LINK_TARGET) return rel;
|
|
89
|
+
const sourceTokens = (rel ?? "").split(/\s+/).filter(Boolean);
|
|
90
|
+
if (sourceTokens.length === 0) return DEFAULT_LINK_REL;
|
|
91
|
+
const tokens = new Set(sourceTokens.filter((token) => token.toLowerCase() !== "opener"));
|
|
92
|
+
tokens.add("noopener");
|
|
93
|
+
tokens.add("noreferrer");
|
|
94
|
+
return Array.from(tokens).join(" ");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// src/cab/tiptapRuns.ts
|
|
98
|
+
var HEADING = "heading";
|
|
99
|
+
var isHeadingMark = (mark) => mark.type === "textStyle" && mark.attrs?.fontSize === HEADING;
|
|
100
|
+
var isRenderableHref = (href) => isValidHref(href) && !DYNAMIC_TOKEN_ANYWHERE_REGEX.test(href);
|
|
101
|
+
var toRun = (node, variables) => {
|
|
102
|
+
const run = {
|
|
103
|
+
bold: false,
|
|
104
|
+
italic: false,
|
|
105
|
+
strike: false,
|
|
106
|
+
text: interpolateAndSanitize(node.text ?? "", variables)
|
|
107
|
+
};
|
|
108
|
+
let link;
|
|
109
|
+
let textStyle;
|
|
110
|
+
for (const mark of listOf(node.marks)) {
|
|
111
|
+
if (mark.type === "bold") run.bold = true;
|
|
112
|
+
else if (mark.type === "italic") run.italic = true;
|
|
113
|
+
else if (mark.type === "strike") run.strike = true;
|
|
114
|
+
else if (mark.type === "link") link = mark;
|
|
115
|
+
else if (mark.type === "textStyle") textStyle = mark;
|
|
116
|
+
}
|
|
117
|
+
const color = textStyle?.attrs?.color;
|
|
118
|
+
const fontSize = textStyle?.attrs?.fontSize;
|
|
119
|
+
if (color) run.color = color;
|
|
120
|
+
if (fontSize && fontSize !== HEADING) run.fontSize = fontSize;
|
|
121
|
+
if (link) {
|
|
122
|
+
const href = interpolateAndSanitize(link.attrs?.href ?? "", variables);
|
|
123
|
+
if (isRenderableHref(href)) {
|
|
124
|
+
const target = link.attrs?.target ?? DEFAULT_LINK_TARGET;
|
|
125
|
+
const rel = ensureSafeRel(link.attrs?.rel, target);
|
|
126
|
+
run.href = href;
|
|
127
|
+
if (rel) run.rel = rel;
|
|
128
|
+
if (target === DEFAULT_LINK_TARGET) run.target = DEFAULT_LINK_TARGET;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return run;
|
|
132
|
+
};
|
|
133
|
+
var tiptapRuns = (paragraph, variables) => {
|
|
134
|
+
const content = listOf(paragraph?.content);
|
|
135
|
+
return {
|
|
136
|
+
heading: content.some((node) => listOf(node.marks).some(isHeadingMark)),
|
|
137
|
+
runs: content.map((node) => toRun(node, variables))
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// src/cab/toCents.ts
|
|
142
|
+
var toCents = (amount) => Math.round(Number(amount ?? 0) * 100);
|
|
143
|
+
export {
|
|
144
|
+
asLayout,
|
|
145
|
+
contentText,
|
|
146
|
+
decoratedOrderId,
|
|
147
|
+
idFromGid,
|
|
148
|
+
interpolateAndSanitize,
|
|
149
|
+
isTiptapDoc,
|
|
150
|
+
selectLanguage,
|
|
151
|
+
tiptapPlainText,
|
|
152
|
+
tiptapRuns,
|
|
153
|
+
toCents
|
|
154
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Variables = Record<string, string | number | null | undefined>;
|
|
2
|
+
/**
|
|
3
|
+
* Interpolate tokens then strip HTML (with the same tag-shaped regex the schema validates against, so the
|
|
4
|
+
* client never mangles copy the server accepts) — defense-in-depth, since token values can carry HTML from
|
|
5
|
+
* cart/merchant settings. Unknown tokens are left intact so missing bindings surface during dev (and so
|
|
6
|
+
* incidental single-brace text that names no variable is never touched).
|
|
7
|
+
*/
|
|
8
|
+
export declare const interpolateAndSanitize: (input: string, variables?: Variables) => string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw-payload tolerance for the list-shaped Tiptap fields. `TiptapDocument.content`,
|
|
3
|
+
* `TiptapParagraph.content` and `TiptapText.marks` are each
|
|
4
|
+
* `z.union([z.array(…), z.strictObject({}).transform(() => [])])` — that second arm exists because stored
|
|
5
|
+
* payloads serialize an empty list as `{}` — and any of them can carry `null` holes. The CAB renderer
|
|
6
|
+
* helpers read UNPARSED payloads, so none of those shapes may throw; a malformed list degrades to empty
|
|
7
|
+
* rather than killing the section around it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const listOf: <T>(value: unknown) => T[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fallback chain: requested language, then 'en', then first available value.
|
|
3
|
+
*
|
|
4
|
+
* `Object.hasOwn`, never `in` — `in` would resolve an inherited `Object.prototype` key (`constructor`,
|
|
5
|
+
* `toString`) as if it were a translation. Far less reachable than in `interpolateAndSanitize`, since a
|
|
6
|
+
* language code is not merchant-authored, but the two must agree on what counts as present.
|
|
7
|
+
*/
|
|
8
|
+
export declare const selectLanguage: <T>(content: Record<string, T> | undefined | null, language: string | undefined) => T | undefined;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TiptapDocument } from '../schema/widgets/checkout-and-beyond/text';
|
|
2
|
+
/** Locale-resolved text content is either a plain string or a Tiptap document. */
|
|
3
|
+
export declare const isTiptapDoc: (value: unknown) => value is TiptapDocument;
|
|
4
|
+
/**
|
|
5
|
+
* The document's bare text — nodes concatenated, paragraphs space-joined; marks and structure drop.
|
|
6
|
+
*
|
|
7
|
+
* Every level goes through `listOf`, including the doc itself, because this reads raw payloads: the `{}`
|
|
8
|
+
* empty-list arm and `null` holes are both shapes the schema accepts, at any depth.
|
|
9
|
+
*/
|
|
10
|
+
export declare const tiptapPlainText: (doc: TiptapDocument) => string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Variables } from '../cab/interpolateAndSanitize';
|
|
2
|
+
import type { InlineTextSizeName, TextColorName } from '../schema/widgets/checkout-and-beyond/common';
|
|
3
|
+
import type { TiptapParagraph } from '../schema/widgets/checkout-and-beyond/text';
|
|
4
|
+
/**
|
|
5
|
+
* One text run of a paragraph with its marks resolved to facts. Tokens (`color`, `fontSize`) are the
|
|
6
|
+
* payload's — mapping them to px/hex/tone is each renderer's. `bold` is reported even inside a heading;
|
|
7
|
+
* whether the heading element is already bold is the renderer's to know.
|
|
8
|
+
*
|
|
9
|
+
* A run carrying `href` is safe to hand to an anchor as-is: the scheme is allowlisted and, for
|
|
10
|
+
* `target: '_blank'`, `rel` is guaranteed to contain `noopener noreferrer`. Render BOTH — dropping `rel`
|
|
11
|
+
* re-opens reverse tabnabbing on merchant-authored links, which is the per-renderer knowledge this
|
|
12
|
+
* subpath exists to delete. A link whose href fails the allowlist yields a run with no `href`/`rel`/
|
|
13
|
+
* `target` at all, so it renders as plain text rather than as a live unsafe link.
|
|
14
|
+
*/
|
|
15
|
+
export type TiptapRun = {
|
|
16
|
+
bold: boolean;
|
|
17
|
+
color?: TextColorName;
|
|
18
|
+
fontSize?: InlineTextSizeName;
|
|
19
|
+
href?: string;
|
|
20
|
+
italic: boolean;
|
|
21
|
+
rel?: string;
|
|
22
|
+
strike: boolean;
|
|
23
|
+
target?: '_blank';
|
|
24
|
+
text: string;
|
|
25
|
+
};
|
|
26
|
+
/** `heading` promotes the WHOLE paragraph (a block signal, never an inline size — see `textSizeNames`). */
|
|
27
|
+
export type TiptapParagraphRuns = {
|
|
28
|
+
heading: boolean;
|
|
29
|
+
runs: TiptapRun[];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* A Tiptap paragraph as ordered styled runs plus its heading promotion — the one mark reading every
|
|
33
|
+
* renderer shares.
|
|
34
|
+
*
|
|
35
|
+
* Takes a payload in EITHER shape: schema-parsed, or raw off the wire. Malformed lists degrade to empty
|
|
36
|
+
* rather than throwing — including a nullish paragraph, so a renderer can map this straight over a raw
|
|
37
|
+
* `doc.content` — and the two link fields the schema defaults (`target`, `rel`) are resolved to the same
|
|
38
|
+
* effective values a parse would have produced, so a run never depends on whether the caller parsed.
|
|
39
|
+
*/
|
|
40
|
+
export declare const tiptapRuns: (paragraph: TiptapParagraph, variables?: Variables) => TiptapParagraphRuns;
|
package/dist/index.cjs
CHANGED
|
@@ -90,6 +90,7 @@ __export(src_exports, {
|
|
|
90
90
|
ContentOperator: () => ContentOperator,
|
|
91
91
|
DEFAULT_ENDPOINTS: () => DEFAULT_ENDPOINTS,
|
|
92
92
|
DESCRIPTION_LABELS: () => DESCRIPTION_LABELS,
|
|
93
|
+
DYNAMIC_TOKEN_ANYWHERE_REGEX: () => DYNAMIC_TOKEN_ANYWHERE_REGEX,
|
|
93
94
|
DYNAMIC_TOKEN_REGEX: () => DYNAMIC_TOKEN_REGEX,
|
|
94
95
|
Direction: () => Direction,
|
|
95
96
|
DiscountSource: () => DiscountSource,
|
|
@@ -1936,6 +1937,7 @@ var import_compat = require("es-toolkit/compat");
|
|
|
1936
1937
|
// src/schema/widgets/checkout-and-beyond/regex.ts
|
|
1937
1938
|
var ARRAY_INDEX_STRING = /^(0|[1-9]\d*)$/;
|
|
1938
1939
|
var DYNAMIC_TOKEN_REGEX = /^\{\{\s*[A-Za-z]+\s*\}\}$/;
|
|
1940
|
+
var DYNAMIC_TOKEN_ANYWHERE_REGEX = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/;
|
|
1939
1941
|
var HEX_COLOR_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
1940
1942
|
var ROOT_RELATIVE_PATH_REGEX = /^\/(?!\/)/;
|
|
1941
1943
|
var HTML_TAGS_REGEX = /<\/?[a-zA-Z][^>]*>/g;
|
package/dist/index.mjs
CHANGED
|
@@ -1642,6 +1642,7 @@ import { get as get2, isString } from "es-toolkit/compat";
|
|
|
1642
1642
|
// src/schema/widgets/checkout-and-beyond/regex.ts
|
|
1643
1643
|
var ARRAY_INDEX_STRING = /^(0|[1-9]\d*)$/;
|
|
1644
1644
|
var DYNAMIC_TOKEN_REGEX = /^\{\{\s*[A-Za-z]+\s*\}\}$/;
|
|
1645
|
+
var DYNAMIC_TOKEN_ANYWHERE_REGEX = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/;
|
|
1645
1646
|
var HEX_COLOR_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
1646
1647
|
var ROOT_RELATIVE_PATH_REGEX = /^\/(?!\/)/;
|
|
1647
1648
|
var HTML_TAGS_REGEX = /<\/?[a-zA-Z][^>]*>/g;
|
|
@@ -4915,6 +4916,7 @@ export {
|
|
|
4915
4916
|
ContentOperator,
|
|
4916
4917
|
DEFAULT_ENDPOINTS,
|
|
4917
4918
|
DESCRIPTION_LABELS,
|
|
4919
|
+
DYNAMIC_TOKEN_ANYWHERE_REGEX,
|
|
4918
4920
|
DYNAMIC_TOKEN_REGEX,
|
|
4919
4921
|
Direction,
|
|
4920
4922
|
DiscountSource,
|
|
@@ -79,6 +79,7 @@ __export(checkout_and_beyond_exports, {
|
|
|
79
79
|
ContentOperator: () => ContentOperator,
|
|
80
80
|
DEFAULT_ENDPOINTS: () => DEFAULT_ENDPOINTS,
|
|
81
81
|
DESCRIPTION_LABELS: () => DESCRIPTION_LABELS,
|
|
82
|
+
DYNAMIC_TOKEN_ANYWHERE_REGEX: () => DYNAMIC_TOKEN_ANYWHERE_REGEX,
|
|
82
83
|
DYNAMIC_TOKEN_REGEX: () => DYNAMIC_TOKEN_REGEX,
|
|
83
84
|
Direction: () => Direction,
|
|
84
85
|
DiscountSource: () => DiscountSource,
|
|
@@ -510,6 +511,7 @@ var IconTone = freezeEnum(iconTones);
|
|
|
510
511
|
// src/schema/widgets/checkout-and-beyond/regex.ts
|
|
511
512
|
var ARRAY_INDEX_STRING = /^(0|[1-9]\d*)$/;
|
|
512
513
|
var DYNAMIC_TOKEN_REGEX = /^\{\{\s*[A-Za-z]+\s*\}\}$/;
|
|
514
|
+
var DYNAMIC_TOKEN_ANYWHERE_REGEX = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/;
|
|
513
515
|
var HEX_COLOR_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
514
516
|
var ROOT_RELATIVE_PATH_REGEX = /^\/(?!\/)/;
|
|
515
517
|
var HTML_TAGS_REGEX = /<\/?[a-zA-Z][^>]*>/g;
|
|
@@ -306,6 +306,7 @@ var IconTone = freezeEnum(iconTones);
|
|
|
306
306
|
// src/schema/widgets/checkout-and-beyond/regex.ts
|
|
307
307
|
var ARRAY_INDEX_STRING = /^(0|[1-9]\d*)$/;
|
|
308
308
|
var DYNAMIC_TOKEN_REGEX = /^\{\{\s*[A-Za-z]+\s*\}\}$/;
|
|
309
|
+
var DYNAMIC_TOKEN_ANYWHERE_REGEX = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/;
|
|
309
310
|
var HEX_COLOR_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
310
311
|
var ROOT_RELATIVE_PATH_REGEX = /^\/(?!\/)/;
|
|
311
312
|
var HTML_TAGS_REGEX = /<\/?[a-zA-Z][^>]*>/g;
|
|
@@ -1942,6 +1943,7 @@ export {
|
|
|
1942
1943
|
ContentOperator,
|
|
1943
1944
|
DEFAULT_ENDPOINTS,
|
|
1944
1945
|
DESCRIPTION_LABELS,
|
|
1946
|
+
DYNAMIC_TOKEN_ANYWHERE_REGEX,
|
|
1945
1947
|
DYNAMIC_TOKEN_REGEX,
|
|
1946
1948
|
Direction,
|
|
1947
1949
|
DiscountSource,
|
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
/** Matches a valid array index string (e.g., "0", "1", "42"). */
|
|
2
2
|
export declare const ARRAY_INDEX_STRING: RegExp;
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* VALIDATION grammar: whether a whole value IS a dynamic token. Gates what may be STORED (a link href in
|
|
5
|
+
* `text.ts`, an image source in `image.ts`), so widening it is a schema shape change that ripples to every
|
|
6
|
+
* consumer. Deliberately narrow — letters only.
|
|
7
|
+
*/
|
|
4
8
|
export declare const DYNAMIC_TOKEN_REGEX: RegExp;
|
|
9
|
+
/**
|
|
10
|
+
* DETECTION grammar: whether a value still CONTAINS a token after interpolation, i.e. one that failed to
|
|
11
|
+
* bind. Deliberately NOT derived from the validation grammar above — the two pull in opposite directions.
|
|
12
|
+
* Validation must stay narrow to keep stored values predictable; detection must be as wide as what
|
|
13
|
+
* `interpolateAndSanitize` binds in the CANONICAL double-brace spelling, because anything it misses there
|
|
14
|
+
* is a dead link handed to a renderer. Hence `[A-Za-z0-9_]+` on the key: `resolveKey` camelizes legacy
|
|
15
|
+
* snake_case (`{{utm_source}}` binds to `utmSource`), so those spellings have to be detectable too.
|
|
16
|
+
*
|
|
17
|
+
* Still double-brace only, and still no `.`: that is what keeps third-party click macros — Google Ads'
|
|
18
|
+
* `{placement}`, Meta's `{{ad.id}}` — out of it. Nothing binds those names today, and dropping a working
|
|
19
|
+
* ad link is worse than leaving a dead one.
|
|
20
|
+
*
|
|
21
|
+
* BOTH exclusions are deliberate carve-outs rather than faithful widenings. `TOKEN_REGEX` quantifies open
|
|
22
|
+
* and close braces independently, so `{slug}` and `{{slug}` bind yet go undetected; its key class is
|
|
23
|
+
* `[^{}\s]+`, so a dotted name would bind too if anything were ever registered under one. Each keeps a
|
|
24
|
+
* probably-dead link, known and tolerated as the cheaper error.
|
|
25
|
+
*/
|
|
26
|
+
export declare const DYNAMIC_TOKEN_ANYWHERE_REGEX: RegExp;
|
|
5
27
|
/** Matches a valid hex color code. */
|
|
6
28
|
export declare const HEX_COLOR_REGEX: RegExp;
|
|
7
29
|
/**
|
package/dist/server/index.cjs
CHANGED
|
@@ -2453,13 +2453,19 @@ var isSkipping = (stack = []) => stack.some(({ skip }) => skip);
|
|
|
2453
2453
|
var makeDoc = (content, blockSpacing) => TiptapDocument.parse({ attrs: { blockSpacing }, content });
|
|
2454
2454
|
|
|
2455
2455
|
// src/transforms/htmlToTiptap/isValidHref.ts
|
|
2456
|
-
var
|
|
2456
|
+
var RELATIVE_BASE = "https://x.invalid";
|
|
2457
2457
|
var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
2458
2458
|
var isValidHref = (href) => {
|
|
2459
2459
|
if (!href) return false;
|
|
2460
2460
|
if (href === "#") return true;
|
|
2461
|
-
if (
|
|
2462
|
-
if (href.startsWith("/")
|
|
2461
|
+
if (DYNAMIC_TOKEN_REGEX.test(href)) return true;
|
|
2462
|
+
if (href.startsWith("/")) {
|
|
2463
|
+
try {
|
|
2464
|
+
return new URL(href, RELATIVE_BASE).origin === RELATIVE_BASE;
|
|
2465
|
+
} catch {
|
|
2466
|
+
return false;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2463
2469
|
try {
|
|
2464
2470
|
const url = new URL(href);
|
|
2465
2471
|
return ALLOWED_PROTOCOLS.has(url.protocol);
|
|
@@ -2468,26 +2474,32 @@ var isValidHref = (href) => {
|
|
|
2468
2474
|
}
|
|
2469
2475
|
};
|
|
2470
2476
|
|
|
2471
|
-
// src/transforms/htmlToTiptap/
|
|
2472
|
-
var
|
|
2473
|
-
var
|
|
2477
|
+
// src/transforms/htmlToTiptap/linkSafety.ts
|
|
2478
|
+
var DEFAULT_LINK_TARGET = "_blank";
|
|
2479
|
+
var DEFAULT_LINK_REL = "noopener noreferrer nofollow";
|
|
2474
2480
|
var ensureSafeRel = (rel, target) => {
|
|
2475
|
-
if (target !==
|
|
2481
|
+
if (target !== DEFAULT_LINK_TARGET) return rel;
|
|
2476
2482
|
const sourceTokens = (rel ?? "").split(/\s+/).filter(Boolean);
|
|
2477
|
-
if (sourceTokens.length === 0) return
|
|
2483
|
+
if (sourceTokens.length === 0) return DEFAULT_LINK_REL;
|
|
2478
2484
|
const tokens = new Set(sourceTokens.filter((token) => token.toLowerCase() !== "opener"));
|
|
2479
2485
|
tokens.add("noopener");
|
|
2480
2486
|
tokens.add("noreferrer");
|
|
2481
2487
|
return Array.from(tokens).join(" ");
|
|
2482
2488
|
};
|
|
2489
|
+
|
|
2490
|
+
// src/transforms/htmlToTiptap/buildLinkMark.ts
|
|
2483
2491
|
var buildLinkMark = (attrs = {}) => {
|
|
2484
2492
|
const { href } = attrs;
|
|
2485
2493
|
if (!href || !isValidHref(href)) return null;
|
|
2486
|
-
const target = attrs.target ??
|
|
2494
|
+
const target = attrs.target ?? DEFAULT_LINK_TARGET;
|
|
2487
2495
|
return {
|
|
2488
2496
|
attrs: {
|
|
2489
2497
|
class: null,
|
|
2490
2498
|
href,
|
|
2499
|
+
// `Mark` is the POST-parse shape, where `rel` is always a string. This value is PRE-parse: a
|
|
2500
|
+
// non-`_blank` link whose source omits `rel` deliberately stays undefined so the schema's
|
|
2501
|
+
// `.default()` supplies it. Hence the cast — the old implicit `rel: string` parameter was
|
|
2502
|
+
// making the same claim, just without saying so.
|
|
2491
2503
|
rel: ensureSafeRel(attrs.rel, target),
|
|
2492
2504
|
target
|
|
2493
2505
|
},
|
package/dist/server/index.mjs
CHANGED
|
@@ -2402,13 +2402,19 @@ var isSkipping = (stack = []) => stack.some(({ skip }) => skip);
|
|
|
2402
2402
|
var makeDoc = (content, blockSpacing) => TiptapDocument.parse({ attrs: { blockSpacing }, content });
|
|
2403
2403
|
|
|
2404
2404
|
// src/transforms/htmlToTiptap/isValidHref.ts
|
|
2405
|
-
var
|
|
2405
|
+
var RELATIVE_BASE = "https://x.invalid";
|
|
2406
2406
|
var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
2407
2407
|
var isValidHref = (href) => {
|
|
2408
2408
|
if (!href) return false;
|
|
2409
2409
|
if (href === "#") return true;
|
|
2410
|
-
if (
|
|
2411
|
-
if (href.startsWith("/")
|
|
2410
|
+
if (DYNAMIC_TOKEN_REGEX.test(href)) return true;
|
|
2411
|
+
if (href.startsWith("/")) {
|
|
2412
|
+
try {
|
|
2413
|
+
return new URL(href, RELATIVE_BASE).origin === RELATIVE_BASE;
|
|
2414
|
+
} catch {
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2412
2418
|
try {
|
|
2413
2419
|
const url = new URL(href);
|
|
2414
2420
|
return ALLOWED_PROTOCOLS.has(url.protocol);
|
|
@@ -2417,26 +2423,32 @@ var isValidHref = (href) => {
|
|
|
2417
2423
|
}
|
|
2418
2424
|
};
|
|
2419
2425
|
|
|
2420
|
-
// src/transforms/htmlToTiptap/
|
|
2421
|
-
var
|
|
2422
|
-
var
|
|
2426
|
+
// src/transforms/htmlToTiptap/linkSafety.ts
|
|
2427
|
+
var DEFAULT_LINK_TARGET = "_blank";
|
|
2428
|
+
var DEFAULT_LINK_REL = "noopener noreferrer nofollow";
|
|
2423
2429
|
var ensureSafeRel = (rel, target) => {
|
|
2424
|
-
if (target !==
|
|
2430
|
+
if (target !== DEFAULT_LINK_TARGET) return rel;
|
|
2425
2431
|
const sourceTokens = (rel ?? "").split(/\s+/).filter(Boolean);
|
|
2426
|
-
if (sourceTokens.length === 0) return
|
|
2432
|
+
if (sourceTokens.length === 0) return DEFAULT_LINK_REL;
|
|
2427
2433
|
const tokens = new Set(sourceTokens.filter((token) => token.toLowerCase() !== "opener"));
|
|
2428
2434
|
tokens.add("noopener");
|
|
2429
2435
|
tokens.add("noreferrer");
|
|
2430
2436
|
return Array.from(tokens).join(" ");
|
|
2431
2437
|
};
|
|
2438
|
+
|
|
2439
|
+
// src/transforms/htmlToTiptap/buildLinkMark.ts
|
|
2432
2440
|
var buildLinkMark = (attrs = {}) => {
|
|
2433
2441
|
const { href } = attrs;
|
|
2434
2442
|
if (!href || !isValidHref(href)) return null;
|
|
2435
|
-
const target = attrs.target ??
|
|
2443
|
+
const target = attrs.target ?? DEFAULT_LINK_TARGET;
|
|
2436
2444
|
return {
|
|
2437
2445
|
attrs: {
|
|
2438
2446
|
class: null,
|
|
2439
2447
|
href,
|
|
2448
|
+
// `Mark` is the POST-parse shape, where `rel` is always a string. This value is PRE-parse: a
|
|
2449
|
+
// non-`_blank` link whose source omits `rel` deliberately stays undefined so the schema's
|
|
2450
|
+
// `.default()` supplies it. Hence the cast — the old implicit `rel: string` parameter was
|
|
2451
|
+
// making the same claim, just without saying so.
|
|
2440
2452
|
rel: ensureSafeRel(attrs.rel, target),
|
|
2441
2453
|
target
|
|
2442
2454
|
},
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema defaults for an `<a>` mark when a payload omits them. Match `TiptapText` in
|
|
3
|
+
* `~/schema/widgets/checkout-and-beyond/text` so anything resolving a link BEFORE (or without) a parse
|
|
4
|
+
* lands on the same effective values the downstream parse would have used.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_LINK_TARGET = "_blank";
|
|
7
|
+
export declare const DEFAULT_LINK_REL = "noopener noreferrer nofollow";
|
|
8
|
+
/**
|
|
9
|
+
* Forces `noopener noreferrer` on every `_blank` link to defend against reverse-tabnabbing
|
|
10
|
+
* (window.opener attacks). When the source omits `rel` entirely, fall back to the schema default
|
|
11
|
+
* (`noopener noreferrer nofollow`). When source `rel` is non-empty, preserve the existing tokens, drop
|
|
12
|
+
* `opener`, and ensure the safe pair is present.
|
|
13
|
+
*
|
|
14
|
+
* Non-`_blank` targets pass through UNCHANGED — they can't be tabnabbed. That includes passing `undefined`
|
|
15
|
+
* straight back: `TiptapText`'s `rel: z.string().default('noopener noreferrer nofollow')` fires on
|
|
16
|
+
* `undefined` only, so substituting `''` here would silently strip `nofollow` off every `_self` link at
|
|
17
|
+
* parse time. The absent value is the signal that the schema default should apply.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ensureSafeRel: (rel: string | undefined, target: string) => string | undefined;
|
|
@@ -1837,13 +1837,19 @@ var isSkipping = (stack = []) => stack.some(({ skip }) => skip);
|
|
|
1837
1837
|
var makeDoc = (content, blockSpacing) => TiptapDocument.parse({ attrs: { blockSpacing }, content });
|
|
1838
1838
|
|
|
1839
1839
|
// src/transforms/htmlToTiptap/isValidHref.ts
|
|
1840
|
-
var
|
|
1840
|
+
var RELATIVE_BASE = "https://x.invalid";
|
|
1841
1841
|
var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
1842
1842
|
var isValidHref = (href) => {
|
|
1843
1843
|
if (!href) return false;
|
|
1844
1844
|
if (href === "#") return true;
|
|
1845
|
-
if (
|
|
1846
|
-
if (href.startsWith("/")
|
|
1845
|
+
if (DYNAMIC_TOKEN_REGEX.test(href)) return true;
|
|
1846
|
+
if (href.startsWith("/")) {
|
|
1847
|
+
try {
|
|
1848
|
+
return new URL(href, RELATIVE_BASE).origin === RELATIVE_BASE;
|
|
1849
|
+
} catch {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1847
1853
|
try {
|
|
1848
1854
|
const url = new URL(href);
|
|
1849
1855
|
return ALLOWED_PROTOCOLS.has(url.protocol);
|
|
@@ -1852,26 +1858,32 @@ var isValidHref = (href) => {
|
|
|
1852
1858
|
}
|
|
1853
1859
|
};
|
|
1854
1860
|
|
|
1855
|
-
// src/transforms/htmlToTiptap/
|
|
1856
|
-
var
|
|
1857
|
-
var
|
|
1861
|
+
// src/transforms/htmlToTiptap/linkSafety.ts
|
|
1862
|
+
var DEFAULT_LINK_TARGET = "_blank";
|
|
1863
|
+
var DEFAULT_LINK_REL = "noopener noreferrer nofollow";
|
|
1858
1864
|
var ensureSafeRel = (rel, target) => {
|
|
1859
|
-
if (target !==
|
|
1865
|
+
if (target !== DEFAULT_LINK_TARGET) return rel;
|
|
1860
1866
|
const sourceTokens = (rel ?? "").split(/\s+/).filter(Boolean);
|
|
1861
|
-
if (sourceTokens.length === 0) return
|
|
1867
|
+
if (sourceTokens.length === 0) return DEFAULT_LINK_REL;
|
|
1862
1868
|
const tokens = new Set(sourceTokens.filter((token) => token.toLowerCase() !== "opener"));
|
|
1863
1869
|
tokens.add("noopener");
|
|
1864
1870
|
tokens.add("noreferrer");
|
|
1865
1871
|
return Array.from(tokens).join(" ");
|
|
1866
1872
|
};
|
|
1873
|
+
|
|
1874
|
+
// src/transforms/htmlToTiptap/buildLinkMark.ts
|
|
1867
1875
|
var buildLinkMark = (attrs = {}) => {
|
|
1868
1876
|
const { href } = attrs;
|
|
1869
1877
|
if (!href || !isValidHref(href)) return null;
|
|
1870
|
-
const target = attrs.target ??
|
|
1878
|
+
const target = attrs.target ?? DEFAULT_LINK_TARGET;
|
|
1871
1879
|
return {
|
|
1872
1880
|
attrs: {
|
|
1873
1881
|
class: null,
|
|
1874
1882
|
href,
|
|
1883
|
+
// `Mark` is the POST-parse shape, where `rel` is always a string. This value is PRE-parse: a
|
|
1884
|
+
// non-`_blank` link whose source omits `rel` deliberately stays undefined so the schema's
|
|
1885
|
+
// `.default()` supplies it. Hence the cast — the old implicit `rel: string` parameter was
|
|
1886
|
+
// making the same claim, just without saying so.
|
|
1875
1887
|
rel: ensureSafeRel(attrs.rel, target),
|
|
1876
1888
|
target
|
|
1877
1889
|
},
|
|
@@ -1778,13 +1778,19 @@ var isSkipping = (stack = []) => stack.some(({ skip }) => skip);
|
|
|
1778
1778
|
var makeDoc = (content, blockSpacing) => TiptapDocument.parse({ attrs: { blockSpacing }, content });
|
|
1779
1779
|
|
|
1780
1780
|
// src/transforms/htmlToTiptap/isValidHref.ts
|
|
1781
|
-
var
|
|
1781
|
+
var RELATIVE_BASE = "https://x.invalid";
|
|
1782
1782
|
var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
1783
1783
|
var isValidHref = (href) => {
|
|
1784
1784
|
if (!href) return false;
|
|
1785
1785
|
if (href === "#") return true;
|
|
1786
|
-
if (
|
|
1787
|
-
if (href.startsWith("/")
|
|
1786
|
+
if (DYNAMIC_TOKEN_REGEX.test(href)) return true;
|
|
1787
|
+
if (href.startsWith("/")) {
|
|
1788
|
+
try {
|
|
1789
|
+
return new URL(href, RELATIVE_BASE).origin === RELATIVE_BASE;
|
|
1790
|
+
} catch {
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1788
1794
|
try {
|
|
1789
1795
|
const url = new URL(href);
|
|
1790
1796
|
return ALLOWED_PROTOCOLS.has(url.protocol);
|
|
@@ -1793,26 +1799,32 @@ var isValidHref = (href) => {
|
|
|
1793
1799
|
}
|
|
1794
1800
|
};
|
|
1795
1801
|
|
|
1796
|
-
// src/transforms/htmlToTiptap/
|
|
1797
|
-
var
|
|
1798
|
-
var
|
|
1802
|
+
// src/transforms/htmlToTiptap/linkSafety.ts
|
|
1803
|
+
var DEFAULT_LINK_TARGET = "_blank";
|
|
1804
|
+
var DEFAULT_LINK_REL = "noopener noreferrer nofollow";
|
|
1799
1805
|
var ensureSafeRel = (rel, target) => {
|
|
1800
|
-
if (target !==
|
|
1806
|
+
if (target !== DEFAULT_LINK_TARGET) return rel;
|
|
1801
1807
|
const sourceTokens = (rel ?? "").split(/\s+/).filter(Boolean);
|
|
1802
|
-
if (sourceTokens.length === 0) return
|
|
1808
|
+
if (sourceTokens.length === 0) return DEFAULT_LINK_REL;
|
|
1803
1809
|
const tokens = new Set(sourceTokens.filter((token) => token.toLowerCase() !== "opener"));
|
|
1804
1810
|
tokens.add("noopener");
|
|
1805
1811
|
tokens.add("noreferrer");
|
|
1806
1812
|
return Array.from(tokens).join(" ");
|
|
1807
1813
|
};
|
|
1814
|
+
|
|
1815
|
+
// src/transforms/htmlToTiptap/buildLinkMark.ts
|
|
1808
1816
|
var buildLinkMark = (attrs = {}) => {
|
|
1809
1817
|
const { href } = attrs;
|
|
1810
1818
|
if (!href || !isValidHref(href)) return null;
|
|
1811
|
-
const target = attrs.target ??
|
|
1819
|
+
const target = attrs.target ?? DEFAULT_LINK_TARGET;
|
|
1812
1820
|
return {
|
|
1813
1821
|
attrs: {
|
|
1814
1822
|
class: null,
|
|
1815
1823
|
href,
|
|
1824
|
+
// `Mark` is the POST-parse shape, where `rel` is always a string. This value is PRE-parse: a
|
|
1825
|
+
// non-`_blank` link whose source omits `rel` deliberately stays undefined so the schema's
|
|
1826
|
+
// `.default()` supplies it. Hence the cast — the old implicit `rel: string` parameter was
|
|
1827
|
+
// making the same claim, just without saying so.
|
|
1816
1828
|
rel: ensureSafeRel(attrs.rel, target),
|
|
1817
1829
|
target
|
|
1818
1830
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebuy/rebuy",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.24.0",
|
|
4
4
|
"description": "Shared zod schemas, legacy-to-CAB widget transforms, and server orchestrators for Rebuy's Shopify consumers (rebuy-api, admin-nextjs, rebuy-shopify-extensions)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Rebuy, Inc.",
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"import": "./dist/index.mjs",
|
|
13
13
|
"require": "./dist/index.cjs"
|
|
14
14
|
},
|
|
15
|
+
"./cab": {
|
|
16
|
+
"types": "./dist/cab/index.d.ts",
|
|
17
|
+
"import": "./dist/cab/index.mjs",
|
|
18
|
+
"require": "./dist/cab/index.cjs"
|
|
19
|
+
},
|
|
15
20
|
"./client": {
|
|
16
21
|
"types": "./dist/client.d.ts",
|
|
17
22
|
"import": "./dist/client.mjs",
|
|
@@ -79,6 +84,9 @@
|
|
|
79
84
|
"types": "./dist/index.d.ts",
|
|
80
85
|
"typesVersions": {
|
|
81
86
|
"*": {
|
|
87
|
+
"cab": [
|
|
88
|
+
"./dist/cab/index.d.ts"
|
|
89
|
+
],
|
|
82
90
|
"client": [
|
|
83
91
|
"./dist/client.d.ts"
|
|
84
92
|
],
|