import { parse } from '@babel/parser'
import { FileType, ProjectPluginStructure } from '@teleporthq/teleport-types'
import { RichTextEmbeds, RichTextEmbedsCodegen } from '@teleporthq/teleport-shared'
import {
NextRichContentEmbedsProjectPlugin,
RICH_CONTENT_EMBEDS_COMPONENT_NAME,
RICH_CONTENT_EMBEDS_FILE_NAME,
} from '../src/rich-content-embeds/project-plugin'
import { EMBED_RUNTIME_FILE_NAME } from '../src/rich-content-embeds/runtime-module'
const APP_CONTENT = `import '../style.css'
const MyApp = ({ Component, pageProps }) => {
return (
)
}
export default MyApp
`
const element = (elementType: string, extra: Record = {}) => ({
type: 'element',
content: { elementType, children: [] as unknown[], ...extra },
})
/** The blog details page's Content node: `html` raw, carrying a ctx binding. */
const boundRichTextNode = () =>
element('html-node', {
attrs: {
html: {
type: 'raw',
content: 'Blog post content...
',
fallback: 'Blog post content...
',
dynamic: { type: 'dynamic', content: { referenceType: 'prop', id: 'content' } },
},
},
})
/** A design-time code embed: the same attribute, but with no binding. */
const staticEmbedNode = () =>
element('html-node', {
attrs: { html: { type: 'raw', content: 'hello
' } },
})
const makeStructure = (pageChild: unknown): ProjectPluginStructure => {
const files = new Map()
files.set('_app', {
path: ['pages'],
files: [{ name: '_app', fileType: FileType.JS, content: APP_CONTENT }],
})
return {
uidl: {
name: 'test-project',
globals: { settings: { title: 'Test', language: 'en' }, assets: [] },
root: { node: element('container') },
components: {
'blog-post-details': { node: element('container', { children: [pageChild] }) },
},
},
files,
dependencies: {},
devDependencies: {},
template: { files: [], subFolders: [] },
} as unknown as ProjectPluginStructure
}
const emittedComponent = (structure: ProjectPluginStructure): string | undefined =>
structure.files.get(RICH_CONTENT_EMBEDS_FILE_NAME)?.files[0]?.content as string | undefined
const emittedRuntime = (structure: ProjectPluginStructure): string | undefined =>
structure.files.get(EMBED_RUNTIME_FILE_NAME)?.files[0]?.content as string | undefined
const appContent = (structure: ProjectPluginStructure): string =>
(structure.files.get('_app')?.files[0]?.content as string) ?? ''
describe('NextRichContentEmbedsProjectPlugin', () => {
it('ships the activator and mounts it in _app when a page renders bound rich text', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(boundRichTextNode())
)
expect(emittedComponent(structure)).toContain('export default RichContentEmbeds')
expect(appContent(structure)).toContain(
`import ${RICH_CONTENT_EMBEDS_COMPONENT_NAME} from '../components/${RICH_CONTENT_EMBEDS_FILE_NAME}';`
)
expect(appContent(structure)).toContain(`<${RICH_CONTENT_EMBEDS_COMPONENT_NAME} />`)
})
it('also ships it for CMS rich text rendered through a markdown node', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(element('div', { semanticType: 'markdown-node' }))
)
expect(emittedComponent(structure)).toBeDefined()
})
it('ships nothing for an editor alone — it previews its own embeds', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(element('RichTextEditor', { semanticType: 'rich-text-editor-node' }))
)
expect(emittedComponent(structure)).toBeUndefined()
})
it('ships nothing for a project with no author-written rich text', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(staticEmbedNode())
)
expect(emittedComponent(structure)).toBeUndefined()
expect(appContent(structure)).toBe(APP_CONTENT)
})
it('adds no npm dependency', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(boundRichTextNode())
)
expect(structure.dependencies).toEqual({})
expect(structure.devDependencies).toEqual({})
})
it('is idempotent — a second pass does not mount the component twice', async () => {
const plugin = new NextRichContentEmbedsProjectPlugin()
const once = await plugin.runAfter(makeStructure(boundRichTextNode()))
const twice = await plugin.runAfter(once)
expect(appContent(twice).match(/RichContentEmbeds/g)).toHaveLength(2)
})
it('ships the shared runtime module beside the activator', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(boundRichTextNode())
)
expect(emittedRuntime(structure)).toBe(RichTextEmbedsCodegen.generateEmbedRuntimeModuleSource())
expect(emittedComponent(structure)).toContain(`from './${EMBED_RUNTIME_FILE_NAME}'`)
})
it('imports only helpers the runtime module actually exports', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(boundRichTextNode())
)
const source = emittedComponent(structure) as string
const runtime = emittedRuntime(structure) as string
const importBlock = source.match(
new RegExp(`import \\{([^}]+)\\} from '\\./${EMBED_RUNTIME_FILE_NAME}'`)
)
expect(importBlock).not.toBeNull()
const imported = (importBlock as RegExpMatchArray)[1]
.split(',')
.map((name) => name.trim())
.filter(Boolean)
expect(imported.length).toBeGreaterThan(0)
imported.forEach((name) => {
expect(runtime).toMatch(new RegExp(`^export (?:var|function) ${name}\\b`, 'm'))
})
})
it('emits a component that parses as a module', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(boundRichTextNode())
)
expect(() =>
parse(emittedComponent(structure) as string, { sourceType: 'module', plugins: ['jsx'] })
).not.toThrow()
})
it('never leaves the sandbox flags open to the host page by default', async () => {
const structure = await new NextRichContentEmbedsProjectPlugin().runAfter(
makeStructure(boundRichTextNode())
)
const source = emittedComponent(structure) as string
// `allow-same-origin` may only reach the DOM through the shared resolver,
// driven by the per-embed opt-in — never as a literal in the wiring, and
// never as the default when the attribute is missing or misspelled.
expect(source).not.toContain('allow-same-origin')
expect(source).toContain('resolveEmbedSandboxFlags(level)')
expect(source).toContain(
"block.getAttribute(EMBED_ATTR.SANDBOX) === 'trusted' ? 'trusted' : 'strict'"
)
})
})