@constructor-io/constructorio-ui-pia 2.18.0 → 2.18.1

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.
@@ -8,7 +8,7 @@ function Answer({ text, componentOverride }) {
8
8
  if (!text) {
9
9
  return null;
10
10
  }
11
- return (react_1.default.createElement(constructorio_ui_components_1.RenderPropsWrapper, { props: { text }, override: componentOverride === null || componentOverride === void 0 ? void 0 : componentOverride.reactNode },
12
- react_1.default.createElement("div", { className: 'cio-pia-answer', "data-testid": 'answer-text', dangerouslySetInnerHTML: { __html: (0, contentTransformers_1.renderMarkdown)(text) } })));
11
+ const html = (0, contentTransformers_1.renderMarkdown)(text);
12
+ return (react_1.default.createElement(constructorio_ui_components_1.RenderPropsWrapper, { props: { text }, override: componentOverride === null || componentOverride === void 0 ? void 0 : componentOverride.reactNode }, html ? (react_1.default.createElement("div", { className: 'cio-pia-answer', "data-testid": 'answer-text', dangerouslySetInnerHTML: { __html: html } })) : (react_1.default.createElement("div", { className: 'cio-pia-answer', "data-testid": 'answer-text' }, text))));
13
13
  }
14
14
  exports.default = Answer;
