@teleporthq/teleport-project-generator-next 0.43.75 → 0.44.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/__tests__/analytics-tracker-consent.test.ts +165 -0
- package/__tests__/booking-context-plugin.test.ts +96 -0
- package/__tests__/content-category-page-end2end.test.ts +3 -1
- package/__tests__/custom-pages-end2end.test.ts +1 -1
- package/__tests__/form-inside-conditional-onsubmit.test.ts +99 -0
- package/__tests__/payment-pay-pages.test.ts +40 -0
- package/__tests__/pwa-project-plugin.test.ts +10 -0
- package/__tests__/state-data-source-fetch-grouping.test.ts +31 -0
- package/__tests__/state-data-source-page-arrival.test.ts +187 -0
- package/__tests__/state-data-source-runtime-user-fetch.test.ts +4 -1
- package/__tests__/state-data-source-single-collection.test.ts +176 -0
- package/dist/cjs/analytics/tracker-source.d.ts +1 -1
- package/dist/cjs/analytics/tracker-source.d.ts.map +1 -1
- package/dist/cjs/analytics/tracker-source.js +1 -1
- package/dist/cjs/analytics/tracker-source.js.map +1 -1
- package/dist/cjs/bookings/booking-context-generator.d.ts +12 -0
- package/dist/cjs/bookings/booking-context-generator.d.ts.map +1 -0
- package/dist/cjs/bookings/booking-context-generator.js +18 -0
- package/dist/cjs/bookings/booking-context-generator.js.map +1 -0
- package/dist/cjs/bookings/project-plugin.d.ts +14 -0
- package/dist/cjs/bookings/project-plugin.d.ts.map +1 -0
- package/dist/cjs/bookings/project-plugin.js +101 -0
- package/dist/cjs/bookings/project-plugin.js.map +1 -0
- package/dist/cjs/dashboard-layout-plugin.d.ts.map +1 -1
- package/dist/cjs/dashboard-layout-plugin.js +1 -1
- package/dist/cjs/dashboard-layout-plugin.js.map +1 -1
- package/dist/cjs/ecommerce/project-plugin.d.ts +14 -0
- package/dist/cjs/ecommerce/project-plugin.d.ts.map +1 -1
- package/dist/cjs/ecommerce/project-plugin.js +95 -73
- package/dist/cjs/ecommerce/project-plugin.js.map +1 -1
- package/dist/cjs/entity-mutation-ssr-finalize-plugin.d.ts +4 -1
- package/dist/cjs/entity-mutation-ssr-finalize-plugin.d.ts.map +1 -1
- package/dist/cjs/entity-mutation-ssr-finalize-plugin.js +8 -4
- package/dist/cjs/entity-mutation-ssr-finalize-plugin.js.map +1 -1
- package/dist/cjs/forms/form-submission-handler.d.ts.map +1 -1
- package/dist/cjs/forms/form-submission-handler.js +11 -0
- package/dist/cjs/forms/form-submission-handler.js.map +1 -1
- package/dist/cjs/global-state/data-source-utils.d.ts +17 -0
- package/dist/cjs/global-state/data-source-utils.d.ts.map +1 -1
- package/dist/cjs/global-state/data-source-utils.js +41 -1
- package/dist/cjs/global-state/data-source-utils.js.map +1 -1
- package/dist/cjs/global-state/project-plugin.d.ts.map +1 -1
- package/dist/cjs/global-state/project-plugin.js +9 -2
- package/dist/cjs/global-state/project-plugin.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +57 -53
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/internationalization/locale-mapper-component.d.ts +2 -0
- package/dist/cjs/internationalization/locale-mapper-component.d.ts.map +1 -1
- package/dist/cjs/internationalization/locale-mapper-component.js +21 -2
- package/dist/cjs/internationalization/locale-mapper-component.js.map +1 -1
- package/dist/cjs/pwa/network-only-paths.d.ts.map +1 -1
- package/dist/cjs/pwa/network-only-paths.js +6 -3
- package/dist/cjs/pwa/network-only-paths.js.map +1 -1
- package/dist/cjs/state-data-source-plugin.d.ts.map +1 -1
- package/dist/cjs/state-data-source-plugin.js +85 -28
- package/dist/cjs/state-data-source-plugin.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/analytics/tracker-source.d.ts +1 -1
- package/dist/esm/analytics/tracker-source.d.ts.map +1 -1
- package/dist/esm/analytics/tracker-source.js +1 -1
- package/dist/esm/analytics/tracker-source.js.map +1 -1
- package/dist/esm/bookings/booking-context-generator.d.ts +12 -0
- package/dist/esm/bookings/booking-context-generator.d.ts.map +1 -0
- package/dist/esm/bookings/booking-context-generator.js +14 -0
- package/dist/esm/bookings/booking-context-generator.js.map +1 -0
- package/dist/esm/bookings/project-plugin.d.ts +14 -0
- package/dist/esm/bookings/project-plugin.d.ts.map +1 -0
- package/dist/esm/bookings/project-plugin.js +98 -0
- package/dist/esm/bookings/project-plugin.js.map +1 -0
- package/dist/esm/dashboard-layout-plugin.d.ts.map +1 -1
- package/dist/esm/dashboard-layout-plugin.js +1 -1
- package/dist/esm/dashboard-layout-plugin.js.map +1 -1
- package/dist/esm/ecommerce/project-plugin.d.ts +14 -0
- package/dist/esm/ecommerce/project-plugin.d.ts.map +1 -1
- package/dist/esm/ecommerce/project-plugin.js +96 -74
- package/dist/esm/ecommerce/project-plugin.js.map +1 -1
- package/dist/esm/entity-mutation-ssr-finalize-plugin.d.ts +4 -1
- package/dist/esm/entity-mutation-ssr-finalize-plugin.d.ts.map +1 -1
- package/dist/esm/entity-mutation-ssr-finalize-plugin.js +8 -4
- package/dist/esm/entity-mutation-ssr-finalize-plugin.js.map +1 -1
- package/dist/esm/forms/form-submission-handler.d.ts.map +1 -1
- package/dist/esm/forms/form-submission-handler.js +11 -0
- package/dist/esm/forms/form-submission-handler.js.map +1 -1
- package/dist/esm/global-state/data-source-utils.d.ts +17 -0
- package/dist/esm/global-state/data-source-utils.d.ts.map +1 -1
- package/dist/esm/global-state/data-source-utils.js +38 -0
- package/dist/esm/global-state/data-source-utils.js.map +1 -1
- package/dist/esm/global-state/project-plugin.d.ts.map +1 -1
- package/dist/esm/global-state/project-plugin.js +10 -3
- package/dist/esm/global-state/project-plugin.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internationalization/locale-mapper-component.d.ts +2 -0
- package/dist/esm/internationalization/locale-mapper-component.d.ts.map +1 -1
- package/dist/esm/internationalization/locale-mapper-component.js +20 -1
- package/dist/esm/internationalization/locale-mapper-component.js.map +1 -1
- package/dist/esm/pwa/network-only-paths.d.ts.map +1 -1
- package/dist/esm/pwa/network-only-paths.js +6 -3
- package/dist/esm/pwa/network-only-paths.js.map +1 -1
- package/dist/esm/state-data-source-plugin.d.ts.map +1 -1
- package/dist/esm/state-data-source-plugin.js +87 -30
- package/dist/esm/state-data-source-plugin.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +19 -19
- package/src/analytics/tracker-source.ts +45 -7
- package/src/bookings/booking-context-generator.ts +48 -0
- package/src/bookings/project-plugin.ts +53 -0
- package/src/dashboard-layout-plugin.ts +5 -0
- package/src/ecommerce/project-plugin.ts +101 -70
- package/src/entity-mutation-ssr-finalize-plugin.ts +8 -3
- package/src/forms/form-submission-handler.ts +10 -0
- package/src/global-state/data-source-utils.ts +42 -0
- package/src/global-state/project-plugin.ts +12 -3
- package/src/index.ts +3 -0
- package/src/internationalization/locale-mapper-component.ts +24 -0
- package/src/pwa/network-only-paths.ts +7 -3
- package/src/state-data-source-plugin.ts +122 -30
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { TRACKER_SOURCE } from '../src/analytics/tracker-source'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retention only recognises visitors who accepted the cookie banner. The banner
|
|
5
|
+
* is accepted on a page the tracker is already running on, so the tracker must
|
|
6
|
+
* pick the consent up mid-visit — reading it only at boot left the accepting
|
|
7
|
+
* visit (and every client-side navigation after it) anonymous, and a visitor
|
|
8
|
+
* needed a THIRD visit before they could ever count as returning.
|
|
9
|
+
*
|
|
10
|
+
* These tests run the emitted tracker against a stubbed browser and read what
|
|
11
|
+
* it actually sends.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type Sent = Record<string, unknown>
|
|
15
|
+
|
|
16
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
|
17
|
+
|
|
18
|
+
function memoryStorage() {
|
|
19
|
+
const values = new Map<string, string>()
|
|
20
|
+
return {
|
|
21
|
+
getItem: (key: string) => (values.has(key) ? values.get(key) : null),
|
|
22
|
+
setItem: (key: string, value: string) => values.set(key, String(value)),
|
|
23
|
+
removeItem: (key: string) => values.delete(key),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function bootTracker() {
|
|
28
|
+
const localStorage = memoryStorage()
|
|
29
|
+
const sessionStorage = memoryStorage()
|
|
30
|
+
const batches: Sent[][] = []
|
|
31
|
+
const heartbeats: Sent[] = []
|
|
32
|
+
const documentListeners: Record<string, () => void> = {}
|
|
33
|
+
|
|
34
|
+
const fakeDocument = {
|
|
35
|
+
visibilityState: 'visible',
|
|
36
|
+
referrer: '',
|
|
37
|
+
documentElement: { scrollHeight: 0 },
|
|
38
|
+
addEventListener: (type: string, listener: () => void) => {
|
|
39
|
+
documentListeners[type] = listener
|
|
40
|
+
},
|
|
41
|
+
querySelector: () => null,
|
|
42
|
+
}
|
|
43
|
+
const fakeWindow = {
|
|
44
|
+
location: { hostname: 'shop.example.com', pathname: '/', search: '' },
|
|
45
|
+
localStorage,
|
|
46
|
+
sessionStorage,
|
|
47
|
+
screen: { width: 1440 },
|
|
48
|
+
innerHeight: 900,
|
|
49
|
+
scrollY: 0,
|
|
50
|
+
addEventListener: () => undefined,
|
|
51
|
+
}
|
|
52
|
+
const fakeFetch = (url: string, init: { body: string }) => {
|
|
53
|
+
const payload = JSON.parse(init.body)
|
|
54
|
+
if (url.endsWith('/heartbeat')) {
|
|
55
|
+
heartbeats.push(payload)
|
|
56
|
+
} else {
|
|
57
|
+
batches.push(payload.events)
|
|
58
|
+
}
|
|
59
|
+
return Promise.resolve({ status: 202 })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = TRACKER_SOURCE.replace(/^export\s+/gm, '')
|
|
63
|
+
const tracker = new Function(
|
|
64
|
+
'process',
|
|
65
|
+
'window',
|
|
66
|
+
'document',
|
|
67
|
+
'navigator',
|
|
68
|
+
'fetch',
|
|
69
|
+
'setInterval',
|
|
70
|
+
`${body}\nreturn { initTeleportAnalytics, trackRouteChange }`
|
|
71
|
+
)(
|
|
72
|
+
{
|
|
73
|
+
env: {
|
|
74
|
+
NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example.com',
|
|
75
|
+
NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'public-key',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
fakeWindow,
|
|
79
|
+
fakeDocument,
|
|
80
|
+
{ webdriver: false },
|
|
81
|
+
fakeFetch,
|
|
82
|
+
() => 0
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
// Hiding the page flushes the queue — the same path a real tab switch takes.
|
|
86
|
+
const flush = (): Sent[] => {
|
|
87
|
+
fakeDocument.visibilityState = 'hidden'
|
|
88
|
+
documentListeners.visibilitychange()
|
|
89
|
+
fakeDocument.visibilityState = 'visible'
|
|
90
|
+
return batches.splice(0).flat()
|
|
91
|
+
}
|
|
92
|
+
// Showing the page again sends a heartbeat straight away.
|
|
93
|
+
const show = () => {
|
|
94
|
+
fakeDocument.visibilityState = 'visible'
|
|
95
|
+
documentListeners.visibilitychange()
|
|
96
|
+
}
|
|
97
|
+
const navigate = (path: string) => {
|
|
98
|
+
fakeWindow.location.pathname = path
|
|
99
|
+
tracker.trackRouteChange(path)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { tracker, localStorage, sessionStorage, heartbeats, flush, show, navigate }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('tracker — consent given or withdrawn mid-visit', () => {
|
|
106
|
+
it('upgrades the running visit to the persistent id once the banner is accepted', () => {
|
|
107
|
+
const page = bootTracker()
|
|
108
|
+
page.tracker.initTeleportAnalytics({})
|
|
109
|
+
const [landing] = page.flush()
|
|
110
|
+
|
|
111
|
+
expect(landing.visitorId).toBeNull()
|
|
112
|
+
expect(page.localStorage.getItem('tp_aid')).toBeNull()
|
|
113
|
+
|
|
114
|
+
page.localStorage.setItem('cookieConsent', 'accepted')
|
|
115
|
+
page.navigate('/pricing')
|
|
116
|
+
const [next] = page.flush()
|
|
117
|
+
|
|
118
|
+
const visitorId = page.localStorage.getItem('tp_aid')
|
|
119
|
+
expect(visitorId).toMatch(UUID)
|
|
120
|
+
expect(next.visitorId).toBe(visitorId)
|
|
121
|
+
// Same session: the server hands it to the consented visitor instead of
|
|
122
|
+
// counting a second visit.
|
|
123
|
+
expect(next.sessionId).toBe(landing.sessionId)
|
|
124
|
+
expect(page.sessionStorage.getItem('tp_sid')).toBe(landing.sessionId)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('sends the persistent id on heartbeats after the banner is accepted', () => {
|
|
128
|
+
const page = bootTracker()
|
|
129
|
+
page.tracker.initTeleportAnalytics({})
|
|
130
|
+
expect(page.heartbeats[0].visitorId).toBeNull()
|
|
131
|
+
|
|
132
|
+
page.localStorage.setItem('cookieConsent', 'accepted')
|
|
133
|
+
page.show()
|
|
134
|
+
|
|
135
|
+
const lastBeat = page.heartbeats[page.heartbeats.length - 1]
|
|
136
|
+
expect(lastBeat.visitorId).toMatch(UUID)
|
|
137
|
+
expect(lastBeat.visitorId).toBe(page.localStorage.getItem('tp_aid'))
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('reuses the stored id for a visitor who accepted on an earlier visit', () => {
|
|
141
|
+
const page = bootTracker()
|
|
142
|
+
page.localStorage.setItem('cookieConsent', 'accepted')
|
|
143
|
+
page.localStorage.setItem('tp_aid', '0f8fad5b-d9cb-469f-a165-70867728950e')
|
|
144
|
+
page.tracker.initTeleportAnalytics({})
|
|
145
|
+
|
|
146
|
+
const [landing] = page.flush()
|
|
147
|
+
expect(landing.visitorId).toBe('0f8fad5b-d9cb-469f-a165-70867728950e')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('drops the persistent id in a fresh anonymous session when consent disappears', () => {
|
|
151
|
+
const page = bootTracker()
|
|
152
|
+
page.localStorage.setItem('cookieConsent', 'accepted')
|
|
153
|
+
page.tracker.initTeleportAnalytics({})
|
|
154
|
+
const [landing] = page.flush()
|
|
155
|
+
expect(landing.visitorId).toMatch(UUID)
|
|
156
|
+
|
|
157
|
+
page.localStorage.removeItem('cookieConsent')
|
|
158
|
+
page.navigate('/contact')
|
|
159
|
+
const [next] = page.flush()
|
|
160
|
+
|
|
161
|
+
expect(next.visitorId).toBeNull()
|
|
162
|
+
expect(next.sessionId).toMatch(UUID)
|
|
163
|
+
expect(next.sessionId).not.toBe(landing.sessionId)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { FileType, ProjectPluginStructure, UIDLBookingSettings } from '@teleporthq/teleport-types'
|
|
2
|
+
import { NextBookingProjectPlugin } from '../src/bookings/project-plugin'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The booking items' category taxonomy is baked into `booking-context.js` and
|
|
6
|
+
* read through `useBookingCategories()` — the booking page's category filter is
|
|
7
|
+
* an array mapper over it, as the products' and the blog's are over theirs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PAGE_WITHOUT_HOOK = 'export default function Book() { return null }'
|
|
11
|
+
const PAGE_WITH_HOOK = [
|
|
12
|
+
"import { useBookingCategories } from '@/booking-context'",
|
|
13
|
+
'export default function Book() { return null }',
|
|
14
|
+
].join('\n')
|
|
15
|
+
|
|
16
|
+
const buildStructure = (params: {
|
|
17
|
+
pageContent: string
|
|
18
|
+
bookingSettings?: UIDLBookingSettings
|
|
19
|
+
}): ProjectPluginStructure => {
|
|
20
|
+
const files = new Map<string, { path: string[]; files: Array<Record<string, unknown>> }>()
|
|
21
|
+
files.set('book', {
|
|
22
|
+
path: ['pages'],
|
|
23
|
+
files: [{ name: 'book', fileType: FileType.JS, content: params.pageContent }],
|
|
24
|
+
})
|
|
25
|
+
return {
|
|
26
|
+
uidl: {
|
|
27
|
+
globals: { env: {} },
|
|
28
|
+
...(params.bookingSettings ? { bookingSettings: params.bookingSettings } : {}),
|
|
29
|
+
},
|
|
30
|
+
files,
|
|
31
|
+
dependencies: {},
|
|
32
|
+
devDependencies: {},
|
|
33
|
+
} as unknown as ProjectPluginStructure
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const findContext = (structure: ProjectPluginStructure) =>
|
|
37
|
+
structure.files.get('booking-context')?.files?.[0] as { content: string } | undefined
|
|
38
|
+
|
|
39
|
+
describe('NextBookingProjectPlugin', () => {
|
|
40
|
+
it('bakes the taxonomy behind a memoized, locale-aware hook', async () => {
|
|
41
|
+
const structure = buildStructure({
|
|
42
|
+
pageContent: PAGE_WITHOUT_HOOK,
|
|
43
|
+
bookingSettings: {
|
|
44
|
+
categories: [
|
|
45
|
+
{
|
|
46
|
+
id: 'rooms',
|
|
47
|
+
name: 'Rooms',
|
|
48
|
+
slug: 'rooms',
|
|
49
|
+
parentId: null,
|
|
50
|
+
order: 0,
|
|
51
|
+
children: [],
|
|
52
|
+
translations: { es: { name: 'Habitaciones' } },
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await new NextBookingProjectPlugin().runAfter(structure)
|
|
59
|
+
|
|
60
|
+
const context = findContext(structure)
|
|
61
|
+
expect(context).toBeDefined()
|
|
62
|
+
expect(context!.content).toContain('export const useBookingCategories')
|
|
63
|
+
expect(context!.content).toContain(
|
|
64
|
+
'resolveCategoryTranslations(BOOKING_CATEGORIES, router.locale)'
|
|
65
|
+
)
|
|
66
|
+
expect(context!.content).toContain('"Habitaciones"')
|
|
67
|
+
expect(context!.content).not.toContain('createContext')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('emits an EMPTY taxonomy for bookings that have no categories yet', async () => {
|
|
71
|
+
const structure = buildStructure({
|
|
72
|
+
pageContent: PAGE_WITHOUT_HOOK,
|
|
73
|
+
bookingSettings: { categories: [] },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await new NextBookingProjectPlugin().runAfter(structure)
|
|
77
|
+
|
|
78
|
+
expect(findContext(structure)!.content).toContain('const BOOKING_CATEGORIES = []')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('backstops a dangling import when the project carries no bookingSettings', async () => {
|
|
82
|
+
const structure = buildStructure({ pageContent: PAGE_WITH_HOOK })
|
|
83
|
+
|
|
84
|
+
await new NextBookingProjectPlugin().runAfter(structure)
|
|
85
|
+
|
|
86
|
+
expect(findContext(structure)!.content).toContain('const BOOKING_CATEGORIES = []')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('leaves a project that never references the hook untouched', async () => {
|
|
90
|
+
const structure = buildStructure({ pageContent: PAGE_WITHOUT_HOOK })
|
|
91
|
+
|
|
92
|
+
await new NextBookingProjectPlugin().runAfter(structure)
|
|
93
|
+
|
|
94
|
+
expect(findContext(structure)).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
})
|
|
@@ -167,7 +167,9 @@ describe('a content preset category page, generated', () => {
|
|
|
167
167
|
`import \\{[\\s\\S]*\\b${RESOLVER}\\b[\\s\\S]*\\} from '${MODULE.replace('/', '\\/')}'`
|
|
168
168
|
)
|
|
169
169
|
)
|
|
170
|
-
expect(page).toMatch(
|
|
170
|
+
expect(page).toMatch(
|
|
171
|
+
new RegExp(`await ${RESOLVER}\\(\\{[\\s\\S]*slug: params\\?\\.\\['slug'\\]`)
|
|
172
|
+
)
|
|
171
173
|
expect(page).toMatch(
|
|
172
174
|
/if \(!response\?\.data\) \{\s*return \{\s*notFound: true,\s*revalidate: 60/
|
|
173
175
|
)
|
|
@@ -232,7 +232,7 @@ describe('Next generator — Custom Pages', () => {
|
|
|
232
232
|
it('server-fetches the published row by the slug route param and 404s when it is missing', () => {
|
|
233
233
|
const { page } = generated
|
|
234
234
|
expect(page).toContain('export async function getStaticProps')
|
|
235
|
-
expect(page).toMatch(/source:\s*'slug',\s*destination:\s*params
|
|
235
|
+
expect(page).toMatch(/source:\s*'slug',\s*destination:\s*params\?\.\['slug'\]/)
|
|
236
236
|
expect(page).toMatch(/source:\s*'status',\s*destination:\s*'published'/)
|
|
237
237
|
expect(page).toContain('notFound: true')
|
|
238
238
|
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { GeneratedFolder, ProjectUIDL, UIDLFormDefinition } from '@teleporthq/teleport-types'
|
|
2
|
+
import uidlSample from '../../../examples/test-samples/project-sample.json'
|
|
3
|
+
import { createNextProjectGenerator } from '../src'
|
|
4
|
+
import NextTemplate from '../src/project-template'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A platform form rendered inside a rendering condition (a results step of a
|
|
8
|
+
* multi-step page: `{activeStep === 7 && (<form data-form-id=…/>)}`) must
|
|
9
|
+
* still receive the generated onSubmit handler. It did not: the handler was
|
|
10
|
+
* declared but never attached, so a workflow's `form.requestSubmit()` posted
|
|
11
|
+
* the form natively and reloaded the page, losing every state value.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const template = JSON.parse(JSON.stringify(NextTemplate)) as GeneratedFolder
|
|
15
|
+
const FORM_ID = 'results-form'
|
|
16
|
+
|
|
17
|
+
const formElement = () => ({
|
|
18
|
+
type: 'element',
|
|
19
|
+
content: {
|
|
20
|
+
elementType: 'form',
|
|
21
|
+
name: 'results',
|
|
22
|
+
attrs: { 'data-form-id': { type: 'static', content: FORM_ID } },
|
|
23
|
+
children: [
|
|
24
|
+
{
|
|
25
|
+
type: 'element',
|
|
26
|
+
content: {
|
|
27
|
+
elementType: 'textinput',
|
|
28
|
+
name: 'email',
|
|
29
|
+
attrs: {
|
|
30
|
+
name: { type: 'static', content: 'email' },
|
|
31
|
+
type: { type: 'static', content: 'email' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const formDefinition = (): UIDLFormDefinition => ({
|
|
40
|
+
id: { type: 'static', content: FORM_ID },
|
|
41
|
+
name: { type: 'static', content: 'Results' },
|
|
42
|
+
formNodeId: { type: 'static', content: 'form-node' },
|
|
43
|
+
fields: {
|
|
44
|
+
email: {
|
|
45
|
+
id: { type: 'static', content: 'email' },
|
|
46
|
+
name: { type: 'static', content: 'email' },
|
|
47
|
+
nodeId: { type: 'static', content: 'email-node' },
|
|
48
|
+
type: 'textinput',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
behaviors: { onSuccess: { action: 'clear-form' }, onError: { action: 'clear-form' } },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const buildUidl = (): ProjectUIDL => {
|
|
55
|
+
const uidl = JSON.parse(JSON.stringify(uidlSample)) as ProjectUIDL
|
|
56
|
+
const indexPage = (uidl.root.node.content.children || []).find(
|
|
57
|
+
(child) =>
|
|
58
|
+
child.type === 'conditional' && (child.content as { value?: string }).value === 'index'
|
|
59
|
+
)
|
|
60
|
+
const pageElement = (indexPage as { content: { node: { content: { children: unknown[] } } } })
|
|
61
|
+
.content.node.content
|
|
62
|
+
const root = uidl.root as { stateDefinitions?: Record<string, unknown> }
|
|
63
|
+
root.stateDefinitions = {
|
|
64
|
+
...(root.stateDefinitions || {}),
|
|
65
|
+
activeStep: { type: 'number', defaultValue: 1 },
|
|
66
|
+
}
|
|
67
|
+
pageElement.children.push({
|
|
68
|
+
type: 'conditional',
|
|
69
|
+
content: {
|
|
70
|
+
reference: { type: 'dynamic', content: { referenceType: 'state', id: 'activeStep' } },
|
|
71
|
+
value: 7,
|
|
72
|
+
node: formElement(),
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
uidl.forms = {
|
|
76
|
+
items: { [FORM_ID]: formDefinition() },
|
|
77
|
+
formsServerUrl: { type: 'env', content: 'NEXT_PUBLIC_FORMS_SERVER_URL' },
|
|
78
|
+
globalConfig: {
|
|
79
|
+
captchaProvider: 'recaptcha',
|
|
80
|
+
defaultCaptchaPublicKey: { type: 'env', content: 'NEXT_PUBLIC_CAPTCHA_PUBLIC_KEY' },
|
|
81
|
+
},
|
|
82
|
+
} as ProjectUIDL['forms']
|
|
83
|
+
return uidl
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const indexPageOf = (folder: GeneratedFolder) =>
|
|
87
|
+
folder.subFolders.find((sub) => sub.name === 'pages')?.files.find((file) => file.name === 'index')
|
|
88
|
+
?.content ?? ''
|
|
89
|
+
|
|
90
|
+
describe('Next forms: a form inside a rendering condition keeps its submit handler', () => {
|
|
91
|
+
it('attaches onSubmit to the form rendered under `activeStep === 7`', async () => {
|
|
92
|
+
const page = indexPageOf(
|
|
93
|
+
await createNextProjectGenerator().generateProject(buildUidl(), template)
|
|
94
|
+
)
|
|
95
|
+
expect(page).toContain('activeStep === 7')
|
|
96
|
+
expect(page).toContain(`data-form-id="${FORM_ID}"`)
|
|
97
|
+
expect(page).toMatch(/onSubmit=\{handleFormSubmit_results_form\}/)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -749,4 +749,44 @@ describe('what the store generator emits for its providers', () => {
|
|
|
749
749
|
STRIPE_WEBHOOK_SECRET: '',
|
|
750
750
|
})
|
|
751
751
|
})
|
|
752
|
+
|
|
753
|
+
it('gives a site paid without a store (its bookings) the credentials, capture and confirmation', async () => {
|
|
754
|
+
const structure = build([])
|
|
755
|
+
delete (structure.uidl as any).ecommerceSettings
|
|
756
|
+
;(structure.uidl as any).paymentSettings = {
|
|
757
|
+
paymentProviders: [
|
|
758
|
+
{ type: 'paypal', credentials: { clientId: 'CONFIGURATION_PAYPAL_CLIENT_ID' } },
|
|
759
|
+
{ type: 'razorpay', credentials: { keyId: 'CONFIGURATION_RAZORPAY_KEY_ID' } },
|
|
760
|
+
],
|
|
761
|
+
}
|
|
762
|
+
;(structure.uidl as any).workflows = {
|
|
763
|
+
workflows: {
|
|
764
|
+
paypalHook: {
|
|
765
|
+
name: 'PayPal Payment Webhook',
|
|
766
|
+
trigger: { type: 'event-webhook-received' },
|
|
767
|
+
webhookConfig: { urlPath: '/api/webhooks/paypal-payment' },
|
|
768
|
+
},
|
|
769
|
+
},
|
|
770
|
+
}
|
|
771
|
+
await new NextEcommerceProjectPlugin().runAfter(structure)
|
|
772
|
+
expect(structure.files.has('ecommerce-api-paypal-capture')).toBe(true)
|
|
773
|
+
expect(structure.files.has('payment-page-razorpay')).toBe(true)
|
|
774
|
+
expect(structure.files.has('ecommerce-api-payment-confirm')).toBe(true)
|
|
775
|
+
expect(structure.uidl.globals.env).toMatchObject({
|
|
776
|
+
PAYPAL_CLIENT_ID: 'teleporthq.secrets.CONFIGURATION_PAYPAL_CLIENT_ID',
|
|
777
|
+
RAZORPAY_KEY_ID: 'teleporthq.secrets.CONFIGURATION_RAZORPAY_KEY_ID',
|
|
778
|
+
})
|
|
779
|
+
// No store: no store runtime.
|
|
780
|
+
expect(structure.files.has('ecommerce-api-settings')).toBe(false)
|
|
781
|
+
expect(structure.files.has('ecommerce-api-cart')).toBe(false)
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it('emits nothing for a site with neither a store nor payment settings', async () => {
|
|
785
|
+
const structure = build([])
|
|
786
|
+
delete (structure.uidl as any).ecommerceSettings
|
|
787
|
+
await new NextEcommerceProjectPlugin().runAfter(structure)
|
|
788
|
+
expect(structure.files.has('ecommerce-api-payment-confirm')).toBe(false)
|
|
789
|
+
expect(structure.files.has('payment-drivers')).toBe(false)
|
|
790
|
+
expect(structure.uidl.globals.env).toEqual({})
|
|
791
|
+
})
|
|
752
792
|
})
|
|
@@ -322,6 +322,16 @@ describe('NextPwaProjectPlugin — installable app', () => {
|
|
|
322
322
|
expect(clientConfig(structure).networkOnly).toEqual(workerConfig(structure).networkOnly)
|
|
323
323
|
})
|
|
324
324
|
|
|
325
|
+
it('keeps the payment pages off the cache on a site paid for its bookings alone', async () => {
|
|
326
|
+
const paid = await run({
|
|
327
|
+
pwa: installableApp(),
|
|
328
|
+
paymentSettings: { paymentProviders: [{ type: 'razorpay' }] },
|
|
329
|
+
})
|
|
330
|
+
expect(workerConfig(paid).networkOnly).toContain('/pay')
|
|
331
|
+
const unpaid = await run({ pwa: installableApp() })
|
|
332
|
+
expect(workerConfig(unpaid).networkOnly).not.toContain('/pay')
|
|
333
|
+
})
|
|
334
|
+
|
|
325
335
|
it('precaches nothing when the home page is private or the site is password protected', async () => {
|
|
326
336
|
const privateHome = await run({ pwa: installableApp({ networkOnlyPaths: ['/'] }) })
|
|
327
337
|
expect(workerConfig(privateHome).precacheUrls).toEqual([])
|
|
@@ -118,6 +118,37 @@ describe('state-data-source-plugin fetch grouping', () => {
|
|
|
118
118
|
expect(listProp![1]).toBe(mirrorProp![1])
|
|
119
119
|
})
|
|
120
120
|
|
|
121
|
+
it('keeps a fetch another plugin pushed into the declaration when it rebuilds it', async () => {
|
|
122
|
+
// The pagination plugin adds a paginated list's page fetch straight into
|
|
123
|
+
// the `const [...] = await Promise.all([...])` this plugin built; a later
|
|
124
|
+
// state registration rebuilt that declaration from its own bookkeeping
|
|
125
|
+
// and the list's fetch was gone — its props then read an undefined name.
|
|
126
|
+
const structure = makeStructure({
|
|
127
|
+
productsList: makeState('SELECT * FROM products ORDER BY sku ASC LIMIT 200'),
|
|
128
|
+
})
|
|
129
|
+
const first = await plugin(structure as any)
|
|
130
|
+
const chunk = first.chunks.find((c: any) => c.name === 'getStaticProps') as any
|
|
131
|
+
const declaration = chunk.meta.parallelFetchData.declaration as types.VariableDeclaration
|
|
132
|
+
const declarator = declaration.declarations[0]
|
|
133
|
+
;(declarator.id as types.ArrayPattern).elements.push(types.identifier('Teleport_products_ds_0'))
|
|
134
|
+
;(
|
|
135
|
+
(declarator.init as types.AwaitExpression).argument as types.CallExpression
|
|
136
|
+
).arguments[0].elements.push(
|
|
137
|
+
types.callExpression(types.identifier('fetchProductsPage'), [types.numericLiteral(1)])
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
structure.uidl.stateDefinitions.lowStockAlerts = makeState()
|
|
141
|
+
const second = await plugin(structure as any)
|
|
142
|
+
|
|
143
|
+
const code = getStaticPropsCode(second as any)
|
|
144
|
+
expect(code.match(/Promise\.all/g)).toHaveLength(1)
|
|
145
|
+
expect(code).toContain('Teleport_products_ds_0')
|
|
146
|
+
expect(code).toContain('fetchProductsPage(1)')
|
|
147
|
+
const names = (second.chunks.find((c: any) => c.name === 'getStaticProps') as any).meta
|
|
148
|
+
.parallelFetchData.names
|
|
149
|
+
expect(new Set(names).size).toBe(names.length)
|
|
150
|
+
})
|
|
151
|
+
|
|
121
152
|
it('separates a query-less binding from a queried one on the same table', async () => {
|
|
122
153
|
// Products page shape: productsList has a rawQuery, lowStockAlerts is a
|
|
123
154
|
// bare binding (whole table) — they must not share the LIMIT 200 fetch.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import generator from '@babel/generator'
|
|
2
|
+
import * as types from '@babel/types'
|
|
3
|
+
import { ChunkType } from '@teleporthq/teleport-types'
|
|
4
|
+
import { createStateDataSourcePlugin } from '../src/state-data-source-plugin'
|
|
5
|
+
import { createEntityMutationSsrFinalizerPlugin } from '../src/entity-mutation-ssr-finalize-plugin'
|
|
6
|
+
|
|
7
|
+
// A page state bound to a data source must hold the values computed for THIS
|
|
8
|
+
// arrival. Seeded from getStaticProps it did not: a client-side navigation got
|
|
9
|
+
// the JSON Next.js had cached in the browser (or prefetched with the link), ISR
|
|
10
|
+
// served the copy rendered before the last write, and a navigation to the page
|
|
11
|
+
// already on screen kept the old `useState` value whatever the new props said.
|
|
12
|
+
// Such a page now renders per request and remounts on every arrival.
|
|
13
|
+
|
|
14
|
+
const DS_ID = 'a784e6dd-ddc9-4d12-b9d2-137d285e313a'
|
|
15
|
+
const FETCHER_FILE_NAME = 'teleport-customers-a784e6dd'
|
|
16
|
+
|
|
17
|
+
const boundState = (query?: string) => ({
|
|
18
|
+
type: 'array',
|
|
19
|
+
defaultValue: [] as unknown[],
|
|
20
|
+
dataSourceBinding: { dataSourceId: DS_ID, refPath: ['customers'] },
|
|
21
|
+
...(query ? { query } : {}),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const makeStructure = (stateDefinitions: Record<string, unknown>) => ({
|
|
25
|
+
uidl: {
|
|
26
|
+
name: 'Customers',
|
|
27
|
+
stateDefinitions,
|
|
28
|
+
node: { type: 'element', content: { elementType: 'container' } },
|
|
29
|
+
outputOptions: { folderPath: [] as string[] },
|
|
30
|
+
},
|
|
31
|
+
chunks: [
|
|
32
|
+
{
|
|
33
|
+
type: 'AST',
|
|
34
|
+
fileType: 'js',
|
|
35
|
+
name: 'jsx-component',
|
|
36
|
+
linkAfter: [] as string[],
|
|
37
|
+
content: types.variableDeclaration('const', [
|
|
38
|
+
types.variableDeclarator(
|
|
39
|
+
types.identifier('Customers'),
|
|
40
|
+
types.arrowFunctionExpression(
|
|
41
|
+
[types.identifier('props')],
|
|
42
|
+
types.blockStatement([types.returnStatement(types.nullLiteral())])
|
|
43
|
+
)
|
|
44
|
+
),
|
|
45
|
+
]),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'AST',
|
|
49
|
+
fileType: 'js',
|
|
50
|
+
name: 'export',
|
|
51
|
+
linkAfter: ['jsx-component'],
|
|
52
|
+
content: types.exportDefaultDeclaration(types.identifier('Customers')),
|
|
53
|
+
},
|
|
54
|
+
] as any[],
|
|
55
|
+
dependencies: {} as Record<string, unknown>,
|
|
56
|
+
options: {
|
|
57
|
+
pagesPath: ['pages'],
|
|
58
|
+
auth: { provider: 'teleport' },
|
|
59
|
+
dataSources: { [DS_ID]: { id: DS_ID, type: 'teleport', config: {} } },
|
|
60
|
+
// Pre-extracted fetcher, so the test does not depend on its generation.
|
|
61
|
+
extractedResources: {
|
|
62
|
+
[`utils/${FETCHER_FILE_NAME}`]: {
|
|
63
|
+
fileName: FETCHER_FILE_NAME,
|
|
64
|
+
fileType: 'js',
|
|
65
|
+
path: ['utils', 'data-sources'],
|
|
66
|
+
content: '// stub',
|
|
67
|
+
},
|
|
68
|
+
} as Record<string, any>,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const runPagePipeline = async (stateDefinitions: Record<string, unknown>) => {
|
|
73
|
+
const structure = makeStructure(stateDefinitions)
|
|
74
|
+
await createStateDataSourcePlugin()(structure as any)
|
|
75
|
+
await createEntityMutationSsrFinalizerPlugin()(structure as any)
|
|
76
|
+
const chunkCode = (name: string) => {
|
|
77
|
+
const chunk = structure.chunks.find((candidate) => candidate.name === name)
|
|
78
|
+
return chunk ? generator(chunk.content as types.Node).code : null
|
|
79
|
+
}
|
|
80
|
+
return { structure, chunkCode }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('page states seeded from a data source', () => {
|
|
84
|
+
it('render per request and remount the page on every arrival', async () => {
|
|
85
|
+
const { structure, chunkCode } = await runPagePipeline({
|
|
86
|
+
customersRows: boundState('SELECT * FROM customers ORDER BY created_at DESC LIMIT 20'),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
expect(chunkCode('getStaticProps')).toBeNull()
|
|
90
|
+
const serverProps = chunkCode('getServerSideProps')
|
|
91
|
+
expect(serverProps).toContain('export async function getServerSideProps(context)')
|
|
92
|
+
expect(serverProps).toContain('customersRows:')
|
|
93
|
+
expect(serverProps).not.toContain('revalidate')
|
|
94
|
+
|
|
95
|
+
expect(chunkCode('export')).toBe('export default remountOnPageArrival(Customers);')
|
|
96
|
+
expect(structure.dependencies.remountOnPageArrival).toEqual({
|
|
97
|
+
type: 'local',
|
|
98
|
+
path: '../utils/page-arrival',
|
|
99
|
+
})
|
|
100
|
+
expect(structure.options.extractedResources['utils/page-arrival']).toBeDefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('remount the page for states fetched in the browser, which stays statically generated', async () => {
|
|
104
|
+
const { structure, chunkCode } = await runPagePipeline({
|
|
105
|
+
invoicesRows: boundState(
|
|
106
|
+
"SELECT * FROM app_invoices WHERE customer_id = '{{Current User.id}}' LIMIT 20"
|
|
107
|
+
),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
expect(chunkCode('getServerSideProps')).toBeNull()
|
|
111
|
+
expect(chunkCode('jsx-component')).toContain('/api/page-state/')
|
|
112
|
+
expect(chunkCode('export')).toBe('export default remountOnPageArrival(Customers);')
|
|
113
|
+
expect(structure.options.extractedResources['utils/page-arrival']).toBeDefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('leave the page alone when no state can be seeded', async () => {
|
|
117
|
+
const { structure, chunkCode } = await runPagePipeline({
|
|
118
|
+
customerRow: boundState("SELECT * FROM customers WHERE id = '{{urlDifferentiator}}'"),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(chunkCode('getStaticProps')).toBeNull()
|
|
122
|
+
expect(chunkCode('getServerSideProps')).toBeNull()
|
|
123
|
+
expect(chunkCode('export')).toBe('export default Customers;')
|
|
124
|
+
expect(structure.dependencies.remountOnPageArrival).toBeUndefined()
|
|
125
|
+
expect(structure.options.extractedResources['utils/page-arrival']).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('getServerSideProps finalizer — data-source states', () => {
|
|
130
|
+
const getStaticPropsChunk = () => {
|
|
131
|
+
const fn = types.functionDeclaration(
|
|
132
|
+
types.identifier('getStaticProps'),
|
|
133
|
+
[types.identifier('context')],
|
|
134
|
+
types.blockStatement([
|
|
135
|
+
types.returnStatement(
|
|
136
|
+
types.objectExpression([
|
|
137
|
+
types.objectProperty(types.identifier('props'), types.objectExpression([])),
|
|
138
|
+
types.objectProperty(types.identifier('revalidate'), types.numericLiteral(1)),
|
|
139
|
+
])
|
|
140
|
+
),
|
|
141
|
+
]),
|
|
142
|
+
false,
|
|
143
|
+
true
|
|
144
|
+
)
|
|
145
|
+
return {
|
|
146
|
+
name: 'getStaticProps',
|
|
147
|
+
type: ChunkType.AST,
|
|
148
|
+
content: types.exportNamedDeclaration(fn),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const finalize = async (
|
|
153
|
+
stateDefinitions: Record<string, unknown>,
|
|
154
|
+
projectDataSources: object
|
|
155
|
+
) => {
|
|
156
|
+
const structure: any = {
|
|
157
|
+
uidl: { name: 'Customers', node: { type: 'element', content: {} }, stateDefinitions },
|
|
158
|
+
chunks: [getStaticPropsChunk()],
|
|
159
|
+
dependencies: {},
|
|
160
|
+
options: { dataSources: projectDataSources },
|
|
161
|
+
}
|
|
162
|
+
await createEntityMutationSsrFinalizerPlugin()(structure)
|
|
163
|
+
return structure.chunks[0].name as string
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const dataSources = { [DS_ID]: { id: DS_ID, type: 'teleport', config: {} } }
|
|
167
|
+
|
|
168
|
+
it('converts a page that seeds a state from a data source', async () => {
|
|
169
|
+
expect(await finalize({ rows: boundState('SELECT 1 AS value') }, dataSources)).toBe(
|
|
170
|
+
'getServerSideProps'
|
|
171
|
+
)
|
|
172
|
+
expect(await finalize({ rows: boundState() }, dataSources)).toBe('getServerSideProps')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('keeps static generation when the bound state is only resolved in the browser', async () => {
|
|
176
|
+
expect(
|
|
177
|
+
await finalize(
|
|
178
|
+
{ rows: boundState("SELECT * FROM t WHERE user_id = '{{Current User.id}}'") },
|
|
179
|
+
dataSources
|
|
180
|
+
)
|
|
181
|
+
).toBe('getStaticProps')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('keeps static generation when the bound data source is not in the project', async () => {
|
|
185
|
+
expect(await finalize({ rows: boundState('SELECT 1 AS value') }, {})).toBe('getStaticProps')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -109,7 +109,10 @@ const runPageStateRoute = async (
|
|
|
109
109
|
throw new Error('unexpected require: ' + name)
|
|
110
110
|
}
|
|
111
111
|
const code = routeSource
|
|
112
|
-
.replace(
|
|
112
|
+
.replace(
|
|
113
|
+
"import { Client, types as pgTypes } from 'pg'",
|
|
114
|
+
"const { Client, types: pgTypes } = require('pg')"
|
|
115
|
+
)
|
|
113
116
|
.replace(/^export default async function handler/m, 'async function handler')
|
|
114
117
|
.concat('\nmodule.exports = handler')
|
|
115
118
|
const moduleObj: { exports: any } = { exports: {} }
|