@teleporthq/teleport-plugin-next-data-source 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 (179) hide show
  1. package/__tests__/array-mapper-cache-wiring.test.ts +11 -0
  2. package/__tests__/data-source-request-locale.test.ts +313 -0
  3. package/__tests__/gated-data-provider.test.ts +88 -0
  4. package/__tests__/localized-columns.test.ts +217 -0
  5. package/__tests__/numbered-pagination.test.ts +15 -0
  6. package/__tests__/product-filter-fields.ts +230 -0
  7. package/__tests__/product-filter-sql.fixture.json +7 -0
  8. package/__tests__/refetch-loading-state.test.ts +23 -0
  9. package/__tests__/search-clear-button.test.ts +122 -0
  10. package/dist/cjs/content-localization.d.ts +37 -0
  11. package/dist/cjs/content-localization.d.ts.map +1 -0
  12. package/dist/cjs/content-localization.js +29 -0
  13. package/dist/cjs/content-localization.js.map +1 -0
  14. package/dist/cjs/count-effect.d.ts +11 -1
  15. package/dist/cjs/count-effect.d.ts.map +1 -1
  16. package/dist/cjs/count-effect.js +30 -2
  17. package/dist/cjs/count-effect.js.map +1 -1
  18. package/dist/cjs/fetchers/airtable.d.ts.map +1 -1
  19. package/dist/cjs/fetchers/airtable.js +1 -1
  20. package/dist/cjs/fetchers/airtable.js.map +1 -1
  21. package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/clickhouse.js +1 -1
  23. package/dist/cjs/fetchers/clickhouse.js.map +1 -1
  24. package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
  25. package/dist/cjs/fetchers/firestore.js +1 -1
  26. package/dist/cjs/fetchers/firestore.js.map +1 -1
  27. package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
  28. package/dist/cjs/fetchers/mariadb.js +1 -1
  29. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  30. package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
  31. package/dist/cjs/fetchers/mongodb.js +1 -1
  32. package/dist/cjs/fetchers/mongodb.js.map +1 -1
  33. package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/mysql.js +1 -1
  35. package/dist/cjs/fetchers/mysql.js.map +1 -1
  36. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/postgresql.js +2 -1
  38. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  39. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/redshift.js +1 -1
  41. package/dist/cjs/fetchers/redshift.js.map +1 -1
  42. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  43. package/dist/cjs/fetchers/teleport.js +10 -3
  44. package/dist/cjs/fetchers/teleport.js.map +1 -1
  45. package/dist/cjs/fetchers/turso.d.ts.map +1 -1
  46. package/dist/cjs/fetchers/turso.js +1 -1
  47. package/dist/cjs/fetchers/turso.js.map +1 -1
  48. package/dist/cjs/index.d.ts +5 -0
  49. package/dist/cjs/index.d.ts.map +1 -1
  50. package/dist/cjs/index.js +60 -8
  51. package/dist/cjs/index.js.map +1 -1
  52. package/dist/cjs/loading-state.d.ts +16 -1
  53. package/dist/cjs/loading-state.d.ts.map +1 -1
  54. package/dist/cjs/loading-state.js +69 -3
  55. package/dist/cjs/loading-state.js.map +1 -1
  56. package/dist/cjs/localized-columns.d.ts +56 -0
  57. package/dist/cjs/localized-columns.d.ts.map +1 -0
  58. package/dist/cjs/localized-columns.js +124 -0
  59. package/dist/cjs/localized-columns.js.map +1 -0
  60. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  61. package/dist/cjs/pagination-plugin.js +123 -45
  62. package/dist/cjs/pagination-plugin.js.map +1 -1
  63. package/dist/cjs/product-filter-fields.d.ts +87 -0
  64. package/dist/cjs/product-filter-fields.d.ts.map +1 -0
  65. package/dist/cjs/product-filter-fields.js +161 -0
  66. package/dist/cjs/product-filter-fields.js.map +1 -0
  67. package/dist/cjs/request-locale.d.ts +49 -0
  68. package/dist/cjs/request-locale.d.ts.map +1 -0
  69. package/dist/cjs/request-locale.js +130 -0
  70. package/dist/cjs/request-locale.js.map +1 -0
  71. package/dist/cjs/search-clear.d.ts +31 -0
  72. package/dist/cjs/search-clear.d.ts.map +1 -0
  73. package/dist/cjs/search-clear.js +103 -0
  74. package/dist/cjs/search-clear.js.map +1 -0
  75. package/dist/cjs/transformations/index.d.ts +16 -2
  76. package/dist/cjs/transformations/index.d.ts.map +1 -1
  77. package/dist/cjs/transformations/index.js +14 -2
  78. package/dist/cjs/transformations/index.js.map +1 -1
  79. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  80. package/dist/cjs/utils.d.ts +11 -1
  81. package/dist/cjs/utils.d.ts.map +1 -1
  82. package/dist/cjs/utils.js +59 -15
  83. package/dist/cjs/utils.js.map +1 -1
  84. package/dist/esm/content-localization.d.ts +37 -0
  85. package/dist/esm/content-localization.d.ts.map +1 -0
  86. package/dist/esm/content-localization.js +24 -0
  87. package/dist/esm/content-localization.js.map +1 -0
  88. package/dist/esm/count-effect.d.ts +11 -1
  89. package/dist/esm/count-effect.d.ts.map +1 -1
  90. package/dist/esm/count-effect.js +28 -1
  91. package/dist/esm/count-effect.js.map +1 -1
  92. package/dist/esm/fetchers/airtable.d.ts.map +1 -1
  93. package/dist/esm/fetchers/airtable.js +1 -1
  94. package/dist/esm/fetchers/airtable.js.map +1 -1
  95. package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
  96. package/dist/esm/fetchers/clickhouse.js +1 -1
  97. package/dist/esm/fetchers/clickhouse.js.map +1 -1
  98. package/dist/esm/fetchers/firestore.d.ts.map +1 -1
  99. package/dist/esm/fetchers/firestore.js +1 -1
  100. package/dist/esm/fetchers/firestore.js.map +1 -1
  101. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  102. package/dist/esm/fetchers/mariadb.js +1 -1
  103. package/dist/esm/fetchers/mariadb.js.map +1 -1
  104. package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
  105. package/dist/esm/fetchers/mongodb.js +1 -1
  106. package/dist/esm/fetchers/mongodb.js.map +1 -1
  107. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  108. package/dist/esm/fetchers/mysql.js +1 -1
  109. package/dist/esm/fetchers/mysql.js.map +1 -1
  110. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  111. package/dist/esm/fetchers/postgresql.js +2 -1
  112. package/dist/esm/fetchers/postgresql.js.map +1 -1
  113. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  114. package/dist/esm/fetchers/redshift.js +1 -1
  115. package/dist/esm/fetchers/redshift.js.map +1 -1
  116. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  117. package/dist/esm/fetchers/teleport.js +10 -3
  118. package/dist/esm/fetchers/teleport.js.map +1 -1
  119. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  120. package/dist/esm/fetchers/turso.js +1 -1
  121. package/dist/esm/fetchers/turso.js.map +1 -1
  122. package/dist/esm/index.d.ts +5 -0
  123. package/dist/esm/index.d.ts.map +1 -1
  124. package/dist/esm/index.js +55 -7
  125. package/dist/esm/index.js.map +1 -1
  126. package/dist/esm/loading-state.d.ts +16 -1
  127. package/dist/esm/loading-state.d.ts.map +1 -1
  128. package/dist/esm/loading-state.js +69 -3
  129. package/dist/esm/loading-state.js.map +1 -1
  130. package/dist/esm/localized-columns.d.ts +56 -0
  131. package/dist/esm/localized-columns.d.ts.map +1 -0
  132. package/dist/esm/localized-columns.js +119 -0
  133. package/dist/esm/localized-columns.js.map +1 -0
  134. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  135. package/dist/esm/pagination-plugin.js +124 -46
  136. package/dist/esm/pagination-plugin.js.map +1 -1
  137. package/dist/esm/product-filter-fields.d.ts +87 -0
  138. package/dist/esm/product-filter-fields.d.ts.map +1 -0
  139. package/dist/esm/product-filter-fields.js +154 -0
  140. package/dist/esm/product-filter-fields.js.map +1 -0
  141. package/dist/esm/request-locale.d.ts +49 -0
  142. package/dist/esm/request-locale.d.ts.map +1 -0
  143. package/dist/esm/request-locale.js +98 -0
  144. package/dist/esm/request-locale.js.map +1 -0
  145. package/dist/esm/search-clear.d.ts +31 -0
  146. package/dist/esm/search-clear.d.ts.map +1 -0
  147. package/dist/esm/search-clear.js +74 -0
  148. package/dist/esm/search-clear.js.map +1 -0
  149. package/dist/esm/transformations/index.d.ts +16 -2
  150. package/dist/esm/transformations/index.d.ts.map +1 -1
  151. package/dist/esm/transformations/index.js +14 -2
  152. package/dist/esm/transformations/index.js.map +1 -1
  153. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  154. package/dist/esm/utils.d.ts +11 -1
  155. package/dist/esm/utils.d.ts.map +1 -1
  156. package/dist/esm/utils.js +59 -15
  157. package/dist/esm/utils.js.map +1 -1
  158. package/package.json +5 -5
  159. package/src/content-localization.ts +53 -0
  160. package/src/count-effect.ts +53 -2
  161. package/src/fetchers/airtable.ts +4 -2
  162. package/src/fetchers/clickhouse.ts +4 -2
  163. package/src/fetchers/firestore.ts +4 -2
  164. package/src/fetchers/mariadb.ts +4 -2
  165. package/src/fetchers/mongodb.ts +4 -2
  166. package/src/fetchers/mysql.ts +4 -2
  167. package/src/fetchers/postgresql.ts +16 -2
  168. package/src/fetchers/redshift.ts +4 -2
  169. package/src/fetchers/teleport.ts +48 -9
  170. package/src/fetchers/turso.ts +4 -2
  171. package/src/index.ts +79 -11
  172. package/src/loading-state.ts +134 -3
  173. package/src/localized-columns.ts +133 -0
  174. package/src/pagination-plugin.ts +177 -53
  175. package/src/product-filter-fields.ts +173 -0
  176. package/src/request-locale.ts +113 -0
  177. package/src/search-clear.ts +102 -0
  178. package/src/transformations/index.ts +30 -5
  179. package/src/utils.ts +83 -15