@@ -1,15 +1,3 @@
1
- div.subheading {
2
- --mediumdark: '#999999';
3
- font-weight: 700;
4
- font-size: 13px;
5
- color: #999;
6
- letter-spacing: 6px;
7
- line-height: 24px;
8
- text-transform: uppercase;
9
- margin-bottom: 12px;
10
- margin-top: 40px;
11
- }
12
-
13
1
  div.link-list {
14
2
  display: grid;
15
3
  grid-template-columns: 1fr;
@@ -78,126 +66,3 @@ a.link-item p {
78
66
  line-height: 20px;
79
67
  }
80
68
 
81
- span.badge {
82
- display: inline-block;
83
- border-radius: 1em;
84
- font-size: 11px;
85
- line-height: 12px;
86
- font-weight: 700;
87
- background: #e7fdd8;
88
- color: #66bf3c;
89
- padding: 4px 12px;
90
- margin-right: 10px;
91
- vertical-align: top;
92
- }
93
-
94
- div.badge-wrapper {
95
- font-size: 13px;
96
- line-height: 20px;
97
- margin-top: 40px;
98
- margin-bottom: 40px;
99
- }
100
-
101
- div.badge-wrapper code {
102
- font-size: 12px;
103
- display: inline-block;
104
- }
105
-
106
- .badge.tip {
107
- background: #e7fdd8;
108
- color: #66bf3c;
109
- }
110
-
111
- .badge.note {
112
- background: #d8e8fd;
113
- color: #3c70bf;
114
- }
115
-
116
- .cio-flow-diagram {
117
- display: flex;
118
- flex-direction: column;
119
- gap: 4px;
120
- margin: 24px 0 0;
121
- font-family: system-ui, sans-serif;
122
- font-size: 14px;
123
- }
124
-
125
- .cio-flow-diagram .flow-step {
126
- display: flex;
127
- align-items: center;
128
- gap: 12px;
129
- }
130
-
131
- .cio-flow-diagram .flow-box {
132
- padding: 10px 16px;
133
- border-radius: 8px;
134
- min-width: 220px;
135
- text-align: center;
136
- }
137
-
138
- .cio-flow-diagram .flow-box--consumer {
139
- background: #eff6ff;
140
- border: 1px solid #bfdbfe;
141
- }
142
-
143
- .cio-flow-diagram .flow-box--internal {
144
- background: #f5f3ff;
145
- border: 1px solid #ddd6fe;
146
- }
147
-
148
- .cio-flow-diagram .flow-box--output {
149
- background: #f0fdf4;
150
- border: 1px solid #bbf7d0;
151
- }
152
-
153
- .cio-flow-diagram .flow-arrow {
154
- margin-left: 110px;
155
- color: #9ca3af;
156
- }
157
-
158
- .cio-flow-diagram .flow-label {
159
- font-size: 12px;
160
- color: #9ca3af;
161
- font-style: italic;
162
- }
163
-
164
- .cio-flow-diagram .flow-callback {
165
- font-size: 12px;
166
- color: #059669;
167
- }
168
-
169
- .cio-flow-diagram .flow-box-subtitle {
170
- font-size: 12px;
171
- color: #6b7280;
172
- }
173
-
174
- .cio-flow-legend {
175
- display: flex;
176
- gap: 16px;
177
- margin: 32px 0 16px;
178
- font-size: 12px;
179
- }
180
-
181
- .cio-flow-legend .legend-swatch {
182
- display: inline-block;
183
- width: 12px;
184
- height: 12px;
185
- border-radius: 3px;
186
- margin-right: 4px;
187
- vertical-align: middle;
188
- }
189
-
190
- .cio-flow-legend .legend-swatch--consumer {
191
- background: #eff6ff;
192
- border: 1px solid #bfdbfe;
193
- }
194
-
195
- .cio-flow-legend .legend-swatch--internal {
196
- background: #f5f3ff;
197
- border: 1px solid #ddd6fe;
198
- }
199
-
200
- .cio-flow-legend .legend-swatch--output {
201
- background: #f0fdf4;
202
- border: 1px solid #bbf7d0;
203
- }
@@ -4,25 +4,71 @@ exports.renderMarkdown = exports.sanitizeHtml = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const dompurify_1 = tslib_1.__importDefault(require("dompurify"));
6
6
  const marked_1 = require("marked");
7
+ // For catalog HTML (product descriptions).
7
8
  const purifyConfig = {
8
9
  FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
9
10
  };
11
+ // For the answer, which an LLM writes from text a shopper can steer, so only the tags `marked`
12
+ // outputs are kept. Images, media and SVG would load a URL with no click and could leak data;
13
+ // form controls and `style` could fake a form.
14
+ const markdownPurifyConfig = {
15
+ ALLOWED_TAGS: 'p br strong em del a ul ol li blockquote hr code pre table thead tbody tr th td h1 h2 h3 h4 h5 h6'.split(' '),
16
+ ALLOWED_ATTR: ['href', 'align', 'start'],
17
+ };
18
+ // Text the agent reads (catalog, knowledge base, reviews) could plant a phishing link, so only
19
+ // links to this site, its subdomains, email and phone stay clickable. A link a custom `sanitize`
20
+ // deliberately keeps (e.g. `sms:`) stays too, but web links to other sites never do.
21
+ function isOnThisSite(href, { keepOtherSchemes = false } = {}) {
22
+ try {
23
+ const url = new URL(href, window.location.href);
24
+ if (url.protocol === 'mailto:' || url.protocol === 'tel:')
25
+ return true;
26
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
27
+ return keepOtherSchemes;
28
+ const site = window.location.hostname.replace(/^www\./, '');
29
+ return url.hostname === site || url.hostname.endsWith(`.${site}`);
30
+ }
31
+ catch (_a) {
32
+ return false;
33
+ }
34
+ }
35
+ function unlinkOffSiteLinks(html, options) {
36
+ const doc = new DOMParser().parseFromString(html, 'text/html');
37
+ doc.querySelectorAll('a[href]').forEach((link) => {
38
+ if (!isOnThisSite(link.getAttribute('href'), options)) {
39
+ link.replaceWith(...Array.from(link.childNodes));
40
+ }
41
+ });
42
+ return doc.body.innerHTML;
43
+ }
44
+ /**
45
+ * Sanitizes catalog HTML such as a product description. Browser only: without a DOM to sanitize
46
+ * with (server rendering) it returns `''`.
47
+ */
10
48
  function sanitizeHtml(html) {
11
- if (!html)
49
+ if (!html || !dompurify_1.default.isSupported)
12
50
  return '';
13
51
  return dompurify_1.default.sanitize(html, purifyConfig);
14
52
  }
15
53
  exports.sanitizeHtml = sanitizeHtml;
54
+ /**
55
+ * Turns the answer's markdown into sanitized HTML. Only the tags markdown produces are kept, and
56
+ * only links to this site, its subdomains, `mailto:` and `tel:` stay clickable. A custom `sanitize`
57
+ * can keep other schemes, but web links to other sites always become plain text. Browser only:
58
+ * without a DOM to sanitize with (server rendering) it returns `''`, so render the plain text.
59
+ */
16
60
  function renderMarkdown(content, options) {
17
- var _a;
18
- if (!content)
61
+ var _a, _b;
62
+ if (!content || !dompurify_1.default.isSupported)
19
63
  return '';
20
64
  const html = marked_1.marked.parse(content, { async: false, breaks: true });
21
65
  if (options === null || options === void 0 ? void 0 : options.sanitize) {
22
66
  const purifier = (0, dompurify_1.default)(window);
23
- const config = Object.assign(Object.assign({}, purifyConfig), { FORBID_TAGS: [...((_a = purifyConfig.FORBID_TAGS) !== null && _a !== void 0 ? _a : [])] });
24
- return options.sanitize(purifier, html, { config });
67
+ const config = Object.assign(Object.assign({}, markdownPurifyConfig), { ALLOWED_TAGS: [...((_a = markdownPurifyConfig.ALLOWED_TAGS) !== null && _a !== void 0 ? _a : [])], ALLOWED_ATTR: [...((_b = markdownPurifyConfig.ALLOWED_ATTR) !== null && _b !== void 0 ? _b : [])] });
68
+ return unlinkOffSiteLinks(options.sanitize(purifier, html, { config }), {
69
+ keepOtherSchemes: true,
70
+ });
25
71
  }
26
- return dompurify_1.default.sanitize(html, purifyConfig);
72
+ return unlinkOffSiteLinks(dompurify_1.default.sanitize(html, markdownPurifyConfig));
27
73
  }
28
74
  exports.renderMarkdown = renderMarkdown;
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.default = '2.18.0';
3
+ exports.default = '2.18.1';
@@ -5,7 +5,7 @@ function Answer({ text, componentOverride }) {
5
5
  if (!text) {
6
6
  return null;
7
7
  }
8
- return (React.createElement(RenderPropsWrapper, { props: { text }, override: componentOverride?.reactNode },
9
- React.createElement("div", { className: 'cio-pia-answer', "data-testid": 'answer-text', dangerouslySetInnerHTML: { __html: renderMarkdown(text) } })));
8
+ const html = renderMarkdown(text);
9
+ return (React.createElement(RenderPropsWrapper, { props: { text }, override: componentOverride?.reactNode }, html ? (React.createElement("div", { className: 'cio-pia-answer', "data-testid": 'answer-text', dangerouslySetInnerHTML: { __html: html } })) : (React.createElement("div", { className: 'cio-pia-answer', "data-testid": 'answer-text' }, text))));
10
10
  }
11
11
  export default Answer;
@@ -1,15 +1,3 @@
1
- div.subheading {
2
- --mediumdark: '#999999';
3
- font-weight: 700;
4
- font-size: 13px;
5
- color: #999;
6
- letter-spacing: 6px;
7
- line-height: 24px;
8
- text-transform: uppercase;
9
- margin-bottom: 12px;
10
- margin-top: 40px;
11
- }
12
-
13
1
  div.link-list {
14
2
  display: grid;
15
3
  grid-template-columns: 1fr;
@@ -78,126 +66,3 @@ a.link-item p {
78
66
  line-height: 20px;
79
67
  }
80
68
 
81
- span.badge {
82
- display: inline-block;
83
- border-radius: 1em;
84
- font-size: 11px;
85
- line-height: 12px;
86
- font-weight: 700;
87
- background: #e7fdd8;
88
- color: #66bf3c;
89
- padding: 4px 12px;
90
- margin-right: 10px;
91
- vertical-align: top;
92
- }
93
-
94
- div.badge-wrapper {
95
- font-size: 13px;
96
- line-height: 20px;
97
- margin-top: 40px;
98
- margin-bottom: 40px;
99
- }
100
-
101
- div.badge-wrapper code {
102
- font-size: 12px;
103
- display: inline-block;
104
- }
105
-
106
- .badge.tip {
107
- background: #e7fdd8;
108
- color: #66bf3c;
109
- }
110
-
111
- .badge.note {
112
- background: #d8e8fd;
113
- color: #3c70bf;
114
- }
115
-
116
- .cio-flow-diagram {
117
- display: flex;
118
- flex-direction: column;
119
- gap: 4px;
120
- margin: 24px 0 0;
121
- font-family: system-ui, sans-serif;
122
- font-size: 14px;
123
- }
124
-
125
- .cio-flow-diagram .flow-step {
126
- display: flex;
127
- align-items: center;
128
- gap: 12px;
129
- }
130
-
131
- .cio-flow-diagram .flow-box {
132
- padding: 10px 16px;
133
- border-radius: 8px;
134
- min-width: 220px;
135
- text-align: center;
136
- }
137
-
138
- .cio-flow-diagram .flow-box--consumer {
139
- background: #eff6ff;
140
- border: 1px solid #bfdbfe;
141
- }
142
-
143
- .cio-flow-diagram .flow-box--internal {
144
- background: #f5f3ff;
145
- border: 1px solid #ddd6fe;
146
- }
147
-
148
- .cio-flow-diagram .flow-box--output {
149
- background: #f0fdf4;
150
- border: 1px solid #bbf7d0;
151
- }
152
-
153
- .cio-flow-diagram .flow-arrow {
154
- margin-left: 110px;
155
- color: #9ca3af;
156
- }
157
-
158
- .cio-flow-diagram .flow-label {
159
- font-size: 12px;
160
- color: #9ca3af;
161
- font-style: italic;
162
- }
163
-
164
- .cio-flow-diagram .flow-callback {
165
- font-size: 12px;
166
- color: #059669;
167
- }
168
-
169
- .cio-flow-diagram .flow-box-subtitle {
170
- font-size: 12px;
171
- color: #6b7280;
172
- }
173
-
174
- .cio-flow-legend {
175
- display: flex;
176
- gap: 16px;
177
- margin: 32px 0 16px;
178
- font-size: 12px;
179
- }
180
-
181
- .cio-flow-legend .legend-swatch {
182
- display: inline-block;
183
- width: 12px;
184
- height: 12px;
185
- border-radius: 3px;
186
- margin-right: 4px;
187
- vertical-align: middle;
188
- }
189
-
190
- .cio-flow-legend .legend-swatch--consumer {
191
- background: #eff6ff;
192
- border: 1px solid #bfdbfe;
193
- }
194
-
195
- .cio-flow-legend .legend-swatch--internal {
196
- background: #f5f3ff;
197
- border: 1px solid #ddd6fe;
198
- }
199
-
200
- .cio-flow-legend .legend-swatch--output {
201
- background: #f0fdf4;
202
- border: 1px solid #bbf7d0;
203
- }
@@ -1,21 +1,71 @@
1
1
  import DOMPurify from 'dompurify';
2
2
  import { marked } from 'marked';
3
+ // For catalog HTML (product descriptions).
3
4
  const purifyConfig = {
4
5
  FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
5
6
  };
7
+ // For the answer, which an LLM writes from text a shopper can steer, so only the tags `marked`
8
+ // outputs are kept. Images, media and SVG would load a URL with no click and could leak data;
9
+ // form controls and `style` could fake a form.
10
+ const markdownPurifyConfig = {
11
+ ALLOWED_TAGS: 'p br strong em del a ul ol li blockquote hr code pre table thead tbody tr th td h1 h2 h3 h4 h5 h6'.split(' '),
12
+ ALLOWED_ATTR: ['href', 'align', 'start'],
13
+ };
14
+ // Text the agent reads (catalog, knowledge base, reviews) could plant a phishing link, so only
15
+ // links to this site, its subdomains, email and phone stay clickable. A link a custom `sanitize`
16
+ // deliberately keeps (e.g. `sms:`) stays too, but web links to other sites never do.
17
+ function isOnThisSite(href, { keepOtherSchemes = false } = {}) {
18
+ try {
19
+ const url = new URL(href, window.location.href);
20
+ if (url.protocol === 'mailto:' || url.protocol === 'tel:')
21
+ return true;
22
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
23
+ return keepOtherSchemes;
24
+ const site = window.location.hostname.replace(/^www\./, '');
25
+ return url.hostname === site || url.hostname.endsWith(`.${site}`);
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ function unlinkOffSiteLinks(html, options) {
32
+ const doc = new DOMParser().parseFromString(html, 'text/html');
33
+ doc.querySelectorAll('a[href]').forEach((link) => {
34
+ if (!isOnThisSite(link.getAttribute('href'), options)) {
35
+ link.replaceWith(...Array.from(link.childNodes));
36
+ }
37
+ });
38
+ return doc.body.innerHTML;
39
+ }
40
+ /**
41
+ * Sanitizes catalog HTML such as a product description. Browser only: without a DOM to sanitize
42
+ * with (server rendering) it returns `''`.
43
+ */
6
44
  export function sanitizeHtml(html) {
7
- if (!html)
45
+ if (!html || !DOMPurify.isSupported)
8
46
  return '';
9
47
  return DOMPurify.sanitize(html, purifyConfig);
10
48
  }
49
+ /**
50
+ * Turns the answer's markdown into sanitized HTML. Only the tags markdown produces are kept, and
51
+ * only links to this site, its subdomains, `mailto:` and `tel:` stay clickable. A custom `sanitize`
52
+ * can keep other schemes, but web links to other sites always become plain text. Browser only:
53
+ * without a DOM to sanitize with (server rendering) it returns `''`, so render the plain text.
54
+ */
11
55
  export function renderMarkdown(content, options) {
12
- if (!content)
56
+ if (!content || !DOMPurify.isSupported)
13
57
  return '';
14
58
  const html = marked.parse(content, { async: false, breaks: true });
15
59
  if (options?.sanitize) {
16
60
  const purifier = DOMPurify(window);
17
- const config = { ...purifyConfig, FORBID_TAGS: [...(purifyConfig.FORBID_TAGS ?? [])] };
18
- return options.sanitize(purifier, html, { config });
61
+ const config = {
62
+ ...markdownPurifyConfig,
63
+ ALLOWED_TAGS: [...(markdownPurifyConfig.ALLOWED_TAGS ?? [])],
64
+ ALLOWED_ATTR: [...(markdownPurifyConfig.ALLOWED_ATTR ?? [])],
65
+ };
66
+ return unlinkOffSiteLinks(options.sanitize(purifier, html, { config }), {
67
+ keepOtherSchemes: true,
68
+ });
19
69
  }
20
- return DOMPurify.sanitize(html, purifyConfig);
70
+ return unlinkOffSiteLinks(DOMPurify.sanitize(html, markdownPurifyConfig));
21
71
  }
@@ -1 +1 @@
1
- export default '2.18.0';
1
+ export default '2.18.1';
@@ -23,5 +23,15 @@ export interface RenderMarkdownOptions {
23
23
  */
24
24
  sanitize?: (purifier: typeof DOMPurify, html: string, options: SanitizeOptions) => string;
25
25
  }
26
+ /**
27
+ * Sanitizes catalog HTML such as a product description. Browser only: without a DOM to sanitize
28
+ * with (server rendering) it returns `''`.
29
+ */
26
30
  export declare function sanitizeHtml(html: string): string;
31
+ /**
32
+ * Turns the answer's markdown into sanitized HTML. Only the tags markdown produces are kept, and
33
+ * only links to this site, its subdomains, `mailto:` and `tel:` stay clickable. A custom `sanitize`
34
+ * can keep other schemes, but web links to other sites always become plain text. Browser only:
35
+ * without a DOM to sanitize with (server rendering) it returns `''`, so render the plain text.
36
+ */
27
37
  export declare function renderMarkdown(content: string, options?: RenderMarkdownOptions): string;
@@ -1,2 +1,2 @@
1
- declare const _default: "2.18.0";
1
+ declare const _default: "2.18.1";
2
2
  export default _default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructor-io/constructorio-ui-pia",
3
- "version": "2.18.0",
3
+ "version": "2.18.1",
4
4
  "description": "Constructor.io AI Product Insights Agent UI library for web applications",
5
5
  "author": "Constructor.io Corporation",
6
6
  "license": "MIT",