@teleporthq/teleport-plugin-next-data-source 0.43.61 → 0.43.63

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 (90) 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__/product-reviews-structured-data.test.ts +196 -0
  6. package/dist/cjs/data-source-fetchers.d.ts +3 -3
  7. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  8. package/dist/cjs/data-source-fetchers.js.map +1 -1
  9. package/dist/cjs/fetchers/teleport.d.ts +2 -2
  10. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  11. package/dist/cjs/fetchers/teleport.js.map +1 -1
  12. package/dist/cjs/index.d.ts +1 -1
  13. package/dist/cjs/index.d.ts.map +1 -1
  14. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  15. package/dist/cjs/pagination-plugin.js.map +1 -1
  16. package/dist/cjs/transformations/blog-post.d.ts +7 -1
  17. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  18. package/dist/cjs/transformations/blog-post.js +4 -2
  19. package/dist/cjs/transformations/blog-post.js.map +1 -1
  20. package/dist/cjs/transformations/category-taxonomy.d.ts +33 -0
  21. package/dist/cjs/transformations/category-taxonomy.d.ts.map +1 -0
  22. package/dist/cjs/transformations/category-taxonomy.js +42 -0
  23. package/dist/cjs/transformations/category-taxonomy.js.map +1 -0
  24. package/dist/cjs/transformations/custom-page.d.ts +18 -0
  25. package/dist/cjs/transformations/custom-page.d.ts.map +1 -0
  26. package/dist/cjs/transformations/custom-page.js +24 -0
  27. package/dist/cjs/transformations/custom-page.js.map +1 -0
  28. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  29. package/dist/cjs/transformations/ecommerce-product.js +2 -35
  30. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  31. package/dist/cjs/transformations/index.d.ts +23 -4
  32. package/dist/cjs/transformations/index.d.ts.map +1 -1
  33. package/dist/cjs/transformations/index.js +38 -8
  34. package/dist/cjs/transformations/index.js.map +1 -1
  35. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  36. package/dist/cjs/transformations/shared-utils.js +1 -1
  37. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  38. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  39. package/dist/cjs/utils.d.ts +3 -3
  40. package/dist/cjs/utils.d.ts.map +1 -1
  41. package/dist/cjs/utils.js.map +1 -1
  42. package/dist/esm/data-source-fetchers.d.ts +3 -3
  43. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  44. package/dist/esm/data-source-fetchers.js.map +1 -1
  45. package/dist/esm/fetchers/teleport.d.ts +2 -2
  46. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  47. package/dist/esm/fetchers/teleport.js.map +1 -1
  48. package/dist/esm/index.d.ts +1 -1
  49. package/dist/esm/index.d.ts.map +1 -1
  50. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  51. package/dist/esm/pagination-plugin.js +1 -1
  52. package/dist/esm/pagination-plugin.js.map +1 -1
  53. package/dist/esm/transformations/blog-post.d.ts +7 -1
  54. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  55. package/dist/esm/transformations/blog-post.js +4 -2
  56. package/dist/esm/transformations/blog-post.js.map +1 -1
  57. package/dist/esm/transformations/category-taxonomy.d.ts +33 -0
  58. package/dist/esm/transformations/category-taxonomy.d.ts.map +1 -0
  59. package/dist/esm/transformations/category-taxonomy.js +37 -0
  60. package/dist/esm/transformations/category-taxonomy.js.map +1 -0
  61. package/dist/esm/transformations/custom-page.d.ts +18 -0
  62. package/dist/esm/transformations/custom-page.d.ts.map +1 -0
  63. package/dist/esm/transformations/custom-page.js +20 -0
  64. package/dist/esm/transformations/custom-page.js.map +1 -0
  65. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  66. package/dist/esm/transformations/ecommerce-product.js +3 -36
  67. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  68. package/dist/esm/transformations/index.d.ts +23 -4
  69. package/dist/esm/transformations/index.d.ts.map +1 -1
  70. package/dist/esm/transformations/index.js +37 -7
  71. package/dist/esm/transformations/index.js.map +1 -1
  72. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  73. package/dist/esm/transformations/shared-utils.js +1 -1
  74. package/dist/esm/transformations/shared-utils.js.map +1 -1
  75. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  76. package/dist/esm/utils.d.ts +3 -3
  77. package/dist/esm/utils.d.ts.map +1 -1
  78. package/dist/esm/utils.js.map +1 -1
  79. package/package.json +5 -5
  80. package/src/data-source-fetchers.ts +3 -3
  81. package/src/fetchers/teleport.ts +2 -2
  82. package/src/index.ts +1 -1
  83. package/src/pagination-plugin.ts +3 -6
  84. package/src/transformations/blog-post.ts +49 -1
  85. package/src/transformations/category-taxonomy.ts +107 -0
  86. package/src/transformations/custom-page.ts +83 -0
  87. package/src/transformations/ecommerce-product.ts +177 -78
  88. package/src/transformations/index.ts +85 -11
  89. package/src/transformations/shared-utils.ts +9 -0
  90. package/src/utils.ts +3 -3