@@ -34,7 +34,12 @@ import {
34
34
  setJSXExpressionAttribute,
35
35
  type PaginationControlKind,
36
36
  } from './pagination-controls'
37
- import { buildCountFetchEffect } from './count-effect'
37
+ import { buildCountFetchEffect, buildCountSeqDeclaration } from './count-effect'
38
+ import {
39
+ findAllSearchClearButtonsInJSX,
40
+ findSiblingSearchInput,
41
+ wireSearchClearButton,
42
+ } from './search-clear'
38
43
  import {
39
44
  buildPageTokensDeclaration,
40
45
  wireNumberedPagination,
@@ -54,6 +59,14 @@ import {
54
59
  type PageResetDeps,
55
60
  } from './page-reset'
56
61
  import { buildPageUrlRefDeclaration, buildPageUrlSyncEffect } from './page-url-sync'
62
+ import { isLocalizedProject } from './content-localization'
63
+ import {
64
+ appendClientLocaleParam,
65
+ buildClientLocaleExpression,
66
+ buildServerLocaleParam,
67
+ ensureRouterDeclaration,
68
+ hasLocaleParam,
69
+ } from './request-locale'
57
70
 
58
71
  // ----- searchDefaultValue support -----
59
72
  //
@@ -723,6 +736,8 @@ function getStateVarsForUsage(usage: DataSourceUsage): {
723
736
  combinedStateVar: string
724
737
  setCombinedStateVar: string
725
738
  skipDebounceRefVar: string
739
+ /** Ref numbering the count requests, so an overtaken count response is dropped. */
740
+ countSeqRefVar: string
726
741
  propsPrefix: string
727
742
  } {
728
743
  const idx = usage.index
@@ -739,6 +754,7 @@ function getStateVarsForUsage(usage: DataSourceUsage): {
739
754
  combinedStateVar: `ds_${idx}_state`,
740
755
  setCombinedStateVar: `setDs_${idx}_state`,
741
756
  skipDebounceRefVar: `ds_${idx}_skipDebounce`,
757
+ countSeqRefVar: `ds_${idx}_countSeq`,
742
758
  propsPrefix: `${usage.dataSourceIdentifier}_ds_${idx}`,
743
759
  }
744
760
  }
@@ -1110,6 +1126,10 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1110
1126
  const getStaticPropsChunk = chunks.find((chunk) => chunk.name === 'getStaticProps')
1111
1127
  const isPage = !!getStaticPropsChunk
1112
1128
 
1129
+ // In a multi-language project every fetch says which language it is for —
1130
+ // see `request-locale.ts`. Decided once for the whole component.
1131
+ const localized = isLocalizedProject(options)
1132
+
1113
1133
  // Add React dependencies
1114
1134
  if (!dependencies.useState) {
1115
1135
  dependencies.useState = {
@@ -1408,6 +1428,7 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1408
1428
  pushPropIdsAsDeps(countEffectDeps, countEffectSeen, usage.filterPropIds)
1409
1429
  // Same goes for URL-driven filters (already documented above).
1410
1430
  pushUrlSearchParamMemoDeps(countEffectDeps, usage)
1431
+ stateDeclarations.push(buildCountSeqDeclaration(vars.countSeqRefVar))
1411
1432
  effectStatements.push(
1412
1433
  buildCountFetchEffect({
1413
1434
  fileName,
@@ -1415,6 +1436,7 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1415
1436
  setMaxPagesVar: vars.setMaxPagesStateVar,
1416
1437
  urlParams,
1417
1438
  deps: countEffectDeps,
1439
+ countSeqRefVar: vars.countSeqRefVar,
1418
1440
  })
1419
1441
  )
1420
1442
 
@@ -1515,6 +1537,7 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1515
1537
  pushPropIdsAsDeps(countDeps, countDepsSeen, usage.filterPropIds)
1516
1538
  pushUrlSearchParamMemoDeps(countDeps, usage)
1517
1539
 
1540
+ stateDeclarations.push(buildCountSeqDeclaration(vars.countSeqRefVar))
1518
1541
  effectStatements.push(
1519
1542
  buildCountFetchEffect({
1520
1543
  fileName,
@@ -1522,6 +1545,7 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1522
1545
  setMaxPagesVar: vars.setMaxPagesStateVar,
1523
1546
  urlParams: countUrlParams,
1524
1547
  deps: countDeps,
1548
+ countSeqRefVar: vars.countSeqRefVar,
1525
1549
  })
1526
1550
  )
1527
1551
  }
@@ -1658,43 +1682,16 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1658
1682
  // `router.query` / `router.isReady` and call `router.replace`. Gated on the
1659
1683
  // same predicate as the effects themselves so router is only injected when
1660
1684
  // it is actually used.
1661
- const needsUseRouter = registry.usages.some(
1662
- (u) => hasUrlSearchParamFilters(u) || usageHasSearchUrlSync(u) || usageHasPageUrlSync(u)
1663
- )
1664
- if (needsUseRouter) {
1665
- if (!dependencies.useRouter) {
1666
- // Match the shape the sibling Next.js plugins use (i18n locale mapper,
1667
- // search-params plugin) so the deduped import line is identical and
1668
- // the dependency-resolver merges instead of emitting a second one.
1669
- dependencies.useRouter = {
1670
- type: 'library',
1671
- path: 'next/router',
1672
- version: '^12.1.10',
1673
- meta: { namedImport: true },
1674
- }
1675
- }
1676
- const hasRouterDecl = blockStatement.body.some(
1677
- (statement) =>
1678
- statement.type === 'VariableDeclaration' &&
1679
- statement.declarations.some(
1680
- (decl) =>
1681
- decl.id.type === 'Identifier' &&
1682
- decl.id.name === 'router' &&
1683
- decl.init?.type === 'CallExpression' &&
1684
- decl.init.callee.type === 'Identifier' &&
1685
- decl.init.callee.name === 'useRouter'
1686
- )
1685
+ // A localized project reads `router.locale` into every wired provider's
1686
+ // params (each of them fetches client-side), so the router is needed as
1687
+ // soon as there is anything to wire.
1688
+ const needsUseRouter =
1689
+ localized ||
1690
+ registry.usages.some(
1691
+ (u) => hasUrlSearchParamFilters(u) || usageHasSearchUrlSync(u) || usageHasPageUrlSync(u)
1687
1692
  )
1688
- if (!hasRouterDecl) {
1689
- blockStatement.body.unshift(
1690
- types.variableDeclaration('const', [
1691
- types.variableDeclarator(
1692
- types.identifier('router'),
1693
- types.callExpression(types.identifier('useRouter'), [])
1694
- ),
1695
- ])
1696
- )
1697
- }
1693
+ if (needsUseRouter) {
1694
+ ensureRouterDeclaration(blockStatement.body, dependencies)
1698
1695
  }
1699
1696
 
1700
1697
  // Both the search write-back and the page ⇄ URL effect write the URL through
@@ -1766,13 +1763,20 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1766
1763
 
1767
1764
  // Update DataProvider based on category
1768
1765
  if (usage.category === 'paginated+search') {
1769
- updateDataProviderForPaginatedSearch(dp, usage, vars, fileName, cacheDeclarations)
1766
+ updateDataProviderForPaginatedSearch(
1767
+ dp,
1768
+ usage,
1769
+ vars,
1770
+ fileName,
1771
+ cacheDeclarations,
1772
+ localized
1773
+ )
1770
1774
  } else if (usage.category === 'paginated-only') {
1771
- updateDataProviderForPaginationOnly(dp, usage, vars, fileName, cacheDeclarations)
1775
+ updateDataProviderForPaginationOnly(dp, usage, vars, fileName, cacheDeclarations, localized)
1772
1776
  } else if (usage.category === 'search-only') {
1773
- updateDataProviderForSearchOnly(dp, usage, vars, fileName, cacheDeclarations)
1777
+ updateDataProviderForSearchOnly(dp, usage, vars, fileName, cacheDeclarations, localized)
1774
1778
  } else if (usage.category === 'plain') {
1775
- updateDataProviderForPlain(dp, fileName, usage)
1779
+ updateDataProviderForPlain(dp, fileName, usage, localized)
1776
1780
  }
1777
1781
 
1778
1782
  // Show the array mapper's loading state while a category/sort/search
@@ -1871,6 +1875,7 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1871
1875
  const searchEnabledUsages = registry.usages.filter((u) => u.searchEnabled)
1872
1876
 
1873
1877
  // Match by order - search input 0 -> searchEnabledUsages[0], etc.
1878
+ const usageBySearchInput = new Map<any, DataSourceUsage>()
1874
1879
  searchInputs.forEach((input, idx) => {
1875
1880
  if (idx >= searchEnabledUsages.length) {
1876
1881
  return
@@ -1879,6 +1884,19 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1879
1884
  const usage = searchEnabledUsages[idx]
1880
1885
  const vars = getStateVarsForUsage(usage)
1881
1886
  wireSearchInput(input.node, vars)
1887
+ usageBySearchInput.set(input.node, usage)
1888
+ })
1889
+
1890
+ // STEP 4.5: Wire the clear buttons, each to the input it shares a parent with.
1891
+ findAllSearchClearButtonsInJSX(blockStatement).forEach((button) => {
1892
+ const input = findSiblingSearchInput(
1893
+ button,
1894
+ searchInputs.map((entry) => entry.node)
1895
+ )
1896
+ const usage = input ? usageBySearchInput.get(input) : undefined
1897
+ if (usage) {
1898
+ wireSearchClearButton(button, getStateVarsForUsage(usage))
1899
+ }
1882
1900
  })
1883
1901
 
1884
1902
  // STEP 5: Wire pagination widgets
@@ -1908,7 +1926,13 @@ export const createNextArrayMapperPaginationPlugin: ComponentPluginFactory<{}> =
1908
1926
 
1909
1927
  // STEP 6: Update getStaticProps if this is a page
1910
1928
  if (isPage) {
1911
- updateGetStaticProps(chunks, registry, dependencies, uidl.outputOptions?.folderPath)
1929
+ updateGetStaticProps(
1930
+ chunks,
1931
+ registry,
1932
+ dependencies,
1933
+ uidl.outputOptions?.folderPath,
1934
+ localized
1935
+ )
1912
1936
  }
1913
1937
 
1914
1938
  return structure
@@ -1964,6 +1988,20 @@ function insertSentinelAfterProvider(
1964
1988
  if (node.alternate) {
1965
1989
  traverse(node.alternate)
1966
1990
  }
1991
+ // `{activeTabIndex === 0 && (<div>…<DataProvider/>…</div>)}` — a tab
1992
+ // panel, a gated section: the provider sits on the RIGHT of a logical
1993
+ // expression (or in a ternary's `test`), which is not a child, a body or
1994
+ // an argument. Left unvisited, the products list under the first tab of
1995
+ // a shop was never paginated or seeded, and rendered nothing.
1996
+ if (node.left) {
1997
+ traverse(node.left)
1998
+ }
1999
+ if (node.right) {
2000
+ traverse(node.right)
2001
+ }
2002
+ if (node.test) {
2003
+ traverse(node.test)
2004
+ }
1967
2005
  if (node.expression) {
1968
2006
  traverse(node.expression)
1969
2007
  }
@@ -2007,6 +2045,20 @@ function findAllDataProvidersInJSX(blockStatement: types.BlockStatement): any[]
2007
2045
  if (node.alternate) {
2008
2046
  traverse(node.alternate)
2009
2047
  }
2048
+ // `{activeTabIndex === 0 && (<div>…<DataProvider/>…</div>)}` — a tab
2049
+ // panel, a gated section: the provider sits on the RIGHT of a logical
2050
+ // expression (or in a ternary's `test`), which is not a child, a body or
2051
+ // an argument. Left unvisited, the products list under the first tab of
2052
+ // a shop was never paginated or seeded, and rendered nothing.
2053
+ if (node.left) {
2054
+ traverse(node.left)
2055
+ }
2056
+ if (node.right) {
2057
+ traverse(node.right)
2058
+ }
2059
+ if (node.test) {
2060
+ traverse(node.test)
2061
+ }
2010
2062
 
2011
2063
  if (node.expression) {
2012
2064
  traverse(node.expression)
@@ -2110,6 +2162,20 @@ function findAllSearchInputsInJSX(
2110
2162
  if (node.alternate) {
2111
2163
  traverse(node.alternate)
2112
2164
  }
2165
+ // `{activeTabIndex === 0 && (<div>…<DataProvider/>…</div>)}` — a tab
2166
+ // panel, a gated section: the provider sits on the RIGHT of a logical
2167
+ // expression (or in a ternary's `test`), which is not a child, a body or
2168
+ // an argument. Left unvisited, the products list under the first tab of
2169
+ // a shop was never paginated or seeded, and rendered nothing.
2170
+ if (node.left) {
2171
+ traverse(node.left)
2172
+ }
2173
+ if (node.right) {
2174
+ traverse(node.right)
2175
+ }
2176
+ if (node.test) {
2177
+ traverse(node.test)
2178
+ }
2113
2179
  if (node.expression) {
2114
2180
  traverse(node.expression)
2115
2181
  }
@@ -2173,6 +2239,20 @@ function findPaginationNodesWithOwners(
2173
2239
  if (node.alternate) {
2174
2240
  traverse(node.alternate)
2175
2241
  }
2242
+ // `{activeTabIndex === 0 && (<div>…<DataProvider/>…</div>)}` — a tab
2243
+ // panel, a gated section: the provider sits on the RIGHT of a logical
2244
+ // expression (or in a ternary's `test`), which is not a child, a body or
2245
+ // an argument. Left unvisited, the products list under the first tab of
2246
+ // a shop was never paginated or seeded, and rendered nothing.
2247
+ if (node.left) {
2248
+ traverse(node.left)
2249
+ }
2250
+ if (node.right) {
2251
+ traverse(node.right)
2252
+ }
2253
+ if (node.test) {
2254
+ traverse(node.test)
2255
+ }
2176
2256
  if (node.expression) {
2177
2257
  traverse(node.expression)
2178
2258
  }
@@ -2239,7 +2319,8 @@ function updateDataProviderForPaginatedSearch(
2239
2319
  usage: DataSourceUsage,
2240
2320
  vars: ReturnType<typeof getStateVarsForUsage>,
2241
2321
  fileName: string,
2242
- cacheDeclarations: types.Statement[]
2322
+ cacheDeclarations: types.Statement[],
2323
+ localized: boolean
2243
2324
  ): void {
2244
2325
  const attrs = dp.openingElement.attributes
2245
2326
 
@@ -2293,6 +2374,9 @@ function updateDataProviderForPaginatedSearch(
2293
2374
  pushStateIdsAsDeps(memoDeps, seenDeps, usage.dynamicSort.depStateIds)
2294
2375
  }
2295
2376
  pushUrlSearchParamMemoDeps(memoDeps, usage)
2377
+ if (localized) {
2378
+ appendClientLocaleParam(paramsProps, memoDeps)
2379
+ }
2296
2380
 
2297
2381
  const cachedInitialData = pushParamsAttribute(dp, usage, paramsProps, memoDeps, cacheDeclarations)
2298
2382
 
@@ -2416,7 +2500,8 @@ function updateDataProviderForPaginationOnly(
2416
2500
  usage: DataSourceUsage,
2417
2501
  vars: ReturnType<typeof getStateVarsForUsage>,
2418
2502
  fileName: string,
2419
- cacheDeclarations: types.Statement[]
2503
+ cacheDeclarations: types.Statement[],
2504
+ localized: boolean
2420
2505
  ): void {
2421
2506
  const attrs = dp.openingElement.attributes
2422
2507
 
@@ -2448,6 +2533,9 @@ function updateDataProviderForPaginationOnly(
2448
2533
  pushStateIdsAsDeps(memoDeps, seenDeps, usage.dynamicSort.depStateIds)
2449
2534
  }
2450
2535
  pushUrlSearchParamMemoDeps(memoDeps, usage)
2536
+ if (localized) {
2537
+ appendClientLocaleParam(paramsProps, memoDeps)
2538
+ }
2451
2539
 
2452
2540
  // Add params
2453
2541
  const cachedInitialData = pushParamsAttribute(dp, usage, paramsProps, memoDeps, cacheDeclarations)
@@ -2522,7 +2610,8 @@ function updateDataProviderForSearchOnly(
2522
2610
  usage: DataSourceUsage,
2523
2611
  vars: ReturnType<typeof getStateVarsForUsage>,
2524
2612
  fileName: string,
2525
- cacheDeclarations: types.Statement[]
2613
+ cacheDeclarations: types.Statement[],
2614
+ localized: boolean
2526
2615
  ): void {
2527
2616
  const attrs = dp.openingElement.attributes
2528
2617
 
@@ -2564,6 +2653,9 @@ function updateDataProviderForSearchOnly(
2564
2653
  pushStateIdsAsDeps(memoDeps, seenDeps, usage.dynamicSort.depStateIds)
2565
2654
  }
2566
2655
  pushUrlSearchParamMemoDeps(memoDeps, usage)
2656
+ if (localized) {
2657
+ appendClientLocaleParam(paramsProps, memoDeps)
2658
+ }
2567
2659
 
2568
2660
  const cachedInitialData = pushParamsAttribute(dp, usage, paramsProps, memoDeps, cacheDeclarations)
2569
2661
 
@@ -2621,7 +2713,12 @@ function updateDataProviderForSearchOnly(
2621
2713
  )
2622
2714
  }
2623
2715
 
2624
- function updateDataProviderForPlain(dp: any, fileName: string, usage: DataSourceUsage): void {
2716
+ function updateDataProviderForPlain(
2717
+ dp: any,
2718
+ fileName: string,
2719
+ usage: DataSourceUsage,
2720
+ localized: boolean
2721
+ ): void {
2625
2722
  const attrs = dp.openingElement.attributes
2626
2723
 
2627
2724
  const clientCache = usage.cache?.client ? usage.cache : undefined
@@ -2658,13 +2755,24 @@ function updateDataProviderForPlain(dp: any, fileName: string, usage: DataSource
2658
2755
  )
2659
2756
  }
2660
2757
 
2661
- // Find the params attribute
2662
- const paramsAttrIndex = attrs.findIndex(
2758
+ // Find the params attribute. A plain list may have none at all — but the
2759
+ // fetch it was just given sends `params` regardless, and in a localized
2760
+ // project those have to carry the locale, so the attribute is created.
2761
+ let paramsAttrIndex = attrs.findIndex(
2663
2762
  (attr: any) => attr.type === 'JSXAttribute' && attr.name.name === 'params'
2664
2763
  )
2665
2764
 
2666
2765
  if (paramsAttrIndex === -1) {
2667
- return
2766
+ if (!localized) {
2767
+ return
2768
+ }
2769
+ attrs.push(
2770
+ types.jsxAttribute(
2771
+ types.jsxIdentifier('params'),
2772
+ types.jsxExpressionContainer(types.objectExpression([]))
2773
+ )
2774
+ )
2775
+ paramsAttrIndex = attrs.length - 1
2668
2776
  }
2669
2777
 
2670
2778
  const paramsAttr = attrs[paramsAttrIndex] as types.JSXAttribute
@@ -2695,6 +2803,9 @@ function updateDataProviderForPlain(dp: any, fileName: string, usage: DataSource
2695
2803
  pushStateIdsAsDeps(memoDeps, memoSeen, usage.filterStateIds)
2696
2804
  pushPropIdsAsDeps(memoDeps, memoSeen, usage.filterPropIds)
2697
2805
  pushUrlSearchParamMemoDeps(memoDeps, usage)
2806
+ if (localized && paramsExpression.type === 'ObjectExpression') {
2807
+ appendClientLocaleParam(paramsExpression.properties, memoDeps)
2808
+ }
2698
2809
 
2699
2810
  // Wrap params in useMemo with filter state dependencies
2700
2811
  const memoizedParams = types.callExpression(types.identifier('useMemo'), [
@@ -2743,11 +2854,18 @@ function stabilizeDataProviderWithoutRepeater(dp: any): void {
2743
2854
  return
2744
2855
  }
2745
2856
 
2746
- // Wrap params in useMemo with empty dependencies array
2747
- // This ensures the object reference stays stable across re-renders
2857
+ // Wrap params in useMemo with an empty dependencies array so the object
2858
+ // reference stays stable across re-renders. The one thing that may vary is
2859
+ // the locale a client-fetched item carries (put there by the data-source
2860
+ // plugin): a locale switch must rebuild the params, or the item would keep
2861
+ // its previous language.
2862
+ const memoDeps: types.Expression[] = []
2863
+ if (paramsExpression.type === 'ObjectExpression' && hasLocaleParam(paramsExpression.properties)) {
2864
+ memoDeps.push(buildClientLocaleExpression())
2865
+ }
2748
2866
  const memoizedParams = types.callExpression(types.identifier('useMemo'), [
2749
2867
  types.arrowFunctionExpression([], paramsExpression),
2750
- types.arrayExpression([]),
2868
+ types.arrayExpression(memoDeps),
2751
2869
  ])
2752
2870
 
2753
2871
  // Replace the params attribute
@@ -3377,7 +3495,8 @@ function updateGetStaticProps(
3377
3495
  chunks: any[],
3378
3496
  registry: StateRegistry,
3379
3497
  dependencies: Record<string, any>,
3380
- folderPath?: string[]
3498
+ folderPath: string[] | undefined,
3499
+ localized: boolean
3381
3500
  ): void {
3382
3501
  const getStaticPropsChunk = chunks.find((c) => c.name === 'getStaticProps')
3383
3502
  if (!getStaticPropsChunk || getStaticPropsChunk.type !== ChunkType.AST) {
@@ -3549,6 +3668,11 @@ function updateGetStaticProps(
3549
3668
  )
3550
3669
  }
3551
3670
 
3671
+ // The prefetch is per locale — getStaticProps runs once per language.
3672
+ if (localized) {
3673
+ fetchParams.push(buildServerLocaleParam())
3674
+ }
3675
+
3552
3676
  // Check if this fetch already exists
3553
3677
  const existingFetchIndex = arrayPattern.elements.findIndex(
3554
3678
  (el: any) => el?.type === 'Identifier' && el.name === vars.propsPrefix
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Filtering a products list on things that are not a plain column.
3
+ *
4
+ * The storefront's product filters ask questions the `teleport_products` row
5
+ * cannot answer with a single column comparison: "what does it cost right now"
6
+ * (`price` net of a live markdown), "how many stars" (an aggregate over the
7
+ * reviews table), "is it on sale", and "does it come in size XL" (a value
8
+ * inside the `variant_options` JSON). Like the discount-aware sort in
9
+ * `product-price-sort.ts`, these have to be answered by the DATABASE — the list
10
+ * is paginated, so filtering the fetched page would only filter 20 of N rows.
11
+ *
12
+ * The editor emits such a filter as an ordinary condition whose `source` is
13
+ * one of the VIRTUAL field names below. Every SQL builder that can receive a
14
+ * products filter rewrites them into the expressions here:
15
+ *
16
+ * - this file → the generated store's `teleport_products` API route
17
+ * (`fetchers/teleport.ts`, `fetchers/postgresql.ts`);
18
+ * - `database-provider-worker/src/lib/product-filter-fields.ts` → the editor
19
+ * canvas. ⛔ A byte-for-byte mirror of the SQL below; both repos pin the
20
+ * strings with a test, because a clause the canvas cannot build is an HTTP
21
+ * 500 the canvas draws as an EMPTY grid.
22
+ *
23
+ * ## The expressions cannot raise
24
+ *
25
+ * Same discipline as the sort: the JSON column is cast only when its text
26
+ * already looks like an array, the axis key is validated against a slug pattern
27
+ * before it is bound, and an unknown key filters to no rows instead of erroring
28
+ * — a filter that the database rejects would empty the whole products page.
29
+ */
30
+
31
+ import {
32
+ PRODUCTS_TABLE_NAME,
33
+ PRODUCT_PRICE_SORT_FIELD,
34
+ buildProductEffectivePriceSql,
35
+ supportsProductPriceSort,
36
+ } from './product-price-sort'
37
+
38
+ /** The reviews table the star-rating aggregate reads. Exists once reviews were enabled. */
39
+ export const PRODUCT_REVIEWS_TABLE_NAME = 'teleport_product_reviews'
40
+
41
+ /** Virtual field: the product's star bucket — `ROUND(AVG(approved rating))`, 1..5, NULL when unreviewed. */
42
+ export const PRODUCT_RATING_FILTER_FIELD = 'rating'
43
+
44
+ /** Virtual field: `'true'` while a markdown is live, else `'false'`. */
45
+ export const PRODUCT_ON_SALE_FILTER_FIELD = 'on_sale'
46
+
47
+ /**
48
+ * Virtual field family: `variant_options.<axisKey>` — the product offers the
49
+ * axis (e.g. `size`) in one of the given values (e.g. `xl`).
50
+ */
51
+ export const PRODUCT_VARIANT_AXIS_FILTER_PREFIX = 'variant_options.'
52
+
53
+ /**
54
+ * What an axis key may look like. Keys are produced by the editor's slug
55
+ * helper (`deriveOptionKey`), so this is what every real key matches; anything
56
+ * else is refused before it can reach the database.
57
+ */
58
+ export const PRODUCT_VARIANT_AXIS_KEY_PATTERN = '^[a-z0-9][a-z0-9_-]{0,63}$'
59
+
60
+ /** Generic multi-value operand: the column equals ANY of the destination values. */
61
+ export const IN_OPERAND = 'in'
62
+
63
+ /**
64
+ * `ROUND(AVG(rating))` over the product's APPROVED reviews. Only approved rows
65
+ * count — moderation is the merchant's veto — and the outer row is the
66
+ * unaliased `teleport_products` the fetchers select from.
67
+ */
68
+ export const buildProductRatingBucketSql = (): string =>
69
+ `(SELECT ROUND(AVG(r.rating)) FROM ${PRODUCT_REVIEWS_TABLE_NAME} r WHERE r.product_id = ${PRODUCTS_TABLE_NAME}.id AND r.status = 'approved' AND r.rating IS NOT NULL)`
70
+
71
+ /** `'true'` when the price paid today is below the list price, i.e. a markdown is live. */
72
+ export const buildProductOnSaleSql = (): string =>
73
+ `(CASE WHEN ${buildProductEffectivePriceSql()} < "price"::numeric THEN 'true' ELSE 'false' END)`
74
+
75
+ /**
76
+ * Only text that already looks like a JSON array is cast; NULL, empty or a
77
+ * hand-edited scalar reads as "no variant axes".
78
+ */
79
+ export const SAFE_VARIANT_OPTIONS_JSONB = `CASE
80
+ WHEN "variant_options" IS NULL OR btrim("variant_options") NOT LIKE '[%' THEN '[]'::jsonb
81
+ ELSE "variant_options"::jsonb
82
+ END`
83
+
84
+ /**
85
+ * True when the product declares the axis bound to `keyParam` with at least one
86
+ * of the values bound to `valuesParam` (a `text[]`). Both are PARAMETERS, never
87
+ * interpolated text.
88
+ */
89
+ export const buildProductVariantAxisExistsSql = (keyParam: string, valuesParam: string): string =>
90
+ `EXISTS (SELECT 1 FROM jsonb_array_elements(${SAFE_VARIANT_OPTIONS_JSONB}) AS ax, jsonb_array_elements(CASE WHEN jsonb_typeof(ax->'values') = 'array' THEN ax->'values' ELSE '[]'::jsonb END) AS v WHERE ax->>'key' = ${keyParam} AND v->>'value' = ANY(${valuesParam}::text[]))`
91
+
92
+ /**
93
+ * The complete `productFilterClause(field, operand, value, pushParam)` helper a
94
+ * SQL fetcher embeds in its generated route.
95
+ *
96
+ * Returns a finished WHERE clause for a virtual (or rewritten) products field,
97
+ * or `null` for anything else so the fetcher's ordinary branches run. `pushParam`
98
+ * binds a value and answers its `$n` placeholder. By the time it is called an
99
+ * `in` filter has been normalised to `=` with an ARRAY value (see
100
+ * `normalizeInOperand` in `utils.ts`), which reads as "any of". A virtual field
101
+ * never answers `null` once matched — an operand it cannot express filters to
102
+ * no rows rather than falling into a branch that would name a column the table
103
+ * does not have.
104
+ *
105
+ * Emitted through `JSON.stringify` and string concatenation only — no backtick
106
+ * and no `${` may appear in the output, which is interpolated into a fetcher's
107
+ * template literal. On a table that is not the products table the helper still
108
+ * exists and always answers `null`, so a fetcher can embed it unconditionally.
109
+ */
110
+ export const generateProductFilterClauseHelper = (tableName: string): string => {
111
+ if (!supportsProductPriceSort(tableName)) {
112
+ return [
113
+ '// Virtual products-table filter fields. This table has none, so every',
114
+ "// condition takes the fetcher's ordinary branches.",
115
+ 'function productFilterClause() {',
116
+ ' return null',
117
+ '}',
118
+ ].join('\n')
119
+ }
120
+
121
+ const lines = [
122
+ "// Rewrites the products table's virtual filter fields - the price paid",
123
+ '// today, the star-rating bucket, the on-sale flag and a variant axis - into',
124
+ '// SQL. Answers null for a plain column so the ordinary branches run. An',
125
+ '// "in" filter arrives here already normalised to "=" with an ARRAY value.',
126
+ 'function productFilterClause(field, operand, value, pushParam) {',
127
+ " var scalarOps = ['=', '!=', '>', '<', '>=', '<=']",
128
+ " var equalityOps = ['=', '!=']",
129
+ ' var compare = function (expression, allowedOps, virtual) {',
130
+ ' if (Array.isArray(value)) {',
131
+ " if (operand === '=') {",
132
+ " return expression + '::text = ANY(' + pushParam(value.map(String)) + '::text[])'",
133
+ ' }',
134
+ " if (operand === '!=') {",
135
+ " return expression + '::text <> ALL(' + pushParam(value.map(String)) + '::text[])'",
136
+ ' }',
137
+ " return virtual ? 'FALSE' : null",
138
+ ' }',
139
+ " if (allowedOps.indexOf(operand) === -1) return virtual ? 'FALSE' : null",
140
+ " return expression + ' ' + operand + ' ' + pushParam(value)",
141
+ ' }',
142
+ ` if (field === ${JSON.stringify(PRODUCT_PRICE_SORT_FIELD)}) {`,
143
+ ` return compare(${JSON.stringify(
144
+ '(' + buildProductEffectivePriceSql() + ')'
145
+ )}, scalarOps, false)`,
146
+ ' }',
147
+ ` if (field === ${JSON.stringify(PRODUCT_RATING_FILTER_FIELD)}) {`,
148
+ ` return compare(${JSON.stringify(buildProductRatingBucketSql())}, scalarOps, true)`,
149
+ ' }',
150
+ ` if (field === ${JSON.stringify(PRODUCT_ON_SALE_FILTER_FIELD)}) {`,
151
+ ` return compare(${JSON.stringify(buildProductOnSaleSql())}, equalityOps, true)`,
152
+ ' }',
153
+ ` if (field.indexOf(${JSON.stringify(PRODUCT_VARIANT_AXIS_FILTER_PREFIX)}) === 0) {`,
154
+ ` var axisKey = field.slice(${PRODUCT_VARIANT_AXIS_FILTER_PREFIX.length})`,
155
+ ` if (!new RegExp(${JSON.stringify(PRODUCT_VARIANT_AXIS_KEY_PATTERN)}).test(axisKey)) {`,
156
+ ' // Not a key the editor could have written: no rows, never an error.',
157
+ " return 'FALSE'",
158
+ ' }',
159
+ " if (operand !== '=') return 'FALSE'",
160
+ ' var values = (Array.isArray(value) ? value : [value]).map(String)',
161
+ ' var keyParam = pushParam(axisKey)',
162
+ ' var valuesParam = pushParam(values)',
163
+ ` return ${JSON.stringify(buildProductVariantAxisExistsSql('__KEY__', '__VALUES__'))}`,
164
+ // Function replacements: a "$1" inside a replacement STRING is a special
165
+ // pattern, so the placeholders are swapped by callback.
166
+ " .replace('__KEY__', function () { return keyParam })",
167
+ " .replace('__VALUES__', function () { return valuesParam })",
168
+ ' }',
169
+ ' return null',
170
+ '}',
171
+ ]
172
+ return lines.join('\n')
173
+ }