@teleporthq/teleport-shared 0.43.60 → 0.43.62
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/__tests__/utils/local-imports.ts +174 -0
- package/__tests__/utils/page-dependencies-prefix.ts +89 -0
- package/__tests__/utils/rich-text-embeds-runtime-module.ts +124 -0
- package/__tests__/utils/rich-text-embeds.ts +254 -0
- package/dist/cjs/index.d.ts +4 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +7 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils/generic.d.ts +46 -0
- package/dist/cjs/utils/generic.d.ts.map +1 -1
- package/dist/cjs/utils/generic.js +67 -1
- package/dist/cjs/utils/generic.js.map +1 -1
- package/dist/cjs/utils/local-imports.d.ts +48 -0
- package/dist/cjs/utils/local-imports.d.ts.map +1 -0
- package/dist/cjs/utils/local-imports.js +168 -0
- package/dist/cjs/utils/local-imports.js.map +1 -0
- package/dist/cjs/utils/rich-text-embeds-codegen.d.ts +9 -0
- package/dist/cjs/utils/rich-text-embeds-codegen.d.ts.map +1 -0
- package/dist/cjs/utils/rich-text-embeds-codegen.js +139 -0
- package/dist/cjs/utils/rich-text-embeds-codegen.js.map +1 -0
- package/dist/cjs/utils/rich-text-embeds.d.ts +251 -0
- package/dist/cjs/utils/rich-text-embeds.d.ts.map +1 -0
- package/dist/cjs/utils/rich-text-embeds.js +852 -0
- package/dist/cjs/utils/rich-text-embeds.js.map +1 -0
- package/dist/esm/index.d.ts +4 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +4 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils/generic.d.ts +46 -0
- package/dist/esm/utils/generic.d.ts.map +1 -1
- package/dist/esm/utils/generic.js +64 -0
- package/dist/esm/utils/generic.js.map +1 -1
- package/dist/esm/utils/local-imports.d.ts +48 -0
- package/dist/esm/utils/local-imports.d.ts.map +1 -0
- package/dist/esm/utils/local-imports.js +161 -0
- package/dist/esm/utils/local-imports.js.map +1 -0
- package/dist/esm/utils/rich-text-embeds-codegen.d.ts +9 -0
- package/dist/esm/utils/rich-text-embeds-codegen.d.ts.map +1 -0
- package/dist/esm/utils/rich-text-embeds-codegen.js +134 -0
- package/dist/esm/utils/rich-text-embeds-codegen.js.map +1 -0
- package/dist/esm/utils/rich-text-embeds.d.ts +251 -0
- package/dist/esm/utils/rich-text-embeds.d.ts.map +1 -0
- package/dist/esm/utils/rich-text-embeds.js +822 -0
- package/dist/esm/utils/rich-text-embeds.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +6 -0
- package/src/utils/generic.ts +57 -0
- package/src/utils/local-imports.ts +226 -0
- package/src/utils/rich-text-embeds-codegen.ts +216 -0
- package/src/utils/rich-text-embeds.ts +966 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { GeneratedFolder } from '@teleporthq/teleport-types'
|
|
2
|
+
import { LocalImports } from '../../src'
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
collectProjectFiles,
|
|
6
|
+
findUnresolvedLocalImports,
|
|
7
|
+
findUnresolvedLocalImportsInProject,
|
|
8
|
+
formatUnresolvedLocalImports,
|
|
9
|
+
} = LocalImports
|
|
10
|
+
|
|
11
|
+
const file = (name: string, fileType: string, content = '') => ({ name, fileType, content })
|
|
12
|
+
|
|
13
|
+
const folder = (
|
|
14
|
+
name: string,
|
|
15
|
+
files: Array<ReturnType<typeof file>> = [],
|
|
16
|
+
subFolders: GeneratedFolder[] = []
|
|
17
|
+
): GeneratedFolder => ({ name, files, subFolders } as GeneratedFolder)
|
|
18
|
+
|
|
19
|
+
describe('collectProjectFiles', () => {
|
|
20
|
+
it('addresses every file relative to the project root, not to the root folder name', () => {
|
|
21
|
+
const project = folder(
|
|
22
|
+
'my-project',
|
|
23
|
+
[file('package', 'json', '{}')],
|
|
24
|
+
[folder('pages', [file('index', 'js', '')], [folder('blog', [file('[slug]', 'js', '')])])]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
collectProjectFiles(project)
|
|
29
|
+
.map((entry) => entry.path)
|
|
30
|
+
.sort()
|
|
31
|
+
).toEqual(['package.json', 'pages/blog/[slug].js', 'pages/index.js'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('skips the content of base64 files', () => {
|
|
35
|
+
const project = folder('my-project', [
|
|
36
|
+
{ ...file('logo', 'png', 'iVBORw0KGgo='), contentEncoding: 'base64' as const },
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
expect(collectProjectFiles(project)[0].content).toBe('')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('findUnresolvedLocalImports', () => {
|
|
44
|
+
it('accepts an import that resolves to a generated file', () => {
|
|
45
|
+
const unresolved = findUnresolvedLocalImports([
|
|
46
|
+
{
|
|
47
|
+
path: 'pages/blog/[slug].js',
|
|
48
|
+
content: `import fetcher from '../../resources/fetch-posts'`,
|
|
49
|
+
},
|
|
50
|
+
{ path: 'resources/fetch-posts.js', content: '' },
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
expect(unresolved).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('reports the page-folder/root-folder collision that broke `next build`', () => {
|
|
57
|
+
const unresolved = findUnresolvedLocalImports([
|
|
58
|
+
{
|
|
59
|
+
path: 'pages/resources/[id].js',
|
|
60
|
+
content: `import fetcher from '../fetch_content_items_detail'`,
|
|
61
|
+
},
|
|
62
|
+
{ path: 'resources/fetch_content_items_detail.js', content: '' },
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
expect(unresolved).toEqual([
|
|
66
|
+
{ filePath: 'pages/resources/[id].js', specifier: '../fetch_content_items_detail' },
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('resolves extensionless, explicit-extension, folder-index and multi-line imports', () => {
|
|
71
|
+
const unresolved = findUnresolvedLocalImports([
|
|
72
|
+
{
|
|
73
|
+
path: 'pages/index.js',
|
|
74
|
+
content: [
|
|
75
|
+
`import {`,
|
|
76
|
+
` Repeater,`,
|
|
77
|
+
`} from '../components/navigation'`,
|
|
78
|
+
`import '../pages/style.css'`,
|
|
79
|
+
`import cache from '../utils/tq-cache'`,
|
|
80
|
+
`const runtime = require('../utils/workflows/runtime')`,
|
|
81
|
+
`const lazy = () => import('../components/footer')`,
|
|
82
|
+
].join('\n'),
|
|
83
|
+
},
|
|
84
|
+
{ path: 'components/navigation.js', content: '' },
|
|
85
|
+
{ path: 'components/footer.jsx', content: '' },
|
|
86
|
+
{ path: 'pages/style.css', content: '' },
|
|
87
|
+
{ path: 'utils/tq-cache/index.js', content: '' },
|
|
88
|
+
{ path: 'utils/workflows/runtime.js', content: '' },
|
|
89
|
+
])
|
|
90
|
+
|
|
91
|
+
expect(unresolved).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('reports an import that climbs above the project root', () => {
|
|
95
|
+
const unresolved = findUnresolvedLocalImports([
|
|
96
|
+
{ path: 'pages/index.js', content: `import x from '../../outside/thing'` },
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
expect(unresolved).toEqual([{ filePath: 'pages/index.js', specifier: '../../outside/thing' }])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('ignores package imports, aliases and non-JS files', () => {
|
|
103
|
+
const unresolved = findUnresolvedLocalImports([
|
|
104
|
+
{
|
|
105
|
+
path: 'pages/index.js',
|
|
106
|
+
content: [
|
|
107
|
+
`import React from 'react'`,
|
|
108
|
+
`import { useGlobalState } from '@/global-state-context'`,
|
|
109
|
+
].join('\n'),
|
|
110
|
+
},
|
|
111
|
+
{ path: 'pages/style.css', content: `@import './missing.css';` },
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
expect(unresolved).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('ignores a bundler suffix on the specifier', () => {
|
|
118
|
+
const unresolved = findUnresolvedLocalImports([
|
|
119
|
+
{ path: 'pages/index.js', content: `import icon from '../public/icon.svg?inline'` },
|
|
120
|
+
{ path: 'public/icon.svg', content: '' },
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
expect(unresolved).toEqual([])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('reports every offending file, sorted, and reports each specifier once', () => {
|
|
127
|
+
const unresolved = findUnresolvedLocalImports([
|
|
128
|
+
{ path: 'pages/b.js', content: `import x from './missing'\nimport y from './missing'` },
|
|
129
|
+
{ path: 'pages/a.js', content: `import z from './also-missing'` },
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
expect(unresolved).toEqual([
|
|
133
|
+
{ filePath: 'pages/a.js', specifier: './also-missing' },
|
|
134
|
+
{ filePath: 'pages/b.js', specifier: './missing' },
|
|
135
|
+
])
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('findUnresolvedLocalImportsInProject', () => {
|
|
140
|
+
it('walks a generated folder', () => {
|
|
141
|
+
const project = folder(
|
|
142
|
+
'my-project',
|
|
143
|
+
[],
|
|
144
|
+
[
|
|
145
|
+
folder(
|
|
146
|
+
'pages',
|
|
147
|
+
[],
|
|
148
|
+
[folder('resources', [file('[id]', 'js', `import r from '../fetch'`)])]
|
|
149
|
+
),
|
|
150
|
+
folder('resources', [file('fetch', 'js', '')]),
|
|
151
|
+
]
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
expect(findUnresolvedLocalImportsInProject(project)).toEqual([
|
|
155
|
+
{ filePath: 'pages/resources/[id].js', specifier: '../fetch' },
|
|
156
|
+
])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('formatUnresolvedLocalImports', () => {
|
|
161
|
+
it('is empty when there is nothing to report', () => {
|
|
162
|
+
expect(formatUnresolvedLocalImports([])).toBe('')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('names every file and specifier', () => {
|
|
166
|
+
const report = formatUnresolvedLocalImports([
|
|
167
|
+
{ filePath: 'pages/resources/[id].js', specifier: '../fetch_content_items_detail' },
|
|
168
|
+
])
|
|
169
|
+
|
|
170
|
+
expect(report).toContain('pages/resources/[id].js')
|
|
171
|
+
expect(report).toContain('../fetch_content_items_detail')
|
|
172
|
+
expect(report).toContain('Module not found')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { GenericUtils } from '../../src'
|
|
2
|
+
|
|
3
|
+
const { generatePageDependenciesPrefix, generatePageToRootPrefix, DEFAULT_PAGES_PATH } =
|
|
4
|
+
GenericUtils
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression guard for the `pages/resources/[id].js` → `../fetch_content_items_detail`
|
|
8
|
+
* build break: the page folder path is relative to the PAGES ROOT while the
|
|
9
|
+
* target folder path is relative to the PROJECT ROOT, and resolving one against
|
|
10
|
+
* the other cancelled the `resources` segment that only looked shared.
|
|
11
|
+
*/
|
|
12
|
+
describe('generatePageDependenciesPrefix', () => {
|
|
13
|
+
it('reaches a root folder from a page at the pages root', () => {
|
|
14
|
+
expect(generatePageDependenciesPrefix({ toPath: ['resources'], folderPath: [] })).toBe(
|
|
15
|
+
'../resources/'
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('reaches a root folder from a page in a subfolder', () => {
|
|
20
|
+
expect(generatePageDependenciesPrefix({ toPath: ['resources'], folderPath: ['blog'] })).toBe(
|
|
21
|
+
'../../resources/'
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does NOT collapse a page folder that is named after the target root folder', () => {
|
|
26
|
+
expect(
|
|
27
|
+
generatePageDependenciesPrefix({ toPath: ['resources'], folderPath: ['resources'] })
|
|
28
|
+
).toBe('../../resources/')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('does not collapse deeper collisions either', () => {
|
|
32
|
+
expect(
|
|
33
|
+
generatePageDependenciesPrefix({
|
|
34
|
+
toPath: ['utils', 'data-sources'],
|
|
35
|
+
folderPath: ['utils', 'data-sources'],
|
|
36
|
+
})
|
|
37
|
+
).toBe('../../../utils/data-sources/')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('counts a multi-segment pages root', () => {
|
|
41
|
+
expect(
|
|
42
|
+
generatePageDependenciesPrefix({
|
|
43
|
+
toPath: ['resources'],
|
|
44
|
+
folderPath: ['resources'],
|
|
45
|
+
pagesPath: ['src', 'pages'],
|
|
46
|
+
})
|
|
47
|
+
).toBe('../../../resources/')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('cancels a genuinely shared ancestor', () => {
|
|
51
|
+
expect(
|
|
52
|
+
generatePageDependenciesPrefix({
|
|
53
|
+
toPath: ['src', 'resources'],
|
|
54
|
+
folderPath: ['blog'],
|
|
55
|
+
pagesPath: ['src', 'pages'],
|
|
56
|
+
})
|
|
57
|
+
).toBe('../../resources/')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('defaults to the Next pages root and to a page at that root', () => {
|
|
61
|
+
expect(generatePageDependenciesPrefix({ toPath: ['components'] })).toBe('../components/')
|
|
62
|
+
expect(DEFAULT_PAGES_PATH).toEqual(['pages'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('treats a missing folder path the same as an empty one', () => {
|
|
66
|
+
expect(
|
|
67
|
+
generatePageDependenciesPrefix({
|
|
68
|
+
toPath: ['resources'],
|
|
69
|
+
folderPath: undefined,
|
|
70
|
+
pagesPath: undefined,
|
|
71
|
+
})
|
|
72
|
+
).toBe('../resources/')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('generatePageToRootPrefix', () => {
|
|
77
|
+
it('matches the hand-rolled depth math it replaced', () => {
|
|
78
|
+
expect(generatePageToRootPrefix({ folderPath: [] })).toBe('../')
|
|
79
|
+
expect(generatePageToRootPrefix({ folderPath: ['admin'] })).toBe('../../')
|
|
80
|
+
expect(generatePageToRootPrefix({ folderPath: ['admin', 'reports'] })).toBe('../../../')
|
|
81
|
+
expect(generatePageToRootPrefix({})).toBe('../')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('counts a multi-segment pages root', () => {
|
|
85
|
+
expect(generatePageToRootPrefix({ folderPath: ['admin'], pagesPath: ['src', 'pages'] })).toBe(
|
|
86
|
+
'../../../'
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { parse } from '@babel/parser'
|
|
2
|
+
import { RichTextEmbeds, RichTextEmbedsCodegen } from '../../src'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The generated project gets `components/embed-runtime.js`, emitted from the
|
|
6
|
+
* very functions in `src/utils/rich-text-embeds.ts` rather than hand-copied
|
|
7
|
+
* into a template. These tests are what makes that safe: the emitted module has
|
|
8
|
+
* to parse, evaluate with no module-scoped references left dangling, and answer
|
|
9
|
+
* exactly what the source module answers.
|
|
10
|
+
*/
|
|
11
|
+
const SOURCE = RichTextEmbedsCodegen.generateEmbedRuntimeModuleSource()
|
|
12
|
+
|
|
13
|
+
/** Evaluates the ES module source in-process and returns its exports. */
|
|
14
|
+
const loadRuntime = (): Record<string, (...args: never[]) => unknown> => {
|
|
15
|
+
const exportedNames = Array.from(
|
|
16
|
+
SOURCE.matchAll(/^export (?:var|function) ([A-Za-z_$][\w$]*)/gm)
|
|
17
|
+
).map((match) => match[1])
|
|
18
|
+
const body = `${SOURCE.replace(/^export /gm, '')}\nreturn { ${exportedNames.join(', ')} }`
|
|
19
|
+
// eslint-disable-next-line no-new-func
|
|
20
|
+
return new Function(body)() as Record<string, (...args: never[]) => unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('the emitted embed runtime module', () => {
|
|
24
|
+
it('parses as an ES module', () => {
|
|
25
|
+
expect(() => parse(SOURCE, { sourceType: 'module' })).not.toThrow()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('carries no reference the module itself does not declare', () => {
|
|
29
|
+
expect(SOURCE).not.toContain('exports.')
|
|
30
|
+
expect(() => loadRuntime()).not.toThrow()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('exports every helper the generated editor and activator call', () => {
|
|
34
|
+
const runtime = loadRuntime()
|
|
35
|
+
const required = [
|
|
36
|
+
'isSafeEmbedUrl',
|
|
37
|
+
'resolveEmbedProvider',
|
|
38
|
+
'buildEmbedHtmlFromUrl',
|
|
39
|
+
'resolveEmbedMode',
|
|
40
|
+
'createEmbedValue',
|
|
41
|
+
'buildEmbedElementHtml',
|
|
42
|
+
'buildEmbedFrameChildren',
|
|
43
|
+
'readEmbedValueFromElement',
|
|
44
|
+
'readEncodedEmbedCode',
|
|
45
|
+
'findEmbedFrame',
|
|
46
|
+
'normalizeEmbedsForStorage',
|
|
47
|
+
'resolveEmbedSandboxFlags',
|
|
48
|
+
'buildEmbedSandboxDocument',
|
|
49
|
+
'parseEmbedHeightMessage',
|
|
50
|
+
'createEmbedToken',
|
|
51
|
+
'encodeEmbedCode',
|
|
52
|
+
'decodeEmbedCode',
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
required.forEach((name) => {
|
|
56
|
+
expect(typeof runtime[name]).toBe('function')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('answers exactly what the source module answers', () => {
|
|
61
|
+
const runtime = loadRuntime() as unknown as typeof RichTextEmbeds
|
|
62
|
+
|
|
63
|
+
const urls = [
|
|
64
|
+
'https://youtu.be/dQw4w9WgXcQ',
|
|
65
|
+
'https://x.com/a/status/1',
|
|
66
|
+
'https://www.figma.com/design/abc/File?node-id=1-2',
|
|
67
|
+
'https://open.spotify.com/track/abc123',
|
|
68
|
+
'https://example.com/plain',
|
|
69
|
+
'javascript:alert(1)',
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
urls.forEach((url) => {
|
|
73
|
+
expect(runtime.isSafeEmbedUrl(url)).toBe(RichTextEmbeds.isSafeEmbedUrl(url))
|
|
74
|
+
|
|
75
|
+
const built = runtime.buildEmbedHtmlFromUrl(url)
|
|
76
|
+
const expected = RichTextEmbeds.buildEmbedHtmlFromUrl(url)
|
|
77
|
+
expect(built?.html).toBe(expected?.html)
|
|
78
|
+
expect(built?.provider.id).toBe(expected?.provider.id)
|
|
79
|
+
|
|
80
|
+
if (!built || !expected) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const params = {
|
|
85
|
+
code: built.html,
|
|
86
|
+
provider: built.provider.id,
|
|
87
|
+
url,
|
|
88
|
+
ratio: built.provider.ratio,
|
|
89
|
+
caption: 'a & "b"',
|
|
90
|
+
}
|
|
91
|
+
expect(runtime.createEmbedValue(params)).toEqual(RichTextEmbeds.createEmbedValue(params))
|
|
92
|
+
expect(runtime.buildEmbedElementHtml(runtime.createEmbedValue(params))).toBe(
|
|
93
|
+
RichTextEmbeds.buildEmbedElementHtml(RichTextEmbeds.createEmbedValue(params))
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('produces the same sandbox payload as the source module', () => {
|
|
99
|
+
const runtime = loadRuntime() as unknown as typeof RichTextEmbeds
|
|
100
|
+
|
|
101
|
+
expect(runtime.resolveEmbedSandboxFlags('strict')).toBe(
|
|
102
|
+
RichTextEmbeds.resolveEmbedSandboxFlags('strict')
|
|
103
|
+
)
|
|
104
|
+
expect(runtime.resolveEmbedSandboxFlags('trusted')).toBe(
|
|
105
|
+
RichTextEmbeds.resolveEmbedSandboxFlags('trusted')
|
|
106
|
+
)
|
|
107
|
+
expect(runtime.buildEmbedSandboxDocument('<b>x</b>', 'tok')).toBe(
|
|
108
|
+
RichTextEmbeds.buildEmbedSandboxDocument('<b>x</b>', 'tok')
|
|
109
|
+
)
|
|
110
|
+
expect(
|
|
111
|
+
runtime.parseEmbedHeightMessage({ type: 'tq-embed-height', token: 'a', height: 9e9 })
|
|
112
|
+
).toEqual(
|
|
113
|
+
RichTextEmbeds.parseEmbedHeightMessage({ type: 'tq-embed-height', token: 'a', height: 9e9 })
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('round-trips base64 the same way in both', () => {
|
|
118
|
+
const runtime = loadRuntime() as unknown as typeof RichTextEmbeds
|
|
119
|
+
const code = '<blockquote>café — 日本語 — 🎉</blockquote><script>x()</script>'
|
|
120
|
+
|
|
121
|
+
expect(runtime.encodeEmbedCode(code)).toBe(RichTextEmbeds.encodeEmbedCode(code))
|
|
122
|
+
expect(runtime.decodeEmbedCode(RichTextEmbeds.encodeEmbedCode(code))).toBe(code)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { parse } from '@babel/parser'
|
|
2
|
+
import { RichTextEmbeds, RichTextEmbedsCodegen } from '../../src'
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
EMBED_ATTR,
|
|
6
|
+
applyEmbedTemplate,
|
|
7
|
+
buildEmbedElementHtml,
|
|
8
|
+
buildEmbedHtmlFromUrl,
|
|
9
|
+
clampEmbedWidth,
|
|
10
|
+
createEmbedValue,
|
|
11
|
+
decodeEmbedCode,
|
|
12
|
+
embedBlockStyle,
|
|
13
|
+
embedFrameStyle,
|
|
14
|
+
encodeEmbedCode,
|
|
15
|
+
isSafeEmbedUrl,
|
|
16
|
+
normalizeEmbedAlign,
|
|
17
|
+
resolveEmbedMode,
|
|
18
|
+
resolveEmbedProvider,
|
|
19
|
+
} = RichTextEmbeds
|
|
20
|
+
|
|
21
|
+
// The DOM half of the contract (`readEmbedValueFromElement`, the layout and
|
|
22
|
+
// caption editors) is covered in teleport-gui, whose test runner has a DOM —
|
|
23
|
+
// this repo's jest runs in a node environment.
|
|
24
|
+
|
|
25
|
+
describe('provider resolution', () => {
|
|
26
|
+
it('matches every YouTube URL spelling to the same embed', () => {
|
|
27
|
+
const urls = [
|
|
28
|
+
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
|
29
|
+
'https://www.youtube.com/watch?list=PL123&v=dQw4w9WgXcQ',
|
|
30
|
+
'https://youtu.be/dQw4w9WgXcQ',
|
|
31
|
+
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
|
|
32
|
+
'https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
urls.forEach((url) => {
|
|
36
|
+
const built = buildEmbedHtmlFromUrl(url)
|
|
37
|
+
expect(built).not.toBeNull()
|
|
38
|
+
expect(built?.provider.id).toBe('youtube')
|
|
39
|
+
expect(built?.html).toContain('https://www.youtube.com/embed/dQw4w9WgXcQ')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('URL-encodes the whole URL where a provider takes it as a query parameter', () => {
|
|
44
|
+
const built = buildEmbedHtmlFromUrl('https://www.figma.com/design/abc123/My-File?node-id=1-2')
|
|
45
|
+
expect(built?.provider.id).toBe('figma')
|
|
46
|
+
expect(built?.html).toContain(
|
|
47
|
+
'url=https%3A%2F%2Fwww.figma.com%2Fdesign%2Fabc123%2FMy-File%3Fnode-id%3D1-2'
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('falls back to a generic iframe only after every specific provider missed', () => {
|
|
52
|
+
expect(resolveEmbedProvider('https://example.com/some/page')?.provider.id).toBe('iframe-url')
|
|
53
|
+
expect(resolveEmbedProvider('https://vimeo.com/76979871')?.provider.id).toBe('vimeo')
|
|
54
|
+
expect(resolveEmbedProvider('https://codepen.io/team/pen/abcDEF')?.provider.id).toBe('codepen')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('refuses anything that is not an http(s) URL', () => {
|
|
58
|
+
expect(isSafeEmbedUrl('javascript:alert(1)')).toBe(false)
|
|
59
|
+
expect(isSafeEmbedUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe(false)
|
|
60
|
+
expect(isSafeEmbedUrl('//evil.example.com')).toBe(false)
|
|
61
|
+
expect(isSafeEmbedUrl('')).toBe(false)
|
|
62
|
+
expect(isSafeEmbedUrl('https://ok.example.com/x')).toBe(true)
|
|
63
|
+
expect(resolveEmbedProvider('javascript:alert(1)')).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('keeps `$$` literal and never honours the replacement patterns String.replace reserves', () => {
|
|
67
|
+
const match = 'https://x.test/1'.match(/^https:\/\/x\.test\/(\d+)$/) as RegExpMatchArray
|
|
68
|
+
expect(applyEmbedTemplate("[$1][$$][$'][$`]", match, 'https://x.test/1')).toBe("[1][$][$'][$`]")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('gives every provider patterns that agree on their capture groups', () => {
|
|
72
|
+
RichTextEmbeds.EMBED_PROVIDERS.forEach((provider) => {
|
|
73
|
+
const groupCounts = provider.patterns.map(
|
|
74
|
+
(pattern) => new RegExp(`${pattern.source}|`).exec('')?.length ?? 0
|
|
75
|
+
)
|
|
76
|
+
expect(new Set(groupCounts).size).toBe(1)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('mode resolution', () => {
|
|
82
|
+
it('quarantines anything that can execute', () => {
|
|
83
|
+
expect(resolveEmbedMode('<script src="https://x.test/a.js"></script>')).toBe('sandbox')
|
|
84
|
+
expect(resolveEmbedMode('<img src=x onerror="alert(1)">')).toBe('sandbox')
|
|
85
|
+
expect(resolveEmbedMode('<a href="javascript:alert(1)">x</a>')).toBe('sandbox')
|
|
86
|
+
expect(resolveEmbedMode('<iframe src="https://x.test"></iframe>')).toBe('inline')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not mistake an attribute merely starting with "on" for a handler', () => {
|
|
90
|
+
expect(resolveEmbedMode('<iframe data-only="1" title="one"></iframe>')).toBe('inline')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('sends every script-based provider to the sandbox and the rest inline', () => {
|
|
94
|
+
expect(resolveEmbedMode(buildEmbedHtmlFromUrl('https://x.com/a/status/1')?.html ?? '')).toBe(
|
|
95
|
+
'sandbox'
|
|
96
|
+
)
|
|
97
|
+
expect(
|
|
98
|
+
resolveEmbedMode(buildEmbedHtmlFromUrl('https://youtu.be/dQw4w9WgXcQ')?.html ?? '')
|
|
99
|
+
).toBe('inline')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('base64 round-trip', () => {
|
|
104
|
+
it('survives non-ASCII markup', () => {
|
|
105
|
+
const code = '<blockquote>café — 日本語 — 🎉</blockquote>'
|
|
106
|
+
expect(decodeEmbedCode(encodeEmbedCode(code))).toBe(code)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('stored markup', () => {
|
|
111
|
+
it('keeps an inline embed as real children so it renders without JavaScript', () => {
|
|
112
|
+
const value = createEmbedValue({
|
|
113
|
+
code: '<iframe src="https://www.youtube.com/embed/abc"></iframe>',
|
|
114
|
+
provider: 'youtube',
|
|
115
|
+
url: 'https://youtu.be/abc',
|
|
116
|
+
ratio: 56.25,
|
|
117
|
+
caption: 'A "quoted" caption & more',
|
|
118
|
+
})
|
|
119
|
+
const html = buildEmbedElementHtml(value)
|
|
120
|
+
|
|
121
|
+
expect(html).toContain(`${EMBED_ATTR.MODE}="inline"`)
|
|
122
|
+
expect(html).not.toContain(EMBED_ATTR.CODE)
|
|
123
|
+
expect(html).toContain('<iframe src="https://www.youtube.com/embed/abc"></iframe>')
|
|
124
|
+
expect(html).toContain('padding-top:56.25%')
|
|
125
|
+
expect(html).toContain('A "quoted" caption & more')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('encodes a script embed and leaves only a noscript fallback in the page', () => {
|
|
129
|
+
const code =
|
|
130
|
+
'<blockquote class="twitter-tweet"></blockquote><script src="https://x.test/w.js"></script>'
|
|
131
|
+
const value = createEmbedValue({ code, provider: 'twitter', url: 'https://x.com/a/status/1' })
|
|
132
|
+
const html = buildEmbedElementHtml(value)
|
|
133
|
+
|
|
134
|
+
expect(html).toContain(`${EMBED_ATTR.MODE}="sandbox"`)
|
|
135
|
+
expect(html).toContain(`${EMBED_ATTR.SANDBOX}="strict"`)
|
|
136
|
+
expect(html).toContain(`${EMBED_ATTR.CODE}="${encodeEmbedCode(code)}"`)
|
|
137
|
+
expect(html).not.toContain('<script')
|
|
138
|
+
expect(html).toContain('<noscript>')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('omits the noscript fallback for a pasted snippet that has no source URL', () => {
|
|
142
|
+
const html = buildEmbedElementHtml(createEmbedValue({ code: '<script>x()</script>' }))
|
|
143
|
+
expect(html).not.toContain('<noscript>')
|
|
144
|
+
expect(html).toContain(`${EMBED_ATTR.PROVIDER}="custom"`)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('layout vocabulary', () => {
|
|
149
|
+
it('clamps the width to a usable range', () => {
|
|
150
|
+
expect(clampEmbedWidth(0)).toBe(10)
|
|
151
|
+
expect(clampEmbedWidth(1000)).toBe(100)
|
|
152
|
+
expect(clampEmbedWidth(NaN)).toBe(100)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('falls back to center for an unknown alignment', () => {
|
|
156
|
+
expect(normalizeEmbedAlign('diagonal')).toBe('center')
|
|
157
|
+
expect(normalizeEmbedAlign('float-right')).toBe('float-right')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('only floats when the alignment asks for it', () => {
|
|
161
|
+
expect(embedBlockStyle({ width: 50, align: 'center' })).toContain('margin-left:auto')
|
|
162
|
+
expect(embedBlockStyle({ width: 50, align: 'center' })).not.toContain('float')
|
|
163
|
+
expect(embedBlockStyle({ width: 50, align: 'float-right' })).toContain('float:right')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('only builds a padding-top box for a usable ratio', () => {
|
|
167
|
+
expect(embedFrameStyle(56.25)).toContain('padding-top:56.25%')
|
|
168
|
+
expect(embedFrameStyle(null)).not.toContain('padding-top')
|
|
169
|
+
expect(embedFrameStyle(0)).not.toContain('padding-top')
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('runtime serialization', () => {
|
|
174
|
+
it('emits a registry the generated module can evaluate', () => {
|
|
175
|
+
// tslint:disable-next-line:no-eval
|
|
176
|
+
const providers = eval(RichTextEmbedsCodegen.serializeEmbedProvidersForRuntime()) as Array<{
|
|
177
|
+
id: string
|
|
178
|
+
patterns: RegExp[]
|
|
179
|
+
}>
|
|
180
|
+
|
|
181
|
+
expect(providers).toHaveLength(RichTextEmbeds.EMBED_PROVIDERS.length)
|
|
182
|
+
expect(providers[0].patterns[0]).toBeInstanceOf(RegExp)
|
|
183
|
+
expect(providers.map((entry) => entry.id)).toEqual(
|
|
184
|
+
RichTextEmbeds.EMBED_PROVIDERS.map((entry) => entry.id)
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('the sandbox payload', () => {
|
|
190
|
+
const {
|
|
191
|
+
EMBED_MAX_HEIGHT,
|
|
192
|
+
EMBED_MIN_HEIGHT,
|
|
193
|
+
buildEmbedSandboxDocument,
|
|
194
|
+
createEmbedToken,
|
|
195
|
+
parseEmbedHeightMessage,
|
|
196
|
+
resolveEmbedSandboxFlags,
|
|
197
|
+
} = RichTextEmbeds
|
|
198
|
+
|
|
199
|
+
it('withholds allow-same-origin unless the author opted in', () => {
|
|
200
|
+
expect(resolveEmbedSandboxFlags('strict')).not.toContain('allow-same-origin')
|
|
201
|
+
expect(resolveEmbedSandboxFlags('trusted')).toContain('allow-same-origin')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('hands the frame a bootstrap script that is valid JavaScript', () => {
|
|
205
|
+
// The document is assembled from concatenated fragments, so a stray
|
|
206
|
+
// character would only surface as a silent parse failure inside a sandboxed
|
|
207
|
+
// frame — where nothing can report it. Parse it here instead.
|
|
208
|
+
const doc = buildEmbedSandboxDocument('', 'tok')
|
|
209
|
+
const script = /<script>([\s\S]*?)<\/script>/.exec(doc)
|
|
210
|
+
|
|
211
|
+
expect(script).not.toBeNull()
|
|
212
|
+
expect(() => parse((script as RegExpExecArray)[1], { sourceType: 'script' })).not.toThrow()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('closes every tag it opens, so the embed lands inside the body', () => {
|
|
216
|
+
const doc = buildEmbedSandboxDocument('<b>x</b>', 'tok')
|
|
217
|
+
|
|
218
|
+
expect(doc.indexOf('<body>')).toBeLessThan(doc.indexOf('<b>x</b>'))
|
|
219
|
+
expect(doc.indexOf('<b>x</b>')).toBeLessThan(doc.indexOf('</body>'))
|
|
220
|
+
expect(doc.endsWith('</body></html>')).toBe(true)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('bakes the frame token into the document it hands the iframe', () => {
|
|
224
|
+
const token = createEmbedToken()
|
|
225
|
+
const doc = buildEmbedSandboxDocument('<blockquote>hi</blockquote>', token)
|
|
226
|
+
|
|
227
|
+
expect(doc).toContain(token)
|
|
228
|
+
expect(doc).not.toContain(RichTextEmbeds.EMBED_TOKEN_PLACEHOLDER)
|
|
229
|
+
expect(doc).toContain('<blockquote>hi</blockquote>')
|
|
230
|
+
expect(doc.indexOf('</head>')).toBeLessThan(doc.indexOf('<blockquote>'))
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('mints a distinct token per frame', () => {
|
|
234
|
+
expect(createEmbedToken()).not.toBe(createEmbedToken())
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('accepts only a well-formed height message, and clamps it', () => {
|
|
238
|
+
expect(parseEmbedHeightMessage(null)).toBeNull()
|
|
239
|
+
expect(parseEmbedHeightMessage('tq-embed-height')).toBeNull()
|
|
240
|
+
expect(parseEmbedHeightMessage({ type: 'other', token: 'a', height: 10 })).toBeNull()
|
|
241
|
+
expect(parseEmbedHeightMessage({ type: 'tq-embed-height', token: 1, height: 10 })).toBeNull()
|
|
242
|
+
expect(parseEmbedHeightMessage({ type: 'tq-embed-height', token: 'a', height: 'x' })).toBeNull()
|
|
243
|
+
expect(parseEmbedHeightMessage({ type: 'tq-embed-height', token: 'a', height: -5 })).toBeNull()
|
|
244
|
+
|
|
245
|
+
expect(parseEmbedHeightMessage({ type: 'tq-embed-height', token: 'a', height: 1 })).toEqual({
|
|
246
|
+
token: 'a',
|
|
247
|
+
height: EMBED_MIN_HEIGHT,
|
|
248
|
+
})
|
|
249
|
+
expect(parseEmbedHeightMessage({ type: 'tq-embed-height', token: 'a', height: 1e9 })).toEqual({
|
|
250
|
+
token: 'a',
|
|
251
|
+
height: EMBED_MAX_HEIGHT,
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
})
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as Constants from './constants';
|
|
|
2
2
|
import * as StringUtils from './utils/string-utils';
|
|
3
3
|
import * as UIDLUtils from './utils/uidl-utils';
|
|
4
4
|
import * as GenericUtils from './utils/generic';
|
|
5
|
+
import * as LocalImports from './utils/local-imports';
|
|
5
6
|
import * as JSIdentifiers from './utils/js-identifiers';
|
|
6
7
|
import * as ASTScope from './utils/ast-scope';
|
|
7
8
|
import * as ASTStatementOrder from './utils/ast-statement-order';
|
|
@@ -13,6 +14,8 @@ import * as StyleDeclarations from './utils/style-declarations';
|
|
|
13
14
|
import * as StorefrontTax from './utils/storefront-tax';
|
|
14
15
|
import * as ProductDiscounts from './utils/product-discounts';
|
|
15
16
|
import * as EmailDate from './utils/email-date';
|
|
17
|
+
import * as RichTextEmbeds from './utils/rich-text-embeds';
|
|
18
|
+
import * as RichTextEmbedsCodegen from './utils/rich-text-embeds-codegen';
|
|
16
19
|
import * as DataCache from './cache';
|
|
17
|
-
export { Constants, StringUtils, UIDLUtils, GenericUtils, JSIdentifiers, ASTScope, ASTStatementOrder, ASTEffectCleanup, RoutePaths, LayoutTopology, HtmlContentModel, StyleDeclarations, StorefrontTax, ProductDiscounts, EmailDate, DataCache, };
|
|
20
|
+
export { Constants, StringUtils, UIDLUtils, GenericUtils, LocalImports, JSIdentifiers, ASTScope, ASTStatementOrder, ASTEffectCleanup, RoutePaths, LayoutTopology, HtmlContentModel, StyleDeclarations, StorefrontTax, ProductDiscounts, EmailDate, RichTextEmbeds, RichTextEmbedsCodegen, DataCache, };
|
|
18
21
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/cjs/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,aAAa,CAAA;AACxC,OAAO,KAAK,WAAW,MAAM,sBAAsB,CAAA;AACnD,OAAO,KAAK,SAAS,MAAM,oBAAoB,CAAA;AAC/C,OAAO,KAAK,YAAY,MAAM,iBAAiB,CAAA;AAC/C,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAA;AACvD,OAAO,KAAK,QAAQ,MAAM,mBAAmB,CAAA;AAC7C,OAAO,KAAK,iBAAiB,MAAM,6BAA6B,CAAA;AAChE,OAAO,KAAK,gBAAgB,MAAM,4BAA4B,CAAA;AAC9D,OAAO,KAAK,UAAU,MAAM,qBAAqB,CAAA;AACjD,OAAO,KAAK,cAAc,MAAM,yBAAyB,CAAA;AACzD,OAAO,KAAK,gBAAgB,MAAM,4BAA4B,CAAA;AAC9D,OAAO,KAAK,iBAAiB,MAAM,4BAA4B,CAAA;AAC/D,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAA;AACvD,OAAO,KAAK,gBAAgB,MAAM,2BAA2B,CAAA;AAC7D,OAAO,KAAK,SAAS,MAAM,oBAAoB,CAAA;AAC/C,OAAO,KAAK,SAAS,MAAM,SAAS,CAAA;AAEpC,OAAO,EACL,SAAS,EACT,WAAW,EACX,SAAS,EACT,YAAY,EACZ,aAAa,EACb,QAAQ,EACR,iBAAiB,EACjB,gBAAgB,EAChB,UAAU,EACV,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,gBAAgB,EAChB,SAAS,EACT,SAAS,GACV,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,aAAa,CAAA;AACxC,OAAO,KAAK,WAAW,MAAM,sBAAsB,CAAA;AACnD,OAAO,KAAK,SAAS,MAAM,oBAAoB,CAAA;AAC/C,OAAO,KAAK,YAAY,MAAM,iBAAiB,CAAA;AAC/C,OAAO,KAAK,YAAY,MAAM,uBAAuB,CAAA;AACrD,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAA;AACvD,OAAO,KAAK,QAAQ,MAAM,mBAAmB,CAAA;AAC7C,OAAO,KAAK,iBAAiB,MAAM,6BAA6B,CAAA;AAChE,OAAO,KAAK,gBAAgB,MAAM,4BAA4B,CAAA;AAC9D,OAAO,KAAK,UAAU,MAAM,qBAAqB,CAAA;AACjD,OAAO,KAAK,cAAc,MAAM,yBAAyB,CAAA;AACzD,OAAO,KAAK,gBAAgB,MAAM,4BAA4B,CAAA;AAC9D,OAAO,KAAK,iBAAiB,MAAM,4BAA4B,CAAA;AAC/D,OAAO,KAAK,aAAa,MAAM,wBAAwB,CAAA;AACvD,OAAO,KAAK,gBAAgB,MAAM,2BAA2B,CAAA;AAC7D,OAAO,KAAK,SAAS,MAAM,oBAAoB,CAAA;AAC/C,OAAO,KAAK,cAAc,MAAM,0BAA0B,CAAA;AAC1D,OAAO,KAAK,qBAAqB,MAAM,kCAAkC,CAAA;AACzE,OAAO,KAAK,SAAS,MAAM,SAAS,CAAA;AAEpC,OAAO,EACL,SAAS,EACT,WAAW,EACX,SAAS,EACT,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,QAAQ,EACR,iBAAiB,EACjB,gBAAgB,EAChB,UAAU,EACV,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,gBAAgB,EAChB,SAAS,EACT,cAAc,EACd,qBAAqB,EACrB,SAAS,GACV,CAAA"}
|