@faststore/core 4.9.3-dev.1 → 4.10.0-dev.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/.turbo/turbo-generate.log +3 -3
- package/.turbo/turbo-test.log +136 -137
- package/CHANGELOG.md +18 -0
- package/cms/faststore/components/cms_component__bannernewsletter.jsonc +1 -1
- package/cms/faststore/components/cms_component__newsletter.jsonc +1 -1
- package/index.ts +3 -0
- package/package.json +6 -6
- package/src/components/ui/RichText/RichText.tsx +2 -0
- package/src/components/ui/RichText/RichTextImageNode.ts +163 -0
- package/src/pages/api/graphql.ts +23 -19
- package/src/utils/trustedForwardedHost.ts +50 -0
- package/test/components/ui/RichText/RichText.browser.test.tsx +321 -0
- package/test/utils/trustedForwardedHost.test.ts +105 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DecoratorNode,
|
|
3
|
+
type DOMExportOutput,
|
|
4
|
+
type EditorConfig,
|
|
5
|
+
type LexicalEditor,
|
|
6
|
+
type NodeKey,
|
|
7
|
+
type SerializedLexicalNode,
|
|
8
|
+
type Spread,
|
|
9
|
+
} from 'lexical'
|
|
10
|
+
|
|
11
|
+
export type ImageAlignment = 'left' | 'center' | 'right'
|
|
12
|
+
|
|
13
|
+
export type SerializedRichTextImageNode = Spread<
|
|
14
|
+
{
|
|
15
|
+
type: 'image'
|
|
16
|
+
version: 1
|
|
17
|
+
src: string
|
|
18
|
+
altText: string
|
|
19
|
+
alignment: ImageAlignment
|
|
20
|
+
widthPercent: number
|
|
21
|
+
},
|
|
22
|
+
SerializedLexicalNode
|
|
23
|
+
>
|
|
24
|
+
|
|
25
|
+
const VALID_ALIGNMENTS = new Set<ImageAlignment>(['left', 'center', 'right'])
|
|
26
|
+
const DEFAULT_ALIGNMENT: ImageAlignment = 'left'
|
|
27
|
+
const DEFAULT_WIDTH_PERCENT = 100
|
|
28
|
+
const RICH_TEXT_CONTENT_MAX_WIDTH = 720
|
|
29
|
+
const DEFAULT_IMAGE_QUALITY = 8
|
|
30
|
+
|
|
31
|
+
function resolveAlignment(alignment: unknown): ImageAlignment {
|
|
32
|
+
return VALID_ALIGNMENTS.has(alignment as ImageAlignment)
|
|
33
|
+
? (alignment as ImageAlignment)
|
|
34
|
+
: DEFAULT_ALIGNMENT
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveWidthPercent(widthPercent: unknown): number {
|
|
38
|
+
if (typeof widthPercent !== 'number' || widthPercent <= 0) {
|
|
39
|
+
return DEFAULT_WIDTH_PERCENT
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Math.min(widthPercent, DEFAULT_WIDTH_PERCENT)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withVtexResizeParams(src: string, widthPercent: number): string {
|
|
46
|
+
if (!src.includes('vtexassets') || !src.includes('/assets')) {
|
|
47
|
+
return src
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(src)
|
|
52
|
+
const targetWidth = Math.max(
|
|
53
|
+
1,
|
|
54
|
+
Math.round((RICH_TEXT_CONTENT_MAX_WIDTH * widthPercent) / 100)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
url.searchParams.set('width', targetWidth.toString())
|
|
58
|
+
url.searchParams.set('aspect', 'true')
|
|
59
|
+
url.searchParams.set('quality', DEFAULT_IMAGE_QUALITY.toString())
|
|
60
|
+
|
|
61
|
+
return url.toString()
|
|
62
|
+
} catch {
|
|
63
|
+
return src
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class RichTextImageNode extends DecoratorNode<null> {
|
|
68
|
+
__src: string
|
|
69
|
+
__altText: string
|
|
70
|
+
__alignment: ImageAlignment
|
|
71
|
+
__widthPercent: number
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
src: string,
|
|
75
|
+
altText: string,
|
|
76
|
+
alignment: ImageAlignment = DEFAULT_ALIGNMENT,
|
|
77
|
+
widthPercent: number = DEFAULT_WIDTH_PERCENT,
|
|
78
|
+
key?: NodeKey
|
|
79
|
+
) {
|
|
80
|
+
super(key)
|
|
81
|
+
this.__src = src
|
|
82
|
+
this.__altText = altText
|
|
83
|
+
this.__alignment = resolveAlignment(alignment)
|
|
84
|
+
this.__widthPercent = resolveWidthPercent(widthPercent)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static getType(): string {
|
|
88
|
+
return 'image'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static clone(node: RichTextImageNode): RichTextImageNode {
|
|
92
|
+
return new RichTextImageNode(
|
|
93
|
+
node.__src,
|
|
94
|
+
node.__altText,
|
|
95
|
+
node.__alignment,
|
|
96
|
+
node.__widthPercent,
|
|
97
|
+
node.__key
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static importDOM(): null {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static importJSON(
|
|
106
|
+
serializedNode: SerializedRichTextImageNode
|
|
107
|
+
): RichTextImageNode {
|
|
108
|
+
return new RichTextImageNode(
|
|
109
|
+
typeof serializedNode.src === 'string' ? serializedNode.src : '',
|
|
110
|
+
typeof serializedNode.altText === 'string' ? serializedNode.altText : '',
|
|
111
|
+
serializedNode.alignment,
|
|
112
|
+
serializedNode.widthPercent
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
exportJSON(): SerializedRichTextImageNode {
|
|
117
|
+
return {
|
|
118
|
+
type: 'image',
|
|
119
|
+
version: 1,
|
|
120
|
+
src: this.__src,
|
|
121
|
+
altText: this.__altText,
|
|
122
|
+
alignment: this.__alignment,
|
|
123
|
+
widthPercent: this.__widthPercent,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
isInline(): boolean {
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
|
|
132
|
+
return document.createElement('div')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
updateDOM(): boolean {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
decorate(): null {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
exportDOM(_editor: LexicalEditor): DOMExportOutput {
|
|
144
|
+
if (!this.__src) {
|
|
145
|
+
return { element: null }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const wrapper = document.createElement('div')
|
|
149
|
+
wrapper.dataset.fsRichTextImageWrapper = ''
|
|
150
|
+
wrapper.dataset.fsRichTextImageAlignment = this.__alignment
|
|
151
|
+
|
|
152
|
+
const img = document.createElement('img')
|
|
153
|
+
img.dataset.fsRichTextImage = ''
|
|
154
|
+
img.src = withVtexResizeParams(this.__src, this.__widthPercent)
|
|
155
|
+
img.alt = this.__altText
|
|
156
|
+
img.setAttribute('loading', 'lazy')
|
|
157
|
+
img.style.width = `${this.__widthPercent}%`
|
|
158
|
+
|
|
159
|
+
wrapper.append(img)
|
|
160
|
+
|
|
161
|
+
return { element: wrapper }
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/pages/api/graphql.ts
CHANGED
|
@@ -11,6 +11,11 @@ import discoveryConfig from 'discovery.config'
|
|
|
11
11
|
import { getJWTAutCookie } from 'src/utils/getCookie'
|
|
12
12
|
import { getRequestHostname } from 'src/utils/getRequestHostname'
|
|
13
13
|
import { isLocalHost } from 'src/utils/isLocalHost'
|
|
14
|
+
import {
|
|
15
|
+
isHostAllowed,
|
|
16
|
+
removeCookieDomain,
|
|
17
|
+
singleForwardedHost,
|
|
18
|
+
} from 'src/utils/trustedForwardedHost'
|
|
14
19
|
import { shouldForceRefreshTokenForValidateSession } from 'src/utils/validateSessionRefreshToken'
|
|
15
20
|
import { execute } from '../../server'
|
|
16
21
|
import { logger } from '@faststore/diagnostics'
|
|
@@ -56,22 +61,10 @@ const shouldReplaceCookieDomain = ({
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
allowList,
|
|
64
|
-
}: {
|
|
65
|
-
host: string
|
|
66
|
-
allowList: string[]
|
|
67
|
-
}) => {
|
|
68
|
-
const normalizedHost = host.toLowerCase()
|
|
69
|
-
|
|
70
|
-
return allowList.some((suffix) => normalizedHost.endsWith(suffix))
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Ensure the cookie domain matches the current host so the browser can store it.
|
|
64
|
+
* On allowlisted hosts (previews, localhost) the upstream cookie domain does not
|
|
65
|
+
* match the browser host, so the Domain attribute is dropped and the cookie
|
|
66
|
+
* becomes host-only. The host is never written into the cookie, so a forged
|
|
67
|
+
* x-forwarded-host cannot choose the cookie scope.
|
|
75
68
|
*/
|
|
76
69
|
const normalizeSetCookieDomain = ({
|
|
77
70
|
request,
|
|
@@ -85,20 +78,31 @@ const normalizeSetCookieDomain = ({
|
|
|
85
78
|
return setCookie
|
|
86
79
|
}
|
|
87
80
|
|
|
88
|
-
|
|
81
|
+
// The preview ingress forwards a client-supplied x-forwarded-host as is, so
|
|
82
|
+
// it only decides whether the Domain is dropped, never which domain is used.
|
|
83
|
+
const forwardedHeader = request.headers['x-forwarded-host']
|
|
84
|
+
const forwardedHost = getRequestHostname(singleForwardedHost(forwardedHeader))
|
|
85
|
+
if (forwardedHeader && !forwardedHost) {
|
|
86
|
+
// The value is client-controllable, so it is not logged.
|
|
87
|
+
OTELLogger('warn', 'Ignoring multi-value or malformed x-forwarded-host')
|
|
88
|
+
}
|
|
89
|
+
const host =
|
|
90
|
+
forwardedHost && isHostAllowed(forwardedHost, ALLOWED_HOST_SUFFIXES)
|
|
91
|
+
? forwardedHost
|
|
92
|
+
: getRequestHostname(request.headers.host)
|
|
89
93
|
if (!host) {
|
|
90
94
|
return setCookie
|
|
91
95
|
}
|
|
92
96
|
const cookieDomain = domainMatch[1]
|
|
93
97
|
|
|
94
98
|
if (
|
|
95
|
-
!
|
|
99
|
+
!isHostAllowed(host, ALLOWED_HOST_SUFFIXES) ||
|
|
96
100
|
!shouldReplaceCookieDomain({ cookieDomain, host })
|
|
97
101
|
) {
|
|
98
102
|
return setCookie
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
return setCookie
|
|
105
|
+
return removeCookieDomain(setCookie)
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
const parseRequest = (request: NextApiRequest) => {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The result is written verbatim into a Set-Cookie Domain attribute, so
|
|
2
|
+
// characters such as ";" or spaces would inject cookie attributes.
|
|
3
|
+
const HOSTNAME_PATTERN = /^[a-z0-9.-]+$/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An entry starting with "." matches subdomains only; any other entry matches
|
|
7
|
+
* the host itself and its subdomains. Matching is anchored on a label boundary,
|
|
8
|
+
* so "evil-localhost" never matches "localhost".
|
|
9
|
+
*/
|
|
10
|
+
export function isHostAllowed(host: string, allowList: string[]) {
|
|
11
|
+
const normalizedHost = host.toLowerCase()
|
|
12
|
+
|
|
13
|
+
if (!HOSTNAME_PATTERN.test(normalizedHost)) {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return allowList.some((entry) =>
|
|
18
|
+
entry.startsWith('.')
|
|
19
|
+
? normalizedHost.endsWith(entry)
|
|
20
|
+
: normalizedHost === entry || normalizedHost.endsWith(`.${entry}`)
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the `x-forwarded-host` value only when exactly one is present.
|
|
26
|
+
*
|
|
27
|
+
* Which hop of a proxy chain is trustworthy is not known here: with several
|
|
28
|
+
* values the leftmost one is whatever the client sent. So a multi-value header
|
|
29
|
+
* (comma-joined by Node, or an array) is ignored and the caller falls back to
|
|
30
|
+
* `host`. A single value still assumes the ingress overwrites the header.
|
|
31
|
+
*/
|
|
32
|
+
export function singleForwardedHost(
|
|
33
|
+
header: string | string[] | undefined
|
|
34
|
+
): string | undefined {
|
|
35
|
+
if (Array.isArray(header)) {
|
|
36
|
+
return header.length === 1 ? singleForwardedHost(header[0]) : undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const value = header?.trim()
|
|
40
|
+
|
|
41
|
+
return value && !value.includes(',') ? value : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Drops every Domain attribute so the cookie becomes host-only: the browser
|
|
46
|
+
* scopes it to the exact host it requested, whatever header picked this path.
|
|
47
|
+
*/
|
|
48
|
+
export function removeCookieDomain(setCookie: string): string {
|
|
49
|
+
return setCookie.replaceAll(/;\s*domain=[^;]*/gi, '')
|
|
50
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { render } from '@testing-library/react'
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const isContentPlatformSourceMock = vi.hoisted(() => vi.fn(() => true))
|
|
9
|
+
|
|
10
|
+
vi.mock('src/server/content/utils', () => ({
|
|
11
|
+
isContentPlatformSource: isContentPlatformSourceMock,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { RichText } from '../../../../src/components/ui/RichText/RichText'
|
|
15
|
+
|
|
16
|
+
function textNode(text: string) {
|
|
17
|
+
return {
|
|
18
|
+
detail: 0,
|
|
19
|
+
format: 0,
|
|
20
|
+
mode: 'normal',
|
|
21
|
+
style: '',
|
|
22
|
+
text,
|
|
23
|
+
type: 'text',
|
|
24
|
+
version: 1,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paragraphNode(children: unknown[]) {
|
|
29
|
+
return {
|
|
30
|
+
children,
|
|
31
|
+
direction: 'ltr',
|
|
32
|
+
format: '',
|
|
33
|
+
indent: 0,
|
|
34
|
+
type: 'paragraph',
|
|
35
|
+
version: 1,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function headingNode(text: string, tag = 'h2') {
|
|
40
|
+
return {
|
|
41
|
+
children: [textNode(text)],
|
|
42
|
+
direction: 'ltr',
|
|
43
|
+
format: '',
|
|
44
|
+
indent: 0,
|
|
45
|
+
type: 'heading',
|
|
46
|
+
version: 1,
|
|
47
|
+
tag,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function linkNode(url: string, text: string) {
|
|
52
|
+
return {
|
|
53
|
+
children: [textNode(text)],
|
|
54
|
+
direction: 'ltr',
|
|
55
|
+
format: '',
|
|
56
|
+
indent: 0,
|
|
57
|
+
type: 'link',
|
|
58
|
+
version: 1,
|
|
59
|
+
rel: null,
|
|
60
|
+
target: null,
|
|
61
|
+
title: null,
|
|
62
|
+
url,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listNode(itemText: string) {
|
|
67
|
+
return {
|
|
68
|
+
children: [
|
|
69
|
+
{
|
|
70
|
+
children: [textNode(itemText)],
|
|
71
|
+
direction: 'ltr',
|
|
72
|
+
format: '',
|
|
73
|
+
indent: 0,
|
|
74
|
+
type: 'listitem',
|
|
75
|
+
version: 1,
|
|
76
|
+
value: 1,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
direction: 'ltr',
|
|
80
|
+
format: '',
|
|
81
|
+
indent: 0,
|
|
82
|
+
type: 'list',
|
|
83
|
+
version: 1,
|
|
84
|
+
listType: 'bullet',
|
|
85
|
+
start: 1,
|
|
86
|
+
tag: 'ul',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function imageNode(overrides: Record<string, unknown> = {}) {
|
|
91
|
+
return {
|
|
92
|
+
type: 'image',
|
|
93
|
+
version: 1,
|
|
94
|
+
src: 'https://storeaccount.vtexassets.com/assets/vtex.file-manager-graphql/images/abc123.png',
|
|
95
|
+
altText: 'A nice picture',
|
|
96
|
+
alignment: 'center',
|
|
97
|
+
widthPercent: 60,
|
|
98
|
+
...overrides,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lexicalContent(children: unknown[]) {
|
|
103
|
+
return JSON.stringify({
|
|
104
|
+
root: {
|
|
105
|
+
children,
|
|
106
|
+
direction: 'ltr',
|
|
107
|
+
format: '',
|
|
108
|
+
indent: 0,
|
|
109
|
+
type: 'root',
|
|
110
|
+
version: 1,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function draftJsContent(text: string) {
|
|
116
|
+
return JSON.stringify({
|
|
117
|
+
blocks: [
|
|
118
|
+
{
|
|
119
|
+
key: '1',
|
|
120
|
+
text,
|
|
121
|
+
type: 'unstyled',
|
|
122
|
+
depth: 0,
|
|
123
|
+
inlineStyleRanges: [],
|
|
124
|
+
entityRanges: [],
|
|
125
|
+
data: {},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
entityMap: {},
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe('RichText', () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
isContentPlatformSourceMock.mockReturnValue(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('renders an image node with the correct src, alt, alignment and width', () => {
|
|
138
|
+
const { container } = render(
|
|
139
|
+
<RichText content={lexicalContent([imageNode()])} />
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const wrapper = container.querySelector('[data-fs-rich-text-image-wrapper]')
|
|
143
|
+
const img = container.querySelector<HTMLImageElement>(
|
|
144
|
+
'[data-fs-rich-text-image]'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
expect(wrapper?.getAttribute('data-fs-rich-text-image-alignment')).toBe(
|
|
148
|
+
'center'
|
|
149
|
+
)
|
|
150
|
+
expect(img?.getAttribute('alt')).toBe('A nice picture')
|
|
151
|
+
expect(img?.getAttribute('loading')).toBe('lazy')
|
|
152
|
+
expect(img?.getAttribute('style')).toBe('width: 60%;')
|
|
153
|
+
|
|
154
|
+
const src = img?.getAttribute('src') ?? ''
|
|
155
|
+
expect(src).toContain('width=432')
|
|
156
|
+
expect(src).toContain('aspect=true')
|
|
157
|
+
expect(src).toContain('quality=8')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('renders images together with text, headings, links and lists in document order', () => {
|
|
161
|
+
const { container } = render(
|
|
162
|
+
<RichText
|
|
163
|
+
content={lexicalContent([
|
|
164
|
+
headingNode('Title'),
|
|
165
|
+
paragraphNode([
|
|
166
|
+
textNode('See '),
|
|
167
|
+
linkNode('https://example.com', 'this link'),
|
|
168
|
+
]),
|
|
169
|
+
listNode('An item'),
|
|
170
|
+
imageNode(),
|
|
171
|
+
])}
|
|
172
|
+
/>
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const html = container.innerHTML
|
|
176
|
+
|
|
177
|
+
expect(container.querySelector('h2')?.textContent).toBe('Title')
|
|
178
|
+
expect(
|
|
179
|
+
container.querySelector('a[href="https://example.com"]')
|
|
180
|
+
).toBeTruthy()
|
|
181
|
+
expect(
|
|
182
|
+
container.querySelector('[data-fs-rich-text-list="true"]')
|
|
183
|
+
).toBeTruthy()
|
|
184
|
+
expect(container.querySelector('[data-fs-rich-text-image]')).toBeTruthy()
|
|
185
|
+
|
|
186
|
+
const headingIndex = html.indexOf('<h2')
|
|
187
|
+
const linkIndex = html.indexOf('href="https://example.com"')
|
|
188
|
+
const listIndex = html.indexOf('data-fs-rich-text-list')
|
|
189
|
+
const imageIndex = html.indexOf('data-fs-rich-text-image-wrapper')
|
|
190
|
+
|
|
191
|
+
expect(headingIndex).toBeLessThan(linkIndex)
|
|
192
|
+
expect(linkIndex).toBeLessThan(listIndex)
|
|
193
|
+
expect(listIndex).toBeLessThan(imageIndex)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('drops an image node with a missing src without breaking the rest of the content', () => {
|
|
197
|
+
const { container } = render(
|
|
198
|
+
<RichText
|
|
199
|
+
content={lexicalContent([
|
|
200
|
+
paragraphNode([textNode('Before')]),
|
|
201
|
+
imageNode({ src: '' }),
|
|
202
|
+
paragraphNode([textNode('After')]),
|
|
203
|
+
])}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
|
|
208
|
+
expect(
|
|
209
|
+
container.querySelector('[data-fs-rich-text-image-wrapper]')
|
|
210
|
+
).toBeNull()
|
|
211
|
+
expect(container.textContent).toContain('Before')
|
|
212
|
+
expect(container.textContent).toContain('After')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('does not throw and skips an image node with an unsupported/unexpected shape', () => {
|
|
216
|
+
expect(() =>
|
|
217
|
+
render(
|
|
218
|
+
<RichText
|
|
219
|
+
content={lexicalContent([
|
|
220
|
+
paragraphNode([textNode('Still here')]),
|
|
221
|
+
{
|
|
222
|
+
type: 'image',
|
|
223
|
+
version: 2,
|
|
224
|
+
imageUrl: 'https://storeaccount.vtexassets.com/assets/x.png',
|
|
225
|
+
},
|
|
226
|
+
])}
|
|
227
|
+
/>
|
|
228
|
+
)
|
|
229
|
+
).not.toThrow()
|
|
230
|
+
|
|
231
|
+
const { container } = render(
|
|
232
|
+
<RichText
|
|
233
|
+
content={lexicalContent([
|
|
234
|
+
paragraphNode([textNode('Still here')]),
|
|
235
|
+
{
|
|
236
|
+
type: 'image',
|
|
237
|
+
version: 2,
|
|
238
|
+
imageUrl: 'https://storeaccount.vtexassets.com/assets/x.png',
|
|
239
|
+
},
|
|
240
|
+
])}
|
|
241
|
+
/>
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
|
|
245
|
+
expect(container.textContent).toContain('Still here')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('renders multiple images, defaulting alignment/width when omitted and passing through non-VTEX URLs unmodified', () => {
|
|
249
|
+
const { container } = render(
|
|
250
|
+
<RichText
|
|
251
|
+
content={lexicalContent([
|
|
252
|
+
imageNode({ alignment: 'right', widthPercent: 30 }),
|
|
253
|
+
imageNode({
|
|
254
|
+
src: 'https://storeaccount.vtexassets.com/assets/vtex.file-manager-graphql/images/no-layout.png',
|
|
255
|
+
alignment: undefined,
|
|
256
|
+
widthPercent: undefined,
|
|
257
|
+
}),
|
|
258
|
+
imageNode({
|
|
259
|
+
src: 'https://cdn.example.com/external.png',
|
|
260
|
+
alignment: 'center',
|
|
261
|
+
widthPercent: 80,
|
|
262
|
+
}),
|
|
263
|
+
])}
|
|
264
|
+
/>
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const wrappers = container.querySelectorAll(
|
|
268
|
+
'[data-fs-rich-text-image-wrapper]'
|
|
269
|
+
)
|
|
270
|
+
expect(wrappers).toHaveLength(3)
|
|
271
|
+
|
|
272
|
+
expect(wrappers[0].getAttribute('data-fs-rich-text-image-alignment')).toBe(
|
|
273
|
+
'right'
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
expect(wrappers[1].getAttribute('data-fs-rich-text-image-alignment')).toBe(
|
|
277
|
+
'left'
|
|
278
|
+
)
|
|
279
|
+
expect(
|
|
280
|
+
wrappers[1]
|
|
281
|
+
.querySelector('[data-fs-rich-text-image]')
|
|
282
|
+
?.getAttribute('style')
|
|
283
|
+
).toBe('width: 100%;')
|
|
284
|
+
|
|
285
|
+
const externalImg = wrappers[2].querySelector('[data-fs-rich-text-image]')
|
|
286
|
+
expect(externalImg?.getAttribute('src')).toBe(
|
|
287
|
+
'https://cdn.example.com/external.png'
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('clamps widthPercent to 100 so the image never overflows its container', () => {
|
|
292
|
+
const { container } = render(
|
|
293
|
+
<RichText content={lexicalContent([imageNode({ widthPercent: 150 })])} />
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const img = container.querySelector('[data-fs-rich-text-image]')
|
|
297
|
+
expect(img?.getAttribute('style')).toBe('width: 100%;')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('keeps rendering existing hCMS Draft.js content unaffected', () => {
|
|
301
|
+
isContentPlatformSourceMock.mockReturnValue(false)
|
|
302
|
+
|
|
303
|
+
const { container } = render(
|
|
304
|
+
<RichText content={draftJsContent('Hello legacy content')} />
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
expect(container.textContent).toContain('Hello legacy content')
|
|
308
|
+
expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('keeps rendering existing Lexical content without images unaffected', () => {
|
|
312
|
+
const { container } = render(
|
|
313
|
+
<RichText
|
|
314
|
+
content={lexicalContent([paragraphNode([textNode('No images here')])])}
|
|
315
|
+
/>
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
expect(container.textContent).toContain('No images here')
|
|
319
|
+
expect(container.querySelector('[data-fs-rich-text-image]')).toBeNull()
|
|
320
|
+
})
|
|
321
|
+
})
|