@teleporthq/teleport-plugin-next-static-props 0.44.0-alpha.0 → 0.44.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.
@@ -0,0 +1,301 @@
1
+ import generator from '@babel/generator'
2
+ import * as types from '@babel/types'
3
+ import { ComponentStructure } from '@teleporthq/teleport-types'
4
+ import { createStaticPropsPlugin } from '../src/index'
5
+
6
+ // Entity-level redirects (`initialPropsData.redirect`): a details page whose
7
+ // fetched row carries a destination answers with an HTTP redirect instead of
8
+ // rendering. Two invariants matter beyond the emitted shape:
9
+ // - the redirect must live INSIDE an IfStatement — several later plugins
10
+ // (addDynamicSeoPropsToGetStaticProps, the parallel/inline-fetch plugins)
11
+ // locate the props return via `.find((s) => s.type === 'ReturnStatement')`
12
+ // on the try block, so a second top-level return would derail them;
13
+ // - `statusCode` (not `permanent`) is emitted, because Next.js maps
14
+ // `permanent: true/false` to 308/307 while the feature promises 301/302.
15
+
16
+ const RESOURCE_ID = 'fetch-blog-post'
17
+
18
+ const makeStructure = (params: {
19
+ redirect?: {
20
+ destinationField: string
21
+ typeField?: string
22
+ unlessFieldEquals?: { field: string; value: string }
23
+ ownRowsFilter?: Array<Record<string, unknown>>
24
+ statusCode?: 301 | 302 | 307 | 308
25
+ }
26
+ skipI18n?: boolean
27
+ revalidate?: number
28
+ resourceParams?: Record<string, unknown>
29
+ }): ComponentStructure => {
30
+ const { redirect, skipI18n, revalidate, resourceParams } = params
31
+ return {
32
+ uidl: {
33
+ name: 'BlogPostDetails',
34
+ node: { type: 'element', content: { elementType: 'container' } },
35
+ outputOptions: {
36
+ pageId: 'page-blog-post-details',
37
+ folderPath: ['blog'],
38
+ fileName: '[slug]',
39
+ initialPropsData: {
40
+ exposeAs: { name: 'blogPost', valuePath: ['data', '0'] },
41
+ resource: { id: RESOURCE_ID, params: resourceParams || {} },
42
+ ...(revalidate ? { cache: { revalidate } } : {}),
43
+ ...(redirect ? { redirect } : {}),
44
+ },
45
+ },
46
+ },
47
+ chunks: [],
48
+ dependencies: {},
49
+ options: {
50
+ skipI18n,
51
+ resources: {
52
+ items: {
53
+ [RESOURCE_ID]: { id: RESOURCE_ID, name: 'blog-post' },
54
+ },
55
+ path: ['utils', 'data-sources'],
56
+ },
57
+ },
58
+ } as unknown as ComponentStructure
59
+ }
60
+
61
+ const generateCode = async (structure: ComponentStructure): Promise<string> => {
62
+ const plugin = createStaticPropsPlugin()
63
+ const result = await plugin(structure)
64
+ const chunk = result.chunks.find((c) => c.name === 'getStaticProps')
65
+ expect(chunk).toBeDefined()
66
+ return generator(chunk?.content as types.Node).code
67
+ }
68
+
69
+ describe('teleport-plugin-next-static-props: two rows at one address', () => {
70
+ const OWN_ROWS = [
71
+ { type: 'condition', source: 'product_page', destination: 'personalise', operand: '=' },
72
+ ]
73
+ const productPage = (ownRowsFilter?: Array<Record<string, unknown>>) =>
74
+ makeStructure({
75
+ redirect: {
76
+ destinationField: 'productPageUrl',
77
+ unlessFieldEquals: { field: 'productPageKey', value: 'personalise' },
78
+ ...(ownRowsFilter ? { ownRowsFilter } : {}),
79
+ statusCode: 302,
80
+ },
81
+ revalidate: 60,
82
+ resourceParams: {
83
+ filters: {
84
+ type: 'expr',
85
+ content:
86
+ 'JSON.stringify([{ type: "condition", source: "slug", destination: params.slug, operand: "=" }])',
87
+ },
88
+ limit: { type: 'static', content: 1 },
89
+ },
90
+ })
91
+
92
+ // Two products may share a slug on different product pages: the page shows
93
+ // its own one, and redirects only when it has none at that address.
94
+ it('looks for an own row at the address before sending the visitor on', async () => {
95
+ const code = await generateCode(productPage(OWN_ROWS))
96
+ expect(code).toContain(
97
+ 'if (String(response?.data?.[0]?.productPageKey ?? "") !== "personalise") {'
98
+ )
99
+ expect(code).toContain('const ownRowsResponse = await')
100
+ expect(code).toContain('"filters": JSON.stringify(JSON.parse(JSON.stringify([{')
101
+ expect(code).toContain('.concat([{')
102
+ expect(code).toContain('destination: "personalise"')
103
+ expect(code).toContain('response.data = ownRowsResponse.data')
104
+ expect(code.indexOf('ownRowsResponse')).toBeLessThan(code.indexOf('entityRedirectUrl'))
105
+ expect(code.indexOf('notFound')).toBeLessThan(code.indexOf('ownRowsResponse'))
106
+ })
107
+
108
+ it('never redirects while pre-rendering: the path answers 404 until it revalidates', async () => {
109
+ const code = await generateCode(productPage(OWN_ROWS))
110
+ const redirectCheck = code.indexOf('if (entityRedirectUrl)')
111
+ const buildCheck = code.indexOf('process.env.NEXT_PHASE === "phase-production-build"')
112
+ expect(buildCheck).toBeGreaterThan(redirectCheck)
113
+ expect(buildCheck).toBeLessThan(code.indexOf('redirect: {'))
114
+ })
115
+
116
+ it('leaves a page without the own-rows filter, and the blog redirects, as they were', async () => {
117
+ const withoutFilter = await generateCode(productPage())
118
+ expect(withoutFilter).not.toContain('ownRowsResponse')
119
+ const blog = await generateCode(
120
+ makeStructure({ redirect: { destinationField: 'redirectUrl', typeField: 'redirectType' } })
121
+ )
122
+ expect(blog).not.toContain('ownRowsResponse')
123
+ expect(blog).not.toContain('NEXT_PHASE')
124
+ })
125
+
126
+ it('keeps the props return the only top-level return', async () => {
127
+ const plugin = createStaticPropsPlugin()
128
+ const result = await plugin(productPage(OWN_ROWS))
129
+ const chunk = result.chunks.find((c) => c.name === 'getStaticProps')
130
+ const fn = (chunk?.content as types.ExportNamedDeclaration)
131
+ .declaration as types.FunctionDeclaration
132
+ const tryStmt = fn.body.body.find(
133
+ (statement): statement is types.TryStatement => statement.type === 'TryStatement'
134
+ )
135
+ expect(
136
+ tryStmt!.block.body.filter((statement) => statement.type === 'ReturnStatement')
137
+ ).toHaveLength(1)
138
+ })
139
+ })
140
+
141
+ describe('teleport-plugin-next-static-props: entity redirect', () => {
142
+ // A page that shows a SUBSET of a table's rows (a custom product page shows
143
+ // the products assigned to it): every other row is sent to its own page,
144
+ // with a temporary status because a row can move back.
145
+ it('redirects only while the row belongs to another page, with the status asked for', async () => {
146
+ const code = await generateCode(
147
+ makeStructure({
148
+ redirect: {
149
+ destinationField: 'productPageUrl',
150
+ unlessFieldEquals: { field: 'productPageKey', value: 'personalise' },
151
+ statusCode: 302,
152
+ },
153
+ revalidate: 60,
154
+ })
155
+ )
156
+
157
+ expect(code).toContain(
158
+ 'const entityRedirectUrl = String(response?.data?.[0]?.productPageKey ?? "") !== "personalise" ? response?.data?.[0]?.productPageUrl : undefined'
159
+ )
160
+ expect(code).toContain('statusCode: 302')
161
+ expect(code).toContain('revalidate: 60')
162
+ expect(code.indexOf('notFound')).toBeLessThan(code.indexOf('entityRedirectUrl'))
163
+ })
164
+
165
+ it('keeps the standard product page own products: its key is empty', async () => {
166
+ const code = await generateCode(
167
+ makeStructure({
168
+ redirect: {
169
+ destinationField: 'productPageUrl',
170
+ unlessFieldEquals: { field: 'productPageKey', value: '' },
171
+ statusCode: 302,
172
+ },
173
+ })
174
+ )
175
+ expect(code).toContain('String(response?.data?.[0]?.productPageKey ?? "") !== ""')
176
+ })
177
+
178
+ it('emits a statusCode redirect between the notFound check and the props return', async () => {
179
+ const code = await generateCode(
180
+ makeStructure({ redirect: { destinationField: 'redirectUrl', typeField: 'redirectType' } })
181
+ )
182
+
183
+ expect(code).toContain('const entityRedirectUrl = response?.data?.[0]?.redirectUrl')
184
+ expect(code).toContain('if (entityRedirectUrl)')
185
+ expect(code).toContain('statusCode: response?.data?.[0]?.redirectType === "302" ? 302 : 301')
186
+ expect(code).not.toContain('permanent')
187
+
188
+ // The 404 for a missing row must win over the redirect check.
189
+ expect(code.indexOf('notFound')).toBeLessThan(code.indexOf('entityRedirectUrl'))
190
+ expect(code.indexOf('entityRedirectUrl')).toBeLessThan(code.indexOf('props:'))
191
+ })
192
+
193
+ /*
194
+ Without `revalidate`, an ISR entry has NO expiry: Next.js caches the branch
195
+ it took and never runs getStaticProps for that path again. A page that
196
+ redirected once would redirect for ever — clearing `redirect_url` in the
197
+ admin would change the row and change nothing a visitor sees — and a row
198
+ that 404'd would stay 404 after being published. Every branch carries the
199
+ window, not just the one that renders: the missing row, the redirect, the
200
+ props and the failed fetch.
201
+ */
202
+ it('gives the redirect and the notFound the same revalidate window as the props', async () => {
203
+ const code = await generateCode(
204
+ makeStructure({
205
+ redirect: { destinationField: 'redirectUrl', typeField: 'redirectType' },
206
+ revalidate: 60,
207
+ })
208
+ )
209
+
210
+ expect(code.match(/revalidate: 60/g)).toHaveLength(4)
211
+ const redirectAt = code.indexOf('if (entityRedirectUrl)')
212
+ const propsAt = code.indexOf('props:')
213
+ // The one between the redirect check and the props return is the redirect's.
214
+ expect(code.indexOf('revalidate: 60', redirectAt)).toBeLessThan(propsAt)
215
+ })
216
+
217
+ it('emits no revalidate anywhere when the page has no cache window', async () => {
218
+ const code = await generateCode(
219
+ makeStructure({ redirect: { destinationField: 'redirectUrl', typeField: 'redirectType' } })
220
+ )
221
+ expect(code).not.toContain('revalidate')
222
+ })
223
+
224
+ it('keeps the props return as the ONLY top-level ReturnStatement in the try block', async () => {
225
+ const plugin = createStaticPropsPlugin()
226
+ const result = await plugin(
227
+ makeStructure({ redirect: { destinationField: 'redirectUrl', typeField: 'redirectType' } })
228
+ )
229
+ const chunk = result.chunks.find((c) => c.name === 'getStaticProps')
230
+ const exportDecl = chunk?.content as types.ExportNamedDeclaration
231
+ const fn = exportDecl.declaration as types.FunctionDeclaration
232
+ const tryStmt = fn.body.body.find(
233
+ (statement): statement is types.TryStatement => statement.type === 'TryStatement'
234
+ )
235
+ expect(tryStmt).toBeDefined()
236
+
237
+ const topLevelReturns = tryStmt!.block.body.filter(
238
+ (statement) => statement.type === 'ReturnStatement'
239
+ )
240
+ expect(topLevelReturns).toHaveLength(1)
241
+
242
+ // And the one `.find(ReturnStatement)` later plugins run must land on the
243
+ // props return, not the redirect.
244
+ const found = tryStmt!.block.body.find(
245
+ (statement) => statement.type === 'ReturnStatement'
246
+ ) as types.ReturnStatement
247
+ const foundCode = generator(found).code
248
+ expect(foundCode).toContain('props:')
249
+ })
250
+
251
+ it('prefixes internal destinations with the active non-default locale when i18n is on', async () => {
252
+ const code = await generateCode(
253
+ makeStructure({ redirect: { destinationField: 'redirectUrl', typeField: 'redirectType' } })
254
+ )
255
+
256
+ expect(code).toContain('context?.locale')
257
+ expect(code).toContain('context?.defaultLocale')
258
+ expect(code).toContain('entityRedirectUrl.startsWith("/")')
259
+ // tslint:disable-next-line:no-invalid-template-strings
260
+ expect(code).toContain('`/${context.locale}${entityRedirectUrl}`')
261
+ })
262
+
263
+ it('uses the destination as-is when i18n is skipped', async () => {
264
+ const code = await generateCode(
265
+ makeStructure({
266
+ redirect: { destinationField: 'redirectUrl', typeField: 'redirectType' },
267
+ skipI18n: true,
268
+ })
269
+ )
270
+
271
+ expect(code).toContain('destination: entityRedirectUrl')
272
+ expect(code).not.toContain('entityRedirectUrl.startsWith')
273
+ })
274
+
275
+ it('defaults to a permanent 301 when no typeField is configured', async () => {
276
+ const code = await generateCode(
277
+ makeStructure({ redirect: { destinationField: 'redirectUrl' } })
278
+ )
279
+
280
+ expect(code).toContain('statusCode: 301')
281
+ expect(code).not.toContain('redirectType')
282
+ })
283
+
284
+ it('emits no redirect code at all without a redirect config (regression guard)', async () => {
285
+ const code = await generateCode(makeStructure({}))
286
+
287
+ expect(code).not.toContain('entityRedirectUrl')
288
+ expect(code).not.toContain('redirect')
289
+ })
290
+ })
291
+
292
+ describe('teleport-plugin-next-static-props: the route reaches the page', () => {
293
+ it("passes the route's parameters as props, before the fetched row", async () => {
294
+ // A child list filtered by the entry the address names reads `props.slug`
295
+ // in the browser; without it the filter was dropped and every row showed.
296
+ const code = (await generateCode(makeStructure({}))).replace(/\s+/g, ' ')
297
+ expect(code).toContain(
298
+ 'props: { ...context?.params, blogPost: response?.data?.[0], ...response?.meta }'
299
+ )
300
+ })
301
+ })
@@ -0,0 +1,69 @@
1
+ import generator from '@babel/generator'
2
+ import * as types from '@babel/types'
3
+ import { ComponentStructure } from '@teleporthq/teleport-types'
4
+ import { createStaticPropsPlugin } from '../src/index'
5
+
6
+ /*
7
+ A details page answers 404 both when its row does not exist and when the
8
+ fetch for it FAILED. The failure is not a fact about the row: a timeout or a
9
+ rate limit — every details page calls its source while the site is built —
10
+ must not leave the record unreachable until the next deploy. Next.js caches a
11
+ `notFound` that carries no `revalidate` for good, so the failure branch
12
+ carries the same window as every other branch.
13
+ */
14
+
15
+ const RESOURCE_ID = 'fetch-template-detail'
16
+
17
+ const makeStructure = (revalidate?: number): ComponentStructure =>
18
+ ({
19
+ uidl: {
20
+ name: 'TemplateDetails',
21
+ node: { type: 'element', content: { elementType: 'container' } },
22
+ outputOptions: {
23
+ pageId: 'page-template-details',
24
+ folderPath: ['templates'],
25
+ fileName: '[slug]',
26
+ initialPropsData: {
27
+ exposeAs: { name: 'template', valuePath: ['data', '0'] },
28
+ resource: { id: RESOURCE_ID, params: {} },
29
+ ...(revalidate ? { cache: { revalidate } } : {}),
30
+ },
31
+ },
32
+ },
33
+ chunks: [],
34
+ dependencies: {},
35
+ options: {
36
+ skipI18n: true,
37
+ resources: {
38
+ items: { [RESOURCE_ID]: { id: RESOURCE_ID, name: 'template-detail' } },
39
+ path: ['utils', 'data-sources'],
40
+ },
41
+ },
42
+ } as unknown as ComponentStructure)
43
+
44
+ const catchBranchCode = async (structure: ComponentStructure): Promise<string> => {
45
+ const result = await createStaticPropsPlugin()(structure)
46
+ const chunk = result.chunks.find((c) => c.name === 'getStaticProps')
47
+ const declaration = (chunk?.content as types.ExportNamedDeclaration)
48
+ .declaration as types.FunctionDeclaration
49
+ const tryStatement = declaration.body.body.find(
50
+ (statement): statement is types.TryStatement => statement.type === 'TryStatement'
51
+ )
52
+ return generator(tryStatement?.handler as types.Node).code
53
+ }
54
+
55
+ describe('teleport-plugin-next-static-props: a failed fetch', () => {
56
+ it('answers 404 with the page revalidate window, so the next request retries', async () => {
57
+ const code = await catchBranchCode(makeStructure(60))
58
+
59
+ expect(code).toContain('notFound: true')
60
+ expect(code).toContain('revalidate: 60')
61
+ })
62
+
63
+ it('carries no revalidate when the page has no window', async () => {
64
+ const code = await catchBranchCode(makeStructure())
65
+
66
+ expect(code).toContain('notFound: true')
67
+ expect(code).not.toContain('revalidate')
68
+ })
69
+ })
@@ -0,0 +1,96 @@
1
+ import { ComponentStructure } from '@teleporthq/teleport-types'
2
+ import { createStaticPropsPlugin } from '../src/index'
3
+
4
+ /**
5
+ * Regression guard for the `pages/resources/[id].js` build break:
6
+ *
7
+ * Module not found: Can't resolve '../fetch_content_items_detail'
8
+ *
9
+ * `outputOptions.folderPath` is relative to the PAGES ROOT while
10
+ * `resources.path` is relative to the PROJECT ROOT, so resolving one against
11
+ * the other cancelled the `resources` segment they only appeared to share and
12
+ * the import pointed inside `pages/`. Every page whose folder is named after a
13
+ * project-root folder hits it — and only those, which is why it went unnoticed.
14
+ */
15
+
16
+ const RESOURCE_ID = 'fetch-content-item'
17
+ const RESOURCE_NAME = 'fetch_content_items_detail'
18
+
19
+ const makeStructure = (params: {
20
+ folderPath: string[]
21
+ resourcesPath?: string[]
22
+ pagesPath?: string[]
23
+ }): ComponentStructure => {
24
+ const { folderPath, resourcesPath = ['resources'], pagesPath } = params
25
+ return {
26
+ uidl: {
27
+ name: 'CustomResources',
28
+ node: { type: 'element', content: { elementType: 'container' } },
29
+ outputOptions: {
30
+ folderPath,
31
+ fileName: '[id]',
32
+ initialPropsData: {
33
+ exposeAs: { name: 'contentItem', valuePath: [] },
34
+ resource: { id: RESOURCE_ID, params: {} },
35
+ },
36
+ },
37
+ },
38
+ chunks: [],
39
+ dependencies: {},
40
+ options: {
41
+ ...(pagesPath && { pagesPath }),
42
+ resources: {
43
+ items: { [RESOURCE_ID]: { id: RESOURCE_ID, name: RESOURCE_NAME } },
44
+ path: resourcesPath,
45
+ },
46
+ },
47
+ } as unknown as ComponentStructure
48
+ }
49
+
50
+ describe('teleport-plugin-next-static-props: resource import path', () => {
51
+ const plugin = createStaticPropsPlugin()
52
+
53
+ it('reaches the project resources folder from a page whose folder is named "resources"', async () => {
54
+ const structure = await plugin(makeStructure({ folderPath: ['resources'] }))
55
+
56
+ expect(structure.dependencies.fetchContentItemsDetailResource.path).toBe(
57
+ `../../resources/${RESOURCE_NAME}`
58
+ )
59
+ })
60
+
61
+ it('keeps working for a page in a non-colliding subfolder', async () => {
62
+ const structure = await plugin(makeStructure({ folderPath: ['blog'] }))
63
+
64
+ expect(structure.dependencies.fetchContentItemsDetailResource.path).toBe(
65
+ `../../resources/${RESOURCE_NAME}`
66
+ )
67
+ })
68
+
69
+ it('keeps working for a page at the pages root', async () => {
70
+ const structure = await plugin(makeStructure({ folderPath: [] }))
71
+
72
+ expect(structure.dependencies.fetchContentItemsDetailResource.path).toBe(
73
+ `../resources/${RESOURCE_NAME}`
74
+ )
75
+ })
76
+
77
+ it('does not collapse a multi-segment resources folder either', async () => {
78
+ const structure = await plugin(
79
+ makeStructure({ folderPath: ['utils'], resourcesPath: ['utils', 'data-sources'] })
80
+ )
81
+
82
+ expect(structure.dependencies.fetchContentItemsDetailResource.path).toBe(
83
+ `../../utils/data-sources/${RESOURCE_NAME}`
84
+ )
85
+ })
86
+
87
+ it('honours a pages root that is not the project root child', async () => {
88
+ const structure = await plugin(
89
+ makeStructure({ folderPath: ['resources'], pagesPath: ['src', 'pages'] })
90
+ )
91
+
92
+ expect(structure.dependencies.fetchContentItemsDetailResource.path).toBe(
93
+ `../../../resources/${RESOURCE_NAME}`
94
+ )
95
+ })
96
+ })