@teleporthq/teleport-plugin-next-workflows 0.43.66 → 0.43.67

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.
Files changed (222) hide show
  1. package/__tests__/_helpers/bind-workflow-utils.ts +30 -0
  2. package/__tests__/_helpers/run-generated-middleware.ts +11 -2
  3. package/__tests__/admin-form-row-hydration.test.ts +55 -0
  4. package/__tests__/client-config-redaction.test.ts +53 -0
  5. package/__tests__/email-locale-module.test.ts +323 -0
  6. package/__tests__/email-locale-senders.test.ts +268 -0
  7. package/__tests__/locale-aware-redirects.test.ts +355 -0
  8. package/__tests__/navigation-go-to-page.test.ts +43 -0
  9. package/__tests__/node-handler-file-inline-map.test.ts +29 -3
  10. package/__tests__/resolve-config-localized-templates.test.ts +262 -0
  11. package/__tests__/sent-email-log-wiring.test.ts +2 -2
  12. package/dist/cjs/account-delete-route-generator.d.ts +20 -0
  13. package/dist/cjs/account-delete-route-generator.d.ts.map +1 -1
  14. package/dist/cjs/account-delete-route-generator.js +16 -22
  15. package/dist/cjs/account-delete-route-generator.js.map +1 -1
  16. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  17. package/dist/cjs/api-route-generator.js +13 -2
  18. package/dist/cjs/api-route-generator.js.map +1 -1
  19. package/dist/cjs/auth-generator.d.ts +2 -1
  20. package/dist/cjs/auth-generator.d.ts.map +1 -1
  21. package/dist/cjs/auth-generator.js +42 -14
  22. package/dist/cjs/auth-generator.js.map +1 -1
  23. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  24. package/dist/cjs/data-api-route-generator.js +19 -0
  25. package/dist/cjs/data-api-route-generator.js.map +1 -1
  26. package/dist/cjs/email-locale/email-locale-code.d.ts +25 -0
  27. package/dist/cjs/email-locale/email-locale-code.d.ts.map +1 -0
  28. package/dist/cjs/email-locale/email-locale-code.js +228 -0
  29. package/dist/cjs/email-locale/email-locale-code.js.map +1 -0
  30. package/dist/cjs/email-locale/email-locale-scope.d.ts +46 -0
  31. package/dist/cjs/email-locale/email-locale-scope.d.ts.map +1 -0
  32. package/dist/cjs/email-locale/email-locale-scope.js +51 -0
  33. package/dist/cjs/email-locale/email-locale-scope.js.map +1 -0
  34. package/dist/cjs/email-locale/index.d.ts +11 -0
  35. package/dist/cjs/email-locale/index.d.ts.map +1 -0
  36. package/dist/cjs/email-locale/index.js +40 -0
  37. package/dist/cjs/email-locale/index.js.map +1 -0
  38. package/dist/cjs/executor-generator.d.ts +11 -1
  39. package/dist/cjs/executor-generator.d.ts.map +1 -1
  40. package/dist/cjs/executor-generator.js +32 -3
  41. package/dist/cjs/executor-generator.js.map +1 -1
  42. package/dist/cjs/index.d.ts +1 -0
  43. package/dist/cjs/index.d.ts.map +1 -1
  44. package/dist/cjs/index.js +7 -1
  45. package/dist/cjs/index.js.map +1 -1
  46. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  47. package/dist/cjs/invoice/api-routes-code.js +14 -3
  48. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  49. package/dist/cjs/invoice/email-sender-code.d.ts.map +1 -1
  50. package/dist/cjs/invoice/email-sender-code.js +14 -2
  51. package/dist/cjs/invoice/email-sender-code.js.map +1 -1
  52. package/dist/cjs/invoice/index.d.ts.map +1 -1
  53. package/dist/cjs/invoice/index.js +4 -1
  54. package/dist/cjs/invoice/index.js.map +1 -1
  55. package/dist/cjs/nodes/account/account-delete-current.d.ts.map +1 -1
  56. package/dist/cjs/nodes/account/account-delete-current.js +10 -3
  57. package/dist/cjs/nodes/account/account-delete-current.js.map +1 -1
  58. package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
  59. package/dist/cjs/nodes/account/account-login.js +3 -1
  60. package/dist/cjs/nodes/account/account-login.js.map +1 -1
  61. package/dist/cjs/nodes/account/account-logout.d.ts.map +1 -1
  62. package/dist/cjs/nodes/account/account-logout.js +4 -2
  63. package/dist/cjs/nodes/account/account-logout.js.map +1 -1
  64. package/dist/cjs/nodes/account/account-signup.d.ts.map +1 -1
  65. package/dist/cjs/nodes/account/account-signup.js +12 -2
  66. package/dist/cjs/nodes/account/account-signup.js.map +1 -1
  67. package/dist/cjs/nodes/account/account-social-login.d.ts.map +1 -1
  68. package/dist/cjs/nodes/account/account-social-login.js +4 -2
  69. package/dist/cjs/nodes/account/account-social-login.js.map +1 -1
  70. package/dist/cjs/nodes/data/data-create-item.d.ts.map +1 -1
  71. package/dist/cjs/nodes/data/data-create-item.js +8 -0
  72. package/dist/cjs/nodes/data/data-create-item.js.map +1 -1
  73. package/dist/cjs/nodes/ecommerce/ecommerce-generate-invoice.d.ts.map +1 -1
  74. package/dist/cjs/nodes/ecommerce/ecommerce-generate-invoice.js +6 -0
  75. package/dist/cjs/nodes/ecommerce/ecommerce-generate-invoice.js.map +1 -1
  76. package/dist/cjs/nodes/navigation/navigation-go-to-page.d.ts.map +1 -1
  77. package/dist/cjs/nodes/navigation/navigation-go-to-page.js +4 -1
  78. package/dist/cjs/nodes/navigation/navigation-go-to-page.js.map +1 -1
  79. package/dist/cjs/nodes/navigation/navigation-navigate-to-url.d.ts.map +1 -1
  80. package/dist/cjs/nodes/navigation/navigation-navigate-to-url.js +5 -2
  81. package/dist/cjs/nodes/navigation/navigation-navigate-to-url.js.map +1 -1
  82. package/dist/cjs/nodes/payment/payment-charge-user.d.ts.map +1 -1
  83. package/dist/cjs/nodes/payment/payment-charge-user.js +7 -2
  84. package/dist/cjs/nodes/payment/payment-charge-user.js.map +1 -1
  85. package/dist/cjs/segment-splitter.d.ts +1 -1
  86. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  87. package/dist/cjs/segment-splitter.js +29 -2
  88. package/dist/cjs/segment-splitter.js.map +1 -1
  89. package/dist/cjs/transactional-email-code.d.ts +27 -0
  90. package/dist/cjs/transactional-email-code.d.ts.map +1 -1
  91. package/dist/cjs/transactional-email-code.js +43 -7
  92. package/dist/cjs/transactional-email-code.js.map +1 -1
  93. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  94. package/dist/cjs/workflow-component-plugin.js +4 -3
  95. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  96. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  97. package/dist/cjs/workflow-project-plugin.js +17 -3
  98. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  99. package/dist/cjs/workflow-utils-alias.d.ts +21 -0
  100. package/dist/cjs/workflow-utils-alias.d.ts.map +1 -0
  101. package/dist/cjs/workflow-utils-alias.js +27 -0
  102. package/dist/cjs/workflow-utils-alias.js.map +1 -0
  103. package/dist/esm/account-delete-route-generator.d.ts +20 -0
  104. package/dist/esm/account-delete-route-generator.d.ts.map +1 -1
  105. package/dist/esm/account-delete-route-generator.js +17 -23
  106. package/dist/esm/account-delete-route-generator.js.map +1 -1
  107. package/dist/esm/api-route-generator.d.ts.map +1 -1
  108. package/dist/esm/api-route-generator.js +13 -2
  109. package/dist/esm/api-route-generator.js.map +1 -1
  110. package/dist/esm/auth-generator.d.ts +2 -1
  111. package/dist/esm/auth-generator.d.ts.map +1 -1
  112. package/dist/esm/auth-generator.js +43 -15
  113. package/dist/esm/auth-generator.js.map +1 -1
  114. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  115. package/dist/esm/data-api-route-generator.js +19 -0
  116. package/dist/esm/data-api-route-generator.js.map +1 -1
  117. package/dist/esm/email-locale/email-locale-code.d.ts +25 -0
  118. package/dist/esm/email-locale/email-locale-code.d.ts.map +1 -0
  119. package/dist/esm/email-locale/email-locale-code.js +222 -0
  120. package/dist/esm/email-locale/email-locale-code.js.map +1 -0
  121. package/dist/esm/email-locale/email-locale-scope.d.ts +46 -0
  122. package/dist/esm/email-locale/email-locale-scope.d.ts.map +1 -0
  123. package/dist/esm/email-locale/email-locale-scope.js +47 -0
  124. package/dist/esm/email-locale/email-locale-scope.js.map +1 -0
  125. package/dist/esm/email-locale/index.d.ts +11 -0
  126. package/dist/esm/email-locale/index.d.ts.map +1 -0
  127. package/dist/esm/email-locale/index.js +28 -0
  128. package/dist/esm/email-locale/index.js.map +1 -0
  129. package/dist/esm/executor-generator.d.ts +11 -1
  130. package/dist/esm/executor-generator.d.ts.map +1 -1
  131. package/dist/esm/executor-generator.js +32 -3
  132. package/dist/esm/executor-generator.js.map +1 -1
  133. package/dist/esm/index.d.ts +1 -0
  134. package/dist/esm/index.d.ts.map +1 -1
  135. package/dist/esm/index.js +1 -0
  136. package/dist/esm/index.js.map +1 -1
  137. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  138. package/dist/esm/invoice/api-routes-code.js +14 -3
  139. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  140. package/dist/esm/invoice/email-sender-code.d.ts.map +1 -1
  141. package/dist/esm/invoice/email-sender-code.js +14 -2
  142. package/dist/esm/invoice/email-sender-code.js.map +1 -1
  143. package/dist/esm/invoice/index.d.ts.map +1 -1
  144. package/dist/esm/invoice/index.js +4 -1
  145. package/dist/esm/invoice/index.js.map +1 -1
  146. package/dist/esm/nodes/account/account-delete-current.d.ts.map +1 -1
  147. package/dist/esm/nodes/account/account-delete-current.js +10 -3
  148. package/dist/esm/nodes/account/account-delete-current.js.map +1 -1
  149. package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
  150. package/dist/esm/nodes/account/account-login.js +3 -1
  151. package/dist/esm/nodes/account/account-login.js.map +1 -1
  152. package/dist/esm/nodes/account/account-logout.d.ts.map +1 -1
  153. package/dist/esm/nodes/account/account-logout.js +4 -2
  154. package/dist/esm/nodes/account/account-logout.js.map +1 -1
  155. package/dist/esm/nodes/account/account-signup.d.ts.map +1 -1
  156. package/dist/esm/nodes/account/account-signup.js +12 -2
  157. package/dist/esm/nodes/account/account-signup.js.map +1 -1
  158. package/dist/esm/nodes/account/account-social-login.d.ts.map +1 -1
  159. package/dist/esm/nodes/account/account-social-login.js +4 -2
  160. package/dist/esm/nodes/account/account-social-login.js.map +1 -1
  161. package/dist/esm/nodes/data/data-create-item.d.ts.map +1 -1
  162. package/dist/esm/nodes/data/data-create-item.js +8 -0
  163. package/dist/esm/nodes/data/data-create-item.js.map +1 -1
  164. package/dist/esm/nodes/ecommerce/ecommerce-generate-invoice.d.ts.map +1 -1
  165. package/dist/esm/nodes/ecommerce/ecommerce-generate-invoice.js +6 -0
  166. package/dist/esm/nodes/ecommerce/ecommerce-generate-invoice.js.map +1 -1
  167. package/dist/esm/nodes/navigation/navigation-go-to-page.d.ts.map +1 -1
  168. package/dist/esm/nodes/navigation/navigation-go-to-page.js +4 -1
  169. package/dist/esm/nodes/navigation/navigation-go-to-page.js.map +1 -1
  170. package/dist/esm/nodes/navigation/navigation-navigate-to-url.d.ts.map +1 -1
  171. package/dist/esm/nodes/navigation/navigation-navigate-to-url.js +5 -2
  172. package/dist/esm/nodes/navigation/navigation-navigate-to-url.js.map +1 -1
  173. package/dist/esm/nodes/payment/payment-charge-user.d.ts.map +1 -1
  174. package/dist/esm/nodes/payment/payment-charge-user.js +7 -2
  175. package/dist/esm/nodes/payment/payment-charge-user.js.map +1 -1
  176. package/dist/esm/segment-splitter.d.ts +1 -1
  177. package/dist/esm/segment-splitter.d.ts.map +1 -1
  178. package/dist/esm/segment-splitter.js +29 -2
  179. package/dist/esm/segment-splitter.js.map +1 -1
  180. package/dist/esm/transactional-email-code.d.ts +27 -0
  181. package/dist/esm/transactional-email-code.d.ts.map +1 -1
  182. package/dist/esm/transactional-email-code.js +39 -6
  183. package/dist/esm/transactional-email-code.js.map +1 -1
  184. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  185. package/dist/esm/workflow-component-plugin.js +4 -3
  186. package/dist/esm/workflow-component-plugin.js.map +1 -1
  187. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  188. package/dist/esm/workflow-project-plugin.js +18 -4
  189. package/dist/esm/workflow-project-plugin.js.map +1 -1
  190. package/dist/esm/workflow-utils-alias.d.ts +21 -0
  191. package/dist/esm/workflow-utils-alias.d.ts.map +1 -0
  192. package/dist/esm/workflow-utils-alias.js +21 -0
  193. package/dist/esm/workflow-utils-alias.js.map +1 -0
  194. package/package.json +4 -4
  195. package/src/account-delete-route-generator.ts +22 -4
  196. package/src/api-route-generator.ts +13 -2
  197. package/src/auth-generator.ts +48 -14
  198. package/src/data-api-route-generator.ts +19 -0
  199. package/src/email-locale/email-locale-code.ts +239 -0
  200. package/src/email-locale/email-locale-scope.ts +65 -0
  201. package/src/email-locale/index.ts +46 -0
  202. package/src/executor-generator.ts +48 -3
  203. package/src/index.ts +7 -0
  204. package/src/invoice/api-routes-code.ts +18 -3
  205. package/src/invoice/email-sender-code.ts +14 -2
  206. package/src/invoice/index.ts +4 -1
  207. package/src/nodes/account/account-delete-current.ts +10 -3
  208. package/src/nodes/account/account-login.ts +3 -1
  209. package/src/nodes/account/account-logout.ts +4 -2
  210. package/src/nodes/account/account-signup.ts +12 -2
  211. package/src/nodes/account/account-social-login.ts +7 -2
  212. package/src/nodes/data/data-create-item.ts +8 -0
  213. package/src/nodes/ecommerce/ecommerce-generate-invoice.ts +6 -0
  214. package/src/nodes/navigation/navigation-go-to-page.ts +5 -1
  215. package/src/nodes/navigation/navigation-navigate-to-url.ts +6 -2
  216. package/src/nodes/payment/payment-charge-user.ts +13 -2
  217. package/src/nodes/workflow-utils-global.d.ts +12 -0
  218. package/src/segment-splitter.ts +33 -2
  219. package/src/transactional-email-code.ts +51 -0
  220. package/src/workflow-component-plugin.ts +4 -3
  221. package/src/workflow-project-plugin.ts +21 -4
  222. package/src/workflow-utils-alias.ts +26 -0
