import { parse } from '@babel/parser' import traverse from '@babel/traverse' import { GeneratedFolder, ProjectUIDL } from '@teleporthq/teleport-types' import uidlSample from '../../../examples/test-samples/project-sample.json' import customPagesFixture from './__fixtures__/custom-pages-uidl.json' import { createNextProjectGenerator } from '../src' import NextTemplate from '../src/project-template' /** * Custom Pages: `/pages/[slug]` and the two components that render it — the * part of the feature nobody can read before it ships. * * Everything else the feature builds is inspectable in the editor. These three * files are not: they are generated, locked, and only prove themselves as * emitted Next.js code. So the fixture is not invented — `__fixtures__/ * custom-pages-uidl.json` is what the GUI's `domainProjectToUIDL` actually * produces for a project with one `Hero` component (a text prop and a rich-text * prop), captured whole: the route, the page node, the `custom-page-block` and * `custom-page-section` components and the two resource fetchers. * * page repeater(parse(props.teleportPage?.blocks)) * block?.component === 'tq:section' → * block?.component !== 'tq:section' → * block comp one gated branch per registry entry (built-in elements + Hero) * section comp a band + grid styled from the row → repeater(block.children) → * * The assertions guard failures that are INVISIBLE in the editor: a repeater * source that is not parse-wrapped renders nothing at all, a missing * `revalidate` freezes the page until the next redeploy, a scalar prop that * gained a fallback would overwrite a deliberately stored `false`/`''`, a * reference the generator cannot resolve becomes a `ReferenceError` on the * first server render, and a per-row SEO override that never reaches the head * ships every page indexed and canonicalised to itself. */ const template = JSON.parse(JSON.stringify(NextTemplate)) as GeneratedFolder const DATA_SOURCE_ID = 'ds-custom-pages' const TABLE_NAME = 'teleport_pages' const ENTITY = 'teleportPage' const REVALIDATE_SECONDS = 60 const SECTION_ID = 'tq:section' interface Fixture { route: Record pageNode: Record components: Record> resources: { items: Record; cache?: unknown } } const fixture = customPagesFixture as unknown as Fixture /** The `Hero` component's id, read off the fixture rather than pinned. */ const HERO_ID = (() => { const text = JSON.stringify(fixture.components['custom-page-block']) const match = text.match(/"data-tq-block-branch":\{"type":"static","content":"(TQ_[^"]+)"\}/) if (!match) { throw new Error('fixture: no component branch found in custom-page-block') } return match[1] })() interface FixtureOptions { /** * The project-global `resources.cache`. Left out, the UIDL decoder defaults it * to `{ revalidate: 60 }` — which is exactly the accident the per-page cache * exists to not depend on. */ globalCache?: unknown /** Drop `initialPropsData.cache`, i.e. the pre-ISR-floor GUI output. */ withoutPerPageCache?: boolean } const buildCustomPagesUidl = (options: FixtureOptions = {}): ProjectUIDL => { const uidl = JSON.parse(JSON.stringify(uidlSample)) as ProjectUIDL const route = JSON.parse(JSON.stringify(fixture.route)) as Record const pageOptions = route.pageOptions as { initialPropsData: { cache?: unknown } } if (options.withoutPerPageCache) { delete pageOptions.initialPropsData.cache } // Without this the data-source utility module the two resource files import // is never emitted and the project would not build. uidl.dataSources = { [DATA_SOURCE_ID]: { id: DATA_SOURCE_ID, name: 'Test DB', type: 'teleport', config: { selectedTables: { [TABLE_NAME]: { name: TABLE_NAME, columns: [ { name: 'id', type: 'uuid' }, { name: 'title', type: 'varchar' }, { name: 'slug', type: 'varchar' }, { name: 'status', type: 'varchar' }, { name: 'blocks', type: 'text' }, { name: 'meta_title', type: 'varchar' }, { name: 'meta_description', type: 'text' }, ], }, }, }, }, } as never uidl.resources = { items: JSON.parse(JSON.stringify(fixture.resources.items)), resourceMappers: {}, ...(options.globalCache === undefined ? {} : { cache: options.globalCache }), } as never uidl.components = { ...(uidl.components || {}), ...(JSON.parse(JSON.stringify(fixture.components)) as ProjectUIDL['components']), } uidl.root.stateDefinitions?.route.values?.push(route as never) uidl.root.node.content.children?.push(JSON.parse(JSON.stringify(fixture.pageNode)) as never) return uidl } interface Generated { page: string blockComponent: string sectionComponent: string dataSourceApi: string } const findFile = (folder: GeneratedFolder, path: string[]): string => { let current: GeneratedFolder | undefined = folder for (const segment of path.slice(0, -1)) { current = current?.subFolders.find((sub) => sub.name === segment) } const file = current?.files.find((entry) => entry.name === path[path.length - 1]) expect(file).toBeDefined() return file?.content as string } const findFileContaining = (folder: GeneratedFolder, needle: string): string => { for (const file of folder.files) { if (String(file.content).includes(needle)) { return String(file.content) } } for (const sub of folder.subFolders) { const found = findFileContaining(sub, needle) if (found) { return found } } return '' } const generate = async (options?: FixtureOptions): Promise => { const generator = createNextProjectGenerator() const outputFolder = await generator.generateProject(buildCustomPagesUidl(options), template) return { page: findFile(outputFolder, ['pages', 'pages', '[slug]']), blockComponent: findFile(outputFolder, ['components', 'custom-page-block']), sectionComponent: findFile(outputFolder, ['components', 'custom-page-section']), dataSourceApi: findFileContaining(outputFolder, 'function buildCustomPage('), } } /** * Every identifier the emitted module reads without ever declaring it. * * This is the `next build` guard: an unresolved reference does not throw at * generation time, it becomes a bare name in the JSX and the route dies with * `ReferenceError: is not defined` on the first server render. */ const collectUndeclaredIdentifiers = (code: string): string[] => { const ast = parse(code, { sourceType: 'module', plugins: ['jsx'] }) const globals = new Set([ 'React', 'JSON', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Math', 'Date', 'console', 'undefined', 'process', 'window', 'document', 'require', 'module', 'exports', ]) const found = new Set() traverse(ast, { ReferencedIdentifier(path: { node: { name: string } scope: { hasBinding: (n: string) => boolean } }) { const { name } = path.node if (globals.has(name) || path.scope.hasBinding(name)) { return } found.add(name) }, } as never) return [...found] } /** `props.block?.props?.heading` or `props.block?.["props"]?.["heading"]` — the quoting is the generator's. */ const propPath = (...segments: string[]): RegExp => new RegExp( ['props\\.block'] .concat( segments.map((segment) => `\\?\\.(?:${segment}|\\["${segment}"\\]|\\['${segment}'\\])`) ) .join('') ) describe('Next generator — Custom Pages', () => { let generated: Generated beforeAll(async () => { generated = await generate() }, 60_000) describe('/pages/[slug]', () => { it('server-fetches the published row by the slug route param and 404s when it is missing', () => { const { page } = generated expect(page).toContain('export async function getStaticProps') expect(page).toMatch(/source:\s*'slug',\s*destination:\s*params\?\.\['slug'\]/) expect(page).toMatch(/source:\s*'status',\s*destination:\s*'published'/) expect(page).toContain('notFound: true') }) it("pre-renders known slugs and serves brand-new ones with fallback: 'blocking'", () => { const { page } = generated expect(page).toContain('export async function getStaticPaths') expect(page).toMatch(/fallback:\s*['"]blocking['"]/) }) it('pre-renders only published rows that do not redirect', () => { // Next fails the WHOLE build on a `redirect` returned from getStaticProps // while prerendering; a redirected row is served on demand instead. A // draft would only be pre-rendered into a 404. const { page } = generated expect(page).toMatch( /fetchTeleportPagesPathsResource\(\s*\{\s*filters:\s*'\[.*"source":"status".*"source":"redirect_url","destination":null.*\]'/s ) // Read as a visitor is served the pages: a draft never becomes a path. expect(page).toMatch( /fetchTeleportPagesPathsResource\([^)]*\},\s*\{\s*as: 'visitor',?\s*\}\s*\)/s ) }) it('emits the ISR floor so an admin edit converges without a redeploy', () => { expect(generated.page).toMatch(new RegExp(`revalidate:\\s*${REVALIDATE_SECONDS}`)) }) it('keeps the ISR floor when the project-global resources cache carries no revalidate', async () => { const { page } = await generate({ globalCache: { staleWhileRevalidate: 30 } }) expect(page).toMatch(new RegExp(`revalidate:\\s*${REVALIDATE_SECONDS}`)) }) it('answers the row’s own redirect before rendering, with a real 301/302', () => { const { page } = generated expect(page).toContain('redirectUrl') expect(page).toContain('redirectType') expect(page).toMatch(/statusCode:/) expect(page).not.toMatch(/permanent:\s*(true|false)/) }) it('reads the JSON column through the parse IIFE and draws the two components', () => { const { page } = generated expect(page).toContain(`(function (__tqJsonArray)`) expect(page).toContain(`props.${ENTITY}?.blocks`) expect(page).toContain(`block?.component === '${SECTION_ID}'`) expect(page).toContain(`block?.component !== '${SECTION_ID}'`) expect(page).toMatch(/ { const { page } = generated expect(page).toContain(`props?.${ENTITY}?.metaTitle`) expect(page).toContain(`props?.${ENTITY}?.metaDescription`) expect(page).toMatch( new RegExp(`props\\?\\.${ENTITY}\\?\\.robotsContent \\?\\? ['"]index, follow['"]`) ) expect(page).toContain(`props?.${ENTITY}?.ogTitle`) expect(page).toContain(`props?.${ENTITY}?.ogDescription`) expect(page).toContain(`props?.${ENTITY}?.ogImage`) expect(page).toMatch(new RegExp(`props\\?\\.${ENTITY}\\?\\.canonicalUrl \\|\\|`)) // Exactly one robots tag: crawlers obey the most restrictive of two. expect(page.match(/name="robots"/g)).toHaveLength(1) }) it('leaves no reference as a bare identifier (SSR ReferenceError guard)', () => { expect(collectUndeclaredIdentifiers(generated.page)).toEqual([]) }) it('puts nothing but a key on a Fragment', () => { // The repeater's item root is a Fragment and React accepts only `key` // (and `children`) on it — a marker attribute leaking from the mapper // would warn on every block row. expect(generated.page).not.toMatch(/ { it('gates one branch per registry entry on the block prop, the project component included', () => { const { blockComponent } = generated expect(blockComponent).toMatch(propPath('component')) expect(blockComponent).toContain(`'${HERO_ID}'`) for (const id of [ 'tq:heading', 'tq:text', 'tq:rich-text', 'tq:image', 'tq:button', 'tq:video', 'tq:separator', 'tq:spacer', ]) { expect(blockComponent).toContain(`'${id}'`) } }) it('binds the component’s scalar prop straight to the row, and its rich text through dangerouslySetInnerHTML', () => { const { blockComponent } = generated expect(blockComponent).toMatch(/import Hero from ['"]\.\/hero['"]/) expect(blockComponent).toMatch( new RegExp(`]*heading=\\{${propPath('props', 'heading').source}\\}`) ) expect(blockComponent).not.toMatch( new RegExp(`${propPath('props', 'heading').source}\\s*(\\|\\||\\?\\?)`) ) expect(blockComponent).toContain('dangerouslySetInnerHTML') expect(blockComponent).toMatch(propPath('props', 'story')) }) it('renders the built-in elements from the row: six heading levels, inline styles from the props', () => { const { blockComponent } = generated for (const tag of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) { expect(blockComponent).toContain(`<${tag}`) } expect(blockComponent).toMatch(/textAlign:\s*props\.block/) expect(blockComponent).toMatch(/backgroundColor:\s*props\.block/) expect(blockComponent).toMatch(/maxWidth:\s*props\.block/) expect(blockComponent).toMatch(/