@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.
- package/__tests__/blog-post-categories.test.ts +131 -0
- package/__tests__/custom-page-transform.test.ts +119 -0
- package/__tests__/ecommerce-product-ratings.test.ts +155 -0
- package/__tests__/ecommerce-product-storefront-contract.test.ts +147 -0
- package/__tests__/product-reviews-structured-data.test.ts +196 -0
- package/dist/cjs/data-source-fetchers.d.ts +3 -3
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts +2 -2
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/transformations/blog-post.d.ts +7 -1
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +4 -2
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/category-taxonomy.d.ts +33 -0
- package/dist/cjs/transformations/category-taxonomy.d.ts.map +1 -0
- package/dist/cjs/transformations/category-taxonomy.js +42 -0
- package/dist/cjs/transformations/category-taxonomy.js.map +1 -0
- package/dist/cjs/transformations/custom-page.d.ts +18 -0
- package/dist/cjs/transformations/custom-page.d.ts.map +1 -0
- package/dist/cjs/transformations/custom-page.js +24 -0
- package/dist/cjs/transformations/custom-page.js.map +1 -0
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +2 -35
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +23 -4
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +38 -8
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
- package/dist/cjs/transformations/shared-utils.js +1 -1
- package/dist/cjs/transformations/shared-utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils.d.ts +3 -3
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/data-source-fetchers.d.ts +3 -3
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts +2 -2
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +1 -1
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/transformations/blog-post.d.ts +7 -1
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +4 -2
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/category-taxonomy.d.ts +33 -0
- package/dist/esm/transformations/category-taxonomy.d.ts.map +1 -0
- package/dist/esm/transformations/category-taxonomy.js +37 -0
- package/dist/esm/transformations/category-taxonomy.js.map +1 -0
- package/dist/esm/transformations/custom-page.d.ts +18 -0
- package/dist/esm/transformations/custom-page.d.ts.map +1 -0
- package/dist/esm/transformations/custom-page.js +20 -0
- package/dist/esm/transformations/custom-page.js.map +1 -0
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +3 -36
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +23 -4
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +37 -7
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
- package/dist/esm/transformations/shared-utils.js +1 -1
- package/dist/esm/transformations/shared-utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils.d.ts +3 -3
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/data-source-fetchers.ts +3 -3
- package/src/fetchers/teleport.ts +2 -2
- package/src/index.ts +1 -1
- package/src/pagination-plugin.ts +3 -6
- package/src/transformations/blog-post.ts +49 -1
- package/src/transformations/category-taxonomy.ts +107 -0
- package/src/transformations/custom-page.ts +83 -0
- package/src/transformations/ecommerce-product.ts +177 -78
- package/src/transformations/index.ts +85 -11
- package/src/transformations/shared-utils.ts +9 -0
- 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
|
-
|
|
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
|
-
//
|
|
256
|
-
// (categories are authored in the GUI and live only in
|
|
257
|
-
// ecommerceSettings.categories); resolved
|
|
258
|
-
// baked in at export time
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
):
|
|
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:
|
|
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 =
|
|
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'
|
|
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}
|
|
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 {
|
|
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:
|
|
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:
|
|
904
|
+
transformOptions: EntityTransformOptions = {}
|
|
905
905
|
): { success: boolean; chunk?: any } => {
|
|
906
906
|
try {
|
|
907
907
|
// Validate node content
|