@@ -0,0 +1,30 @@
1
+ import { EmailLocaleConfig, generateEmailLocaleModuleCode } from '../../src/email-locale'
2
+ import { WORKFLOW_UTILS_ALIAS } from '../../src/workflow-utils-alias'
3
+
4
+ // A node handler evaluated on its own has no generated file around it to bind
5
+ // `__workflowUtils` (see workflow-utils-alias.ts), so a test that RUNS such a
6
+ // handler binds it here — to the real generated locale helpers, so the
7
+ // behaviour under test is the one a generated project ships.
8
+
9
+ export const TEST_LOCALES: EmailLocaleConfig = { locales: ['en', 'es'], defaultLocale: 'en' }
10
+
11
+ export const loadEmailLocaleModule = (
12
+ config: EmailLocaleConfig = TEST_LOCALES
13
+ ): Record<string, unknown> => {
14
+ const moduleShim = { exports: {} as Record<string, unknown> }
15
+ // eslint-disable-next-line no-new-func
16
+ new Function('module', 'exports', generateEmailLocaleModuleCode(config))(
17
+ moduleShim,
18
+ moduleShim.exports
19
+ )
20
+ return moduleShim.exports
21
+ }
22
+
23
+ /** Binds the alias on the global scope; returns the function that unbinds it. */
24
+ export const bindWorkflowUtils = (config: EmailLocaleConfig = TEST_LOCALES): (() => void) => {
25
+ const globalRef = global as unknown as Record<string, unknown>
26
+ globalRef[WORKFLOW_UTILS_ALIAS] = loadEmailLocaleModule(config)
27
+ return () => {
28
+ delete globalRef[WORKFLOW_UTILS_ALIAS]
29
+ }
30
+ }
@@ -29,6 +29,13 @@ export interface MiddlewareRequestOptions {
29
29
  /** Session user the stubbed `/api/auth/session` returns; omit for a guest. */
30
30
  sessionUser?: MiddlewareSessionUser | null
31
31
  origin?: string
32
+ /**
33
+ * The request's language as Next.js reports it on `nextUrl` — the prefix
34
+ * is already stripped from `pathname`, so pass the bare route. Omit both for
35
+ * a project without internationalization.
36
+ */
37
+ locale?: string
38
+ defaultLocale?: string
32
39
  }