@@ -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
@@ -1,7 +1,8 @@
1
- import type { GeneratorOptions } from '@teleporthq/teleport-types'
1
+ import type { GeneratorOptions, UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
2
  import { StorefrontTax } from '@teleporthq/teleport-shared'
3
3
  import { generateSharedTransformationCode } from './shared-utils'
4
4
  import { generateBlogPostTransformationCode } from './blog-post'
5
+ import { generateCustomPageTransformationCode } from './custom-page'
5
6
  import {
6
7
  generateEcommerceProductTransformationCode,
7
8
  type EcommerceProductTransformOptions,
@@ -9,6 +10,17 @@ import {
9
10
 
10
11
  export type { EcommerceProductTransformOptions }
11
12
 
13
+ /**
14
+ * Everything a generated fetcher bakes in, for EITHER entity. One options object
15
+ * because `getTransformationCode` is called with a table name and has to be able
16
+ * to serve whichever transform that resolves to — the product fields are ignored
17
+ * for a blog table and vice versa.
18
+ */
19
+ export interface EntityTransformOptions extends EcommerceProductTransformOptions {
20
+ /** Blog post-category taxonomy — see `blogSettings.categories`. */
21
+ blogCategories?: UIDLEcommerceCategory[]
22
+ }
23
+
12
24
  /**
13
25
  * Stock never gates purchasability when the merchant disabled stock management
14
26
  * entirely or opted into backorders. Mirrors the GUI rule in
@@ -35,14 +47,15 @@ const resolveAllowBackorders = (
35
47
  * "stock never blocks a purchase" flag — see `resolveAllowBackorders`.
36
48
  */
37
49
  export const buildProductTransformOptions = (
38
- options: Pick<GeneratorOptions, 'ecommerceSettings' | 'invoiceSettings'>
39
- ): EcommerceProductTransformOptions => ({
50
+ options: Pick<GeneratorOptions, 'ecommerceSettings' | 'invoiceSettings' | 'blogSettings'>
51
+ ): EntityTransformOptions => ({
40
52
  categories: options.ecommerceSettings?.categories,
53
+ blogCategories: options.blogSettings?.categories,
41
54
  storefrontTaxRate: StorefrontTax.resolveStorefrontTaxRate(options.invoiceSettings),
42
55
  allowBackorders: resolveAllowBackorders(options.ecommerceSettings),
43
56
  })
44
57
 
45
- export type TransformationType = 'blog-post' | 'ecommerce-product' | null
58
+ export type TransformationType = 'blog-post' | 'ecommerce-product' | 'custom-page' | null
46
59
 
47
60
  /**
48
61
  * Strips a leading `schema.` qualifier from a table name so that only the
@@ -80,6 +93,9 @@ export const detectTransformationType = (tableName: string): TransformationType
80
93
  if (bare === 'teleport_products') {
81
94
  return 'ecommerce-product'
82
95
  }
96
+ if (bare === 'teleport_pages') {
97
+ return 'custom-page'
98
+ }
83
99
  return null
84
100
  }
85
101
 
@@ -90,7 +106,7 @@ export const detectTransformationType = (tableName: string): TransformationType
90
106
  */
91
107
  export const getTransformationCode = (
92
108
  tableName: string,
93
- options: EcommerceProductTransformOptions = {}
109
+ options: EntityTransformOptions = {}
94
110
  ): string => {
95
111
  const type = detectTransformationType(tableName)
96
112
  if (!type) {
@@ -101,9 +117,11 @@ export const getTransformationCode = (
101
117
 
102
118
  switch (type) {
103
119
  case 'blog-post':
104
- return shared + generateBlogPostTransformationCode()
120
+ return shared + generateBlogPostTransformationCode({ categories: options.blogCategories })
105
121
  case 'ecommerce-product':
106
122
  return shared + generateEcommerceProductTransformationCode(options)
123
+ case 'custom-page':
124
+ return shared + generateCustomPageTransformationCode()
107
125
  default:
108
126
  return ''
109
127
  }
@@ -126,11 +144,23 @@ export const getTransformExpression = (tableName: string): string | null => {
126
144
  return 'await transformRecords(safeData, getClient, req.query)'
127
145
  case 'ecommerce-product':
128
146
  return 'await transformRecords(safeData, getClient, req.query)'
147
+ case 'custom-page':
148
+ return 'await transformRecords(safeData, getClient, req.query)'
129
149
  default:
130
150
  return null
131
151
  }
132
152
  }
133
153
 
154
+ /**
155
+ * How many reviews a product page carries into its structured data.
156
+ *
157
+ * They are inlined TWICE — once into the page's props, once into the JSON-LD
158
+ * script rendered from them — so this number is paid for in bytes on every
159
+ * request. Five is what a review snippet shows; the rest are on the page itself,
160
+ * fetched by the reviews section's own paginated query.
161
+ */
162
+ export const REVIEWS_PER_PRODUCT = 5
163
+
134
164
  /**
135
165
  * Returns the transform wrapper function code that handles asset map loading
136
166
  * and calls the appropriate transformer.
@@ -142,7 +172,12 @@ export const getTransformWrapperCode = (tableName: string): string => {
142
172
  return ''
143
173
  }
144
174
 
145
- const transformFn = type === 'blog-post' ? 'transformBlogPosts' : 'transformEcommerceProducts'
175
+ const transformFn =
176
+ type === 'blog-post'
177
+ ? 'transformBlogPosts'
178
+ : type === 'custom-page'
179
+ ? 'transformCustomPages'
180
+ : 'transformEcommerceProducts'
146
181
 
147
182
  // Products additionally get their purchasable variant combinations attached in
148
183
  // ONE batched query (keyed by product id). Blog posts have no such enrichment.
@@ -160,8 +195,11 @@ export const getTransformWrapperCode = (tableName: string): string => {
160
195
  type === 'ecommerce-product'
161
196
  ? `
162
197
  var variantsByProductId = null
198
+ // Declared OUTSIDE the try because the ratings lookup below reuses it. \`var\`
199
+ // would hoist it anyway, but relying on that would make this block break
200
+ // silently the day someone modernises it to \`let\`.
201
+ var __variantPids = []
163
202
  try {
164
- var __variantPids = []
165
203
  for (var __i = 0; __i < records.length; __i++) {
166
204
  if (records[__i] && records[__i].id != null) __variantPids.push(records[__i].id)
167
205
  }
@@ -178,11 +216,41 @@ export const getTransformWrapperCode = (tableName: string): string => {
178
216
  variantsByProductId = await getVariantsMap(getClientFn, __variantPids)
179
217
  } catch (e) {
180
218
  // Leaves the map null — "unknown", never "none".
219
+ }
220
+ // The aggregate star rating for the same id set, in ONE more query. Reuses
221
+ // __variantPids so a details page's related cards get their ratings from the
222
+ // same round trip rather than one query per card.
223
+ //
224
+ // Unlike the variants map this stays {} on failure: "no rating to show" is
225
+ // both the empty answer and the safe answer, so there is no unknown state for
226
+ // a caller to reason about.
227
+ var ratingsByProductId = {}
228
+ try {
229
+ ratingsByProductId = await getRatingsMap(getClientFn, __variantPids)
230
+ } catch (e) {
231
+ // Leaves the map empty — the star rows simply stay hidden.
232
+ }
233
+ // The individual reviews behind the stars, for the product page's JSON-LD.
234
+ //
235
+ // Gated on the SINGLE-RECORD heuristic, unlike the two lookups above. Those
236
+ // feed something every card on the page draws; this feeds structured data,
237
+ // which only a details page emits — and a listing of 24 products would
238
+ // otherwise fetch and inline 120 review rows that nothing on it renders or
239
+ // markup-references.
240
+ var reviewsByProductId = {}
241
+ if (Array.isArray(records) && records.length === 1) {
242
+ try {
243
+ reviewsByProductId = await getProductReviewsMap(getClientFn, __variantPids, ${REVIEWS_PER_PRODUCT})
244
+ } catch (e) {
245
+ // Best-effort; the product simply ships without review snippets.
246
+ }
181
247
  }`
182
248
  : ''
183
249
 
184
250
  const variantOption =
185
- type === 'ecommerce-product' ? ', variantsByProductId: variantsByProductId' : ''
251
+ type === 'ecommerce-product'
252
+ ? ', variantsByProductId: variantsByProductId, ratingsByProductId: ratingsByProductId, reviewsByProductId: reviewsByProductId'
253
+ : ''
186
254
 
187
255
  // Related items are resolved for a SINGLE-record fetch only — which is the
188
256
  // details page, the one surface that renders them (it looks the row up by
@@ -195,9 +263,13 @@ export const getTransformWrapperCode = (tableName: string): string => {
195
263
  // A one-product store's listing does pay for one extra query. That is the
196
264
  // whole cost of the heuristic, and it beats plumbing a page-role flag through
197
265
  // every fetcher.
266
+ // Custom pages have no related items and no variants: their transform only
267
+ // normalises the row's SEO fields.
268
+ const hasRelatedItems = type === 'blog-post' || type === 'ecommerce-product'
198
269
  const relatedMapVar = type === 'blog-post' ? 'relatedPostsById' : 'relatedProductsById'
199
270
  const relatedMapFn = type === 'blog-post' ? 'getRelatedPostsMap' : 'getRelatedProductsMap'
200
- const relatedEnrichment = `
271
+ const relatedEnrichment = hasRelatedItems
272
+ ? `
201
273
  var ${relatedMapVar} = null
202
274
  if (Array.isArray(records) && records.length === 1) {
203
275
  try {
@@ -206,6 +278,8 @@ export const getTransformWrapperCode = (tableName: string): string => {
206
278
  // Best-effort; the related rail stays hidden behind its empty gate.
207
279
  }
208
280
  }`
281
+ : ''
282
+ const relatedOption = hasRelatedItems ? `, ${relatedMapVar}: ${relatedMapVar}` : ''
209
283
 
210
284
  return `
211
285
  async function transformRecords(records, getClientFn, reqQuery) {
@@ -217,7 +291,7 @@ async function transformRecords(records, getClientFn, reqQuery) {
217
291
  }${relatedEnrichment}${variantEnrichment}
218
292
  var currentLanguage = (reqQuery && reqQuery.lang) || null
219
293
  var mainLanguage = (reqQuery && reqQuery.mainLang) || null
220
- var options = { assetMap: assetMap, currentLanguage: currentLanguage, mainLanguage: mainLanguage${variantOption}, ${relatedMapVar}: ${relatedMapVar} }
294
+ var options = { assetMap: assetMap, currentLanguage: currentLanguage, mainLanguage: mainLanguage${variantOption}${relatedOption} }
221
295
  return ${transformFn}(records, options)
222
296
  }
223
297
  `
@@ -248,6 +248,15 @@ function resolveMediaUrl(value, assetMap) {
248
248
  return isResolvedMediaUrl(resolved) ? resolved : null
249
249
  }
250
250
 
251
+ // Trims a per-row SEO URL cell (blog posts, custom pages); blank/non-string
252
+ // values — including columns that don't exist yet on tables provisioned before
253
+ // the feature — become null.
254
+ function normalizeSeoUrlField(value) {
255
+ if (typeof value !== 'string') return null
256
+ var trimmed = value.trim()
257
+ return trimmed.length > 0 ? trimmed : null
258
+ }
259
+
251
260
  function resolveAssetUrls(values, assetMap) {
252
261
  if (!Array.isArray(values)) return []
253
262
  return values
package/src/utils.ts CHANGED
@@ -12,7 +12,7 @@ import * as types from '@babel/types'
12
12
  import { ASTUtils } from '@teleporthq/teleport-plugin-common'
13
13
  import { GenericUtils, StringUtils } from '@teleporthq/teleport-shared'
14
14
  import { generateDataSourceFetcherWithCore } from './data-source-fetchers'
15
- import type { EcommerceProductTransformOptions } from './transformations'
15
+ import type { EntityTransformOptions } from './transformations'
16
16
  import { DATA_SOURCE_ISR_REVALIDATE_SECONDS } from './isr'
17
17
 
18
18
  const VALID_DATA_SOURCE_TYPES: DataSourceType[] = [
@@ -192,7 +192,7 @@ export const extractDataSourceIntoNextAPIFolder = (
192
192
  dataSources: Record<string, UIDLDataSource>,
193
193
  componentChunk: ChunkDefinition,
194
194
  extractedResources: GeneratorOptions['extractedResources'],
195
- transformOptions: EcommerceProductTransformOptions = {}
195
+ transformOptions: EntityTransformOptions = {}
196
196
  ) => {
197
197
  try {
198
198
  // Validate node content structure
@@ -901,7 +901,7 @@ export const extractDataSourceIntoGetStaticProps = (
901
901
  dependencies: Record<string, any>,
902
902
  dynamicRouteAttr?: string,
903
903
  folderPath?: string[],
904
- transformOptions: EcommerceProductTransformOptions = {}
904
+ transformOptions: EntityTransformOptions = {}
905
905
  ): { success: boolean; chunk?: any } => {
906
906
  try {
907
907
  // Validate node content