@teleporthq/teleport-plugin-next-data-source 0.43.62 → 0.43.64

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 (200) hide show
  1. package/__tests__/blog-post-categories.test.ts +131 -0
  2. package/__tests__/custom-page-transform.test.ts +119 -0
  3. package/__tests__/ecommerce-product-ratings.test.ts +155 -0
  4. package/__tests__/ecommerce-product-storefront-contract.test.ts +147 -0
  5. package/__tests__/filter-groups.test.ts +174 -0
  6. package/__tests__/product-reviews-structured-data.test.ts +196 -0
  7. package/dist/cjs/data-source-fetchers.d.ts +3 -3
  8. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  9. package/dist/cjs/data-source-fetchers.js.map +1 -1
  10. package/dist/cjs/fetchers/airtable.d.ts.map +1 -1
  11. package/dist/cjs/fetchers/airtable.js +1 -1
  12. package/dist/cjs/fetchers/airtable.js.map +1 -1
  13. package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
  14. package/dist/cjs/fetchers/clickhouse.js +1 -1
  15. package/dist/cjs/fetchers/clickhouse.js.map +1 -1
  16. package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
  17. package/dist/cjs/fetchers/csv-file.js +3 -3
  18. package/dist/cjs/fetchers/csv-file.js.map +1 -1
  19. package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/firestore.js +1 -1
  21. package/dist/cjs/fetchers/firestore.js.map +1 -1
  22. package/dist/cjs/fetchers/google-sheets.d.ts.map +1 -1
  23. package/dist/cjs/fetchers/google-sheets.js +1 -1
  24. package/dist/cjs/fetchers/google-sheets.js.map +1 -1
  25. package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
  26. package/dist/cjs/fetchers/javascript.js +1 -1
  27. package/dist/cjs/fetchers/javascript.js.map +1 -1
  28. package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
  29. package/dist/cjs/fetchers/mariadb.js +1 -1
  30. package/dist/cjs/fetchers/mariadb.js.map +1 -1
  31. package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
  32. package/dist/cjs/fetchers/mongodb.js +1 -1
  33. package/dist/cjs/fetchers/mongodb.js.map +1 -1
  34. package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
  35. package/dist/cjs/fetchers/mysql.js +1 -1
  36. package/dist/cjs/fetchers/mysql.js.map +1 -1
  37. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  38. package/dist/cjs/fetchers/postgresql.js +1 -1
  39. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  40. package/dist/cjs/fetchers/redis.d.ts.map +1 -1
  41. package/dist/cjs/fetchers/redis.js +1 -1
  42. package/dist/cjs/fetchers/redis.js.map +1 -1
  43. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  44. package/dist/cjs/fetchers/redshift.js +2 -2
  45. package/dist/cjs/fetchers/redshift.js.map +1 -1
  46. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  47. package/dist/cjs/fetchers/rest-api.js +2 -2
  48. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  49. package/dist/cjs/fetchers/supabase.d.ts.map +1 -1
  50. package/dist/cjs/fetchers/supabase.js +1 -1
  51. package/dist/cjs/fetchers/supabase.js.map +1 -1
  52. package/dist/cjs/fetchers/teleport.d.ts +2 -2
  53. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  54. package/dist/cjs/fetchers/teleport.js +1 -1
  55. package/dist/cjs/fetchers/teleport.js.map +1 -1
  56. package/dist/cjs/fetchers/turso.d.ts.map +1 -1
  57. package/dist/cjs/fetchers/turso.js +1 -1
  58. package/dist/cjs/fetchers/turso.js.map +1 -1
  59. package/dist/cjs/index.d.ts +1 -1
  60. package/dist/cjs/index.d.ts.map +1 -1
  61. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  62. package/dist/cjs/pagination-plugin.js.map +1 -1
  63. package/dist/cjs/transformations/blog-post.d.ts +7 -1
  64. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  65. package/dist/cjs/transformations/blog-post.js +4 -2
  66. package/dist/cjs/transformations/blog-post.js.map +1 -1
  67. package/dist/cjs/transformations/category-taxonomy.d.ts +33 -0
  68. package/dist/cjs/transformations/category-taxonomy.d.ts.map +1 -0
  69. package/dist/cjs/transformations/category-taxonomy.js +42 -0
  70. package/dist/cjs/transformations/category-taxonomy.js.map +1 -0
  71. package/dist/cjs/transformations/custom-page.d.ts +18 -0
  72. package/dist/cjs/transformations/custom-page.d.ts.map +1 -0
  73. package/dist/cjs/transformations/custom-page.js +24 -0
  74. package/dist/cjs/transformations/custom-page.js.map +1 -0
  75. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  76. package/dist/cjs/transformations/ecommerce-product.js +2 -35
  77. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  78. package/dist/cjs/transformations/index.d.ts +23 -4
  79. package/dist/cjs/transformations/index.d.ts.map +1 -1
  80. package/dist/cjs/transformations/index.js +38 -8
  81. package/dist/cjs/transformations/index.js.map +1 -1
  82. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  83. package/dist/cjs/transformations/shared-utils.js +1 -1
  84. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  85. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  86. package/dist/cjs/utils.d.ts +35 -3
  87. package/dist/cjs/utils.d.ts.map +1 -1
  88. package/dist/cjs/utils.js +116 -18
  89. package/dist/cjs/utils.js.map +1 -1
  90. package/dist/esm/data-source-fetchers.d.ts +3 -3
  91. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  92. package/dist/esm/data-source-fetchers.js.map +1 -1
  93. package/dist/esm/fetchers/airtable.d.ts.map +1 -1
  94. package/dist/esm/fetchers/airtable.js +2 -2
  95. package/dist/esm/fetchers/airtable.js.map +1 -1
  96. package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
  97. package/dist/esm/fetchers/clickhouse.js +2 -2
  98. package/dist/esm/fetchers/clickhouse.js.map +1 -1
  99. package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
  100. package/dist/esm/fetchers/csv-file.js +4 -4
  101. package/dist/esm/fetchers/csv-file.js.map +1 -1
  102. package/dist/esm/fetchers/firestore.d.ts.map +1 -1
  103. package/dist/esm/fetchers/firestore.js +2 -2
  104. package/dist/esm/fetchers/firestore.js.map +1 -1
  105. package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
  106. package/dist/esm/fetchers/google-sheets.js +2 -2
  107. package/dist/esm/fetchers/google-sheets.js.map +1 -1
  108. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  109. package/dist/esm/fetchers/javascript.js +2 -2
  110. package/dist/esm/fetchers/javascript.js.map +1 -1
  111. package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
  112. package/dist/esm/fetchers/mariadb.js +2 -2
  113. package/dist/esm/fetchers/mariadb.js.map +1 -1
  114. package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
  115. package/dist/esm/fetchers/mongodb.js +2 -2
  116. package/dist/esm/fetchers/mongodb.js.map +1 -1
  117. package/dist/esm/fetchers/mysql.d.ts.map +1 -1
  118. package/dist/esm/fetchers/mysql.js +2 -2
  119. package/dist/esm/fetchers/mysql.js.map +1 -1
  120. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  121. package/dist/esm/fetchers/postgresql.js +2 -2
  122. package/dist/esm/fetchers/postgresql.js.map +1 -1
  123. package/dist/esm/fetchers/redis.d.ts.map +1 -1
  124. package/dist/esm/fetchers/redis.js +2 -2
  125. package/dist/esm/fetchers/redis.js.map +1 -1
  126. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  127. package/dist/esm/fetchers/redshift.js +3 -3
  128. package/dist/esm/fetchers/redshift.js.map +1 -1
  129. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  130. package/dist/esm/fetchers/rest-api.js +3 -3
  131. package/dist/esm/fetchers/rest-api.js.map +1 -1
  132. package/dist/esm/fetchers/supabase.d.ts.map +1 -1
  133. package/dist/esm/fetchers/supabase.js +2 -2
  134. package/dist/esm/fetchers/supabase.js.map +1 -1
  135. package/dist/esm/fetchers/teleport.d.ts +2 -2
  136. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  137. package/dist/esm/fetchers/teleport.js +2 -2
  138. package/dist/esm/fetchers/teleport.js.map +1 -1
  139. package/dist/esm/fetchers/turso.d.ts.map +1 -1
  140. package/dist/esm/fetchers/turso.js +2 -2
  141. package/dist/esm/fetchers/turso.js.map +1 -1
  142. package/dist/esm/index.d.ts +1 -1
  143. package/dist/esm/index.d.ts.map +1 -1
  144. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  145. package/dist/esm/pagination-plugin.js +1 -1
  146. package/dist/esm/pagination-plugin.js.map +1 -1
  147. package/dist/esm/transformations/blog-post.d.ts +7 -1
  148. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  149. package/dist/esm/transformations/blog-post.js +4 -2
  150. package/dist/esm/transformations/blog-post.js.map +1 -1
  151. package/dist/esm/transformations/category-taxonomy.d.ts +33 -0
  152. package/dist/esm/transformations/category-taxonomy.d.ts.map +1 -0
  153. package/dist/esm/transformations/category-taxonomy.js +37 -0
  154. package/dist/esm/transformations/category-taxonomy.js.map +1 -0
  155. package/dist/esm/transformations/custom-page.d.ts +18 -0
  156. package/dist/esm/transformations/custom-page.d.ts.map +1 -0
  157. package/dist/esm/transformations/custom-page.js +20 -0
  158. package/dist/esm/transformations/custom-page.js.map +1 -0
  159. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  160. package/dist/esm/transformations/ecommerce-product.js +3 -36
  161. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  162. package/dist/esm/transformations/index.d.ts +23 -4
  163. package/dist/esm/transformations/index.d.ts.map +1 -1
  164. package/dist/esm/transformations/index.js +37 -7
  165. package/dist/esm/transformations/index.js.map +1 -1
  166. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  167. package/dist/esm/transformations/shared-utils.js +1 -1
  168. package/dist/esm/transformations/shared-utils.js.map +1 -1
  169. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  170. package/dist/esm/utils.d.ts +35 -3
  171. package/dist/esm/utils.d.ts.map +1 -1
  172. package/dist/esm/utils.js +112 -17
  173. package/dist/esm/utils.js.map +1 -1
  174. package/package.json +5 -5
  175. package/src/data-source-fetchers.ts +3 -3
  176. package/src/fetchers/airtable.ts +35 -36
  177. package/src/fetchers/clickhouse.ts +26 -36
  178. package/src/fetchers/csv-file.ts +13 -21
  179. package/src/fetchers/firestore.ts +41 -35
  180. package/src/fetchers/google-sheets.ts +15 -23
  181. package/src/fetchers/javascript.ts +11 -21
  182. package/src/fetchers/mariadb.ts +33 -44
  183. package/src/fetchers/mongodb.ts +66 -81
  184. package/src/fetchers/mysql.ts +33 -45
  185. package/src/fetchers/postgresql.ts +52 -62
  186. package/src/fetchers/redis.ts +6 -5
  187. package/src/fetchers/redshift.ts +29 -41
  188. package/src/fetchers/rest-api.ts +11 -21
  189. package/src/fetchers/supabase.ts +104 -41
  190. package/src/fetchers/teleport.ts +55 -66
  191. package/src/fetchers/turso.ts +27 -39
  192. package/src/index.ts +1 -1
  193. package/src/pagination-plugin.ts +3 -6
  194. package/src/transformations/blog-post.ts +28 -9
  195. package/src/transformations/category-taxonomy.ts +107 -0
  196. package/src/transformations/custom-page.ts +83 -0
  197. package/src/transformations/ecommerce-product.ts +177 -78
  198. package/src/transformations/index.ts +85 -11
  199. package/src/transformations/shared-utils.ts +9 -0
  200. package/src/utils.ts +191 -34