33
40
 
34
41
  interface RawMiddlewareResult {
@@ -113,9 +120,11 @@ export const compileGeneratedMiddleware = (auth: UIDLAuthentication) => {
113
120
 
114
121
  const getTokenStub = async (): Promise<null> => null
115
122
  const middleware = factory(NEXT_RESPONSE_STUB, getTokenStub, fetchImpl)
123
+ const localePrefix =
124
+ options.locale && options.locale !== options.defaultLocale ? `/${options.locale}` : ''
116
125
  const result = await middleware({
117
- nextUrl: { pathname },
118
- url: `${origin}${pathname}`,
126
+ nextUrl: { pathname, locale: options.locale, defaultLocale: options.defaultLocale },
127
+ url: `${origin}${localePrefix}${pathname}`,
119
128
  cookies: {
120
129
  get: (name: string) =>
121
130
  sessionUser && name === 'next-auth.session-token' ? { name, value: 'stub' } : undefined,
@@ -0,0 +1,55 @@
1
+ import { readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ // The admin form hydration helpers are emitted verbatim into the generated
5
+ // page from a template literal in the plugin. Pull them out of the source and
6
+ // run them, so what is asserted is what the page runs.
7
+ const pluginSource = readFileSync(join(__dirname, '../src/workflow-component-plugin.ts'), 'utf8')
8
+
9
+ const extractHelper = (name: string): string => {
10
+ const match = pluginSource.match(new RegExp(`function ${name}\\([^)]*\\) \\{[\\s\\S]*?\\n\\}\\n`))
11
+ if (!match) {
12
+ throw new Error(`helper ${name} not found in the plugin source`)
13
+ }
14
+ // Backticks are escaped inside the template literal.
15
+ return match[0].replace(/\\`/g, '`')
16
+ }
17
+
18
+ const helpers = new Function(
19
+ `${extractHelper('__normalizeTagsForFormInput')}\n${extractHelper('__normalizeAdminFormRow')}\n` +
20
+ 'return { normalizeRow: __normalizeAdminFormRow, normalizeTags: __normalizeTagsForFormInput }'
21
+ )() as {
22
+ normalizeRow: (row: unknown, defaults: Record<string, unknown>) => Record<string, unknown>
23
+ normalizeTags: (value: unknown) => string
24
+ }
25
+
26
+ const DEFAULTS = { name: '', tags: '', es_name: '', es_tags: '', zh_tags: '' }
27
+
28
+ describe('__normalizeAdminFormRow', () => {
29
+ it('keeps the per-language columns the form binds', () => {
30
+ const row = { id: 3, name: 'Lamp', es_name: 'Lámpara', color: 'red' }
31
+ expect(helpers.normalizeRow(row, DEFAULTS)).toEqual({
32
+ name: 'Lamp',
33
+ tags: '',
34
+ es_name: 'Lámpara',
35
+ es_tags: '',
36
+ zh_tags: '',
37
+ })
38
+ })
39
+
40
+ it('reshapes a language copy of the tags like the tags themselves', () => {
41
+ const row = { tags: ['light', 'desk'], es_tags: '{"luz","escritorio"}', zh_tags: ['灯'] }
42
+ expect(helpers.normalizeRow(row, DEFAULTS)).toMatchObject({
43
+ tags: 'light, desk',
44
+ es_tags: 'luz, escritorio',
45
+ zh_tags: '灯',
46
+ })
47
+ })
48
+
49
+ it('falls back to the default for a language the row has nothing for', () => {
50
+ expect(helpers.normalizeRow({ name: 'Lamp', es_name: null }, DEFAULTS)).toMatchObject({
51
+ es_name: '',
52
+ es_tags: '',
53
+ })
54
+ })
55
+ })
@@ -222,3 +222,56 @@ describe('server API route still carries the SQL', () => {
222
222
  expect(route).toContain(DATA_SOURCE_ID)
223
223
  })
224
224
  })
225
+
226
+ describe('route-backed account nodes: the email their route sends never ships to the browser', () => {
227
+ const { redactServerNodeConfig } = require('../src/segment-splitter')
228
+
229
+ const signupConfig = {
230
+ email: { type: 'workflowContext', nodeId: 'form', path: ['form', 'email'] },
231
+ password: { type: 'workflowContext', nodeId: 'form', path: ['form', 'password'] },
232
+ name: 'Jane',
233
+ company: 'Acme',
234
+ emailProvider: 'email-resend',
235
+ apiKey: { type: 'dynamic', content: { referenceType: 'secret', id: 'RESEND_APIKEY' } },
236
+ from: 'hello@example.com',
237
+ subject: 'Welcome {{userName}}',
238
+ body: '<p>SECRET_WELCOME_BODY</p>',
239
+ templateParams: [{ key: 'userName', value: 'Jane' }],
240
+ localizedTemplates: { es: { body: '<p>SECRET_WELCOME_BODY_ES</p>' } },
241
+ }
242
+
243
+ it('keeps the credentials and the user columns of a signup, drops the welcome email', () => {
244
+ const redacted = redactServerNodeConfig(signupConfig, 'client', 'account-signup')
245
+ expect(redacted).toEqual({
246
+ email: signupConfig.email,
247
+ password: signupConfig.password,
248
+ name: 'Jane',
249
+ company: 'Acme',
250
+ })
251
+ })
252
+
253
+ it('keeps what the delete handler reads, drops the farewell email', () => {
254
+ const redacted = redactServerNodeConfig(
255
+ {
256
+ successMessage: 'Bye',
257
+ redirectTo: '/',
258
+ emailProvider: 'email-resend',
259
+ serverToken: 'x',
260
+ domain: 'mg.example.com',
261
+ deletedEmailPattern: 'deleted-{{userId}}@x.invalid',
262
+ subject: 'Goodbye',
263
+ body: '<p>SECRET_FAREWELL</p>',
264
+ localizedTemplates: { es: { body: '<p>SECRET_FAREWELL_ES</p>' } },
265
+ },
266
+ 'client',
267
+ 'account-delete-current'
268
+ )
269
+ expect(redacted).toEqual({ successMessage: 'Bye', redirectTo: '/' })
270
+ })
271
+
272
+ it('leaves every other client node config untouched', () => {
273
+ const config = { message: 'hi', subject: 'kept', body: 'kept' }
274
+ expect(redactServerNodeConfig(config, 'client', 'toast-show')).toBe(config)
275
+ expect(redactServerNodeConfig(config, 'client')).toBe(config)
276
+ })
277
+ })
@@ -0,0 +1,323 @@
1
+ import {
2
+ generateEmailLocaleModuleCode,
3
+ resolveEmailLocaleConfig,
4
+ EMAIL_LOCALE_HEADER,
5
+ } from '../src/email-locale'
6
+
7
+ /**
8
+ * The shared locale module decides which language a customer email goes out
9
+ * in and which path a navigation lands on. It is EXECUTED here rather than
10
+ * grepped: every helper must be total (an unknown locale can never select a
11
+ * template that does not exist, nor send a visitor to a locale the app does
12
+ * not serve), and the precedence between the explicit header, the referring
13
+ * page and the Next.js cookie is what keeps a request without the header from
14
+ * silently falling back to the main language.
15
+ */
16
+
17
+ interface EmailLocaleModule {
18
+ EMAIL_LOCALES: string[]
19
+ DEFAULT_EMAIL_LOCALE: string
20
+ normalizeEmailLocale: (value: unknown) => string | null
21
+ resolveRequestLocale: (req: unknown) => string
22
+ getClientLocale: () => string | null
23
+ localizeHref: (href: unknown, locale: unknown) => unknown
24
+ pickLocalizedTemplate: (
25
+ base: { subject?: string; body?: string },
26
+ localized: unknown,
27
+ locale: unknown
28
+ ) => { subject?: string; body?: string; locale: string }
29
+ }
30
+
31
+ const loadModule = (config: { locales: string[]; defaultLocale: string }): EmailLocaleModule => {
32
+ const code = generateEmailLocaleModuleCode(config)
33
+ const moduleShim = { exports: {} as EmailLocaleModule }
34
+ // eslint-disable-next-line no-new-func
35
+ new Function('module', 'exports', 'require', code)(moduleShim, moduleShim.exports, () => ({}))
36
+ return moduleShim.exports
37
+ }
38
+
39
+ const request = (headers: Record<string, string | string[]>) => ({ headers })
40
+
41
+ describe('resolveEmailLocaleConfig', () => {
42
+ it('reads the project languages and the main locale off the UIDL', () => {
43
+ const uidl = {
44
+ internationalization: {
45
+ main: { name: 'English', locale: 'en' },
46
+ languages: { en: 'English', es: 'Spanish' },
47
+ translations: {},
48
+ },
49
+ } as any
50
+ expect(resolveEmailLocaleConfig(uidl)).toEqual({ locales: ['en', 'es'], defaultLocale: 'en' })
51
+ })
52
+
53
+ it('always lists the main locale, even when the language map omits it', () => {
54
+ const uidl = {
55
+ internationalization: {
56
+ main: { name: 'French', locale: 'fr' },
57
+ languages: { es: 'Spanish' },
58
+ translations: {},
59
+ },
60
+ } as any
61
+ expect(resolveEmailLocaleConfig(uidl)).toEqual({ locales: ['fr', 'es'], defaultLocale: 'fr' })
62
+ })
63
+
64
+ it('has no locales at all for a project without internationalization', () => {
65
+ expect(resolveEmailLocaleConfig({} as any)).toEqual({ locales: [], defaultLocale: '' })
66
+ })
67
+ })
68
+
69
+ describe('email-locale module', () => {
70
+ const locales = loadModule({ locales: ['en', 'es', 'pt-BR'], defaultLocale: 'en' })
71
+
72
+ describe('normalizeEmailLocale', () => {
73
+ it('answers the canonical code, case-insensitively', () => {
74
+ expect(locales.normalizeEmailLocale('es')).toBe('es')
75
+ expect(locales.normalizeEmailLocale(' ES ')).toBe('es')
76
+ expect(locales.normalizeEmailLocale('pt-br')).toBe('pt-BR')
77
+ })
78
+
79
+ it('rejects anything that is not a project locale', () => {
80
+ expect(locales.normalizeEmailLocale('de')).toBeNull()
81
+ expect(locales.normalizeEmailLocale('')).toBeNull()
82
+ expect(locales.normalizeEmailLocale(null)).toBeNull()
83
+ expect(locales.normalizeEmailLocale(42)).toBeNull()
84
+ expect(locales.normalizeEmailLocale({ locale: 'es' })).toBeNull()
85
+ })
86
+ })
87
+
88
+ describe('resolveRequestLocale', () => {
89
+ it('prefers the explicit header the client runtime sends', () => {
90
+ expect(
91
+ locales.resolveRequestLocale(
92
+ request({
93
+ [EMAIL_LOCALE_HEADER]: 'es',
94
+ cookie: 'NEXT_LOCALE=pt-BR',
95
+ referer: 'https://shop.example/pt-BR/checkout',
96
+ })
97
+ )
98
+ ).toBe('es')
99
+ })
100
+
101
+ it('takes the first value of a repeated header', () => {
102
+ expect(locales.resolveRequestLocale(request({ [EMAIL_LOCALE_HEADER]: ['es', 'en'] }))).toBe(
103
+ 'es'
104
+ )
105
+ })
106
+
107
+ it('falls back to the locale prefix of the referring page', () => {
108
+ expect(
109
+ locales.resolveRequestLocale(request({ referer: 'https://shop.example/es/cart' }))
110
+ ).toBe('es')
111
+ })
112
+
113
+ it('trusts the page the request came from over a stored locale cookie', () => {
114
+ expect(
115
+ locales.resolveRequestLocale(
116
+ request({ cookie: 'session=abc; NEXT_LOCALE=pt-BR; other=1', referer: 'https://x/es/' })
117
+ )
118
+ ).toBe('es')
119
+ })
120
+
121
+ it('falls back to the Next.js locale cookie when nothing names the page', () => {
122
+ expect(
123
+ locales.resolveRequestLocale(request({ cookie: 'session=abc; NEXT_LOCALE=pt-BR; other=1' }))
124
+ ).toBe('pt-BR')
125
+ })
126
+
127
+ it('reads an unprefixed referring page as the default language', () => {
128
+ expect(locales.resolveRequestLocale(request({ referer: 'https://shop.example/cart' }))).toBe(
129
+ 'en'
130
+ )
131
+ })
132
+
133
+ it('ignores values that are not project locales and lands on the default', () => {
134
+ expect(
135
+ locales.resolveRequestLocale(
136
+ request({
137
+ [EMAIL_LOCALE_HEADER]: 'de',
138
+ cookie: 'NEXT_LOCALE=fr',
139
+ referer: 'not a url',
140
+ })
141
+ )
142
+ ).toBe('en')
143
+ })
144
+
145
+ it('answers the default language for a request without headers', () => {
146
+ expect(locales.resolveRequestLocale(null)).toBe('en')
147
+ expect(locales.resolveRequestLocale({})).toBe('en')
148
+ })
149
+ })
150
+
151
+ describe('pickLocalizedTemplate', () => {
152
+ const base = { subject: 'Hello {{name}}', body: '<p>Hi {{name}}</p>' }
153
+ const localized = {
154
+ es: { subject: 'Hola {{name}}', body: '<p>Hola {{name}}</p>' },
155
+ 'pt-BR': { body: '<p>Olá {{name}}</p>' },
156
+ }
157
+
158
+ it('returns the copy of the requested language', () => {
159
+ expect(locales.pickLocalizedTemplate(base, localized, 'es')).toEqual({
160
+ subject: 'Hola {{name}}',
161
+ body: '<p>Hola {{name}}</p>',
162
+ locale: 'es',
163
+ })
164
+ })
165
+
166
+ it('keeps the base subject when the language has no subject of its own', () => {
167
+ expect(locales.pickLocalizedTemplate(base, localized, 'pt-br')).toEqual({
168
+ subject: 'Hello {{name}}',
169
+ body: '<p>Olá {{name}}</p>',
170
+ locale: 'pt-BR',
171
+ })
172
+ })
173
+
174
+ it('returns the base copy for the default language', () => {
175
+ expect(locales.pickLocalizedTemplate(base, localized, 'en')).toEqual({
176
+ ...base,
177
+ locale: 'en',
178
+ })
179
+ })
180
+
181
+ it('returns the base copy for a language without a copy, an unknown one, or none', () => {
182
+ const expected = { ...base, locale: 'en' }
183
+ expect(locales.pickLocalizedTemplate(base, { es: localized.es }, 'pt-BR')).toEqual(expected)
184
+ expect(locales.pickLocalizedTemplate(base, localized, 'de')).toEqual(expected)
185
+ expect(locales.pickLocalizedTemplate(base, localized, undefined)).toEqual(expected)
186
+ expect(locales.pickLocalizedTemplate(base, null, 'es')).toEqual(expected)
187
+ })
188
+
189
+ it('never selects a copy with an empty body', () => {
190
+ expect(
191
+ locales.pickLocalizedTemplate(base, { es: { subject: 'Hola', body: '' } }, 'es')
192
+ ).toEqual({ ...base, locale: 'en' })
193
+ })
194
+ })
195
+
196
+ describe('getClientLocale', () => {
197
+ const globalRef = global as unknown as Record<string, unknown>
198
+
199
+ const withBrowser = <T>(browser: Record<string, unknown>, run: () => T): T => {
200
+ globalRef.window = browser.window
201
+ globalRef.document = browser.document
202
+ try {
203
+ return run()
204
+ } finally {
205
+ delete globalRef.window
206
+ delete globalRef.document
207
+ }
208
+ }
209
+
210
+ it('is inert outside a browser', () => {
211
+ expect(locales.getClientLocale()).toBeNull()
212
+ })
213
+
214
+ it('reads the router live, so a client-side language switch is not lost', () => {
215
+ // After `router.push(..., { locale: 'es' })` Next.js updates the router
216
+ // and <html lang>, but the page data keeps the locale the document was
217
+ // served in.
218
+ expect(
219
+ withBrowser(
220
+ {
221
+ window: { next: { router: { locale: 'es' } }, __NEXT_DATA__: { locale: 'en' } },
222
+ document: { documentElement: { lang: 'es' } },
223
+ },
224
+ () => locales.getClientLocale()
225
+ )
226
+ ).toBe('es')
227
+ })
228
+
229
+ it('falls back to <html lang>, then to the page data', () => {
230
+ expect(
231
+ withBrowser(
232
+ {
233
+ window: { __NEXT_DATA__: { locale: 'en' } },
234
+ document: { documentElement: { lang: 'es' } },
235
+ },
236
+ () => locales.getClientLocale()
237
+ )
238
+ ).toBe('es')
239
+ expect(
240
+ withBrowser({ window: { __NEXT_DATA__: { locale: 'pt-br' } }, document: {} }, () =>
241
+ locales.getClientLocale()
242
+ )
243
+ ).toBe('pt-BR')
244
+ })
245
+
246
+ it('ignores a candidate that is not a project locale', () => {
247
+ expect(
248
+ withBrowser(
249
+ {
250
+ window: { next: { router: { locale: 'de' } } },
251
+ document: { documentElement: { lang: 'x' } },
252
+ },
253
+ () => locales.getClientLocale()
254
+ )
255
+ ).toBeNull()
256
+ })
257
+ })
258
+
259
+ describe('localizeHref', () => {
260
+ it('prefixes a site-relative page path with a non-default locale', () => {
261
+ expect(locales.localizeHref('/', 'es')).toBe('/es')
262
+ expect(locales.localizeHref('/sign-in', 'es')).toBe('/es/sign-in')
263
+ expect(locales.localizeHref('/orders/ORD-42?payment=success', 'pt-br')).toBe(
264
+ '/pt-BR/orders/ORD-42?payment=success'
265
+ )
266
+ expect(locales.localizeHref('/?ref=1#top', 'es')).toBe('/es?ref=1#top')
267
+ expect(locales.localizeHref('/products/', 'es')).toBe('/es/products/')
268
+ })
269
+
270
+ it('keeps the bare path for the default language, an unknown locale and no locale', () => {
271
+ expect(locales.localizeHref('/sign-in', 'en')).toBe('/sign-in')
272
+ expect(locales.localizeHref('/sign-in', 'de')).toBe('/sign-in')
273
+ expect(locales.localizeHref('/sign-in', undefined)).toBe('/sign-in')
274
+ expect(locales.localizeHref('/sign-in', null)).toBe('/sign-in')
275
+ })
276
+
277
+ it('never touches a path that already names a locale', () => {
278
+ expect(locales.localizeHref('/es/cart', 'es')).toBe('/es/cart')
279
+ expect(locales.localizeHref('/es', 'es')).toBe('/es')
280
+ expect(locales.localizeHref('/en/cart', 'es')).toBe('/en/cart')
281
+ })
282
+
283
+ it('never touches an API route, a Next.js internal path or a file', () => {
284
+ expect(locales.localizeHref('/api/invoices/download?id=1', 'es')).toBe(
285
+ '/api/invoices/download?id=1'
286
+ )
287
+ expect(locales.localizeHref('/api', 'es')).toBe('/api')
288
+ expect(locales.localizeHref('/_next/static/x.js', 'es')).toBe('/_next/static/x.js')
289
+ expect(locales.localizeHref('/brochure.pdf', 'es')).toBe('/brochure.pdf')
290
+ expect(locales.localizeHref('/sitemap.xml?v=2', 'es')).toBe('/sitemap.xml?v=2')
291
+ })
292
+
293
+ it('never touches anything that is not a site-relative path', () => {
294
+ expect(locales.localizeHref('https://shop.example/cart', 'es')).toBe(
295
+ 'https://shop.example/cart'
296
+ )
297
+ expect(locales.localizeHref('//cdn.example/x', 'es')).toBe('//cdn.example/x')
298
+ expect(locales.localizeHref('#top', 'es')).toBe('#top')
299
+ expect(locales.localizeHref('mailto:a@b.c', 'es')).toBe('mailto:a@b.c')
300
+ expect(locales.localizeHref('products', 'es')).toBe('products')
301
+ expect(locales.localizeHref('', 'es')).toBe('')
302
+ expect(locales.localizeHref(undefined, 'es')).toBeUndefined()
303
+ expect(locales.localizeHref(42, 'es')).toBe(42)
304
+ })
305
+ })
306
+ })
307
+
308
+ describe('email-locale module without internationalization', () => {
309
+ const none = loadModule({ locales: [], defaultLocale: '' })
310
+
311
+ it('recognises no locale and keeps the single template', () => {
312
+ expect(none.normalizeEmailLocale('en')).toBeNull()
313
+ expect(none.resolveRequestLocale(request({ [EMAIL_LOCALE_HEADER]: 'en' }))).toBe('')
314
+ expect(
315
+ none.pickLocalizedTemplate({ subject: 'S', body: 'B' }, { en: { body: 'X' } }, 'en')
316
+ ).toEqual({ subject: 'S', body: 'B', locale: '' })
317
+ })
318
+
319
+ it('never prefixes a path', () => {
320
+ expect(none.localizeHref('/sign-in', 'en')).toBe('/sign-in')
321
+ expect(none.localizeHref('/', 'es')).toBe('/')
322
+ })
323
+ })