@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.
- 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__/filter-groups.test.ts +174 -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/airtable.d.ts.map +1 -1
- package/dist/cjs/fetchers/airtable.js +1 -1
- package/dist/cjs/fetchers/airtable.js.map +1 -1
- package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
- package/dist/cjs/fetchers/clickhouse.js +1 -1
- package/dist/cjs/fetchers/clickhouse.js.map +1 -1
- package/dist/cjs/fetchers/csv-file.d.ts.map +1 -1
- package/dist/cjs/fetchers/csv-file.js +3 -3
- package/dist/cjs/fetchers/csv-file.js.map +1 -1
- package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
- package/dist/cjs/fetchers/firestore.js +1 -1
- package/dist/cjs/fetchers/firestore.js.map +1 -1
- package/dist/cjs/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/cjs/fetchers/google-sheets.js +1 -1
- package/dist/cjs/fetchers/google-sheets.js.map +1 -1
- package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
- package/dist/cjs/fetchers/javascript.js +1 -1
- package/dist/cjs/fetchers/javascript.js.map +1 -1
- package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
- package/dist/cjs/fetchers/mariadb.js +1 -1
- package/dist/cjs/fetchers/mariadb.js.map +1 -1
- package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
- package/dist/cjs/fetchers/mongodb.js +1 -1
- package/dist/cjs/fetchers/mongodb.js.map +1 -1
- package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
- package/dist/cjs/fetchers/mysql.js +1 -1
- package/dist/cjs/fetchers/mysql.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +1 -1
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/redis.d.ts.map +1 -1
- package/dist/cjs/fetchers/redis.js +1 -1
- package/dist/cjs/fetchers/redis.js.map +1 -1
- package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
- package/dist/cjs/fetchers/redshift.js +2 -2
- package/dist/cjs/fetchers/redshift.js.map +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +2 -2
- package/dist/cjs/fetchers/rest-api.js.map +1 -1
- package/dist/cjs/fetchers/supabase.d.ts.map +1 -1
- package/dist/cjs/fetchers/supabase.js +1 -1
- package/dist/cjs/fetchers/supabase.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 +1 -1
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/turso.d.ts.map +1 -1
- package/dist/cjs/fetchers/turso.js +1 -1
- package/dist/cjs/fetchers/turso.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 +35 -3
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +116 -18
- 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/airtable.d.ts.map +1 -1
- package/dist/esm/fetchers/airtable.js +2 -2
- package/dist/esm/fetchers/airtable.js.map +1 -1
- package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
- package/dist/esm/fetchers/clickhouse.js +2 -2
- package/dist/esm/fetchers/clickhouse.js.map +1 -1
- package/dist/esm/fetchers/csv-file.d.ts.map +1 -1
- package/dist/esm/fetchers/csv-file.js +4 -4
- package/dist/esm/fetchers/csv-file.js.map +1 -1
- package/dist/esm/fetchers/firestore.d.ts.map +1 -1
- package/dist/esm/fetchers/firestore.js +2 -2
- package/dist/esm/fetchers/firestore.js.map +1 -1
- package/dist/esm/fetchers/google-sheets.d.ts.map +1 -1
- package/dist/esm/fetchers/google-sheets.js +2 -2
- package/dist/esm/fetchers/google-sheets.js.map +1 -1
- package/dist/esm/fetchers/javascript.d.ts.map +1 -1
- package/dist/esm/fetchers/javascript.js +2 -2
- package/dist/esm/fetchers/javascript.js.map +1 -1
- package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
- package/dist/esm/fetchers/mariadb.js +2 -2
- package/dist/esm/fetchers/mariadb.js.map +1 -1
- package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
- package/dist/esm/fetchers/mongodb.js +2 -2
- package/dist/esm/fetchers/mongodb.js.map +1 -1
- package/dist/esm/fetchers/mysql.d.ts.map +1 -1
- package/dist/esm/fetchers/mysql.js +2 -2
- package/dist/esm/fetchers/mysql.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +2 -2
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/redis.d.ts.map +1 -1
- package/dist/esm/fetchers/redis.js +2 -2
- package/dist/esm/fetchers/redis.js.map +1 -1
- package/dist/esm/fetchers/redshift.d.ts.map +1 -1
- package/dist/esm/fetchers/redshift.js +3 -3
- package/dist/esm/fetchers/redshift.js.map +1 -1
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +3 -3
- package/dist/esm/fetchers/rest-api.js.map +1 -1
- package/dist/esm/fetchers/supabase.d.ts.map +1 -1
- package/dist/esm/fetchers/supabase.js +2 -2
- package/dist/esm/fetchers/supabase.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 +2 -2
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/turso.d.ts.map +1 -1
- package/dist/esm/fetchers/turso.js +2 -2
- package/dist/esm/fetchers/turso.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 +35 -3
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js +112 -17
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/data-source-fetchers.ts +3 -3
- package/src/fetchers/airtable.ts +35 -36
- package/src/fetchers/clickhouse.ts +26 -36
- package/src/fetchers/csv-file.ts +13 -21
- package/src/fetchers/firestore.ts +41 -35
- package/src/fetchers/google-sheets.ts +15 -23
- package/src/fetchers/javascript.ts +11 -21
- package/src/fetchers/mariadb.ts +33 -44
- package/src/fetchers/mongodb.ts +66 -81
- package/src/fetchers/mysql.ts +33 -45
- package/src/fetchers/postgresql.ts +52 -62
- package/src/fetchers/redis.ts +6 -5
- package/src/fetchers/redshift.ts +29 -41
- package/src/fetchers/rest-api.ts +11 -21
- package/src/fetchers/supabase.ts +104 -41
- package/src/fetchers/teleport.ts +55 -66
- package/src/fetchers/turso.ts +27 -39
- package/src/index.ts +1 -1
- package/src/pagination-plugin.ts +3 -6
- package/src/transformations/blog-post.ts +28 -9
- 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 +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 = (
|
|
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
|
-
|
|
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
|