@@ -1,13 +1,25 @@
1
+ import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
+ import { generateCategoryTaxonomyCode } from './category-taxonomy'
3
+
4
+ /** What the blog-post transform bakes in at export time beyond the row itself. */
5
+ export interface BlogPostTransformOptions {
6
+ /** Category taxonomy — lives only in the UIDL, there is no DB table for it. */
7
+ categories?: UIDLEcommerceCategory[]
8
+ }
9
+
1
10
  /**
2
11
  * Generates JavaScript code for blog post data transformation.
3
12
  * Transforms raw snake_case database records into the camelCase shape
4
13
  * that UIDL components expect.
5
14
  */
6
- export const generateBlogPostTransformationCode = (): string => {
15
+ export const generateBlogPostTransformationCode = (
16
+ options: BlogPostTransformOptions = {}
17
+ ): string => {
7
18
  return `
8
19
  // ============================================================
9
20
  // Blog Post Transformation
10
21
  // ============================================================
22
+ ${generateCategoryTaxonomyCode('BLOG_CATEGORIES_BY_ID', options.categories)}
11
23
 
12
24
  function buildBlogPost(record, options) {
13
25
  if (!record || typeof record !== 'object') return record
@@ -24,6 +36,20 @@ function buildBlogPost(record, options) {
24
36
  var content = resolveI18nField(record, 'content', 'content', currentLang, mainLang) || ''
25
37
  var excerpt = resolveI18nField(record, 'excerpt', 'excerpt', currentLang, mainLang) || ''
26
38
  var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null
39
+
40
+ // Assigned category ids -> resolved {id,name,slug} objects for the post-details
41
+ // BREADCRUMBS (and anything else mapping over them). The taxonomy itself is not
42
+ // in the DB (categories are authored in the GUI and live only in
43
+ // blogSettings.categories); resolved against BLOG_CATEGORIES_BY_ID, baked in at
44
+ // export time. Unknown/stale ids are silently dropped. \`category\` above stays
45
+ // the denormalized PRIMARY name.
46
+ var categories = resolveAssignedCategories(
47
+ record.category_ids,
48
+ BLOG_CATEGORIES_BY_ID,
49
+ currentLang,
50
+ mainLang,
51
+ category
52
+ )
27
53
  var metaTitle = resolveI18nField(record, 'meta_title', 'metaTitle', currentLang, mainLang) || null
28
54
  var metaDescription = resolveI18nField(record, 'meta_description', 'metaDescription', currentLang, mainLang) || null
29
55
  var featuredImageAlt = resolveI18nField(record, 'featured_image_alt', 'featuredImageAlt', currentLang, mainLang) || null
@@ -145,6 +171,7 @@ function buildBlogPost(record, options) {
145
171
  excerpt: excerpt,
146
172
  status: status,
147
173
  category: category,
174
+ categories: categories,
148
175
  tags: tags,
149
176
  relatedPostIds: relatedPostIds,
150
177
  relatedPosts: relatedPosts,
@@ -179,14 +206,6 @@ function transformBlogPosts(records, options) {
179
206
  return records.map(function(record) { return buildBlogPost(record, options) })
180
207
  }
181
208
 
182
- // Trims a per-post SEO URL cell; blank/non-string values (including columns
183
- // that don't exist yet on tables provisioned before the feature) become null.
184
- function normalizeSeoUrlField(value) {
185
- if (typeof value !== 'string') return null
186
- var trimmed = value.trim()
187
- return trimmed.length > 0 ? trimmed : null
188
- }
189
-
190
209
  // Batched fetch of the post rows a set of posts reference as "related", keyed by
191
210
  // id. Best-effort in the same way as the product side: a table provisioned
192
211
  // before the column yields no ids (the read is of an absent property, not of a
@@ -0,0 +1,107 @@
1
+ import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
+
3
+ /**
4
+ * The category-taxonomy half of the entity transforms, shared by products and
5
+ * blog posts.
6
+ *
7
+ * A taxonomy has no DB table — it lives only in the UIDL
8
+ * (`ecommerceSettings.categories` / `blogSettings.categories`, baked at export
9
+ * time), so each generated fetcher carries its own flattened copy and resolves
10
+ * the row's `category_ids` against it with no runtime lookup.
11
+ */
12
+
13
+ /** What the runtime map holds per category id. */
14
+ export interface BakedCategoryInfo {
15
+ name: string
16
+ slug: string
17
+ translations?: UIDLEcommerceCategory['translations']
18
+ }
19
+
20
+ /**
21
+ * Flattens the nested category tree into an `id -> {name, slug, translations}`
22
+ * lookup map. `translations` is carried through UNRESOLVED (a per-language
23
+ * `{name, description}` map) so the transform can resolve it against the SAME
24
+ * per-request `currentLanguage`/`mainLanguage` used for every other i18n field —
25
+ * `slug` is never localized (it is the language-neutral join key). Tolerant of a
26
+ * missing/malformed tree.
27
+ */
28
+ export const flattenCategoriesById = (
29
+ categories: UIDLEcommerceCategory[] | undefined
30
+ ): Record<string, BakedCategoryInfo> => {
31
+ const byId: Record<string, BakedCategoryInfo> = {}
32
+ const visit = (nodes: UIDLEcommerceCategory[] | undefined): void => {
33
+ if (!Array.isArray(nodes)) {
34
+ return
35
+ }
36
+ for (const node of nodes) {
37
+ if (node && typeof node.id === 'string') {
38
+ byId[node.id] = {
39
+ name: node.name || '',
40
+ slug: node.slug || '',
41
+ translations: node.translations,
42
+ }
43
+ visit(node.children)
44
+ }
45
+ }
46
+ }
47
+ visit(categories)
48
+ return byId
49
+ }
50
+
51
+ /**
52
+ * The baked map plus the `resolveCategoryName` helper the transforms call.
53
+ * Emitted once per fetcher file — the two entity transforms are never
54
+ * concatenated into the same file (`getTransformationCode` picks one per table),
55
+ * so the helper name is unambiguous.
56
+ */
57
+ export const generateCategoryTaxonomyCode = (
58
+ mapVarName: string,
59
+ categories: UIDLEcommerceCategory[] | undefined
60
+ ): string => `
61
+ // Category taxonomy (id -> {name, slug, translations}), baked in at export
62
+ // time — see flattenCategoriesById in category-taxonomy.ts. Empty when the
63
+ // project has no taxonomy, or the row's category_ids simply resolve to nothing.
64
+ var ${mapVarName} = ${JSON.stringify(flattenCategoriesById(categories))}
65
+
66
+ // Resolve a baked category's name to currentLang/mainLang from its
67
+ // translations map (same per-request locale as every other i18n field via
68
+ // resolveI18nField), falling back to the main-language name.
69
+ function resolveCategoryName(categoryInfo, currentLang, mainLang) {
70
+ if (currentLang && mainLang && currentLang !== mainLang && categoryInfo.translations) {
71
+ var override = categoryInfo.translations[currentLang]
72
+ if (override && override.name) return override.name
73
+ }
74
+ return categoryInfo.name
75
+ }
76
+
77
+ // The row's assigned category ids -> resolved {id, name, slug} objects, in the
78
+ // order they were assigned. Ids that resolve to nothing (a deleted category) are
79
+ // silently dropped. MUST mirror resolveAssignedCategories in the editor
80
+ // (teleport-gui features/shared/taxonomy/category-tree.ts), which is what the
81
+ // canvas renders from.
82
+ function resolveAssignedCategories(categoryIdsRaw, byId, currentLang, mainLang, legacyName) {
83
+ var ids = parseJsonArray(categoryIdsRaw)
84
+ var out = []
85
+ for (var ci = 0; ci < ids.length; ci++) {
86
+ var info = byId[ids[ci]]
87
+ if (!info) continue
88
+ out.push({
89
+ id: ids[ci],
90
+ name: resolveCategoryName(info, currentLang, mainLang),
91
+ slug: info.slug,
92
+ })
93
+ }
94
+ // A row that predates the id column, or one imported with only a name, still
95
+ // shows what it carries — with an EMPTY id, so a filter link built from it
96
+ // carries no value and every listing reads that as "no filter".
97
+ if (out.length === 0 && typeof legacyName === 'string' && legacyName.trim()) {
98
+ var trimmed = legacyName.trim()
99
+ out.push({
100
+ id: '',
101
+ name: trimmed,
102
+ slug: trimmed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'category',
103
+ })
104
+ }
105
+ return out
106
+ }
107
+ `
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Generates JavaScript code for the `teleport_pages` row transformation — the
3
+ * rows the Custom Pages feature stores from its admin panel.
4
+ *
5
+ * The raw row is kept whole (every column, snake_case) and the SEO fields are
6
+ * normalised beside it under camelCase names, the same way the blog post
7
+ * transform exposes its per-post overrides. That is what lets the page's
8
+ * `<Head>` bind `metaTitle` / `robotsContent` / `canonicalUrl` / the Open Graph
9
+ * fields with plain `??` fallbacks, and lets getStaticProps answer the row's
10
+ * own redirect — without a single table-specific branch in the head or the
11
+ * static-props generators.
12
+ *
13
+ * `metaTitle` falls back to `title`: the column is nullable so an author who
14
+ * never filled it in still gets a real `<title>`, and `ogTitle`/`ogDescription`
15
+ * fall back to the meta pair for the same reason.
16
+ */
17
+ export const generateCustomPageTransformationCode = (): string => {
18
+ return `
19
+ // ============================================================
20
+ // Custom Page Transformation
21
+ // ============================================================
22
+
23
+ function buildCustomPage(record, options) {
24
+ if (!record || typeof record !== 'object') return record
25
+ options = options || {}
26
+ var assetMap = options.assetMap || {}
27
+
28
+ // Every stored column travels through untouched — the blocks column above
29
+ // all, which the page's array mapper parses — and the normalised SEO fields
30
+ // are added beside them.
31
+ var out = {}
32
+ for (var key in record) {
33
+ if (Object.prototype.hasOwnProperty.call(record, key)) out[key] = record[key]
34
+ }
35
+
36
+ var title = typeof record.title === 'string' ? record.title : ''
37
+ var metaTitle = normalizeSeoTextField(pickFirst(record.meta_title, record.metaTitle)) || title || null
38
+ var metaDescription = normalizeSeoTextField(pickFirst(record.meta_description, record.metaDescription))
39
+
40
+ var noIndex = coerceBoolean(pickFirst(record.no_index, record.noIndex), false)
41
+ var canonicalUrl = normalizeSeoUrlField(pickFirst(record.canonical_url, record.canonicalUrl))
42
+ var redirectUrl = normalizeSeoUrlField(pickFirst(record.redirect_url, record.redirectUrl))
43
+ var rawRedirectType = pickFirst(record.redirect_type, record.redirectType)
44
+ var redirectType =
45
+ redirectUrl && (rawRedirectType === '301' || rawRedirectType === '302')
46
+ ? rawRedirectType
47
+ : null
48
+ // null — never '' or false — for unset rows, so the page-level fallback (the
49
+ // \`??\` in the generated Head) can take over.
50
+ var robotsContent = noIndex ? 'noindex' : null
51
+
52
+ var ogTitle = normalizeSeoTextField(pickFirst(record.og_title, record.ogTitle)) || metaTitle
53
+ var ogDescription =
54
+ normalizeSeoTextField(pickFirst(record.og_description, record.ogDescription)) || metaDescription
55
+ var rawOgImage = normalizeSeoUrlField(pickFirst(record.og_image, record.ogImage))
56
+ var ogImage = rawOgImage ? resolveAssetUrl(rawOgImage, assetMap) || rawOgImage : null
57
+
58
+ out.metaTitle = metaTitle
59
+ out.metaDescription = metaDescription
60
+ out.noIndex = noIndex
61
+ out.canonicalUrl = canonicalUrl
62
+ out.redirectUrl = redirectUrl
63
+ out.redirectType = redirectType
64
+ out.robotsContent = robotsContent
65
+ out.ogTitle = ogTitle
66
+ out.ogDescription = ogDescription
67
+ out.ogImage = ogImage
68
+ return out
69
+ }
70
+
71
+ function transformCustomPages(records, options) {
72
+ if (!Array.isArray(records)) return []
73
+ return records.map(function(record) { return buildCustomPage(record, options) })
74
+ }
75
+
76
+ // Trims a free-text SEO cell; blank/non-string values become null.
77
+ function normalizeSeoTextField(value) {
78
+ if (typeof value !== 'string') return null
79
+ var trimmed = value.trim()
80
+ return trimmed.length > 0 ? trimmed : null
81
+ }
82
+ `
83
+ }
@@ -1,5 +1,6 @@
1
1
  import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
- import { ProductDiscounts, StorefrontTax } from '@teleporthq/teleport-shared'
2
+ import { ProductDiscounts, ProductRatings, StorefrontTax } from '@teleporthq/teleport-shared'
3
+ import { generateCategoryTaxonomyCode } from './category-taxonomy'
3
4
 
4
5
  /**
5
6
  * Everything the product transform needs baked in at export time, beyond the
@@ -28,47 +29,6 @@ export interface EcommerceProductTransformOptions {
28
29
  allowBackorders?: boolean
29
30
  }
30
31
 
31
- /**
32
- * Flattens the nested category tree (`ecommerceSettings.categories`, baked at
33
- * export time) into an `id -> {name, slug, translations}` lookup map,
34
- * JSON-embedded into the generated transform below so a product's
35
- * `category_ids` can be resolved to display names with no runtime DB lookup
36
- * (there is no DB table for the taxonomy — it lives only in the UIDL).
37
- * `translations` is carried through unresolved (a per-language `{name,
38
- * description}` map) so `buildEcommerceProduct` can resolve it against the
39
- * SAME per-request `currentLanguage`/`mainLanguage` used for every other i18n
40
- * field — `slug` is never localized (it's the language-neutral join key).
41
- * Tolerant of a missing/malformed tree.
42
- */
43
- const flattenCategoriesById = (
44
- categories: UIDLEcommerceCategory[] | undefined
45
- ): Record<
46
- string,
47
- { name: string; slug: string; translations?: UIDLEcommerceCategory['translations'] }
48
- > => {
49
- const byId: Record<
50
- string,
51
- { name: string; slug: string; translations?: UIDLEcommerceCategory['translations'] }
52
- > = {}
53
- const visit = (nodes: UIDLEcommerceCategory[] | undefined): void => {
54
- if (!Array.isArray(nodes)) {
55
- return
56
- }
57
- for (const node of nodes) {
58
- if (node && typeof node.id === 'string') {
59
- byId[node.id] = {
60
- name: node.name || '',
61
- slug: node.slug || '',
62
- translations: node.translations,
63
- }
64
- visit(node.children)
65
- }
66
- }
67
- }
68
- visit(categories)
69
- return byId
70
- }
71
-
72
32
  /**
73
33
  * Generates JavaScript code for e-commerce product data transformation.
74
34
  * Transforms raw snake_case database records into the camelCase shape
@@ -77,7 +37,6 @@ const flattenCategoriesById = (
77
37
  export const generateEcommerceProductTransformationCode = (
78
38
  options: EcommerceProductTransformOptions = {}
79
39
  ): string => {
80
- const categoriesByIdJson = JSON.stringify(flattenCategoriesById(options.categories))
81
40
  const taxHelperCode = StorefrontTax.generateStorefrontTaxHelperCode(
82
41
  options.storefrontTaxRate ?? 0
83
42
  )
@@ -86,29 +45,14 @@ export const generateEcommerceProductTransformationCode = (
86
45
  return `
87
46
  ${taxHelperCode}
88
47
  ${ProductDiscounts.generateProductDiscountHelperCode()}
48
+ ${ProductRatings.generateProductRatingHelperCode()}
89
49
 
90
50
  // Baked from the merchant's stock settings: TRUE when stock never blocks a
91
51
  // purchase (stock management off OR backorders allowed). Regenerating the
92
52
  // project refreshes it — do not hand-edit. See EcommerceProductTransformOptions.
93
53
  var ALLOW_BACKORDERS = ${allowBackordersLiteral}
94
54
 
95
- // Category taxonomy (id -> {name, slug, translations}), baked in at export
96
- // time — see flattenCategoriesById in ecommerce-product.ts. Empty when the
97
- // store has no category taxonomy, or products.category_ids simply resolves
98
- // to nothing.
99
- var PRODUCT_CATEGORIES_BY_ID = ${categoriesByIdJson}
100
-
101
- // Resolve a baked category's name to currentLang/mainLang from its
102
- // translations map (same per-request locale as every other i18n field via
103
- // resolveI18nField), falling back to the main-language name.
104
- function resolveCategoryName(categoryInfo, currentLang, mainLang) {
105
- if (currentLang && mainLang && currentLang !== mainLang && categoryInfo.translations) {
106
- var override = categoryInfo.translations[currentLang]
107
- if (override && override.name) return override.name
108
- }
109
- return categoryInfo.name
110
- }
111
-
55
+ ${generateCategoryTaxonomyCode('PRODUCT_CATEGORIES_BY_ID', options.categories)}
112
56
  // The merchant's "Show 3D model by default" toggle, as every backend may hand
113
57
  // it back: a real boolean, 1/0, 'true'/'false'/'t'/'f' strings — and NULL /
114
58
  // absent / '' as ON, because a product that has a model shows it unless the
@@ -251,24 +195,17 @@ function buildEcommerceProduct(record, options) {
251
195
  visibleProperties.push(classifyProductProperty(propKey, propValue))
252
196
  })
253
197
 
254
- // Assigned category ids -> resolved {id,name,slug} objects for the
255
- // storefront category pills mapper. The taxonomy itself is not in the DB
256
- // (categories are authored in the GUI and live only in
257
- // ecommerceSettings.categories); resolved here against PRODUCT_CATEGORIES_BY_ID,
258
- // baked in at export time (see flattenCategoriesById). Unknown/stale ids
259
- // (a deleted category) are silently dropped.
260
- var categoryIds = parseJsonArray(record.category_ids)
261
- var categories = []
262
- for (var ci = 0; ci < categoryIds.length; ci++) {
263
- var categoryInfo = PRODUCT_CATEGORIES_BY_ID[categoryIds[ci]]
264
- if (categoryInfo) {
265
- categories.push({
266
- id: categoryIds[ci],
267
- name: resolveCategoryName(categoryInfo, currentLang, mainLang),
268
- slug: categoryInfo.slug,
269
- })
270
- }
271
- }
198
+ // Assigned category ids -> resolved {id,name,slug} objects for the storefront
199
+ // category pills mapper and the product-details breadcrumbs. The taxonomy
200
+ // itself is not in the DB (categories are authored in the GUI and live only in
201
+ // ecommerceSettings.categories); resolved against PRODUCT_CATEGORIES_BY_ID,
202
+ // baked in at export time. Unknown/stale ids are silently dropped.
203
+ var categories = resolveAssignedCategories(
204
+ record.category_ids,
205
+ PRODUCT_CATEGORIES_BY_ID,
206
+ currentLang,
207
+ mainLang
208
+ )
272
209
 
273
210
  // Purchasable variant COMBINATIONS for this product ({id, options map, price,
274
211
  // stock, image}). Empty for flat products. Fetched in ONE batched query by
@@ -323,6 +260,9 @@ function buildEcommerceProduct(record, options) {
323
260
  // card's picker. The batched query includes the related ids for exactly
324
261
  // this reason (see getTransformWrapperCode).
325
262
  variantsByProductId: options.variantsByProductId,
263
+ // Same reason: a related card draws its own star row, and the batched
264
+ // aggregate already covered its id.
265
+ ratingsByProductId: options.ratingsByProductId,
326
266
  }
327
267
  for (var rp = 0; rp < relatedProductIds.length; rp++) {
328
268
  var relatedId = relatedProductIds[rp]
@@ -531,6 +471,31 @@ function buildEcommerceProduct(record, options) {
531
471
  (requiresVariantSelection !== 'true' || !variantsResolved || hasInStockCombination)
532
472
  ? 'true'
533
473
  : 'false'
474
+ // Aggregate review rating, from ONE batched COUNT/AVG over approved reviews
475
+ // that transformRecords runs for every product on the page (keyed by product
476
+ // id in options.ratingsByProductId).
477
+ //
478
+ // ⛔ An ABSENT entry and a NULL map both mean "no rating to show", never "0
479
+ // stars": a product nobody has reviewed and a product whose rating query
480
+ // failed must both render with no stars at all. "0.0 out of 5" is the single
481
+ // worst thing a storefront can say about a product by accident.
482
+ //
483
+ // MUST mirror buildProductRatingFields in the GUI's
484
+ // features/e-commerce/utils/product-ratings.ts.
485
+ var ratingsByProductId = options.ratingsByProductId || {}
486
+ var ratingFields = __prBuildRatingFields(id != null ? ratingsByProductId[id] : null)
487
+
488
+ // The individual reviews behind those stars, for the product page's JSON-LD.
489
+ // Fetched ONLY for a single-product request (see getTransformWrapperCode), so
490
+ // this is an empty array on every listing — which is correct: a listing emits
491
+ // no Product markup to put them in.
492
+ //
493
+ // Not rendered anywhere. That is why there is no canvas mirror for it: the
494
+ // three-mirror rule exists for fields the editor DRAWS, and structured data
495
+ // is a publish-time concern the canvas has no equivalent of.
496
+ var reviewsByProductId = options.reviewsByProductId || {}
497
+ var reviews = (id != null && reviewsByProductId[id]) || []
498
+
534
499
  // Stringified companions: a data-* attr bound to an ARRAY renders
535
500
  // '[object Object]', so the picker's on-mount/click workflows read the
536
501
  // combinations + axes from these JSON strings via getAttribute.
@@ -598,6 +563,22 @@ function buildEcommerceProduct(record, options) {
598
563
  originalDisplayPrice: originalDisplayPrice,
599
564
  originalDefaultVariantDisplayPrice: originalDefaultVariantDisplayPrice,
600
565
  hasPurchasableVariant: hasPurchasableVariant,
566
+ // Aggregate review rating — see the ratingFields block above.
567
+ ratingAverage: ratingFields.ratingAverage,
568
+ ratingCount: ratingFields.ratingCount,
569
+ ratingAverageLabel: ratingFields.ratingAverageLabel,
570
+ ratingCountLabel: ratingFields.ratingCountLabel,
571
+ hasRatings: ratingFields.hasRatings,
572
+ ratingStar1: ratingFields.ratingStar1,
573
+ ratingStar2: ratingFields.ratingStar2,
574
+ ratingStar3: ratingFields.ratingStar3,
575
+ ratingStar4: ratingFields.ratingStar4,
576
+ ratingStar5: ratingFields.ratingStar5,
577
+ // Domain-shaped, not schema.org-shaped: the head-config plugin's
578
+ // 'reviewList' computed kind does the vocabulary. NO BACKTICKS ANYWHERE IN
579
+ // THIS FILE — it is one template literal, and a backtick in a comment ends
580
+ // the string.
581
+ reviews: reviews,
601
582
  }
602
583
  }
603
584
 
@@ -871,6 +852,124 @@ async function getRelatedProductsMap(getClientFn, records) {
871
852
  return map
872
853
  }
873
854
 
855
+ // Aggregate review rating for every product on the page, keyed by product id,
856
+ // so a card can draw its star row without a query per product.
857
+ //
858
+ // APPROVED ONLY, and 'approved' is a fixed literal rather than a bound value
859
+ // because nothing may ever widen it: a pending review is one the merchant has
860
+ // not agreed to publish, and a rejected one is a review they actively removed.
861
+ //
862
+ // Unlike getVariantsMap this returns an EMPTY MAP on failure rather than null.
863
+ // The two "unknown" answers differ because the consequences differ: an unknown
864
+ // variant map must stay permissive (never make a catalogue unbuyable), whereas
865
+ // an unknown rating has exactly one safe rendering — no stars — which is also
866
+ // what a product with no reviews shows. Collapsing both onto {} means every
867
+ // caller has one case to handle instead of two.
868
+ async function getRatingsMap(getClientFn, productIds) {
869
+ var map = {}
870
+ if (!Array.isArray(productIds) || productIds.length === 0) return map
871
+ var uniqueIds = []
872
+ var seenIds = {}
873
+ for (var u = 0; u < productIds.length; u++) {
874
+ var pid0 = productIds[u]
875
+ if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }
876
+ }
877
+ if (uniqueIds.length === 0) return map
878
+ var client
879
+ try {
880
+ client = getClientFn()
881
+ await client.connect()
882
+ var result = await client.query(
883
+ "SELECT product_id, AVG(rating)::float AS average, COUNT(*)::int AS count " +
884
+ 'FROM teleport_product_reviews ' +
885
+ "WHERE status = 'approved' AND rating IS NOT NULL AND product_id = ANY($1) " +
886
+ 'GROUP BY product_id',
887
+ [uniqueIds]
888
+ )
889
+ if (result && result.rows) {
890
+ for (var r = 0; r < result.rows.length; r++) {
891
+ var row = result.rows[r]
892
+ if (row && row.product_id != null) {
893
+ map[row.product_id] = { average: row.average, count: row.count }
894
+ }
895
+ }
896
+ }
897
+ } catch (e) {
898
+ // teleport_product_reviews may not exist (reviews were never enabled), or
899
+ // the query may have failed. Either way there is no rating to show, which
900
+ // is exactly what an empty map produces.
901
+ map = {}
902
+ } finally {
903
+ if (client) {
904
+ try { await client.end() } catch (e) { /* ignore */ }
905
+ }
906
+ }
907
+ return map
908
+ }
909
+
910
+ // The most recent APPROVED reviews for a product, for the product page's
911
+ // structured data. Keyed by product id like every other batched lookup, though
912
+ // in practice it is only ever called with one id.
913
+ //
914
+ // Three filters, and each one exists because of what the output is used for:
915
+ // - APPROVED only, as a fixed literal, for the same reason the aggregate is:
916
+ // a pending review is one the merchant has not agreed to publish.
917
+ // - A NON-EMPTY reviewer name. Google requires author.name on a review
918
+ // snippet, and an anonymous row would produce a Review object it rejects —
919
+ // taking the whole Product block down with it on some validators.
920
+ // - A CAP. The reviews are inlined into the page's props and then into its
921
+ // HTML twice over (once as data, once as JSON-LD); a product with four
922
+ // hundred reviews would otherwise put all of them in both.
923
+ async function getProductReviewsMap(getClientFn, productIds, perProduct) {
924
+ var map = {}
925
+ if (!Array.isArray(productIds) || productIds.length === 0) return map
926
+ var uniqueIds = []
927
+ var seenIds = {}
928
+ for (var u = 0; u < productIds.length; u++) {
929
+ var pid0 = productIds[u]
930
+ if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }
931
+ }
932
+ if (uniqueIds.length === 0) return map
933
+ var client
934
+ try {
935
+ client = getClientFn()
936
+ await client.connect()
937
+ var result = await client.query(
938
+ 'SELECT product_id, reviewer_name, rating, content, created_at ' +
939
+ 'FROM teleport_product_reviews ' +
940
+ "WHERE status = 'approved' AND rating IS NOT NULL " +
941
+ "AND reviewer_name IS NOT NULL AND btrim(reviewer_name) <> '' " +
942
+ 'AND product_id = ANY($1) ' +
943
+ 'ORDER BY created_at DESC',
944
+ [uniqueIds]
945
+ )
946
+ var cap = typeof perProduct === 'number' && perProduct > 0 ? perProduct : 5
947
+ if (result && result.rows) {
948
+ for (var r = 0; r < result.rows.length; r++) {
949
+ var row = result.rows[r]
950
+ if (!row || row.product_id == null) continue
951
+ var bucket = map[row.product_id] || (map[row.product_id] = [])
952
+ if (bucket.length >= cap) continue
953
+ bucket.push({
954
+ author: String(row.reviewer_name).trim(),
955
+ rating: Number(row.rating),
956
+ body: row.content == null ? '' : String(row.content),
957
+ datePublished: row.created_at ? new Date(row.created_at).toISOString() : null,
958
+ })
959
+ }
960
+ }
961
+ } catch (e) {
962
+ // The reviews table may not exist (reviews were never enabled). No reviews
963
+ // to show is both the empty answer and the safe one.
964
+ map = {}
965
+ } finally {
966
+ if (client) {
967
+ try { await client.end() } catch (e) { /* ignore */ }
968
+ }
969
+ }
970
+ return map
971
+ }
972
+
874
973
  // Combinations for every product on the page, keyed by product id. Returns NULL
875
974
  // when the lookup could not run, which the transform reads as "unknown" and
876
975
  // stays permissive with; an empty map means the query ran and this catalogue