@teleporthq/teleport-plugin-next-data-source 0.43.67 → 0.43.68
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__/array-overlap-sql.test.ts +53 -0
- package/__tests__/blog-post-page-enrichment.test.ts +214 -0
- package/__tests__/browser-row-policy.test.ts +503 -0
- package/__tests__/data-source-cache-wrapping.test.ts +18 -2
- package/__tests__/ecommerce-product-options.test.ts +206 -0
- package/__tests__/ecommerce-product-pages.test.ts +82 -0
- package/__tests__/ecommerce-product-pre-options.golden.json +505 -0
- package/__tests__/ecommerce-product-storefront-contract.test.ts +293 -0
- package/__tests__/ecommerce-product-subscriptions.test.ts +121 -0
- package/__tests__/fetchers.test.ts +2 -2
- package/__tests__/items-path.test.ts +150 -0
- package/__tests__/product-options-storefront.fixture.json +2510 -0
- package/__tests__/product-page-url-fixtures.json +25 -0
- package/__tests__/product-price-sort.ts +4 -2
- package/__tests__/raw-rows-mode.test.ts +111 -0
- package/__tests__/rest-api-response-cache.test.ts +434 -0
- package/__tests__/table-read-route-protected-tables.test.ts +493 -0
- package/dist/cjs/array-overlap-sql.d.ts +21 -0
- package/dist/cjs/array-overlap-sql.d.ts.map +1 -0
- package/dist/cjs/array-overlap-sql.js +25 -0
- package/dist/cjs/array-overlap-sql.js.map +1 -0
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +10 -4
- package/dist/cjs/data-source-fetchers.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/postgresql.d.ts +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +8 -4
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/raw-query.d.ts +8 -1
- package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
- package/dist/cjs/fetchers/raw-query.js +42 -4
- package/dist/cjs/fetchers/raw-query.js.map +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +7 -2
- package/dist/cjs/fetchers/rest-api.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +8 -5
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +43 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.js +66 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.d.ts +8 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.js +12 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.js.map +1 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts +40 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.js +64 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.js.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts +23 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.js +34 -0
- package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -0
- package/dist/cjs/items-path.d.ts +10 -0
- package/dist/cjs/items-path.d.ts.map +1 -0
- package/dist/cjs/items-path.js +86 -0
- package/dist/cjs/items-path.js.map +1 -0
- package/dist/cjs/localized-columns.d.ts.map +1 -1
- package/dist/cjs/localized-columns.js +13 -0
- package/dist/cjs/localized-columns.js.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +17 -5
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts +16 -0
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -0
- package/dist/cjs/transformations/blog-adjacent-posts.js +25 -0
- package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -0
- package/dist/cjs/transformations/blog-comments.d.ts +30 -0
- package/dist/cjs/transformations/blog-comments.d.ts.map +1 -0
- package/dist/cjs/transformations/blog-comments.js +34 -0
- package/dist/cjs/transformations/blog-comments.js.map +1 -0
- package/dist/cjs/transformations/blog-post.d.ts +2 -0
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +4 -1
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts +9 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +28 -1
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/heading-anchors.d.ts +25 -0
- package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -0
- package/dist/cjs/transformations/heading-anchors.js +29 -0
- package/dist/cjs/transformations/heading-anchors.js.map +1 -0
- package/dist/cjs/transformations/index.d.ts +47 -4
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +56 -18
- 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.map +1 -1
- package/dist/cjs/utils.js +1 -1
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/array-overlap-sql.d.ts +21 -0
- package/dist/esm/array-overlap-sql.d.ts.map +1 -0
- package/dist/esm/array-overlap-sql.js +21 -0
- package/dist/esm/array-overlap-sql.js.map +1 -0
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +11 -5
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/javascript.d.ts.map +1 -1
- package/dist/esm/fetchers/javascript.js +1 -1
- package/dist/esm/fetchers/javascript.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +8 -4
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/raw-query.d.ts +8 -1
- package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
- package/dist/esm/fetchers/raw-query.js +42 -4
- package/dist/esm/fetchers/raw-query.js.map +1 -1
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +7 -2
- package/dist/esm/fetchers/rest-api.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +8 -5
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts +43 -0
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/browser-row-policy.js +60 -0
- package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -0
- package/dist/esm/fetchers/utils/request-sql-guards.d.ts +8 -0
- package/dist/esm/fetchers/utils/request-sql-guards.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/request-sql-guards.js +8 -0
- package/dist/esm/fetchers/utils/request-sql-guards.js.map +1 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts +40 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.js +59 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.js.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts +23 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.js +29 -0
- package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -0
- package/dist/esm/items-path.d.ts +10 -0
- package/dist/esm/items-path.d.ts.map +1 -0
- package/dist/esm/items-path.js +58 -0
- package/dist/esm/items-path.js.map +1 -0
- package/dist/esm/localized-columns.d.ts.map +1 -1
- package/dist/esm/localized-columns.js +13 -0
- package/dist/esm/localized-columns.js.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +17 -5
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/transformations/blog-adjacent-posts.d.ts +16 -0
- package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -0
- package/dist/esm/transformations/blog-adjacent-posts.js +21 -0
- package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -0
- package/dist/esm/transformations/blog-comments.d.ts +30 -0
- package/dist/esm/transformations/blog-comments.d.ts.map +1 -0
- package/dist/esm/transformations/blog-comments.js +30 -0
- package/dist/esm/transformations/blog-comments.js.map +1 -0
- package/dist/esm/transformations/blog-post.d.ts +2 -0
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +4 -1
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/ecommerce-product.d.ts +9 -1
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +29 -2
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/heading-anchors.d.ts +25 -0
- package/dist/esm/transformations/heading-anchors.d.ts.map +1 -0
- package/dist/esm/transformations/heading-anchors.js +25 -0
- package/dist/esm/transformations/heading-anchors.js.map +1 -0
- package/dist/esm/transformations/index.d.ts +47 -4
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +56 -18
- 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.map +1 -1
- package/dist/esm/utils.js +1 -1
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/array-overlap-sql.ts +41 -0
- package/src/data-source-fetchers.ts +41 -5
- package/src/fetchers/javascript.ts +9 -1
- package/src/fetchers/postgresql.ts +37 -10
- package/src/fetchers/raw-query.ts +60 -5
- package/src/fetchers/rest-api.ts +27 -5
- package/src/fetchers/teleport.ts +37 -13
- package/src/fetchers/utils/browser-row-policy.ts +212 -0
- package/src/fetchers/utils/request-sql-guards.ts +25 -0
- package/src/fetchers/utils/rest-api-response-cache.ts +188 -0
- package/src/fetchers/utils/table-access-guard.ts +39 -0
- package/src/items-path.ts +74 -0
- package/src/localized-columns.ts +13 -0
- package/src/pagination-plugin.ts +29 -7
- package/src/transformations/blog-adjacent-posts.ts +74 -0
- package/src/transformations/blog-comments.ts +132 -0
- package/src/transformations/blog-post.ts +38 -5
- package/src/transformations/ecommerce-product.ts +568 -5
- package/src/transformations/heading-anchors.ts +85 -0
- package/src/transformations/index.ts +165 -22
- package/src/transformations/shared-utils.ts +6 -0
- package/src/utils.ts +26 -4
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many TOP-LEVEL comments a post page carries: the newest ones, shown
|
|
3
|
+
* oldest-first, each with all of its replies. The count in the section's
|
|
4
|
+
* heading still counts every approved comment.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ PAIRED with `BLOG_COMMENTS_RENDER_LIMIT` in teleport-gui
|
|
7
|
+
* `features/blog/constants/blog-comments.ts` (the canvas mirror).
|
|
8
|
+
*/
|
|
9
|
+
export declare const BLOG_COMMENTS_RENDER_LIMIT = 200;
|
|
10
|
+
/**
|
|
11
|
+
* Generates the code that loads and shapes a post's approved comments.
|
|
12
|
+
*
|
|
13
|
+
* Read server-side, with the post: only a post DETAILS fetch (a single record)
|
|
14
|
+
* asks, so a listing never pays for it, and the comments arrive in the page's
|
|
15
|
+
* HTML — rendered by SSR/ISR, carried along a client-side navigation to another
|
|
16
|
+
* post, no request of their own.
|
|
17
|
+
*
|
|
18
|
+
* Public columns only. `teleport_blog_comments` holds the commenters' email
|
|
19
|
+
* addresses and the comments still waiting for approval, which is why it is a
|
|
20
|
+
* customer-record table no browser may read (see teleport-shared's
|
|
21
|
+
* `table-access.ts`); nothing here selects either.
|
|
22
|
+
*
|
|
23
|
+
* Replies are one level deep: a reply whose parent is not among the rows (not
|
|
24
|
+
* approved, deleted) is dropped rather than promoted to the top level.
|
|
25
|
+
*
|
|
26
|
+
* MUST mirror `buildBlogComments` in teleport-gui
|
|
27
|
+
* `packages/renderer/src/utils/blog-comments.ts` (the canvas).
|
|
28
|
+
*/
|
|
29
|
+
export declare const generateBlogCommentsCode: () => string;
|
|
30
|
+
//# sourceMappingURL=blog-comments.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blog-comments.d.ts","sourceRoot":"","sources":["../../../src/transformations/blog-comments.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,MAAM,CAAA;AAE7C;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,wBAAwB,QAAO,MAsG3C,CAAA"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateBlogCommentsCode = exports.BLOG_COMMENTS_RENDER_LIMIT = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* How many TOP-LEVEL comments a post page carries: the newest ones, shown
|
|
6
|
+
* oldest-first, each with all of its replies. The count in the section's
|
|
7
|
+
* heading still counts every approved comment.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ PAIRED with `BLOG_COMMENTS_RENDER_LIMIT` in teleport-gui
|
|
10
|
+
* `features/blog/constants/blog-comments.ts` (the canvas mirror).
|
|
11
|
+
*/
|
|
12
|
+
exports.BLOG_COMMENTS_RENDER_LIMIT = 200;
|
|
13
|
+
/**
|
|
14
|
+
* Generates the code that loads and shapes a post's approved comments.
|
|
15
|
+
*
|
|
16
|
+
* Read server-side, with the post: only a post DETAILS fetch (a single record)
|
|
17
|
+
* asks, so a listing never pays for it, and the comments arrive in the page's
|
|
18
|
+
* HTML — rendered by SSR/ISR, carried along a client-side navigation to another
|
|
19
|
+
* post, no request of their own.
|
|
20
|
+
*
|
|
21
|
+
* Public columns only. `teleport_blog_comments` holds the commenters' email
|
|
22
|
+
* addresses and the comments still waiting for approval, which is why it is a
|
|
23
|
+
* customer-record table no browser may read (see teleport-shared's
|
|
24
|
+
* `table-access.ts`); nothing here selects either.
|
|
25
|
+
*
|
|
26
|
+
* Replies are one level deep: a reply whose parent is not among the rows (not
|
|
27
|
+
* approved, deleted) is dropped rather than promoted to the top level.
|
|
28
|
+
*
|
|
29
|
+
* MUST mirror `buildBlogComments` in teleport-gui
|
|
30
|
+
* `packages/renderer/src/utils/blog-comments.ts` (the canvas).
|
|
31
|
+
*/
|
|
32
|
+
var generateBlogCommentsCode = function () { return "\nvar BLOG_COMMENTS_RENDER_LIMIT = ".concat(exports.BLOG_COMMENTS_RENDER_LIMIT, "\n\nvar BLOG_COMMENTS_SQL =\n 'WITH top AS (' +\n ' SELECT id FROM teleport_blog_comments' +\n \" WHERE post_id = $1 AND parent_id IS NULL AND status = 'approved'\" +\n ' ORDER BY created_at DESC, id DESC LIMIT ' + BLOG_COMMENTS_RENDER_LIMIT +\n ')' +\n ' SELECT c.id, c.parent_id, c.author_name, c.content, c.is_author_reply, c.created_at' +\n ' FROM teleport_blog_comments c' +\n \" WHERE c.post_id = $1 AND c.status = 'approved'\" +\n ' AND (c.id IN (SELECT id FROM top) OR c.parent_id IN (SELECT id FROM top))' +\n ' ORDER BY c.created_at ASC, c.id ASC'\n\n// Every approved comment the page could show \u2014 a reply counts only while the\n// comment it answers is approved too.\nvar BLOG_COMMENTS_COUNT_SQL =\n 'SELECT COUNT(*) AS n FROM teleport_blog_comments c' +\n \" WHERE c.post_id = $1 AND c.status = 'approved'\" +\n ' AND (c.parent_id IS NULL OR EXISTS (' +\n ' SELECT 1 FROM teleport_blog_comments p' +\n \" WHERE p.id = c.parent_id AND p.status = 'approved'))\"\n\nfunction blogAuthorInitials(name) {\n if (typeof name !== 'string') return ''\n var words = name.trim().split(' ').filter(function(word) { return word !== '' })\n if (words.length === 0) return ''\n // Code points, not UTF-16 units: an initial may be an emoji or a letter\n // outside the BMP, and half of a surrogate pair renders as a box.\n var first = Array.from(words[0])[0] || ''\n var last = words.length > 1 ? Array.from(words[words.length - 1])[0] || '' : ''\n return (first + last).toUpperCase()\n}\n\nfunction buildBlogComment(row) {\n var name = typeof row.author_name === 'string' ? row.author_name.trim() : ''\n var authorName = name || 'Reader'\n return {\n id: String(row.id),\n authorName: authorName,\n authorInitials: blogAuthorInitials(authorName),\n content: typeof row.content === 'string' ? row.content : '',\n createdAt: normalizeTimestamp(row.created_at),\n isAuthorReply: coerceBoolean(row.is_author_reply, false),\n }\n}\n\nfunction isTopLevelComment(row) {\n return row.parent_id === null || row.parent_id === undefined || row.parent_id === ''\n}\n\nfunction buildBlogComments(rows) {\n var comments = []\n if (!Array.isArray(rows)) return comments\n var byId = {}\n for (var i = 0; i < rows.length; i++) {\n var row = rows[i]\n if (!row || row.id == null || !isTopLevelComment(row)) continue\n var comment = buildBlogComment(row)\n comment.replies = []\n byId[comment.id] = comment\n comments.push(comment)\n }\n for (var j = 0; j < rows.length; j++) {\n var reply = rows[j]\n if (!reply || reply.id == null || isTopLevelComment(reply)) continue\n var parent = byId[String(reply.parent_id)]\n if (parent) parent.replies.push(buildBlogComment(reply))\n }\n return comments\n}\n\n// { [postId]: { rows, count } } for a single-record (details) fetch; {} for\n// anything else, and on any failure \u2014 a blog whose comments table does not\n// exist yet renders its posts without comments rather than failing the page.\nasync function getBlogCommentsMap(getClientFn, records) {\n var map = {}\n if (!Array.isArray(records) || records.length !== 1) return map\n var postId = records[0] && records[0].id\n if (postId === null || postId === undefined) return map\n\n var client\n try {\n client = getClientFn()\n await client.connect()\n var listed = await client.query(BLOG_COMMENTS_SQL, [String(postId)])\n var counted = await client.query(BLOG_COMMENTS_COUNT_SQL, [String(postId)])\n // Through JSON, like every other row the fetcher hands the transform: the\n // driver returns timestamps as Date objects.\n var rows = listed && Array.isArray(listed.rows) ? JSON.parse(JSON.stringify(listed.rows)) : []\n var count = counted && counted.rows && counted.rows[0] ? Number(counted.rows[0].n) : NaN\n map[String(postId)] = { rows: rows, count: isNaN(count) ? rows.length : count }\n } catch (e) {\n // Leaves the post without comments.\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n"); };
|
|
33
|
+
exports.generateBlogCommentsCode = generateBlogCommentsCode;
|
|
34
|
+
//# sourceMappingURL=blog-comments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blog-comments.js","sourceRoot":"","sources":["../../../src/transformations/blog-comments.ts"],"names":[],"mappings":";;;AAAA;;;;;;;GAOG;AACU,QAAA,0BAA0B,GAAG,GAAG,CAAA;AAE7C;;;;;;;;;;;;;;;;;;GAkBG;AACI,IAAM,wBAAwB,GAAG,cAAc,OAAA,6CACnB,kCAA0B,y/HAqG5D,EAtGqD,CAsGrD,CAAA;AAtGY,QAAA,wBAAwB,4BAsGpC"}
|
|
@@ -3,6 +3,8 @@ import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types';
|
|
|
3
3
|
export interface BlogPostTransformOptions {
|
|
4
4
|
/** Category taxonomy — lives only in the UIDL, there is no DB table for it. */
|
|
5
5
|
categories?: UIDLEcommerceCategory[];
|
|
6
|
+
/** Content headings get ids and `#` links — see `heading-anchors.ts`. */
|
|
7
|
+
headingAnchors?: boolean;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
10
|
* Generates JavaScript code for blog post data transformation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"blog-post.d.ts","sourceRoot":"","sources":["../../../src/transformations/blog-post.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;
|
|
1
|
+
{"version":3,"file":"blog-post.d.ts","sourceRoot":"","sources":["../../../src/transformations/blog-post.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAMvE,kFAAkF;AAClF,MAAM,WAAW,wBAAwB;IACvC,+EAA+E;IAC/E,UAAU,CAAC,EAAE,qBAAqB,EAAE,CAAA;IACpC,yEAAyE;IACzE,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,kCAAkC,aACpC,wBAAwB,KAChC,MAqQF,CAAA"}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.generateBlogPostTransformationCode = void 0;
|
|
4
4
|
var category_taxonomy_1 = require("./category-taxonomy");
|
|
5
|
+
var blog_adjacent_posts_1 = require("./blog-adjacent-posts");
|
|
6
|
+
var blog_comments_1 = require("./blog-comments");
|
|
7
|
+
var heading_anchors_1 = require("./heading-anchors");
|
|
5
8
|
/**
|
|
6
9
|
* Generates JavaScript code for blog post data transformation.
|
|
7
10
|
* Transforms raw snake_case database records into the camelCase shape
|
|
@@ -9,7 +12,7 @@ var category_taxonomy_1 = require("./category-taxonomy");
|
|
|
9
12
|
*/
|
|
10
13
|
var generateBlogPostTransformationCode = function (options) {
|
|
11
14
|
if (options === void 0) { options = {}; }
|
|
12
|
-
return "\n// ============================================================\n// Blog Post Transformation\n// ============================================================\n".concat((0, category_taxonomy_1.generateCategoryTaxonomyCode)('BLOG_CATEGORIES_BY_ID', options.categories), "\n\nfunction buildBlogPost(record, options) {\n if (!record || typeof record !== 'object') return record\n options = options || {}\n var currentLang = options.currentLanguage || null\n var mainLang = options.mainLanguage || null\n var assetMap = options.assetMap || {}\n\n var id = record.id !== undefined && record.id !== null ? record.id : null\n\n // i18n-resolved text fields\n var title = resolveI18nField(record, 'title', 'title', currentLang, mainLang) || ''\n var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''\n var content = resolveI18nField(record, 'content', 'content', currentLang, mainLang) || ''\n var excerpt = resolveI18nField(record, 'excerpt', 'excerpt', currentLang, mainLang) || ''\n var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null\n\n // Assigned category ids -> resolved {id,name,slug} objects for the post-details\n // BREADCRUMBS (and anything else mapping over them). The taxonomy itself is not\n // in the DB (categories are authored in the GUI and live only in\n // blogSettings.categories); resolved against BLOG_CATEGORIES_BY_ID, baked in at\n // export time. Unknown/stale ids are silently dropped. `category` above stays\n // the denormalized PRIMARY name.\n var categories = resolveAssignedCategories(\n record.category_ids,\n BLOG_CATEGORIES_BY_ID,\n currentLang,\n mainLang,\n category\n )\n var metaTitle = resolveI18nField(record, 'meta_title', 'metaTitle', currentLang, mainLang) || null\n var metaDescription = resolveI18nField(record, 'meta_description', 'metaDescription', currentLang, mainLang) || null\n var featuredImageAlt = resolveI18nField(record, 'featured_image_alt', 'featuredImageAlt', currentLang, mainLang) || null\n\n // Status\n var status = record.status || 'draft'\n\n // Asset fields - resolve IDs to URLs\n var rawFeaturedImage = pickFirst(record.featured_image_url, record.featuredImageUrl)\n var TELEPORT_DEFAULT_FEATURED_IMG = 'https://play.teleporthq.io/static/svg/default-img.svg'\n var featuredImageUrl = resolveAssetUrl(rawFeaturedImage, assetMap)\n if (\n !featuredImageUrl ||\n rawFeaturedImage === TELEPORT_DEFAULT_FEATURED_IMG ||\n featuredImageUrl === TELEPORT_DEFAULT_FEATURED_IMG\n ) {\n featuredImageUrl =\n 'data:image/svg+xml;charset=utf-8,' +\n encodeURIComponent(\n '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"240\" viewBox=\"0 0 400 240\"><rect fill=\"#e5e7eb\" width=\"400\" height=\"240\"/><text x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" fill=\"#9ca3af\" font-family=\"system-ui,sans-serif\" font-size=\"13\">No image</text></svg>'\n )\n }\n\n var rawAuthorAvatar = pickFirst(record.author_avatar_url, record.authorAvatarUrl)\n var authorAvatarUrl = resolveAssetUrl(rawAuthorAvatar, assetMap)\n\n // Simple pass-through fields\n var authorName = pickFirst(record.author_name, record.authorName)\n var authorEmail = pickFirst(record.author_email, record.authorEmail)\n var readingTimeMinutes = safeNumber(pickFirst(record.reading_time_minutes, record.readingTimeMinutes), null)\n\n // Tags - i18n-resolved then parsed as JSON array\n var rawTags = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)\n var tags = parseJsonArray(rawTags)\n\n // Gallery images - parse JSON array then resolve each asset URL\n var rawGalleryImages = pickFirst(record.gallery_images, record.galleryImages)\n var parsedGalleryImages = parseJsonArray(rawGalleryImages)\n var galleryImages = resolveAssetUrls(parsedGalleryImages, assetMap)\n\n // Computed: allImages = [featuredImageUrl, ...galleryImages] (skip null featured)\n var allImages = []\n if (featuredImageUrl) allImages.push(featuredImageUrl)\n allImages = allImages.concat(galleryImages)\n\n // Boolean fields\n var isFeatured = coerceBoolean(pickFirst(record.is_featured, record.isFeatured), false)\n var allowComments = record.allow_comments !== undefined\n ? coerceBoolean(record.allow_comments, true)\n : record.allowComments !== undefined\n ? coerceBoolean(record.allowComments, true)\n : true\n\n // Per-post SEO overrides. The transform is the single normalization point \u2014\n // rows can also be written by the raw admin CRUD form, so whitespace and\n // unknown redirect types are neutralized here. Absent columns (a table\n // provisioned before the feature) read as undefined and normalize to null.\n // robotsContent is null \u2014 never '' or false \u2014 for unset rows, so the\n // page-level fallback (the `??` in the generated Head) can take over.\n var noIndex = coerceBoolean(pickFirst(record.no_index, record.noIndex), false)\n var canonicalUrl = normalizeSeoUrlField(pickFirst(record.canonical_url, record.canonicalUrl))\n var redirectUrl = normalizeSeoUrlField(pickFirst(record.redirect_url, record.redirectUrl))\n var rawRedirectType = pickFirst(record.redirect_type, record.redirectType)\n var redirectType =\n redirectUrl && (rawRedirectType === '301' || rawRedirectType === '302')\n ? rawRedirectType\n : null\n var robotsContent = noIndex ? 'noindex' : null\n\n // The author's related-post picks. `relatedPosts` carries the TRANSFORMED\n // rows, not ids, so the details page's related-posts rail can map over it and\n // draw an article card per entry; `relatedPostIds` keeps the raw selection.\n // The rows arrive from ONE batched query (getRelatedPostsMap) that\n // transformRecords runs, keyed by id in options.relatedPostsById.\n //\n // MUST mirror buildRelatedBlogPosts in packages/renderer/src/utils/blog-posts.ts\n // (the canvas-preview SSOT): the author's order, UNPUBLISHED posts dropped,\n // self dropped, and the nested build deliberately WITHOUT the map \u2014 two posts\n // pointing at each other is ordinary and would otherwise recurse forever.\n var relatedPostIds = parseRelatedIds(record.related_post_ids)\n var relatedPostsById = options.relatedPostsById\n var relatedPosts = []\n if (relatedPostsById && relatedPostIds.length > 0) {\n var relatedNestedOptions = {\n assetMap: assetMap,\n currentLanguage: currentLang,\n mainLanguage: mainLang,\n }\n for (var rp = 0; rp < relatedPostIds.length; rp++) {\n var relatedId = relatedPostIds[rp]\n if (id != null && relatedId === String(id)) continue\n var relatedRecord = relatedPostsById[relatedId]\n if (!relatedRecord) continue\n // A draft must not reach the storefront through another post's rail.\n // Missing status reads as 'draft', the same default as above.\n if ((relatedRecord.status || 'draft') !== 'published') continue\n relatedPosts.push(buildBlogPost(relatedRecord, relatedNestedOptions))\n }\n }\n\n // Timestamps\n var rawPublishedAt = pickFirst(record.published_at, record.publishedAt)\n var publishedAt = rawPublishedAt != null ? normalizeTimestamp(rawPublishedAt) : null\n\n var rawCreatedAt = pickFirst(record.created_at, record.createdAt, record.created)\n var createdAt = normalizeTimestamp(rawCreatedAt)\n\n var rawUpdatedAt = pickFirst(record.updated_at, record.updatedAt, record.updated)\n var updatedAt = rawUpdatedAt != null ? normalizeTimestamp(rawUpdatedAt) : createdAt\n\n // Computed: created = Unix seconds from createdAt milliseconds\n var created = Math.floor(createdAt / 1000)\n\n return {\n id: id,\n title: title,\n slug: slug,\n content: content,\n excerpt: excerpt,\n status: status,\n category: category,\n categories: categories,\n tags: tags,\n relatedPostIds: relatedPostIds,\n relatedPosts: relatedPosts,\n featuredImageUrl: featuredImageUrl,\n featuredImageAlt: featuredImageAlt,\n galleryImages: galleryImages,\n allImages: allImages,\n authorName: authorName,\n authorEmail: authorEmail,\n author_name: authorName,\n author_email: authorEmail,\n authorAvatarUrl: authorAvatarUrl,\n metaTitle: metaTitle,\n metaDescription: metaDescription,\n readingTimeMinutes: readingTimeMinutes,\n isFeatured: isFeatured,\n allowComments: allowComments,\n noIndex: noIndex,\n canonicalUrl: canonicalUrl,\n redirectUrl: redirectUrl,\n redirectType: redirectType,\n robotsContent: robotsContent,\n publishedAt: publishedAt,\n createdAt: createdAt,\n updatedAt: updatedAt,\n created: created,\n }\n}\n\nfunction transformBlogPosts(records, options) {\n if (!Array.isArray(records)) return []\n return records.map(function(record) { return buildBlogPost(record, options) })\n}\n\n// Batched fetch of the post rows a set of posts reference as \"related\", keyed by\n// id. Best-effort in the same way as the product side: a table provisioned\n// before the column yields no ids (the read is of an absent property, not of a\n// column \u2014 the SQL never names it), the map stays empty, and the rail stays\n// hidden behind its is_not_empty gate.\nasync function getRelatedPostsMap(getClientFn, records) {\n var map = {}\n if (!Array.isArray(records) || records.length === 0) return map\n\n var wantedIds = []\n for (var i = 0; i < records.length; i++) {\n var ids = parseRelatedIds(records[i] && records[i].related_post_ids)\n for (var j = 0; j < ids.length; j++) {\n if (wantedIds.indexOf(ids[j]) === -1) wantedIds.push(ids[j])\n }\n }\n if (wantedIds.length === 0) return map\n\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query('SELECT * FROM teleport_blog_posts WHERE id = ANY($1)', [\n wantedIds,\n ])\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.id != null) map[String(row.id)] = row\n }\n }\n } catch (e) {\n // Leaves relatedPosts empty rather than failing the page.\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n");
|
|
15
|
+
return "\n// ============================================================\n// Blog Post Transformation\n// ============================================================\n".concat((0, category_taxonomy_1.generateCategoryTaxonomyCode)('BLOG_CATEGORIES_BY_ID', options.categories), "\n").concat((0, blog_comments_1.generateBlogCommentsCode)(), "\n").concat((0, blog_adjacent_posts_1.generateAdjacentPostsCode)(), "\n").concat((0, heading_anchors_1.generateHeadingAnchorsCode)(), "\nvar BLOG_HEADING_ANCHORS = ").concat(options.headingAnchors === true, "\n\nfunction buildBlogPost(record, options) {\n if (!record || typeof record !== 'object') return record\n options = options || {}\n var currentLang = options.currentLanguage || null\n var mainLang = options.mainLanguage || null\n var assetMap = options.assetMap || {}\n\n var id = record.id !== undefined && record.id !== null ? record.id : null\n\n // i18n-resolved text fields\n var title = resolveI18nField(record, 'title', 'title', currentLang, mainLang) || ''\n var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''\n var content = resolveI18nField(record, 'content', 'content', currentLang, mainLang) || ''\n if (BLOG_HEADING_ANCHORS) content = addHeadingAnchors(content)\n var excerpt = resolveI18nField(record, 'excerpt', 'excerpt', currentLang, mainLang) || ''\n var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null\n\n // Assigned category ids -> resolved {id,name,slug} objects for the post-details\n // BREADCRUMBS (and anything else mapping over them). The taxonomy itself is not\n // in the DB (categories are authored in the GUI and live only in\n // blogSettings.categories); resolved against BLOG_CATEGORIES_BY_ID, baked in at\n // export time. Unknown/stale ids are silently dropped. `category` above stays\n // the denormalized PRIMARY name.\n var categories = resolveAssignedCategories(\n record.category_ids,\n BLOG_CATEGORIES_BY_ID,\n currentLang,\n mainLang,\n category\n )\n var metaTitle = resolveI18nField(record, 'meta_title', 'metaTitle', currentLang, mainLang) || null\n var metaDescription = resolveI18nField(record, 'meta_description', 'metaDescription', currentLang, mainLang) || null\n var featuredImageAlt = resolveI18nField(record, 'featured_image_alt', 'featuredImageAlt', currentLang, mainLang) || null\n var authorBio = resolveI18nField(record, 'author_bio', 'authorBio', currentLang, mainLang) || null\n\n // Status\n var status = record.status || 'draft'\n\n // Asset fields - resolve IDs to URLs\n var rawFeaturedImage = pickFirst(record.featured_image_url, record.featuredImageUrl)\n var TELEPORT_DEFAULT_FEATURED_IMG = 'https://play.teleporthq.io/static/svg/default-img.svg'\n var featuredImageUrl = resolveAssetUrl(rawFeaturedImage, assetMap)\n if (\n !featuredImageUrl ||\n rawFeaturedImage === TELEPORT_DEFAULT_FEATURED_IMG ||\n featuredImageUrl === TELEPORT_DEFAULT_FEATURED_IMG\n ) {\n featuredImageUrl =\n 'data:image/svg+xml;charset=utf-8,' +\n encodeURIComponent(\n '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"240\" viewBox=\"0 0 400 240\"><rect fill=\"#e5e7eb\" width=\"400\" height=\"240\"/><text x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" fill=\"#9ca3af\" font-family=\"system-ui,sans-serif\" font-size=\"13\">No image</text></svg>'\n )\n }\n\n var rawAuthorAvatar = pickFirst(record.author_avatar_url, record.authorAvatarUrl)\n var authorAvatarUrl = resolveAssetUrl(rawAuthorAvatar, assetMap)\n\n // Simple pass-through fields\n var authorName = pickFirst(record.author_name, record.authorName)\n var authorInitials = blogAuthorInitials(authorName)\n var authorEmail = pickFirst(record.author_email, record.authorEmail)\n var readingTimeMinutes = safeNumber(pickFirst(record.reading_time_minutes, record.readingTimeMinutes), null)\n\n // Tags - i18n-resolved then parsed as JSON array\n var rawTags = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)\n var tags = parseJsonArray(rawTags)\n\n // Gallery images - parse JSON array then resolve each asset URL\n var rawGalleryImages = pickFirst(record.gallery_images, record.galleryImages)\n var parsedGalleryImages = parseJsonArray(rawGalleryImages)\n var galleryImages = resolveAssetUrls(parsedGalleryImages, assetMap)\n\n // Computed: allImages = [featuredImageUrl, ...galleryImages] (skip null featured)\n var allImages = []\n if (featuredImageUrl) allImages.push(featuredImageUrl)\n allImages = allImages.concat(galleryImages)\n\n // Boolean fields\n var isFeatured = coerceBoolean(pickFirst(record.is_featured, record.isFeatured), false)\n var allowComments = record.allow_comments !== undefined\n ? coerceBoolean(record.allow_comments, true)\n : record.allowComments !== undefined\n ? coerceBoolean(record.allowComments, true)\n : true\n\n // Per-post SEO overrides. The transform is the single normalization point \u2014\n // rows can also be written by the raw admin CRUD form, so whitespace and\n // unknown redirect types are neutralized here. Absent columns (a table\n // provisioned before the feature) read as undefined and normalize to null.\n // robotsContent is null \u2014 never '' or false \u2014 for unset rows, so the\n // page-level fallback (the `??` in the generated Head) can take over.\n var noIndex = coerceBoolean(pickFirst(record.no_index, record.noIndex), false)\n var canonicalUrl = normalizeSeoUrlField(pickFirst(record.canonical_url, record.canonicalUrl))\n var redirectUrl = normalizeSeoUrlField(pickFirst(record.redirect_url, record.redirectUrl))\n var rawRedirectType = pickFirst(record.redirect_type, record.redirectType)\n var redirectType =\n redirectUrl && (rawRedirectType === '301' || rawRedirectType === '302')\n ? rawRedirectType\n : null\n var robotsContent = noIndex ? 'noindex' : null\n\n // The author's related-post picks. `relatedPosts` carries the TRANSFORMED\n // rows, not ids, so the details page's related-posts rail can map over it and\n // draw an article card per entry; `relatedPostIds` keeps the raw selection.\n // The rows arrive from ONE batched query (getRelatedPostsMap) that\n // transformRecords runs, keyed by id in options.relatedPostsById.\n //\n // MUST mirror buildRelatedBlogPosts in packages/renderer/src/utils/blog-posts.ts\n // (the canvas-preview SSOT): the author's order, UNPUBLISHED posts dropped,\n // self dropped, and the nested build deliberately WITHOUT the map \u2014 two posts\n // pointing at each other is ordinary and would otherwise recurse forever.\n var relatedPostIds = parseRelatedIds(record.related_post_ids)\n var relatedPostsById = options.relatedPostsById\n var relatedPosts = []\n // The nested builds of the related and adjacent posts get NO maps: each is a\n // card, not a page, and two posts that point at each other would recurse.\n var relatedNestedOptions = {\n assetMap: assetMap,\n currentLanguage: currentLang,\n mainLanguage: mainLang,\n }\n if (relatedPostsById && relatedPostIds.length > 0) {\n for (var rp = 0; rp < relatedPostIds.length; rp++) {\n var relatedId = relatedPostIds[rp]\n if (id != null && relatedId === String(id)) continue\n var relatedRecord = relatedPostsById[relatedId]\n if (!relatedRecord) continue\n // A draft must not reach the storefront through another post's rail.\n // Missing status reads as 'draft', the same default as above.\n if ((relatedRecord.status || 'draft') !== 'published') continue\n relatedPosts.push(buildBlogPost(relatedRecord, relatedNestedOptions))\n }\n }\n\n // The previous (older) and next (newer) published post, as 0-or-1-item\n // lists: the post navigation maps over them like the related rail does, so an\n // end of the blog simply renders no card on that side.\n var adjacent = options.adjacentPostsById && id != null ? options.adjacentPostsById[String(id)] : null\n var previousPosts = adjacent && adjacent.previous ? [buildBlogPost(adjacent.previous, relatedNestedOptions)] : []\n var nextPosts = adjacent && adjacent.next ? [buildBlogPost(adjacent.next, relatedNestedOptions)] : []\n\n // The approved comments \u2014 the newest top-level ones, each with its replies \u2014\n // present only on a details fetch; see blog-comments.ts.\n var commentsEntry = options.commentsByPostId && id != null ? options.commentsByPostId[String(id)] : null\n var comments = commentsEntry ? buildBlogComments(commentsEntry.rows) : []\n var commentsCount = commentsEntry ? commentsEntry.count : 0\n\n // Timestamps\n var rawPublishedAt = pickFirst(record.published_at, record.publishedAt)\n var publishedAt = rawPublishedAt != null ? normalizeTimestamp(rawPublishedAt) : null\n\n var rawCreatedAt = pickFirst(record.created_at, record.createdAt, record.created)\n var createdAt = normalizeTimestamp(rawCreatedAt)\n\n var rawUpdatedAt = pickFirst(record.updated_at, record.updatedAt, record.updated)\n var updatedAt = rawUpdatedAt != null ? normalizeTimestamp(rawUpdatedAt) : createdAt\n\n // Computed: created = Unix seconds from createdAt milliseconds\n var created = Math.floor(createdAt / 1000)\n\n return {\n id: id,\n title: title,\n slug: slug,\n content: content,\n excerpt: excerpt,\n status: status,\n category: category,\n categories: categories,\n tags: tags,\n relatedPostIds: relatedPostIds,\n relatedPosts: relatedPosts,\n featuredImageUrl: featuredImageUrl,\n featuredImageAlt: featuredImageAlt,\n galleryImages: galleryImages,\n allImages: allImages,\n authorName: authorName,\n authorEmail: authorEmail,\n author_name: authorName,\n author_email: authorEmail,\n authorAvatarUrl: authorAvatarUrl,\n authorBio: authorBio,\n authorInitials: authorInitials,\n metaTitle: metaTitle,\n metaDescription: metaDescription,\n readingTimeMinutes: readingTimeMinutes,\n isFeatured: isFeatured,\n allowComments: allowComments,\n comments: comments,\n commentsCount: commentsCount,\n previousPosts: previousPosts,\n nextPosts: nextPosts,\n noIndex: noIndex,\n canonicalUrl: canonicalUrl,\n redirectUrl: redirectUrl,\n redirectType: redirectType,\n robotsContent: robotsContent,\n publishedAt: publishedAt,\n createdAt: createdAt,\n updatedAt: updatedAt,\n created: created,\n }\n}\n\nfunction transformBlogPosts(records, options) {\n if (!Array.isArray(records)) return []\n return records.map(function(record) { return buildBlogPost(record, options) })\n}\n\n// Batched fetch of the post rows a set of posts reference as \"related\", keyed by\n// id. Best-effort in the same way as the product side: a table provisioned\n// before the column yields no ids (the read is of an absent property, not of a\n// column \u2014 the SQL never names it), the map stays empty, and the rail stays\n// hidden behind its is_not_empty gate.\nasync function getRelatedPostsMap(getClientFn, records) {\n var map = {}\n if (!Array.isArray(records) || records.length === 0) return map\n\n var wantedIds = []\n for (var i = 0; i < records.length; i++) {\n var ids = parseRelatedIds(records[i] && records[i].related_post_ids)\n for (var j = 0; j < ids.length; j++) {\n if (wantedIds.indexOf(ids[j]) === -1) wantedIds.push(ids[j])\n }\n }\n if (wantedIds.length === 0) return map\n\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query('SELECT * FROM teleport_blog_posts WHERE id = ANY($1)', [\n wantedIds,\n ])\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.id != null) map[String(row.id)] = row\n }\n }\n } catch (e) {\n // Leaves relatedPosts empty rather than failing the page.\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n");
|
|
13
16
|
};
|
|
14
17
|
exports.generateBlogPostTransformationCode = generateBlogPostTransformationCode;
|
|
15
18
|
//# sourceMappingURL=blog-post.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"blog-post.js","sourceRoot":"","sources":["../../../src/transformations/blog-post.ts"],"names":[],"mappings":";;;AACA,yDAAkE;
|
|
1
|
+
{"version":3,"file":"blog-post.js","sourceRoot":"","sources":["../../../src/transformations/blog-post.ts"],"names":[],"mappings":";;;AACA,yDAAkE;AAClE,6DAAiE;AACjE,iDAA0D;AAC1D,qDAA8D;AAU9D;;;;GAIG;AACI,IAAM,kCAAkC,GAAG,UAChD,OAAsC;IAAtC,wBAAA,EAAA,YAAsC;IAEtC,OAAO,2KAIP,IAAA,gDAA4B,EAAC,uBAAuB,EAAE,OAAO,CAAC,UAAU,CAAC,eACzE,IAAA,wCAAwB,GAAE,eAC1B,IAAA,+CAAyB,GAAE,eAC3B,IAAA,4CAA0B,GAAE,0CACD,OAAO,CAAC,cAAc,KAAK,IAAI,8xWA2P3D,CAAA;AACD,CAAC,CAAA;AAvQY,QAAA,kCAAkC,sCAuQ9C"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types';
|
|
1
|
+
import type { UIDLEcommerceCategory, UIDLEcommerceProductPages } from '@teleporthq/teleport-types';
|
|
2
2
|
/**
|
|
3
3
|
* Everything the product transform needs baked in at export time, beyond the
|
|
4
4
|
* row itself. An options OBJECT rather than positional parameters: the list has
|
|
@@ -24,6 +24,14 @@ export interface EcommerceProductTransformOptions {
|
|
|
24
24
|
* `apps/gui/app/project-page/features/e-commerce/utils/product-variants.ts`.
|
|
25
25
|
*/
|
|
26
26
|
allowBackorders?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Where the standard product page and every custom product page live
|
|
29
|
+
* (`ecommerceSettings.productPages`). Present only when the store has a
|
|
30
|
+
* custom product page; then every product carries `productPageKey` (the page
|
|
31
|
+
* it opens on, '' = the standard one) and `productPageUrl` (its canonical
|
|
32
|
+
* locale-less address). Absent, the transform is exactly what it was.
|
|
33
|
+
*/
|
|
34
|
+
productPages?: UIDLEcommerceProductPages;
|
|
27
35
|
}
|
|
28
36
|
/**
|
|
29
37
|
* Generates JavaScript code for e-commerce product data transformation.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ecommerce-product.d.ts","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;
|
|
1
|
+
{"version":3,"file":"ecommerce-product.d.ts","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAA;AASlG;;;;GAIG;AACH,MAAM,WAAW,gCAAgC;IAC/C,+EAA+E;IAC/E,UAAU,CAAC,EAAE,qBAAqB,EAAE,CAAA;IACpC;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;;;;;;;OAUG;IACH,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,yBAAyB,CAAA;CACzC;AAuDD;;;;GAIG;AACH,eAAO,MAAM,0CAA0C,aAC5C,gCAAgC,KACxC,MAk8CF,CAAA"}
|
|
@@ -3,6 +3,29 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.generateEcommerceProductTransformationCode = void 0;
|
|
4
4
|
var teleport_shared_1 = require("@teleporthq/teleport-shared");
|
|
5
5
|
var category_taxonomy_1 = require("./category-taxonomy");
|
|
6
|
+
/**
|
|
7
|
+
* The product-page helpers baked into the transform. TWIN of the GUI's
|
|
8
|
+
* `features/e-commerce/utils/product-pages/product-pages-config.ts`
|
|
9
|
+
* (`normalizeProductPageKey` + `buildProductPagePath`) — both pinned by the
|
|
10
|
+
* shared `product-page-url-fixtures.json`. Only routes and attributes are baked:
|
|
11
|
+
* the page names never reach the site.
|
|
12
|
+
*/
|
|
13
|
+
var generateProductPagesCode = function (productPages) {
|
|
14
|
+
var standard = productPages.standard;
|
|
15
|
+
var baked = {
|
|
16
|
+
standard: standard && typeof standard.route === 'string' && typeof standard.attribute === 'string'
|
|
17
|
+
? { route: standard.route, attribute: standard.attribute }
|
|
18
|
+
: null,
|
|
19
|
+
pages: Object.keys(productPages.pages || {}).reduce(function (acc, key) {
|
|
20
|
+
var page = productPages.pages[key];
|
|
21
|
+
if (page && typeof page.route === 'string' && typeof page.attribute === 'string') {
|
|
22
|
+
acc[key] = { route: page.route, attribute: page.attribute };
|
|
23
|
+
}
|
|
24
|
+
return acc;
|
|
25
|
+
}, {}),
|
|
26
|
+
};
|
|
27
|
+
return "\n// Where every product page lives, baked from the store's custom product pages.\n// A product opens on the custom page its product_page column names while that\n// page exists, on the standard product page otherwise. Regenerating the project\n// refreshes it \u2014 do not hand-edit.\nvar PRODUCT_PAGES = ".concat(JSON.stringify(baked), "\n\nfunction resolveProductPageKey(raw) {\n var key = typeof raw === 'string' ? raw.trim() : ''\n return key !== '' && Object.prototype.hasOwnProperty.call(PRODUCT_PAGES.pages, key) ? key : ''\n}\n\n// A product's canonical address: its page's static base, then the value of the\n// column the page's address ends with, encoded. '' when either is missing.\n// values holds the slug in the page's language and the id; any other column\n// the address ends with (a SKU) is read from the row.\nfunction buildProductPageUrl(key, values, record) {\n var page = key !== '' ? PRODUCT_PAGES.pages[key] : PRODUCT_PAGES.standard\n if (!page || !page.route) return ''\n var value = values[page.attribute]\n if ((value === null || value === undefined) && record) value = record[page.attribute]\n if (value === null || value === undefined || String(value) === '') return ''\n var base = page.route === '/' ? '' : page.route\n return base + '/' + encodeURIComponent(String(value))\n}\n");
|
|
28
|
+
};
|
|
6
29
|
/**
|
|
7
30
|
* Generates JavaScript code for e-commerce product data transformation.
|
|
8
31
|
* Transforms raw snake_case database records into the camelCase shape
|
|
@@ -13,7 +36,11 @@ var generateEcommerceProductTransformationCode = function (options) {
|
|
|
13
36
|
if (options === void 0) { options = {}; }
|
|
14
37
|
var taxHelperCode = teleport_shared_1.StorefrontTax.generateStorefrontTaxHelperCode((_a = options.storefrontTaxRate) !== null && _a !== void 0 ? _a : 0);
|
|
15
38
|
var allowBackordersLiteral = options.allowBackorders === true ? 'true' : 'false';
|
|
16
|
-
return "\n".concat(taxHelperCode, "\n").concat(teleport_shared_1.ProductDiscounts.generateProductDiscountHelperCode(), "\n").concat(teleport_shared_1.ProductRatings.generateProductRatingHelperCode(), "\n\n// Baked from the merchant's stock settings: TRUE when stock never blocks a\n// purchase (stock management off OR backorders allowed). Regenerating the\n// project refreshes it \u2014 do not hand-edit. See EcommerceProductTransformOptions.\nvar ALLOW_BACKORDERS = ").concat(allowBackordersLiteral, "\n\n").concat((0, category_taxonomy_1.generateCategoryTaxonomyCode)('PRODUCT_CATEGORIES_BY_ID', options.categories), "\n// The merchant's \"Show 3D model by default\" toggle, as every backend may hand\n// it back: a real boolean, 1/0, 'true'/'false'/'t'/'f' strings \u2014 and NULL /\n// absent / '' as ON, because a product that has a model shows it unless the\n// merchant switched it off. MUST mirror normalizeShowModelFlag in the GUI's\n// features/e-commerce/constants/product-model.ts.\nfunction normalizeShowModelFlag(raw) {\n if (raw === null || raw === undefined || raw === '') return true\n if (typeof raw === 'boolean') return raw\n if (typeof raw === 'number') return raw !== 0\n if (typeof raw === 'string') {\n var token = raw.trim().toLowerCase()\n return !(token === 'false' || token === 'f' || token === '0' || token === 'no' || token === 'off')\n }\n return true\n}\n\n// The product's media gallery in display order, HERO FIRST: the model when it\n// is shown by default, otherwise the images with the model last. Each entry is\n// { kind: 'image' | 'model', src, poster, thumbnail, alt }; 'thumbnail' is what\n// a strip draws and is never empty for an entry that can appear in one. MUST\n// mirror buildProductGalleryMedia in the GUI's\n// features/e-commerce/utils/product-gallery-media.ts.\nfunction buildProductGalleryMedia(params) {\n var imageItems = []\n for (var gi = 0; gi < params.images.length; gi++) {\n imageItems.push({\n kind: 'image',\n src: params.images[gi],\n poster: '',\n thumbnail: params.images[gi],\n alt: params.imageAlt || params.name,\n })\n }\n if (!params.modelUrl) return imageItems\n var modelItem = {\n kind: 'model',\n src: params.modelUrl,\n poster: params.modelPoster || '',\n thumbnail: params.modelPoster || params.mainImage || params.images[0] || '',\n alt: params.name,\n }\n return params.showModel === 'true' ? [modelItem].concat(imageItems) : imageItems.concat([modelItem])\n}\n\n// ============================================================\n// E-Commerce Product Transformation\n// ============================================================\n\nfunction buildEcommerceProduct(record, options) {\n if (!record || typeof record !== 'object') return record\n options = options || {}\n var currentLang = options.currentLanguage || null\n var mainLang = options.mainLanguage || null\n var assetMap = options.assetMap || {}\n\n var id = record.id !== undefined && record.id !== null ? record.id : null\n\n // i18n-resolved text fields\n var name = resolveI18nField(record, 'name', 'name', currentLang, mainLang) || ''\n var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''\n var description = resolveI18nField(record, 'description', 'description', currentLang, mainLang) || ''\n var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null\n var tagsRaw = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)\n var imageAlt = resolveI18nField(record, 'image_alt', 'imageAlt', currentLang, mainLang) || null\n\n // Computed: plain-text description (HTML stripped)\n var descriptionText = stripHtmlTags(description)\n\n // Numeric fields (with NaN protection)\n var price = safeNumber(record.price, 0)\n var quantity = safeNumber(record.quantity, null)\n\n // Currency\n var currency = (record.currency || 'USD').toUpperCase()\n var rawCurrencySymbol = pickFirst(record.currency_symbol, record.currencySymbol)\n var currencySymbol = rawCurrencySymbol || getCurrencySymbol(currency)\n\n // Status and active (normalize case \u2014 DB / forms may send \"Active\", \"Inactive\", etc.)\n var statusRaw = record.status\n if (statusRaw == null || statusRaw === '') {\n statusRaw = record.active ? 'active' : 'inactive'\n }\n var status =\n typeof statusRaw === 'string'\n ? statusRaw.trim().toLowerCase()\n : String(statusRaw).toLowerCase()\n var active = status === 'active'\n\n // Payment fields\n var paymentType = pickFirst(record.payment_type, record.paymentType) || 'one_time'\n var recurringInterval = pickFirst(record.recurring_interval, record.recurringInterval) || null\n var rawRecurringCount = pickFirst(record.recurring_interval_count, record.recurringIntervalCount)\n var recurringIntervalCount = safeNumber(rawRecurringCount, null)\n\n // Physical product fields\n var sku = record.sku || null\n var weight = safeNumber(record.weight, null)\n var weightUnit = pickFirst(record.weight_unit, record.weightUnit) || null\n var dimensions = record.dimensions || null\n\n // Tags - parse as JSON array (handles JSON strings, arrays, comma-separated)\n var tags = parseJsonArray(tagsRaw)\n\n // Variant AXES (translatable). The i18n-resolved column already carries the\n // display labels for the current language (with the SAME stable keys as the\n // base column); we just parse + normalize. The purchasable combinations live\n // in teleport_product_variants and are fetched by the variant picker widget.\n var variantOptionsRaw = resolveI18nField(record, 'variant_options', 'variantOptions', currentLang, mainLang)\n var variantOptions = normalizeVariantOptions(variantOptionsRaw)\n // Emitted as a STRING ('true'/'false') to match the strict-equality checks the\n // storefront uses (same convention as outOfStock).\n var requiresVariantSelection = variantOptions.length > 0 ? 'true' : 'false'\n\n // Provider fields\n var providerType = pickFirst(record.provider_type, record.providerType) || null\n var providerProductId = pickFirst(record.provider_product_id, record.providerProductId) || null\n var defaultPrice = pickFirst(record.default_price, record.defaultPrice) || null\n\n // Metadata\n var metadata = parseJsonObject(record.metadata)\n\n // Visible custom properties -> [{key,value,kind,color,imageUrl}] for the\n // storefront pills mapper. MUST mirror buildVisibleProductProperties /\n // classifyProductProperty in\n // apps/gui/app/project-page/features/e-commerce/utils/product-property-display.ts\n // (the canvas-preview SSOT) field-for-field: same reserved-key exclusions,\n // same default-VISIBLE (opt-out) rule, same color/image classification. A\n // property is shown unless it is a reserved key, has an empty value, or is\n // explicitly hidden (false) in the _tqPropVisibility map -- so it also\n // renders for products whose form was never opened (no visibility map yet).\n var propVisibility = parseJsonObject(metadata._tqPropVisibility)\n var visibleProperties = []\n Object.keys(metadata).forEach(function (propKey) {\n if (isReservedProductMetadataKey(propKey)) return\n if (propVisibility[propKey] === false) return\n var rawValue = metadata[propKey]\n if (rawValue == null) return\n var propValue = String(rawValue)\n if (propValue.trim() === '') return\n visibleProperties.push(classifyProductProperty(propKey, propValue))\n })\n\n // Assigned category ids -> resolved {id,name,slug} objects for the storefront\n // category pills mapper and the product-details breadcrumbs. The taxonomy\n // itself is not in the DB (categories are authored in the GUI and live only in\n // ecommerceSettings.categories); resolved against PRODUCT_CATEGORIES_BY_ID,\n // baked in at export time. Unknown/stale ids are silently dropped.\n var categories = resolveAssignedCategories(\n record.category_ids,\n PRODUCT_CATEGORIES_BY_ID,\n currentLang,\n mainLang\n )\n\n // Purchasable variant COMBINATIONS for this product ({id, options map, price,\n // stock, image}). Empty for flat products. Fetched in ONE batched query by\n // transformRecords and passed in via options.variantsByProductId, keyed by\n // product id. The storefront card/details read these to default-select the\n // first in-stock variant, disable dead options, and resolve the chosen id.\n //\n // NULL (not {}) means the lookup could not run \u2014 the table is missing, or the\n // query failed. That is \"combinations UNKNOWN\", which is NOT \"this product has\n // none\": a transient DB failure must never turn every variant product in the\n // catalogue into an unbuyable one. See hasPurchasableVariant below.\n var variantsResolved = options.variantsByProductId != null\n var variantsByProductId = options.variantsByProductId || {}\n var rawVariants = variantsByProductId[id] || []\n var variants = []\n for (var vi = 0; vi < rawVariants.length; vi++) {\n var vrow = rawVariants[vi] || {}\n var vopts = parseJsonObject(vrow.options)\n variants.push({\n id: vrow.id != null ? String(vrow.id) : '',\n options: vopts && typeof vopts === 'object' ? vopts : {},\n price: safeNumber(vrow.price, null),\n quantity: safeNumber(vrow.quantity, null),\n image_url: vrow.image_url ? resolveAssetUrl(vrow.image_url, assetMap) : null,\n })\n }\n\n // The merchant's related-product picks. `relatedProducts` carries the\n // TRANSFORMED rows, not ids, so the details page's related-products rail can\n // map over it and draw a product card per entry; `relatedProductIds` keeps\n // the raw selection for anything that wants it. The rows arrive from ONE\n // batched query (getRelatedProductsMap) that transformRecords runs, keyed by\n // id in options.relatedProductsById.\n //\n // MUST mirror buildRelatedEcommerceProducts in\n // packages/renderer/src/utils/ecommerce-products.ts (the canvas-preview SSOT):\n // the merchant's order, inactive products dropped, self dropped, and the\n // nested build deliberately WITHOUT the map \u2014 two products referencing each\n // other is the normal case (they are each other's accessories) and threading\n // the map through would recurse until the stack ran out.\n var relatedProductIds = parseRelatedIds(record.related_product_ids)\n var relatedProductsById = options.relatedProductsById\n var relatedProducts = []\n if (relatedProductsById && relatedProductIds.length > 0) {\n var relatedNestedOptions = {\n assetMap: assetMap,\n currentLanguage: currentLang,\n mainLanguage: mainLang,\n // Passed through UNCOALESCED so a related product inherits the same\n // known/unknown answer as its parent \u2014 `|| {}` here would tell the nested\n // build \"the lookup ran and found nothing\" and strike out every related\n // card's picker. The batched query includes the related ids for exactly\n // this reason (see getTransformWrapperCode).\n variantsByProductId: options.variantsByProductId,\n // Same reason: a related card draws its own star row, and the batched\n // aggregate already covered its id.\n ratingsByProductId: options.ratingsByProductId,\n }\n for (var rp = 0; rp < relatedProductIds.length; rp++) {\n var relatedId = relatedProductIds[rp]\n if (id != null && relatedId === String(id)) continue\n var relatedRecord = relatedProductsById[relatedId]\n if (!relatedRecord) continue\n // A product taken off sale must not come back through the back door on\n // every product that references it. Missing status reads as active, the\n // same default the main status normalization uses above.\n var relatedStatusRaw = relatedRecord.status\n var relatedStatus =\n relatedStatusRaw == null || relatedStatusRaw === ''\n ? 'active'\n : String(relatedStatusRaw).trim().toLowerCase()\n if (relatedStatus !== 'active') continue\n relatedProducts.push(buildEcommerceProduct(relatedRecord, relatedNestedOptions))\n }\n }\n\n // -------------------------------------------------------\n // Image resolution (complex multi-step process)\n // -------------------------------------------------------\n\n // Step 1: Get raw image values\n var rawImageUrl = pickFirst(record.image_url, record.mainImage, record.imageUrl)\n var rawGalleryImages = pickFirst(record.gallery_images, record.galleryImages)\n var legacyImages = Array.isArray(record.images)\n ? record.images.filter(function(v) { return typeof v === 'string' })\n : []\n\n // Step 2: Parse gallery images from JSON\n var parsedGalleryImages = parseJsonArray(rawGalleryImages)\n\n // Step 3: Build combined raw images array\n var allRawImages = []\n if (rawImageUrl) allRawImages.push(rawImageUrl)\n allRawImages = allRawImages.concat(parsedGalleryImages).concat(legacyImages)\n\n // Step 4: Resolve all asset URLs first, then deduplicate\n var resolvedAll = resolveAssetUrls(allRawImages, assetMap)\n var resolvedImages = deduplicateStrings(resolvedAll)\n\n // Step 5: Determine mainImage\n var mainImage = rawImageUrl\n ? resolveAssetUrl(rawImageUrl, assetMap)\n : (resolvedImages.length > 0 ? resolvedImages[0] : null)\n\n // Step 6: galleryImages = resolvedImages without mainImage\n var galleryImages = mainImage\n ? resolvedImages.filter(function(url) { return url !== mainImage })\n : resolvedImages.slice()\n\n // Step 7: images = full resolved deduplicated array\n var images = resolvedImages\n\n // -------------------------------------------------------\n // 3D model (one per product; GUI: constants/product-model.ts)\n // -------------------------------------------------------\n // model_url / model_poster_url hold an asset id OR a raw URL like image_url.\n // A poster the merchant never got (capture failed, plan cap) falls back to\n // the main image so <model-viewer> always has something to paint first.\n var modelUrl = resolveMediaUrl(record.model_url, assetMap)\n var modelPoster = modelUrl ? resolveMediaUrl(record.model_poster_url, assetMap) || mainImage : null\n var hasModel = modelUrl ? 'true' : 'false'\n // STRING gate ('true'/'false') like outOfStock: whether the model replaces the\n // main image on the card / details hero. A product whose only media is the\n // model always shows it. An absent field must read as the IMAGE branch, so the\n // builders gate the viewer on = 'true' and the image on != 'true'.\n var showModel =\n modelUrl && (normalizeShowModelFlag(record.show_model_by_default) || images.length === 0)\n ? 'true'\n : 'false'\n var galleryMedia = buildProductGalleryMedia({\n images: images,\n mainImage: mainImage,\n modelUrl: modelUrl,\n modelPoster: modelPoster,\n showModel: showModel,\n imageAlt: imageAlt,\n name: name,\n })\n // Everything that is not the hero \u2014 the details thumbnail strip. When the\n // model is the hero the main image moves in here, so the modal stays reachable.\n var galleryThumbnails = galleryMedia.slice(1)\n\n // Timestamps\n var rawCreatedAt = pickFirst(record.created_at, record.createdAt, record.created)\n var createdAt = normalizeTimestamp(rawCreatedAt)\n\n var rawUpdatedAt = pickFirst(record.updated_at, record.updatedAt, record.updated)\n var updatedAt = rawUpdatedAt != null ? normalizeTimestamp(rawUpdatedAt) : createdAt\n\n // Computed: created = Unix seconds\n var created = Math.floor(createdAt / 1000)\n\n // Computed: outOfStock (emitted as a STRING \u2014 'true' / 'false' \u2014 so it\n // matches the strict equality check the AI generates on product\n // cards/details pages: `ecommerceProduct?.outOfStock === 'true'`.\n // A boolean here would silently fail that comparison (boolean !==\n // string), leaving the Add to Cart button visible on out-of-stock\n // products. NULL/NaN quantity \u2192 'false' (treated as \"unlimited\n // stock\", same as the upstream cart-availability rewriter).\n // With ALLOW_BACKORDERS the merchant sells past zero (or does not track\n // stock at all), so nothing is ever presented as out of stock.\n var outOfStock = (!ALLOW_BACKORDERS && quantity !== null && !isNaN(quantity) && quantity <= 0) ? 'true' : 'false'\n\n // Per-value availability + default-selection flags, derived from the\n // purchasable combinations, so the storefront picker renders its\n // Default / Selected / Unavailable states with NO runtime fetch. The card\n // (N instances) first-paints from these data flags; the details page seeds\n // its selection from the same combinations. axisValueKey (axisKey|valueKey)\n // disambiguates same-named values across axes (Size \"s\" vs Sleeve \"s\").\n // Emitted as STRINGS to match the storefront's string-equality convention.\n var variantAxisKeys = []\n for (var ak = 0; ak < variantOptions.length; ak++) { variantAxisKeys.push(variantOptions[ak].key) }\n var firstVariant = pickFirstAvailableVariant(variants, variantAxisKeys)\n var deadValueKeys = computeDeadVariantKeys(variants, variantOptions, variantsResolved)\n for (var ai = 0; ai < variantOptions.length; ai++) {\n var vax = variantOptions[ai]\n for (var vj = 0; vj < vax.values.length; vj++) {\n var vval = vax.values[vj]\n var composite = vax.key + '|' + vval.value\n vval.axisValueKey = composite\n vval.isDead = deadValueKeys[composite] ? 'true' : 'false'\n vval.isDefault =\n firstVariant && firstVariant.options && firstVariant.options[vax.key] === vval.value\n ? 'true'\n : 'false'\n // Whether THIS value renders as a colour swatch vs a text pill. A colour\n // axis whose value has no resolvable colour degrades to a text pill so it\n // is never an invisible empty circle. Emitted as a string to match the\n // picker's string-equality rendering gate. MUST mirror the renderer\n // enrichment in packages/renderer/src/utils/ecommerce-products.ts.\n vval.showSwatch = vax.type === 'color' && vval.color ? 'true' : 'false'\n }\n }\n // The combination the picker auto-selects on first paint: the first in-stock\n // one covering every axis. Absent means there is nothing the shopper could add\n // without choosing \u2014 which is what gates the buy button below.\n var hasInStockCombination = !!(firstVariant && isVariantInStock(firstVariant))\n var defaultVariantId = hasInStockCombination ? firstVariant.id : ''\n var defaultVariantPrice = firstVariant ? formatVariantPrice(firstVariant.price, price) : ''\n // Gross (tax-inclusive) counterparts of the three price fields the STOREFRONT\n // renders. `price` / `defaultVariantPrice` / `variantsJson` deliberately stay\n // NET: the same transform feeds the merchant's admin panel, and every write\n // path (add-to-cart, checkout, the invoice route) re-reads the raw row and\n // must keep seeing the price the merchant typed. Only these three are safe to\n // gross, because nothing writes them back.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n // The scheduled markdown in force right now, resolved from the raw column on\n // every read so a discount starts and expires on its own. It comes off the NET\n // price BEFORE tax: tax is a percentage of what is actually charged.\n var activeDiscount = __pdResolveActive(record.discounts, Date.now())\n var displayPrice = grossMoney(__pdDiscountedPrice(price, activeDiscount))\n var defaultVariantNetPrice = firstVariant\n ? safeNumber(formatVariantPrice(firstVariant.price, price), 0)\n : 0\n var defaultVariantDisplayPrice = firstVariant\n ? grossMoney(__pdDiscountedPrice(defaultVariantNetPrice, activeDiscount))\n : ''\n var variantsDisplay = []\n for (var dv = 0; dv < variants.length; dv++) {\n var dvRow = variants[dv]\n // A null variant price INHERITS the product price, so discount and gross the\n // RESOLVED value \u2014 otherwise an inheriting combination would fall back to\n // the net base price in the picker while an overriding one showed gross.\n var dvNetPrice = dvRow.price == null ? price : dvRow.price\n variantsDisplay.push({\n id: dvRow.id,\n options: dvRow.options,\n price: applyStorefrontTax(__pdDiscountedPrice(dvNetPrice, activeDiscount)),\n // What this combination costs WITHOUT the discount, for the struck-through\n // price beside it. Null when nothing is discounted, so the picker's click\n // resolver can tell \"no saving\" from \"a saving of zero\".\n originalPrice: activeDiscount ? applyStorefrontTax(dvNetPrice) : null,\n quantity: dvRow.quantity,\n image_url: dvRow.image_url,\n })\n }\n // Discount display fields. Strings throughout: rendering conditions compare\n // operands as strings, and a bound text node prints its value verbatim.\n var hasDiscount = activeDiscount ? 'true' : 'false'\n var discountLabel = activeDiscount\n ? __pdDiscountLabel(activeDiscount, currencySymbol, getCurrencyInfo(currency).position)\n : ''\n var originalDisplayPrice = activeDiscount ? grossMoney(price) : ''\n var originalDefaultVariantDisplayPrice =\n activeDiscount && firstVariant ? grossMoney(defaultVariantNetPrice) : ''\n // Can this product be added to the cart at all, before the shopper touches the\n // picker? A STRING, same convention as outOfStock/requiresVariantSelection.\n //\n // flat product -> 'true' (stock is governed by outOfStock)\n // combinations UNKNOWN -> 'true' (the lookup could not run; a transient DB\n // failure must never make a whole catalogue\n // unbuyable \u2014 same permissive rule as\n // dead-marking)\n // otherwise -> whether an in-stock combination covering every axis\n // exists. 'false' therefore covers BOTH \"every\n // combination is sold out\" AND \"the axes have no\n // combinations at all\" \u2014 to a shopper those are the\n // same thing: there is no size to pick.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n var hasPurchasableVariant =\n (requiresVariantSelection !== 'true' || !variantsResolved || hasInStockCombination)\n ? 'true'\n : 'false'\n // Aggregate review rating, from ONE batched COUNT/AVG over approved reviews\n // that transformRecords runs for every product on the page (keyed by product\n // id in options.ratingsByProductId).\n //\n // \u26D4 An ABSENT entry and a NULL map both mean \"no rating to show\", never \"0\n // stars\": a product nobody has reviewed and a product whose rating query\n // failed must both render with no stars at all. \"0.0 out of 5\" is the single\n // worst thing a storefront can say about a product by accident.\n //\n // MUST mirror buildProductRatingFields in the GUI's\n // features/e-commerce/utils/product-ratings.ts.\n var ratingsByProductId = options.ratingsByProductId || {}\n var ratingFields = __prBuildRatingFields(id != null ? ratingsByProductId[id] : null)\n\n // The individual reviews behind those stars, for the product page's JSON-LD.\n // Fetched ONLY for a single-product request (see getTransformWrapperCode), so\n // this is an empty array on every listing \u2014 which is correct: a listing emits\n // no Product markup to put them in.\n //\n // Not rendered anywhere. That is why there is no canvas mirror for it: the\n // three-mirror rule exists for fields the editor DRAWS, and structured data\n // is a publish-time concern the canvas has no equivalent of.\n var reviewsByProductId = options.reviewsByProductId || {}\n var reviews = (id != null && reviewsByProductId[id]) || []\n\n // Stringified companions: a data-* attr bound to an ARRAY renders\n // '[object Object]', so the picker's on-mount/click workflows read the\n // combinations + axes from these JSON strings via getAttribute.\n var variantOptionsJson = safeStringifyJson(variantOptions)\n var variantsJson = safeStringifyJson(variants)\n var variantsDisplayJson = safeStringifyJson(variantsDisplay)\n\n return {\n id: id,\n name: name,\n slug: slug,\n description: description,\n descriptionText: descriptionText,\n price: price,\n currency: currency,\n currencySymbol: currencySymbol,\n status: status,\n active: active,\n quantity: quantity,\n paymentType: paymentType,\n recurringInterval: recurringInterval,\n recurringIntervalCount: recurringIntervalCount,\n sku: sku,\n weight: weight,\n weightUnit: weightUnit,\n dimensions: dimensions,\n category: category,\n categories: categories,\n tags: tags,\n relatedProductIds: relatedProductIds,\n relatedProducts: relatedProducts,\n imageAlt: imageAlt,\n providerType: providerType,\n providerProductId: providerProductId,\n default_price: defaultPrice,\n metadata: metadata,\n visibleProperties: visibleProperties,\n mainImage: mainImage,\n imageUrl: mainImage,\n image_url: mainImage,\n galleryImages: galleryImages,\n images: images,\n modelUrl: modelUrl,\n modelPoster: modelPoster,\n hasModel: hasModel,\n showModel: showModel,\n galleryMedia: galleryMedia,\n galleryThumbnails: galleryThumbnails,\n createdAt: createdAt,\n updatedAt: updatedAt,\n created: created,\n outOfStock: outOfStock,\n variantOptions: variantOptions,\n requiresVariantSelection: requiresVariantSelection,\n variants: variants,\n variantOptionsJson: variantOptionsJson,\n variantsJson: variantsJson,\n variantsDisplayJson: variantsDisplayJson,\n defaultVariantId: defaultVariantId,\n defaultVariantPrice: defaultVariantPrice,\n displayPrice: displayPrice,\n defaultVariantDisplayPrice: defaultVariantDisplayPrice,\n hasDiscount: hasDiscount,\n discountLabel: discountLabel,\n originalDisplayPrice: originalDisplayPrice,\n originalDefaultVariantDisplayPrice: originalDefaultVariantDisplayPrice,\n hasPurchasableVariant: hasPurchasableVariant,\n // Aggregate review rating \u2014 see the ratingFields block above.\n ratingAverage: ratingFields.ratingAverage,\n ratingCount: ratingFields.ratingCount,\n ratingAverageLabel: ratingFields.ratingAverageLabel,\n ratingCountLabel: ratingFields.ratingCountLabel,\n hasRatings: ratingFields.hasRatings,\n ratingStar1: ratingFields.ratingStar1,\n ratingStar2: ratingFields.ratingStar2,\n ratingStar3: ratingFields.ratingStar3,\n ratingStar4: ratingFields.ratingStar4,\n ratingStar5: ratingFields.ratingStar5,\n // Domain-shaped, not schema.org-shaped: the head-config plugin's\n // 'reviewList' computed kind does the vocabulary. NO BACKTICKS ANYWHERE IN\n // THIS FILE \u2014 it is one template literal, and a backtick in a comment ends\n // the string.\n reviews: reviews,\n }\n}\n\n// --- Variant availability helpers (mirror packages/renderer ecommerce-products) ---\n// A variant is in stock when its quantity is null (unlimited) or > 0. With\n// ALLOW_BACKORDERS every EXISTING combination is purchasable regardless of its\n// quantity \u2014 but a missing combination (null) is still not in stock: there is\n// no variant row to backorder.\nfunction isVariantInStock(v) {\n if (!v) return false\n if (ALLOW_BACKORDERS) return true\n return v.quantity == null || v.quantity > 0\n}\nfunction variantCoversAllAxes(v, axisKeys) {\n if (!v || !v.options) return false\n for (var i = 0; i < axisKeys.length; i++) {\n if (v.options[axisKeys[i]] == null) return false\n }\n return true\n}\n// First in-stock combination covering every axis; falls back to the first\n// covering combination (even if OOS) so a fully-OOS product still has a\n// visual default; null when no combination covers the axes.\nfunction pickFirstAvailableVariant(variants, axisKeys) {\n if (!Array.isArray(variants) || !axisKeys.length) return null\n for (var i = 0; i < variants.length; i++) {\n if (variantCoversAllAxes(variants[i], axisKeys) && isVariantInStock(variants[i])) return variants[i]\n }\n for (var j = 0; j < variants.length; j++) {\n if (variantCoversAllAxes(variants[j], axisKeys)) return variants[j]\n }\n return null\n}\n// Map of composite \"axisKey|valueKey\" -> true for every value that appears in\n// NO in-stock combination (rendered as the Unavailable state).\n//\n// variantsResolved is the whole subtlety: dead-marking is only meaningful\n// against combinations we actually LOOKED UP. When the lookup could not run,\n// nothing is dead \u2014 a missing table or a failed query must not grey out every\n// picker in the store. When it DID run, an empty list is a fact, not a gap:\n// every value is unbuyable and renders as Unavailable.\nfunction computeDeadVariantKeys(variants, axes, variantsResolved) {\n if (!variantsResolved || !Array.isArray(variants)) return {}\n var alive = {}\n for (var i = 0; i < variants.length; i++) {\n var v = variants[i]\n if (!isVariantInStock(v)) continue\n for (var k in v.options) {\n if (Object.prototype.hasOwnProperty.call(v.options, k)) alive[k + '|' + v.options[k]] = true\n }\n }\n var dead = {}\n for (var a = 0; a < axes.length; a++) {\n var ax = axes[a]\n for (var b = 0; b < ax.values.length; b++) {\n var key = ax.key + '|' + ax.values[b].value\n if (!alive[key]) dead[key] = true\n }\n }\n return dead\n}\n// Variant price with base-price inheritance (null variant price -> product price).\nfunction formatVariantPrice(vprice, basePrice) {\n var p = vprice == null || isNaN(vprice) ? basePrice : vprice\n return String(Number(p).toFixed(2))\n}\nfunction safeStringifyJson(obj) {\n try {\n return JSON.stringify(obj)\n } catch (e) {\n return '[]'\n }\n}\n\n// Parse + normalize the variant_options JSON into render-ready axes. Tolerant of\n// null / malformed / partial data (returns []). Keeps ONLY the fields the pill\n// widget needs; the labels are already language-resolved by the caller.\nfunction normalizeVariantOptions(raw) {\n if (!raw) return []\n var parsed = raw\n if (typeof raw === 'string') {\n try { parsed = JSON.parse(raw) } catch (e) { return [] }\n }\n if (!Array.isArray(parsed)) return []\n var out = []\n for (var i = 0; i < parsed.length; i++) {\n var axis = parsed[i]\n if (!axis || typeof axis !== 'object' || !Array.isArray(axis.values)) continue\n var name = typeof axis.name === 'string' ? axis.name : ''\n var key = typeof axis.key === 'string' && axis.key ? axis.key : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!key) continue\n var type = axis.type === 'color' ? 'color' : 'text'\n var values = []\n for (var j = 0; j < axis.values.length; j++) {\n var v = axis.values[j]\n if (!v || typeof v !== 'object') continue\n var label = typeof v.label === 'string' ? v.label : ''\n var value = typeof v.value === 'string' && v.value ? v.value : label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!value) continue\n var entry = { value: value, label: label }\n if (type === 'color' && typeof v.color === 'string' && v.color) entry.color = v.color\n values.push(entry)\n }\n if (!values.length) continue\n out.push({ key: key, name: name, type: type, values: values })\n }\n return out\n}\n\n// --- Custom-property classification (MUST mirror product-property-display.ts) ---\n\n// Product-attribute keys that can leak into the free-form `metadata` map (e.g.\n// from a payment-provider sync or legacy data) but are NOT user custom\n// properties. Kept in sync with the GUI's RESERVED_PRODUCT_METADATA_KEYS.\nvar RESERVED_PRODUCT_METADATA_KEYS = {\n paymentType: true, recurringInterval: true, recurringIntervalCount: true,\n price: true, currency: true, status: true, sku: true, weightUnit: true,\n dimensions: true, category: true, brand: true, gtin: true, mpn: true,\n condition: true, tags: true,\n}\nfunction isReservedProductMetadataKey(key) {\n return !!RESERVED_PRODUCT_METADATA_KEYS[key] ||\n key.indexOf('mainImage') === 0 ||\n key.indexOf('secondaryImage') === 0 ||\n key.indexOf('_tq') === 0\n}\n\nvar CSS_NAMED_COLORS = {\n aliceblue: 1, antiquewhite: 1, aqua: 1, aquamarine: 1, azure: 1, beige: 1, bisque: 1,\n black: 1, blanchedalmond: 1, blue: 1, blueviolet: 1, brown: 1, burlywood: 1,\n cadetblue: 1, chartreuse: 1, chocolate: 1, coral: 1, cornflowerblue: 1, cornsilk: 1,\n crimson: 1, cyan: 1, darkblue: 1, darkcyan: 1, darkgoldenrod: 1, darkgray: 1,\n darkgrey: 1, darkgreen: 1, darkkhaki: 1, darkmagenta: 1, darkolivegreen: 1,\n darkorange: 1, darkorchid: 1, darkred: 1, darksalmon: 1, darkseagreen: 1,\n darkslateblue: 1, darkslategray: 1, darkslategrey: 1, darkturquoise: 1,\n darkviolet: 1, deeppink: 1, deepskyblue: 1, dimgray: 1, dimgrey: 1, dodgerblue: 1,\n firebrick: 1, floralwhite: 1, forestgreen: 1, fuchsia: 1, gainsboro: 1,\n ghostwhite: 1, gold: 1, goldenrod: 1, gray: 1, grey: 1, green: 1, greenyellow: 1,\n honeydew: 1, hotpink: 1, indianred: 1, indigo: 1, ivory: 1, khaki: 1, lavender: 1,\n lavenderblush: 1, lawngreen: 1, lemonchiffon: 1, lightblue: 1, lightcoral: 1,\n lightcyan: 1, lightgoldenrodyellow: 1, lightgray: 1, lightgrey: 1, lightgreen: 1,\n lightpink: 1, lightsalmon: 1, lightseagreen: 1, lightskyblue: 1, lightslategray: 1,\n lightslategrey: 1, lightsteelblue: 1, lightyellow: 1, lime: 1, limegreen: 1,\n linen: 1, magenta: 1, maroon: 1, mediumaquamarine: 1, mediumblue: 1,\n mediumorchid: 1, mediumpurple: 1, mediumseagreen: 1, mediumslateblue: 1,\n mediumspringgreen: 1, mediumturquoise: 1, mediumvioletred: 1, midnightblue: 1,\n mintcream: 1, mistyrose: 1, moccasin: 1, navajowhite: 1, navy: 1, oldlace: 1,\n olive: 1, olivedrab: 1, orange: 1, orangered: 1, orchid: 1, palegoldenrod: 1,\n palegreen: 1, paleturquoise: 1, palevioletred: 1, papayawhip: 1, peachpuff: 1,\n peru: 1, pink: 1, plum: 1, powderblue: 1, purple: 1, rebeccapurple: 1, red: 1,\n rosybrown: 1, royalblue: 1, saddlebrown: 1, salmon: 1, sandybrown: 1, seagreen: 1,\n seashell: 1, sienna: 1, silver: 1, skyblue: 1, slateblue: 1, slategray: 1,\n slategrey: 1, snow: 1, springgreen: 1, steelblue: 1, tan: 1, teal: 1, thistle: 1,\n tomato: 1, turquoise: 1, violet: 1, wheat: 1, white: 1, whitesmoke: 1, yellow: 1,\n yellowgreen: 1, transparent: 1,\n}\nvar HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\nvar FUNCTIONAL_COLOR_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\\((?:[0-9a-z%.,\\s/+-]+)\\)$/i\nvar COLOR_KEY_WORDS = {\n color: 1, colors: 1, colour: 1, colours: 1, shade: 1, shades: 1, hue: 1, hues: 1,\n tint: 1, tints: 1, tone: 1, tones: 1, swatch: 1, swatches: 1,\n}\nfunction splitKeyWords(key) {\n return key.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)\n}\nfunction keyIndicatesColor(key) {\n var normalized = key.toLowerCase()\n if (/colou?r/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!COLOR_KEY_WORDS[word] })\n}\nfunction resolvePropertyColor(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (HEX_COLOR_RE.test(v) || FUNCTIONAL_COLOR_RE.test(v)) return v\n if (CSS_NAMED_COLORS[v.toLowerCase()] && keyIndicatesColor(key)) return v\n return ''\n}\n\nvar DATA_IMAGE_URI_RE = /^data:image\\//i\nvar ABSOLUTE_URL_RE = /^(?:https?:)?\\/\\//i\nvar IMAGE_EXTENSION_RE = /\\.(?:png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?|heic|heif)$/i\nvar IMAGE_KEY_WORDS = {\n image: 1, images: 1, img: 1, imgs: 1, photo: 1, photos: 1, picture: 1, pictures: 1,\n pic: 1, pics: 1, thumbnail: 1, thumbnails: 1, thumb: 1, thumbs: 1, icon: 1, icons: 1,\n logo: 1, logos: 1, avatar: 1, avatars: 1, banner: 1, banners: 1, cover: 1, covers: 1,\n gallery: 1, screenshot: 1, screenshots: 1, poster: 1, posters: 1, artwork: 1,\n graphic: 1, graphics: 1, illustration: 1, wallpaper: 1, preview: 1, snapshot: 1,\n media: 1, visual: 1, visuals: 1,\n}\nfunction keyIndicatesImage(key) {\n var normalized = key.toLowerCase()\n if (/(?:image|img|photo|picture|thumb|icon|logo|avatar|banner|gallery|screenshot|poster|artwork|graphic|wallpaper)/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!IMAGE_KEY_WORDS[word] })\n}\nfunction stripUrlQueryAndHash(url) {\n return url.split(/[?#]/)[0]\n}\nfunction resolvePropertyImage(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (DATA_IMAGE_URI_RE.test(v)) return v\n var isAbsoluteUrl = ABSOLUTE_URL_RE.test(v)\n var isRootRelative = v.charAt(0) === '/' && !isAbsoluteUrl\n if (!isAbsoluteUrl && !isRootRelative) return ''\n if (IMAGE_EXTENSION_RE.test(stripUrlQueryAndHash(v))) return v\n if (isAbsoluteUrl && keyIndicatesImage(key)) return v\n return ''\n}\n\n// Image wins over colour (a URL can never be a CSS colour, but checking image\n// first keeps the precedence explicit), text is the fallback.\nfunction classifyProductProperty(key, value) {\n var imageUrl = resolvePropertyImage(key, value)\n if (imageUrl) return { key: key, value: value, kind: 'image', color: '', imageUrl: imageUrl }\n var color = resolvePropertyColor(key, value)\n if (color) return { key: key, value: value, kind: 'color', color: color, imageUrl: '' }\n return { key: key, value: value, kind: 'text', color: '', imageUrl: '' }\n}\n\nfunction transformEcommerceProducts(records, options) {\n if (!Array.isArray(records)) return []\n return records.map(function(record) { return buildEcommerceProduct(record, options) })\n}\n\n// Batched fetch of the purchasable variant rows for a set of product ids, keyed\n// by product_id, so buildEcommerceProduct can attach product.variants without a\n// per-product query. Best-effort: a store with no variants (or before the table\n// exists) yields an empty map and flat products keep variants: [].\n// Batched fetch of the product rows a set of products reference as \"related\",\n// keyed by id, so buildEcommerceProduct can attach product.relatedProducts\n// without a query per product.\n//\n// Best-effort by design: a store whose products table predates the column\n// yields no ids at all (the read below is of an absent property, not of a\n// column \u2014 the SQL never names it), the map stays empty, and the details page's\n// rail simply stays hidden behind its is_not_empty gate. Same for a failed\n// query.\nasync function getRelatedProductsMap(getClientFn, records) {\n var map = {}\n if (!Array.isArray(records) || records.length === 0) return map\n\n var wantedIds = []\n for (var i = 0; i < records.length; i++) {\n var ids = parseRelatedIds(records[i] && records[i].related_product_ids)\n for (var j = 0; j < ids.length; j++) {\n if (wantedIds.indexOf(ids[j]) === -1) wantedIds.push(ids[j])\n }\n }\n if (wantedIds.length === 0) return map\n\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query('SELECT * FROM teleport_products WHERE id = ANY($1)', [\n wantedIds,\n ])\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.id != null) map[String(row.id)] = row\n }\n }\n } catch (e) {\n // Leaves relatedProducts empty rather than failing the page.\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// Aggregate review rating for every product on the page, keyed by product id,\n// so a card can draw its star row without a query per product.\n//\n// APPROVED ONLY, and 'approved' is a fixed literal rather than a bound value\n// because nothing may ever widen it: a pending review is one the merchant has\n// not agreed to publish, and a rejected one is a review they actively removed.\n//\n// Unlike getVariantsMap this returns an EMPTY MAP on failure rather than null.\n// The two \"unknown\" answers differ because the consequences differ: an unknown\n// variant map must stay permissive (never make a catalogue unbuyable), whereas\n// an unknown rating has exactly one safe rendering \u2014 no stars \u2014 which is also\n// what a product with no reviews shows. Collapsing both onto {} means every\n// caller has one case to handle instead of two.\nasync function getRatingsMap(getClientFn, productIds) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n \"SELECT product_id, AVG(rating)::float AS average, COUNT(*)::int AS count \" +\n 'FROM teleport_product_reviews ' +\n \"WHERE status = 'approved' AND rating IS NOT NULL AND product_id = ANY($1) \" +\n 'GROUP BY product_id',\n [uniqueIds]\n )\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.product_id != null) {\n map[row.product_id] = { average: row.average, count: row.count }\n }\n }\n }\n } catch (e) {\n // teleport_product_reviews may not exist (reviews were never enabled), or\n // the query may have failed. Either way there is no rating to show, which\n // is exactly what an empty map produces.\n map = {}\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// The most recent APPROVED reviews for a product, for the product page's\n// structured data. Keyed by product id like every other batched lookup, though\n// in practice it is only ever called with one id.\n//\n// Three filters, and each one exists because of what the output is used for:\n// - APPROVED only, as a fixed literal, for the same reason the aggregate is:\n// a pending review is one the merchant has not agreed to publish.\n// - A NON-EMPTY reviewer name. Google requires author.name on a review\n// snippet, and an anonymous row would produce a Review object it rejects \u2014\n// taking the whole Product block down with it on some validators.\n// - A CAP. The reviews are inlined into the page's props and then into its\n// HTML twice over (once as data, once as JSON-LD); a product with four\n// hundred reviews would otherwise put all of them in both.\nasync function getProductReviewsMap(getClientFn, productIds, perProduct) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n 'SELECT product_id, reviewer_name, rating, content, created_at ' +\n 'FROM teleport_product_reviews ' +\n \"WHERE status = 'approved' AND rating IS NOT NULL \" +\n \"AND reviewer_name IS NOT NULL AND btrim(reviewer_name) <> '' \" +\n 'AND product_id = ANY($1) ' +\n 'ORDER BY created_at DESC',\n [uniqueIds]\n )\n var cap = typeof perProduct === 'number' && perProduct > 0 ? perProduct : 5\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (!row || row.product_id == null) continue\n var bucket = map[row.product_id] || (map[row.product_id] = [])\n if (bucket.length >= cap) continue\n bucket.push({\n author: String(row.reviewer_name).trim(),\n rating: Number(row.rating),\n body: row.content == null ? '' : String(row.content),\n datePublished: row.created_at ? new Date(row.created_at).toISOString() : null,\n })\n }\n }\n } catch (e) {\n // The reviews table may not exist (reviews were never enabled). No reviews\n // to show is both the empty answer and the safe one.\n map = {}\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// Combinations for every product on the page, keyed by product id. Returns NULL\n// when the lookup could not run, which the transform reads as \"unknown\" and\n// stays permissive with; an empty map means the query ran and this catalogue\n// simply has no combinations.\nasync function getVariantsMap(getClientFn, productIds) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n 'SELECT id, product_id, options, price, quantity, image_url FROM teleport_product_variants WHERE product_id = ANY($1) ORDER BY position ASC, id ASC',\n [uniqueIds]\n )\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n var pid = row.product_id\n if (!map[pid]) map[pid] = []\n map[pid].push(row)\n }\n }\n } catch (e) {\n // teleport_product_variants may not exist for a non-variant store, or the\n // query may have failed. Either way the combinations are UNKNOWN \u2014 null\n // keeps every picker selectable instead of declaring the catalogue unbuyable.\n map = null\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n");
|
|
39
|
+
return "\n".concat(taxHelperCode, "\n").concat(teleport_shared_1.ProductDiscounts.generateProductDiscountHelperCode(), "\n").concat(teleport_shared_1.ProductOptions.generateProductOptionsHelperCode(), "\n").concat(teleport_shared_1.ProductRatings.generateProductRatingHelperCode(), "\n\n// Baked from the merchant's stock settings: TRUE when stock never blocks a\n// purchase (stock management off OR backorders allowed). Regenerating the\n// project refreshes it \u2014 do not hand-edit. See EcommerceProductTransformOptions.\nvar ALLOW_BACKORDERS = ").concat(allowBackordersLiteral, "\n").concat(options.productPages ? generateProductPagesCode(options.productPages) : '', "\n").concat((0, category_taxonomy_1.generateCategoryTaxonomyCode)('PRODUCT_CATEGORIES_BY_ID', options.categories), "\n// The merchant's \"Show 3D model by default\" toggle, as every backend may hand\n// it back: a real boolean, 1/0, 'true'/'false'/'t'/'f' strings \u2014 and NULL /\n// absent / '' as ON, because a product that has a model shows it unless the\n// merchant switched it off. MUST mirror normalizeShowModelFlag in the GUI's\n// features/e-commerce/constants/product-model.ts.\nfunction normalizeShowModelFlag(raw) {\n if (raw === null || raw === undefined || raw === '') return true\n if (typeof raw === 'boolean') return raw\n if (typeof raw === 'number') return raw !== 0\n if (typeof raw === 'string') {\n var token = raw.trim().toLowerCase()\n return !(token === 'false' || token === 'f' || token === '0' || token === 'no' || token === 'off')\n }\n return true\n}\n\n// A BOOLEAN column as every backend may hand it back; NULL / absent reads as\n// off. MUST mirror parseBoolean in the GUI's features/e-commerce/utils/parse-boolean.ts.\nfunction readBooleanFlag(raw) {\n if (typeof raw === 'boolean') return raw\n if (typeof raw === 'number') return raw !== 0\n if (typeof raw === 'string') {\n var token = raw.trim().toLowerCase()\n return token === 'true' || token === 't' || token === '1' || token === 'yes'\n }\n return false\n}\n\n// \"month\", \"3 months\" \u2014 the period a recurring price is billed per. The\n// interval is read however the driver spelled it (anything unknown is a\n// month) and the count is a positive whole number (anything else is 1). MUST\n// mirror formatBillingPeriod in the GUI's features/e-commerce/constants/subscriptions.ts.\nvar BILLING_INTERVALS = ['day', 'week', 'month', 'year']\nfunction formatBillingPeriodLabel(interval, intervalCount) {\n var unit = String(interval == null ? '' : interval).trim().toLowerCase()\n if (BILLING_INTERVALS.indexOf(unit) === -1) unit = 'month'\n var count = normalizeBillingIntervalCountValue(intervalCount)\n return count === 1 ? unit : count + ' ' + unit + 's'\n}\n\n// A positive whole number of intervals; anything else is 1. MUST mirror\n// normalizeBillingIntervalCount in the GUI's features/e-commerce/constants/subscriptions.ts.\nfunction normalizeBillingIntervalCountValue(intervalCount) {\n var count = Math.floor(Number(intervalCount))\n return isFinite(count) && count >= 1 ? count : 1\n}\n\n// A positive whole number read off an INTEGER column, or 0 for NULL, zero, a\n// negative or anything unreadable. MUST mirror resolvePositiveCount in the\n// GUI's features/e-commerce/utils/digital-products/digital-product.ts (which\n// answers null where this answers 0).\nfunction resolvePositiveCountValue(raw) {\n if (raw === null || raw === undefined || raw === '') return 0\n var parsed = Math.floor(Number(raw))\n return isFinite(parsed) && parsed > 0 ? parsed : 0\n}\n\n// \"Billed every month\", \"Billed every 3 months \u00B7 14-day free trial\" \u2014 the\n// purchase-details line of a recurring product. MUST mirror\n// formatBillingSummary in the GUI's\n// features/e-commerce/utils/subscriptions/billing-summary.ts.\nfunction formatBillingSummaryLabel(interval, intervalCount, trialDays) {\n var summary = 'Billed every ' + formatBillingPeriodLabel(interval, intervalCount)\n var trial = resolvePositiveCountValue(trialDays)\n return trial === 0 ? summary : summary + ' \u00B7 ' + trial + '-day free trial'\n}\n\n// The product's media gallery in display order, HERO FIRST: the model when it\n// is shown by default, otherwise the images with the model last. Each entry is\n// { kind: 'image' | 'model', src, poster, thumbnail, alt }; 'thumbnail' is what\n// a strip draws and is never empty for an entry that can appear in one. MUST\n// mirror buildProductGalleryMedia in the GUI's\n// features/e-commerce/utils/product-gallery-media.ts.\nfunction buildProductGalleryMedia(params) {\n var imageItems = []\n for (var gi = 0; gi < params.images.length; gi++) {\n imageItems.push({\n kind: 'image',\n src: params.images[gi],\n poster: '',\n thumbnail: params.images[gi],\n alt: params.imageAlt || params.name,\n })\n }\n if (!params.modelUrl) return imageItems\n var modelItem = {\n kind: 'model',\n src: params.modelUrl,\n poster: params.modelPoster || '',\n thumbnail: params.modelPoster || params.mainImage || params.images[0] || '',\n alt: params.name,\n }\n return params.showModel === 'true' ? [modelItem].concat(imageItems) : imageItems.concat([modelItem])\n}\n\n// ============================================================\n// E-Commerce Product Transformation\n// ============================================================\n\nfunction buildEcommerceProduct(record, options) {\n if (!record || typeof record !== 'object') return record\n options = options || {}\n var currentLang = options.currentLanguage || null\n var mainLang = options.mainLanguage || null\n var assetMap = options.assetMap || {}\n\n var id = record.id !== undefined && record.id !== null ? record.id : null\n\n // i18n-resolved text fields\n var name = resolveI18nField(record, 'name', 'name', currentLang, mainLang) || ''\n var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''").concat(options.productPages
|
|
40
|
+
? "\n var productPageKey = resolveProductPageKey(pickFirst(record.product_page, record.productPage))"
|
|
41
|
+
: '', "\n var description = resolveI18nField(record, 'description', 'description', currentLang, mainLang) || ''\n var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null\n var tagsRaw = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)\n var imageAlt = resolveI18nField(record, 'image_alt', 'imageAlt', currentLang, mainLang) || null\n\n // Computed: plain-text description (HTML stripped)\n var descriptionText = stripHtmlTags(description)\n\n // Numeric fields (with NaN protection)\n var price = safeNumber(record.price, 0)\n var quantity = safeNumber(record.quantity, null)\n\n // Currency\n var currency = (record.currency || 'USD').toUpperCase()\n var rawCurrencySymbol = pickFirst(record.currency_symbol, record.currencySymbol)\n var currencySymbol = rawCurrencySymbol || getCurrencySymbol(currency)\n\n // Status and active (normalize case \u2014 DB / forms may send \"Active\", \"Inactive\", etc.)\n var statusRaw = record.status\n if (statusRaw == null || statusRaw === '') {\n statusRaw = record.active ? 'active' : 'inactive'\n }\n var status =\n typeof statusRaw === 'string'\n ? statusRaw.trim().toLowerCase()\n : String(statusRaw).toLowerCase()\n var active = status === 'active'\n\n // Payment fields\n var paymentType = pickFirst(record.payment_type, record.paymentType) || 'one_time'\n var recurringInterval = pickFirst(record.recurring_interval, record.recurringInterval) || null\n var rawRecurringCount = pickFirst(record.recurring_interval_count, record.recurringIntervalCount)\n var isRecurring = String(paymentType).trim().toLowerCase() === 'recurring'\n // A recurring product's count is a whole number of intervals (anything\n // unreadable is 1); a one-time product keeps the column as stored.\n var recurringIntervalCount = isRecurring\n ? normalizeBillingIntervalCountValue(rawRecurringCount)\n : safeNumber(rawRecurringCount, null)\n // What a recurring price is followed by (\"month\", \"3 months\"; '' for a\n // one-time product), the purchase-details sentence (\"Billed every 3 months \u00B7\n // 14-day free trial\"; '' for a one-time product), the free trial in days (0\n // for none), and whether the product is delivered as a download or issued\n // as a gift card. Strings where a rendering condition reads them, like\n // hasDiscount: a bound text node prints the period verbatim and a rendering\n // condition compares the flags as strings. MUST mirror\n // packages/renderer/src/utils/ecommerce-products.ts, whose period comes from\n // formatBillingPeriod in constants/subscriptions.ts.\n var billingPeriod = isRecurring\n ? formatBillingPeriodLabel(recurringInterval, recurringIntervalCount)\n : ''\n var trialDays = resolvePositiveCountValue(pickFirst(record.trial_days, record.trialDays))\n var billingSummary = isRecurring\n ? formatBillingSummaryLabel(recurringInterval, recurringIntervalCount, trialDays)\n : ''\n var isDigital = readBooleanFlag(pickFirst(record.is_digital, record.isDigital)) ? 'true' : 'false'\n var isGiftCard = readBooleanFlag(pickFirst(record.is_gift_card, record.isGiftCard))\n ? 'true'\n : 'false'\n\n // Physical product fields\n var sku = record.sku || null\n var weight = safeNumber(record.weight, null)\n var weightUnit = pickFirst(record.weight_unit, record.weightUnit) || null\n var dimensions = record.dimensions || null\n\n // Tags - parse as JSON array (handles JSON strings, arrays, comma-separated)\n var tags = parseJsonArray(tagsRaw)\n\n // Variant AXES (translatable). The i18n-resolved column already carries the\n // display labels for the current language (with the SAME stable keys as the\n // base column); we just parse + normalize. The purchasable combinations live\n // in teleport_product_variants and are fetched by the variant picker widget.\n var variantOptionsRaw = resolveI18nField(record, 'variant_options', 'variantOptions', currentLang, mainLang)\n var variantOptions = normalizeVariantOptions(variantOptionsRaw)\n // Emitted as a STRING ('true'/'false') to match the strict-equality checks the\n // storefront uses (same convention as outOfStock).\n var requiresVariantSelection = variantOptions.length > 0 ? 'true' : 'false'\n\n // Provider fields\n var providerType = pickFirst(record.provider_type, record.providerType) || null\n var providerProductId = pickFirst(record.provider_product_id, record.providerProductId) || null\n var defaultPrice = pickFirst(record.default_price, record.defaultPrice) || null\n\n // Metadata\n var metadata = parseJsonObject(record.metadata)\n\n // Visible custom properties -> [{key,value,kind,color,imageUrl}] for the\n // storefront pills mapper. MUST mirror buildVisibleProductProperties /\n // classifyProductProperty in\n // apps/gui/app/project-page/features/e-commerce/utils/product-property-display.ts\n // (the canvas-preview SSOT) field-for-field: same reserved-key exclusions,\n // same default-VISIBLE (opt-out) rule, same color/image classification. A\n // property is shown unless it is a reserved key, has an empty value, or is\n // explicitly hidden (false) in the _tqPropVisibility map -- so it also\n // renders for products whose form was never opened (no visibility map yet).\n var propVisibility = parseJsonObject(metadata._tqPropVisibility)\n var visibleProperties = []\n Object.keys(metadata).forEach(function (propKey) {\n if (isReservedProductMetadataKey(propKey)) return\n if (propVisibility[propKey] === false) return\n var rawValue = metadata[propKey]\n if (rawValue == null) return\n var propValue = String(rawValue)\n if (propValue.trim() === '') return\n visibleProperties.push(classifyProductProperty(propKey, propValue))\n })\n\n // Assigned category ids -> resolved {id,name,slug} objects for the storefront\n // category pills mapper and the product-details breadcrumbs. The taxonomy\n // itself is not in the DB (categories are authored in the GUI and live only in\n // ecommerceSettings.categories); resolved against PRODUCT_CATEGORIES_BY_ID,\n // baked in at export time. Unknown/stale ids are silently dropped.\n var categories = resolveAssignedCategories(\n record.category_ids,\n PRODUCT_CATEGORIES_BY_ID,\n currentLang,\n mainLang\n )\n\n // Purchasable variant COMBINATIONS for this product ({id, options map, price,\n // stock, image}). Empty for flat products. Fetched in ONE batched query by\n // transformRecords and passed in via options.variantsByProductId, keyed by\n // product id. The storefront card/details read these to default-select the\n // first in-stock variant, disable dead options, and resolve the chosen id.\n //\n // NULL (not {}) means the lookup could not run \u2014 the table is missing, or the\n // query failed. That is \"combinations UNKNOWN\", which is NOT \"this product has\n // none\": a transient DB failure must never turn every variant product in the\n // catalogue into an unbuyable one. See hasPurchasableVariant below.\n var variantsResolved = options.variantsByProductId != null\n var variantsByProductId = options.variantsByProductId || {}\n var rawVariants = variantsByProductId[id] || []\n var variants = []\n for (var vi = 0; vi < rawVariants.length; vi++) {\n var vrow = rawVariants[vi] || {}\n var vopts = parseJsonObject(vrow.options)\n variants.push({\n id: vrow.id != null ? String(vrow.id) : '',\n options: vopts && typeof vopts === 'object' ? vopts : {},\n price: safeNumber(vrow.price, null),\n quantity: safeNumber(vrow.quantity, null),\n image_url: vrow.image_url ? resolveAssetUrl(vrow.image_url, assetMap) : null,\n })\n }\n\n // The merchant's related-product picks. `relatedProducts` carries the\n // TRANSFORMED rows, not ids, so the details page's related-products rail can\n // map over it and draw a product card per entry; `relatedProductIds` keeps\n // the raw selection for anything that wants it. The rows arrive from ONE\n // batched query (getRelatedProductsMap) that transformRecords runs, keyed by\n // id in options.relatedProductsById.\n //\n // MUST mirror buildRelatedEcommerceProducts in\n // packages/renderer/src/utils/ecommerce-products.ts (the canvas-preview SSOT):\n // the merchant's order, inactive products dropped, self dropped, and the\n // nested build deliberately WITHOUT the map \u2014 two products referencing each\n // other is the normal case (they are each other's accessories) and threading\n // the map through would recurse until the stack ran out.\n var relatedProductIds = parseRelatedIds(record.related_product_ids)\n var relatedProductsById = options.relatedProductsById\n var relatedProducts = []\n if (relatedProductsById && relatedProductIds.length > 0) {\n var relatedNestedOptions = {\n assetMap: assetMap,\n currentLanguage: currentLang,\n mainLanguage: mainLang,\n // Passed through UNCOALESCED so a related product inherits the same\n // known/unknown answer as its parent \u2014 `|| {}` here would tell the nested\n // build \"the lookup ran and found nothing\" and strike out every related\n // card's picker. The batched query includes the related ids for exactly\n // this reason (see getTransformWrapperCode).\n variantsByProductId: options.variantsByProductId,\n // Same reason: a related card draws its own star row, and the batched\n // aggregate already covered its id.\n ratingsByProductId: options.ratingsByProductId,\n }\n for (var rp = 0; rp < relatedProductIds.length; rp++) {\n var relatedId = relatedProductIds[rp]\n if (id != null && relatedId === String(id)) continue\n var relatedRecord = relatedProductsById[relatedId]\n if (!relatedRecord) continue\n // A product taken off sale must not come back through the back door on\n // every product that references it. Missing status reads as active, the\n // same default the main status normalization uses above.\n var relatedStatusRaw = relatedRecord.status\n var relatedStatus =\n relatedStatusRaw == null || relatedStatusRaw === ''\n ? 'active'\n : String(relatedStatusRaw).trim().toLowerCase()\n if (relatedStatus !== 'active') continue\n relatedProducts.push(buildEcommerceProduct(relatedRecord, relatedNestedOptions))\n }\n }\n\n // -------------------------------------------------------\n // Image resolution (complex multi-step process)\n // -------------------------------------------------------\n\n // Step 1: Get raw image values\n var rawImageUrl = pickFirst(record.image_url, record.mainImage, record.imageUrl)\n var rawGalleryImages = pickFirst(record.gallery_images, record.galleryImages)\n var legacyImages = Array.isArray(record.images)\n ? record.images.filter(function(v) { return typeof v === 'string' })\n : []\n\n // Step 2: Parse gallery images from JSON\n var parsedGalleryImages = parseJsonArray(rawGalleryImages)\n\n // Step 3: Build combined raw images array\n var allRawImages = []\n if (rawImageUrl) allRawImages.push(rawImageUrl)\n allRawImages = allRawImages.concat(parsedGalleryImages).concat(legacyImages)\n\n // Step 4: Resolve all asset URLs first, then deduplicate\n var resolvedAll = resolveAssetUrls(allRawImages, assetMap)\n var resolvedImages = deduplicateStrings(resolvedAll)\n\n // Step 5: Determine mainImage\n var mainImage = rawImageUrl\n ? resolveAssetUrl(rawImageUrl, assetMap)\n : (resolvedImages.length > 0 ? resolvedImages[0] : null)\n\n // Step 6: galleryImages = resolvedImages without mainImage\n var galleryImages = mainImage\n ? resolvedImages.filter(function(url) { return url !== mainImage })\n : resolvedImages.slice()\n\n // Step 7: images = full resolved deduplicated array\n var images = resolvedImages\n\n // -------------------------------------------------------\n // 3D model (one per product; GUI: constants/product-model.ts)\n // -------------------------------------------------------\n // model_url / model_poster_url hold an asset id OR a raw URL like image_url.\n // A poster the merchant never got (capture failed, plan cap) falls back to\n // the main image so <model-viewer> always has something to paint first.\n var modelUrl = resolveMediaUrl(record.model_url, assetMap)\n var modelPoster = modelUrl ? resolveMediaUrl(record.model_poster_url, assetMap) || mainImage : null\n var hasModel = modelUrl ? 'true' : 'false'\n // STRING gate ('true'/'false') like outOfStock: whether the model replaces the\n // main image on the card / details hero. A product whose only media is the\n // model always shows it. An absent field must read as the IMAGE branch, so the\n // builders gate the viewer on = 'true' and the image on != 'true'.\n var showModel =\n modelUrl && (normalizeShowModelFlag(record.show_model_by_default) || images.length === 0)\n ? 'true'\n : 'false'\n var galleryMedia = buildProductGalleryMedia({\n images: images,\n mainImage: mainImage,\n modelUrl: modelUrl,\n modelPoster: modelPoster,\n showModel: showModel,\n imageAlt: imageAlt,\n name: name,\n })\n // Everything that is not the hero \u2014 the details thumbnail strip. When the\n // model is the hero the main image moves in here, so the modal stays reachable.\n var galleryThumbnails = galleryMedia.slice(1)\n\n // Timestamps\n var rawCreatedAt = pickFirst(record.created_at, record.createdAt, record.created)\n var createdAt = normalizeTimestamp(rawCreatedAt)\n\n var rawUpdatedAt = pickFirst(record.updated_at, record.updatedAt, record.updated)\n var updatedAt = rawUpdatedAt != null ? normalizeTimestamp(rawUpdatedAt) : createdAt\n\n // Computed: created = Unix seconds\n var created = Math.floor(createdAt / 1000)\n\n // Computed: outOfStock (emitted as a STRING \u2014 'true' / 'false' \u2014 so it\n // matches the strict equality check the AI generates on product\n // cards/details pages: `ecommerceProduct?.outOfStock === 'true'`.\n // A boolean here would silently fail that comparison (boolean !==\n // string), leaving the Add to Cart button visible on out-of-stock\n // products. NULL/NaN quantity \u2192 'false' (treated as \"unlimited\n // stock\", same as the upstream cart-availability rewriter).\n // With ALLOW_BACKORDERS the merchant sells past zero (or does not track\n // stock at all), so nothing is ever presented as out of stock.\n var outOfStock = (!ALLOW_BACKORDERS && quantity !== null && !isNaN(quantity) && quantity <= 0) ? 'true' : 'false'\n\n // Per-value availability + default-selection flags, derived from the\n // purchasable combinations, so the storefront picker renders its\n // Default / Selected / Unavailable states with NO runtime fetch. The card\n // (N instances) first-paints from these data flags; the details page seeds\n // its selection from the same combinations. axisValueKey (axisKey|valueKey)\n // disambiguates same-named values across axes (Size \"s\" vs Sleeve \"s\").\n // Emitted as STRINGS to match the storefront's string-equality convention.\n var variantAxisKeys = []\n for (var ak = 0; ak < variantOptions.length; ak++) { variantAxisKeys.push(variantOptions[ak].key) }\n var firstVariant = pickFirstAvailableVariant(variants, variantAxisKeys)\n var deadValueKeys = computeDeadVariantKeys(variants, variantOptions, variantsResolved)\n for (var ai = 0; ai < variantOptions.length; ai++) {\n var vax = variantOptions[ai]\n for (var vj = 0; vj < vax.values.length; vj++) {\n var vval = vax.values[vj]\n var composite = vax.key + '|' + vval.value\n vval.axisValueKey = composite\n vval.isDead = deadValueKeys[composite] ? 'true' : 'false'\n vval.isDefault =\n firstVariant && firstVariant.options && firstVariant.options[vax.key] === vval.value\n ? 'true'\n : 'false'\n // Whether THIS value renders as a colour swatch vs a text pill. A colour\n // axis whose value has no resolvable colour degrades to a text pill so it\n // is never an invisible empty circle. Emitted as a string to match the\n // picker's string-equality rendering gate. MUST mirror the renderer\n // enrichment in packages/renderer/src/utils/ecommerce-products.ts.\n vval.showSwatch = vax.type === 'color' && vval.color ? 'true' : 'false'\n }\n }\n // The combination the picker auto-selects on first paint: the first in-stock\n // one covering every axis. Absent means there is nothing the shopper could add\n // without choosing \u2014 which is what gates the buy button below.\n var hasInStockCombination = !!(firstVariant && isVariantInStock(firstVariant))\n var defaultVariantId = hasInStockCombination ? firstVariant.id : ''\n var defaultVariantPrice = firstVariant ? formatVariantPrice(firstVariant.price, price) : ''\n // Gross (tax-inclusive) counterparts of the three price fields the STOREFRONT\n // renders. `price` / `defaultVariantPrice` / `variantsJson` deliberately stay\n // NET: the same transform feeds the merchant's admin panel, and every write\n // path (add-to-cart, checkout, the invoice route) re-reads the raw row and\n // must keep seeing the price the merchant typed. Only these three are safe to\n // gross, because nothing writes them back.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n // The scheduled markdown in force right now, resolved from the raw column on\n // every read so a discount starts and expires on its own. It comes off the NET\n // price BEFORE tax: tax is a percentage of what is actually charged.\n var activeDiscount = __pdResolveActive(record.discounts, Date.now())\n var displayPrice = grossMoney(__pdDiscountedPrice(price, activeDiscount))\n var defaultVariantNetPrice = firstVariant\n ? safeNumber(formatVariantPrice(firstVariant.price, price), 0)\n : 0\n var defaultVariantDisplayPrice = firstVariant\n ? grossMoney(__pdDiscountedPrice(defaultVariantNetPrice, activeDiscount))\n : ''\n var variantsDisplay = []\n for (var dv = 0; dv < variants.length; dv++) {\n var dvRow = variants[dv]\n // A null variant price INHERITS the product price, so discount and gross the\n // RESOLVED value \u2014 otherwise an inheriting combination would fall back to\n // the net base price in the picker while an overriding one showed gross.\n var dvNetPrice = dvRow.price == null ? price : dvRow.price\n variantsDisplay.push({\n id: dvRow.id,\n options: dvRow.options,\n price: applyStorefrontTax(__pdDiscountedPrice(dvNetPrice, activeDiscount)),\n // What this combination costs WITHOUT the discount, for the struck-through\n // price beside it. Null when nothing is discounted, so the picker's click\n // resolver can tell \"no saving\" from \"a saving of zero\".\n originalPrice: activeDiscount ? applyStorefrontTax(dvNetPrice) : null,\n quantity: dvRow.quantity,\n image_url: dvRow.image_url,\n })\n }\n // Discount display fields. Strings throughout: rendering conditions compare\n // operands as strings, and a bound text node prints its value verbatim.\n var hasDiscount = activeDiscount ? 'true' : 'false'\n var discountLabel = activeDiscount\n ? __pdDiscountLabel(activeDiscount, currencySymbol, getCurrencyInfo(currency).position)\n : ''\n var originalDisplayPrice = activeDiscount ? grossMoney(price) : ''\n var originalDefaultVariantDisplayPrice =\n activeDiscount && firstVariant ? grossMoney(defaultVariantNetPrice) : ''\n // Can this product be added to the cart at all, before the shopper touches the\n // picker? A STRING, same convention as outOfStock/requiresVariantSelection.\n //\n // flat product -> 'true' (stock is governed by outOfStock)\n // combinations UNKNOWN -> 'true' (the lookup could not run; a transient DB\n // failure must never make a whole catalogue\n // unbuyable \u2014 same permissive rule as\n // dead-marking)\n // otherwise -> whether an in-stock combination covering every axis\n // exists. 'false' therefore covers BOTH \"every\n // combination is sold out\" AND \"the axes have no\n // combinations at all\" \u2014 to a shopper those are the\n // same thing: there is no size to pick.\n //\n // MUST mirror packages/renderer/src/utils/ecommerce-products.ts.\n var hasPurchasableVariant =\n (requiresVariantSelection !== 'true' || !variantsResolved || hasInStockCombination)\n ? 'true'\n : 'false'\n // Aggregate review rating, from ONE batched COUNT/AVG over approved reviews\n // that transformRecords runs for every product on the page (keyed by product\n // id in options.ratingsByProductId).\n //\n // \u26D4 An ABSENT entry and a NULL map both mean \"no rating to show\", never \"0\n // stars\": a product nobody has reviewed and a product whose rating query\n // failed must both render with no stars at all. \"0.0 out of 5\" is the single\n // worst thing a storefront can say about a product by accident.\n //\n // MUST mirror buildProductRatingFields in the GUI's\n // features/e-commerce/utils/product-ratings.ts.\n var ratingsByProductId = options.ratingsByProductId || {}\n var ratingFields = __prBuildRatingFields(id != null ? ratingsByProductId[id] : null)\n\n // The individual reviews behind those stars, for the product page's JSON-LD.\n // Fetched ONLY for a single-product request (see getTransformWrapperCode), so\n // this is an empty array on every listing \u2014 which is correct: a listing emits\n // no Product markup to put them in.\n //\n // Not rendered anywhere. That is why there is no canvas mirror for it: the\n // three-mirror rule exists for fields the editor DRAWS, and structured data\n // is a publish-time concern the canvas has no equivalent of.\n var reviewsByProductId = options.reviewsByProductId || {}\n var reviews = (id != null && reviewsByProductId[id]) || []\n\n // Stringified companions: a data-* attr bound to an ARRAY renders\n // '[object Object]', so the picker's on-mount/click workflows read the\n // combinations + axes from these JSON strings via getAttribute.\n var variantOptionsJson = safeStringifyJson(variantOptions)\n var variantsJson = safeStringifyJson(variants)\n var variantsDisplayJson = safeStringifyJson(variantsDisplay)\n\n // Product options (configurator, configured price, card quick-add gate): the\n // three flags always, the detail only for the details page's single-record\n // fetch (options.includeOptionDetail). See buildProductOptionFields below.\n var optionFields = buildProductOptionFields(record, {\n productId: id != null ? String(id) : '',\n currentLanguage: currentLang,\n mainLanguage: mainLang,\n includeDetail: options.includeOptionDetail === true,\n basePrice: price,\n activeDiscount: activeDiscount,\n variants: variants,\n defaultVariant: firstVariant,\n symbol: currencySymbol,\n position: getCurrencyInfo(currency).position,\n assetMap: assetMap,\n })\n\n return {\n id: id,\n name: name,\n slug: slug,\n description: description,\n descriptionText: descriptionText,\n price: price,\n currency: currency,\n currencySymbol: currencySymbol,\n status: status,\n active: active,\n quantity: quantity,\n paymentType: paymentType,\n recurringInterval: recurringInterval,\n recurringIntervalCount: recurringIntervalCount,\n billingPeriod: billingPeriod,\n trialDays: trialDays,\n billingSummary: billingSummary,\n isDigital: isDigital,\n isGiftCard: isGiftCard,\n sku: sku,\n weight: weight,\n weightUnit: weightUnit,\n dimensions: dimensions,\n category: category,\n categories: categories,\n tags: tags,\n relatedProductIds: relatedProductIds,\n relatedProducts: relatedProducts,\n imageAlt: imageAlt,\n providerType: providerType,\n providerProductId: providerProductId,\n default_price: defaultPrice,\n metadata: metadata,\n visibleProperties: visibleProperties,\n mainImage: mainImage,\n imageUrl: mainImage,\n image_url: mainImage,\n galleryImages: galleryImages,\n images: images,\n modelUrl: modelUrl,\n modelPoster: modelPoster,\n hasModel: hasModel,\n showModel: showModel,\n galleryMedia: galleryMedia,\n galleryThumbnails: galleryThumbnails,\n createdAt: createdAt,\n updatedAt: updatedAt,\n created: created,\n outOfStock: outOfStock,\n variantOptions: variantOptions,\n requiresVariantSelection: requiresVariantSelection,\n variants: variants,\n variantOptionsJson: variantOptionsJson,\n variantsJson: variantsJson,\n variantsDisplayJson: variantsDisplayJson,\n defaultVariantId: defaultVariantId,\n defaultVariantPrice: defaultVariantPrice,\n displayPrice: displayPrice,\n defaultVariantDisplayPrice: defaultVariantDisplayPrice,\n hasDiscount: hasDiscount,\n discountLabel: discountLabel,\n originalDisplayPrice: originalDisplayPrice,\n originalDefaultVariantDisplayPrice: originalDefaultVariantDisplayPrice,\n hasPurchasableVariant: hasPurchasableVariant,\n // Aggregate review rating \u2014 see the ratingFields block above.\n ratingAverage: ratingFields.ratingAverage,\n ratingCount: ratingFields.ratingCount,\n ratingAverageLabel: ratingFields.ratingAverageLabel,\n ratingCountLabel: ratingFields.ratingCountLabel,\n hasRatings: ratingFields.hasRatings,\n ratingStar1: ratingFields.ratingStar1,\n ratingStar2: ratingFields.ratingStar2,\n ratingStar3: ratingFields.ratingStar3,\n ratingStar4: ratingFields.ratingStar4,\n ratingStar5: ratingFields.ratingStar5,\n // Domain-shaped, not schema.org-shaped: the head-config plugin's\n // 'reviewList' computed kind does the vocabulary. NO BACKTICKS ANYWHERE IN\n // THIS FILE \u2014 it is one template literal, and a backtick in a comment ends\n // the string.\n reviews: reviews,\n // Product options \u2014 see buildProductOptionFields.\n hasProductOptions: optionFields.hasProductOptions,\n optionsRequireInput: optionFields.optionsRequireInput,\n optionsQuickAdd: optionFields.optionsQuickAdd,\n optionGroups: optionFields.optionGroups,\n optionGroupsJson: optionFields.optionGroupsJson,\n optionsPricingJson: optionFields.optionsPricingJson,\n optionsDefaultDisplayPrice: optionFields.optionsDefaultDisplayPrice,\n optionsDefaultOriginalDisplayPrice: optionFields.optionsDefaultOriginalDisplayPrice,\n optionsDefaultSummary: optionFields.optionsDefaultSummary,\n optionsFreshKey: optionFields.optionsFreshKey,\n optionsValidKey: optionFields.optionsValidKey,\n optionsOriginalKey: optionFields.optionsOriginalKey,\n optionsSummaryKey: optionFields.optionsSummaryKey,\n optionsFilesKey: optionFields.optionsFilesKey,").concat(options.productPages
|
|
42
|
+
? "\n productPageKey: productPageKey,\n productPageUrl: buildProductPageUrl(productPageKey, { slug: slug, id: id }, record),"
|
|
43
|
+
: '', "\n }\n}\n\n// --- Variant availability helpers (mirror packages/renderer ecommerce-products) ---\n// A variant is in stock when its quantity is null (unlimited) or > 0. With\n// ALLOW_BACKORDERS every EXISTING combination is purchasable regardless of its\n// quantity \u2014 but a missing combination (null) is still not in stock: there is\n// no variant row to backorder.\nfunction isVariantInStock(v) {\n if (!v) return false\n if (ALLOW_BACKORDERS) return true\n return v.quantity == null || v.quantity > 0\n}\nfunction variantCoversAllAxes(v, axisKeys) {\n if (!v || !v.options) return false\n for (var i = 0; i < axisKeys.length; i++) {\n if (v.options[axisKeys[i]] == null) return false\n }\n return true\n}\n// First in-stock combination covering every axis; falls back to the first\n// covering combination (even if OOS) so a fully-OOS product still has a\n// visual default; null when no combination covers the axes.\nfunction pickFirstAvailableVariant(variants, axisKeys) {\n if (!Array.isArray(variants) || !axisKeys.length) return null\n for (var i = 0; i < variants.length; i++) {\n if (variantCoversAllAxes(variants[i], axisKeys) && isVariantInStock(variants[i])) return variants[i]\n }\n for (var j = 0; j < variants.length; j++) {\n if (variantCoversAllAxes(variants[j], axisKeys)) return variants[j]\n }\n return null\n}\n// Map of composite \"axisKey|valueKey\" -> true for every value that appears in\n// NO in-stock combination (rendered as the Unavailable state).\n//\n// variantsResolved is the whole subtlety: dead-marking is only meaningful\n// against combinations we actually LOOKED UP. When the lookup could not run,\n// nothing is dead \u2014 a missing table or a failed query must not grey out every\n// picker in the store. When it DID run, an empty list is a fact, not a gap:\n// every value is unbuyable and renders as Unavailable.\nfunction computeDeadVariantKeys(variants, axes, variantsResolved) {\n if (!variantsResolved || !Array.isArray(variants)) return {}\n var alive = {}\n for (var i = 0; i < variants.length; i++) {\n var v = variants[i]\n if (!isVariantInStock(v)) continue\n for (var k in v.options) {\n if (Object.prototype.hasOwnProperty.call(v.options, k)) alive[k + '|' + v.options[k]] = true\n }\n }\n var dead = {}\n for (var a = 0; a < axes.length; a++) {\n var ax = axes[a]\n for (var b = 0; b < ax.values.length; b++) {\n var key = ax.key + '|' + ax.values[b].value\n if (!alive[key]) dead[key] = true\n }\n }\n return dead\n}\n// Variant price with base-price inheritance (null variant price -> product price).\nfunction formatVariantPrice(vprice, basePrice) {\n var p = vprice == null || isNaN(vprice) ? basePrice : vprice\n return String(Number(p).toFixed(2))\n}\nfunction safeStringifyJson(obj) {\n try {\n return JSON.stringify(obj)\n } catch (e) {\n return '[]'\n }\n}\n\n// --- Product options -------------------------------------------------------\n// MUST mirror buildStorefrontProductOptionFields in the GUI's\n// features/e-commerce/utils/product-options/product-options-storefront.ts\n// (the canvas copy): both run over __tests__/product-options-storefront.fixture.json.\n//\n// Always emitted: hasProductOptions, optionsRequireInput, optionsQuickAdd.\n// Everything else is details-page only, so a grid of cards never ships every\n// product's option tree. Structure and prices come from the main option_groups;\n// the request language's clone only overlays strings, so a stale translation\n// can never misprice. Flags are 'true'/'false' STRINGS, prices gross 2-decimal\n// strings, and every state key is scoped by the product id.\n\n// The configurator's state-key prefixes. MUST mirror OPTION_KEY_PREFIX in the\n// GUI's features/e-commerce/utils/product-options/product-options-uidl.ts.\nvar OPTION_STATE_KEY_PREFIX = {\n selected: 'sel:',\n hidden: 'hide:',\n shown: 'show:',\n missing: 'miss:',\n invalid: 'inv:',\n rejected: 'rej:',\n failed: 'fail:',\n uploaded: 'up:',\n uploading: 'load:',\n fresh: 'fresh:',\n valid: 'valid:',\n original: 'orig:',\n summary: 'sum:',\n files: 'files:',\n}\n\n// A product without options, and the detail-free shape a listing gets.\nfunction emptyProductOptionFields() {\n return {\n hasProductOptions: 'false',\n optionsRequireInput: 'false',\n optionsQuickAdd: 'true',\n optionGroups: [],\n optionGroupsJson: '[]',\n optionsPricingJson: '{}',\n optionsDefaultDisplayPrice: '',\n optionsDefaultOriginalDisplayPrice: '',\n optionsDefaultSummary: '',\n optionsFreshKey: '',\n optionsValidKey: '',\n optionsOriginalKey: '',\n optionsSummaryKey: '',\n optionsFilesKey: '',\n }\n}\n\nfunction buildProductOptionFields(record, ctx) {\n var groups = __poParseGroups(pickFirst(record.option_groups, record.optionGroups))\n if (groups.length === 0) return emptyProductOptionFields()\n // Gift cards and subscriptions may carry options, never priced ones.\n var pricesDisabled =\n __poFlag(pickFirst(record.is_gift_card, record.isGiftCard)) ||\n __poText(pickFirst(record.payment_type, record.paymentType)).trim().toLowerCase() === 'recurring'\n var defaultNet = ctx.defaultVariant\n ? optionChargedNet(ctx.defaultVariant, ctx)\n : __pdDiscountedPrice(ctx.basePrice, ctx.activeDiscount)\n var fields = emptyProductOptionFields()\n fields.hasProductOptions = 'true'\n fields.optionsRequireInput = __poRequiresInput(groups) ? 'true' : 'false'\n fields.optionsQuickAdd = __poQuickAdd(groups, defaultNet, pricesDisabled) ? 'true' : 'false'\n if (!ctx.includeDetail) return fields\n\n var localized = __poLocalize({ main: groups, clone: readOptionGroupsClone(record, ctx) })\n var initial = __poResolve(defaultNet, localized, __poDefaultEntries(localized), pricesDisabled)\n var view = {\n productId: ctx.productId,\n freshKey: OPTION_STATE_KEY_PREFIX.fresh + ctx.productId,\n // The default answers: which price variant is in force before the shopper\n // touches anything.\n selection: __poDefaultSelection(localized),\n visibleKeys: initial.visibleKeys,\n pricesDisabled: pricesDisabled,\n symbol: ctx.symbol,\n position: ctx.position,\n assetMap: ctx.assetMap,\n }\n var defaultOriginalNet = ctx.defaultVariant\n ? optionListNet(ctx.defaultVariant, ctx)\n : __pdDiscountedPrice(ctx.basePrice, null)\n var optionGroups = []\n for (var gi = 0; gi < localized.length; gi++) {\n optionGroups.push(buildOptionGroupView(localized[gi], view))\n }\n fields.optionGroups = optionGroups\n fields.optionGroupsJson = __poSerialize(withResolvedOptionValueImages(localized, ctx.assetMap)) || '[]'\n fields.optionsPricingJson = JSON.stringify(buildOptionPricing(ctx, pricesDisabled))\n fields.optionsDefaultDisplayPrice = optionDisplayMoney(defaultNet + initial.delta)\n fields.optionsDefaultOriginalDisplayPrice = ctx.activeDiscount\n ? optionDisplayMoney(defaultOriginalNet + initial.delta)\n : ''\n fields.optionsDefaultSummary = initial.label\n fields.optionsFreshKey = view.freshKey\n fields.optionsValidKey = OPTION_STATE_KEY_PREFIX.valid + ctx.productId\n fields.optionsOriginalKey = OPTION_STATE_KEY_PREFIX.original + ctx.productId\n fields.optionsSummaryKey = OPTION_STATE_KEY_PREFIX.summary + ctx.productId\n fields.optionsFilesKey = OPTION_STATE_KEY_PREFIX.files + ctx.productId\n return fields\n}\n\nfunction buildOptionGroupView(group, view) {\n var type = group.type\n var isChoice = type === 'choice'\n var isMulti = type === 'multi-choice'\n var isToggle = type === 'toggle'\n var listsValues = isChoice || isMulti\n var isSwatches = listsValues && group.display === 'swatches'\n var isCards = listsValues && group.display === 'cards'\n var isSelectable = listsValues || isToggle\n var scope = view.productId + '|' + group.key\n var priceVariants =\n __poPricedTypes.indexOf(type) !== -1\n ? buildOptionPriceVariants(group, group.key, null, type === 'number' ? group.unit : undefined, view)\n : []\n var acceptKind = type === 'upload' ? group.accept || 'any' : ''\n var values = []\n if (listsValues) {\n var groupValues = group.values || []\n for (var vi = 0; vi < groupValues.length; vi++) {\n values.push(buildOptionValueView(group, groupValues[vi], isSwatches, isCards, view))\n }\n } else if (isToggle) {\n values.push(buildOptionToggleValue(group, priceVariants, view))\n }\n var input = describeOptionInput(group)\n return {\n id: scope,\n key: group.key,\n label: group.label,\n helpText: group.helpText || '',\n placeholder: group.placeholder || '',\n unit: group.unit || '',\n required: group.required === true ? 'true' : 'false',\n isChoice: isChoice ? 'true' : 'false',\n isMulti: isMulti ? 'true' : 'false',\n isText: type === 'text' ? 'true' : 'false',\n isLongText: type === 'long-text' ? 'true' : 'false',\n isNumber: type === 'number' ? 'true' : 'false',\n isToggle: isToggle ? 'true' : 'false',\n isUpload: type === 'upload' ? 'true' : 'false',\n isButtons: isSelectable && !isSwatches && !isCards ? 'true' : 'false',\n isSwatches: isSwatches ? 'true' : 'false',\n isCards: isCards ? 'true' : 'false',\n isSelectable: isSelectable ? 'true' : 'false',\n isInput: type === 'text' || type === 'number' ? 'true' : 'false',\n initiallyVisible: view.visibleKeys.indexOf(group.key) !== -1 ? 'true' : 'false',\n cellRole: isChoice ? 'radio' : isMulti ? 'checkbox' : isToggle ? 'switch' : '',\n rowRole: isChoice ? 'radiogroup' : 'group',\n // aria-required is allowed on a radiogroup only: a group row gets null, which\n // renders no attribute (never undefined, which getStaticProps cannot serialize).\n rowRequired: isChoice ? (group.required === true ? 'true' : 'false') : null,\n idleTabIndex: isChoice ? '-1' : '0',\n inputType: input.inputType,\n inputMode: input.inputMode,\n maxLengthText: input.maxLengthText,\n minText: input.minText,\n maxText: input.maxText,\n stepText: input.stepText,\n defaultValueText: input.defaultValueText,\n rangeHint: input.rangeHint,\n acceptKind: acceptKind,\n acceptMime: acceptKind ? __poUploadMime[acceptKind].join(',') : '',\n priceVariants: priceVariants,\n headerPriceVariants: isSelectable ? [] : priceVariants,\n hiddenKey: OPTION_STATE_KEY_PREFIX.hidden + scope,\n shownKey: OPTION_STATE_KEY_PREFIX.shown + scope,\n missingKey: OPTION_STATE_KEY_PREFIX.missing + scope,\n invalidKey: OPTION_STATE_KEY_PREFIX.invalid + scope,\n rejectedKey: OPTION_STATE_KEY_PREFIX.rejected + scope,\n failedKey: OPTION_STATE_KEY_PREFIX.failed + scope,\n uploadedKey: OPTION_STATE_KEY_PREFIX.uploaded + scope,\n // Product-level: one upload at a time blocks every upload button.\n uploadingKey: OPTION_STATE_KEY_PREFIX.uploading + view.productId,\n freshKey: view.freshKey,\n values: values,\n }\n}\n\n// The groups as the page's resolver reads them (data-option-groups), with each\n// value's picture as a URL the browser can load: the shopper's choices hand it\n// on to anything on the page that shows the chosen value's picture. A picture\n// that resolves to nothing is dropped, never shipped as a bare asset id. TWIN\n// of withResolvedValueImages in the GUI's product-options-storefront.ts.\nfunction withResolvedOptionValueImages(groups, assetMap) {\n var out = []\n for (var gi = 0; gi < groups.length; gi++) {\n var group = groups[gi]\n if (!Array.isArray(group.values)) {\n out.push(group)\n continue\n }\n var values = []\n for (var vi = 0; vi < group.values.length; vi++) {\n var value = group.values[vi]\n if (!value.imageUrl) {\n values.push(value)\n continue\n }\n var valueCopy = {}\n for (var vk in value) {\n if (Object.prototype.hasOwnProperty.call(value, vk) && vk !== 'imageUrl') valueCopy[vk] = value[vk]\n }\n var resolved = resolveMediaUrl(value.imageUrl, assetMap) || ''\n if (resolved) valueCopy.imageUrl = resolved\n values.push(valueCopy)\n }\n var groupCopy = {}\n for (var gk in group) {\n if (Object.prototype.hasOwnProperty.call(group, gk)) groupCopy[gk] = group[gk]\n }\n groupCopy.values = values\n out.push(groupCopy)\n }\n return out\n}\n\nfunction buildOptionValueView(group, value, isSwatches, isCards, view) {\n var scope = view.productId + '|' + group.key + '|' + value.key\n var color = value.color || ''\n // An asset id that resolves to nothing is never shipped as a bare id.\n var imageUrl = value.imageUrl ? resolveMediaUrl(value.imageUrl, view.assetMap) || '' : ''\n return {\n id: scope,\n key: value.key,\n label: value.label,\n description: value.description || '',\n color: color,\n imageUrl: imageUrl,\n isDefault: value['default'] === true ? 'true' : 'false',\n showSwatch: isSwatches && color !== '' ? 'true' : 'false',\n showImage: isCards && imageUrl !== '' ? 'true' : 'false',\n selectedKey: OPTION_STATE_KEY_PREFIX.selected + scope,\n freshKey: view.freshKey,\n priceVariants: buildOptionPriceVariants(value, group.key, value.key, undefined, view),\n }\n}\n\n// A toggle is one switch cell: its label is the group's, its price the group's price.\nfunction buildOptionToggleValue(group, priceVariants, view) {\n var scope = view.productId + '|' + group.key + '|on'\n return {\n id: scope,\n key: 'on',\n label: group.label,\n description: '',\n color: '',\n imageUrl: '',\n isDefault: 'false',\n showSwatch: 'false',\n showImage: 'false',\n selectedKey: OPTION_STATE_KEY_PREFIX.selected + scope,\n freshKey: view.freshKey,\n priceVariants: priceVariants,\n }\n}\n\n// One entry per priceWhen override (in order), then the default price ('d').\n// The configurator shows the entry whose activeKey the resolver wrote, or the\n// isInitial one before the shopper touched anything.\nfunction buildOptionPriceVariants(target, groupKey, valueKey, unit, view) {\n var initial = __poEffectiveVariant(target, view.selection, view.visibleKeys)\n var overrides = Array.isArray(target.priceWhen) ? target.priceWhen : []\n var variants = []\n for (var oi = 0; oi < overrides.length; oi++) {\n variants.push(buildOptionPriceVariant(overrides[oi].price, oi, initial, groupKey, valueKey, unit, view))\n }\n variants.push(buildOptionPriceVariant(target.price, 'd', initial, groupKey, valueKey, unit, view))\n return variants\n}\n\nfunction buildOptionPriceVariant(rule, variant, initial, groupKey, valueKey, unit, view) {\n return {\n activeKey: __poPriceVariantKey(view.productId, groupKey, valueKey, variant),\n label: view.pricesDisabled\n ? ''\n : __poPriceLabel(rule, view.symbol, view.position, STOREFRONT_TAX_RATE, unit),\n isInitial: variant === initial ? 'true' : 'false',\n // Repeated on every variant: the badge gates inside this list's mapper can\n // only read the list's own item.\n freshKey: view.freshKey,\n }\n}\n\nfunction describeOptionInput(group) {\n var input = {\n inputType: '',\n inputMode: '',\n maxLengthText: '',\n minText: '',\n maxText: '',\n stepText: '',\n defaultValueText: '',\n rangeHint: '',\n }\n if (group.type === 'text' || group.type === 'long-text') {\n var limit = group.type === 'text' ? __poLimits.text : __poLimits.longText\n input.inputType = 'text'\n input.inputMode = 'text'\n input.maxLengthText = String(typeof group.maxLength === 'number' ? Math.min(limit, group.maxLength) : limit)\n } else if (group.type === 'number') {\n input.inputType = 'number'\n input.inputMode = 'decimal'\n input.minText = typeof group.min === 'number' ? String(group.min) : ''\n input.maxText = typeof group.max === 'number' ? String(group.max) : ''\n input.stepText = typeof group.step === 'number' ? String(group.step) : 'any'\n input.defaultValueText = typeof group.defaultValue === 'number' ? String(group.defaultValue) : ''\n input.rangeHint = describeOptionRange(input.minText, input.maxText, group.unit || '')\n }\n return input\n}\n\n// \"10 \u2013 200 cm\", \"\u2265 10 cm\", \"\u2264 200 cm\"; '' without bounds.\nfunction describeOptionRange(minText, maxText, unit) {\n var range = ''\n if (minText && maxText) range = minText + ' \u2013 ' + maxText\n else if (minText) range = '\u2265 ' + minText\n else if (maxText) range = '\u2264 ' + maxText\n return range && unit ? range + ' ' + unit : range\n}\n\n// What the configurator's resolver needs to price the combination the shopper\n// picks in the variant picker: every combination, charged and list NET.\nfunction buildOptionPricing(ctx, pricesDisabled) {\n var discounted = !!ctx.activeDiscount\n var variants = []\n for (var pi = 0; pi < ctx.variants.length; pi++) {\n var variant = ctx.variants[pi]\n variants.push({\n id: variant.id,\n options: variant.options,\n net: optionChargedNet(variant, ctx),\n originalNet: discounted ? optionListNet(variant, ctx) : null,\n })\n }\n return {\n productId: ctx.productId,\n netBase: __pdDiscountedPrice(ctx.basePrice, ctx.activeDiscount),\n originalNetBase: discounted ? __pdDiscountedPrice(ctx.basePrice, null) : null,\n taxRate: STOREFRONT_TAX_RATE,\n symbol: ctx.symbol,\n position: ctx.position,\n pricesDisabled: pricesDisabled,\n defaultCombinationId: ctx.defaultVariant ? ctx.defaultVariant.id : '',\n variants: variants,\n }\n}\n\n// A combination's own price, else the product's (a null price inherits).\nfunction optionVariantListPrice(variant, basePrice) {\n return typeof variant.price === 'number' && isFinite(variant.price) ? variant.price : basePrice\n}\nfunction optionChargedNet(variant, ctx) {\n return __pdDiscountedPrice(optionVariantListPrice(variant, ctx.basePrice), ctx.activeDiscount)\n}\nfunction optionListNet(variant, ctx) {\n return __pdDiscountedPrice(optionVariantListPrice(variant, ctx.basePrice), null)\n}\n\nfunction readOptionGroupsClone(record, ctx) {\n if (!ctx.currentLanguage || !ctx.mainLanguage || ctx.currentLanguage === ctx.mainLanguage) return null\n return record[ctx.currentLanguage + '_option_groups']\n}\n\n// A unit price as the shopper reads it: NET rounded to the cent, then gross.\nfunction optionDisplayMoney(net) {\n return applyStorefrontTax(__poRound2(net)).toFixed(2)\n}\n\n// Parse + normalize the variant_options JSON into render-ready axes. Tolerant of\n// null / malformed / partial data (returns []). Keeps ONLY the fields the pill\n// widget needs; the labels are already language-resolved by the caller.\nfunction normalizeVariantOptions(raw) {\n if (!raw) return []\n var parsed = raw\n if (typeof raw === 'string') {\n try { parsed = JSON.parse(raw) } catch (e) { return [] }\n }\n if (!Array.isArray(parsed)) return []\n var out = []\n for (var i = 0; i < parsed.length; i++) {\n var axis = parsed[i]\n if (!axis || typeof axis !== 'object' || !Array.isArray(axis.values)) continue\n var name = typeof axis.name === 'string' ? axis.name : ''\n var key = typeof axis.key === 'string' && axis.key ? axis.key : name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!key) continue\n var type = axis.type === 'color' ? 'color' : 'text'\n var values = []\n for (var j = 0; j < axis.values.length; j++) {\n var v = axis.values[j]\n if (!v || typeof v !== 'object') continue\n var label = typeof v.label === 'string' ? v.label : ''\n var value = typeof v.value === 'string' && v.value ? v.value : label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')\n if (!value) continue\n var entry = { value: value, label: label }\n if (type === 'color' && typeof v.color === 'string' && v.color) entry.color = v.color\n values.push(entry)\n }\n if (!values.length) continue\n out.push({ key: key, name: name, type: type, values: values })\n }\n return out\n}\n\n// --- Custom-property classification (MUST mirror product-property-display.ts) ---\n\n// Product-attribute keys that can leak into the free-form `metadata` map (e.g.\n// from a payment-provider sync or legacy data) but are NOT user custom\n// properties. Kept in sync with the GUI's RESERVED_PRODUCT_METADATA_KEYS.\nvar RESERVED_PRODUCT_METADATA_KEYS = {\n paymentType: true, recurringInterval: true, recurringIntervalCount: true,\n price: true, currency: true, status: true, sku: true, weightUnit: true,\n dimensions: true, category: true, brand: true, gtin: true, mpn: true,\n condition: true, tags: true,\n}\nfunction isReservedProductMetadataKey(key) {\n return !!RESERVED_PRODUCT_METADATA_KEYS[key] ||\n key.indexOf('mainImage') === 0 ||\n key.indexOf('secondaryImage') === 0 ||\n key.indexOf('_tq') === 0\n}\n\nvar CSS_NAMED_COLORS = {\n aliceblue: 1, antiquewhite: 1, aqua: 1, aquamarine: 1, azure: 1, beige: 1, bisque: 1,\n black: 1, blanchedalmond: 1, blue: 1, blueviolet: 1, brown: 1, burlywood: 1,\n cadetblue: 1, chartreuse: 1, chocolate: 1, coral: 1, cornflowerblue: 1, cornsilk: 1,\n crimson: 1, cyan: 1, darkblue: 1, darkcyan: 1, darkgoldenrod: 1, darkgray: 1,\n darkgrey: 1, darkgreen: 1, darkkhaki: 1, darkmagenta: 1, darkolivegreen: 1,\n darkorange: 1, darkorchid: 1, darkred: 1, darksalmon: 1, darkseagreen: 1,\n darkslateblue: 1, darkslategray: 1, darkslategrey: 1, darkturquoise: 1,\n darkviolet: 1, deeppink: 1, deepskyblue: 1, dimgray: 1, dimgrey: 1, dodgerblue: 1,\n firebrick: 1, floralwhite: 1, forestgreen: 1, fuchsia: 1, gainsboro: 1,\n ghostwhite: 1, gold: 1, goldenrod: 1, gray: 1, grey: 1, green: 1, greenyellow: 1,\n honeydew: 1, hotpink: 1, indianred: 1, indigo: 1, ivory: 1, khaki: 1, lavender: 1,\n lavenderblush: 1, lawngreen: 1, lemonchiffon: 1, lightblue: 1, lightcoral: 1,\n lightcyan: 1, lightgoldenrodyellow: 1, lightgray: 1, lightgrey: 1, lightgreen: 1,\n lightpink: 1, lightsalmon: 1, lightseagreen: 1, lightskyblue: 1, lightslategray: 1,\n lightslategrey: 1, lightsteelblue: 1, lightyellow: 1, lime: 1, limegreen: 1,\n linen: 1, magenta: 1, maroon: 1, mediumaquamarine: 1, mediumblue: 1,\n mediumorchid: 1, mediumpurple: 1, mediumseagreen: 1, mediumslateblue: 1,\n mediumspringgreen: 1, mediumturquoise: 1, mediumvioletred: 1, midnightblue: 1,\n mintcream: 1, mistyrose: 1, moccasin: 1, navajowhite: 1, navy: 1, oldlace: 1,\n olive: 1, olivedrab: 1, orange: 1, orangered: 1, orchid: 1, palegoldenrod: 1,\n palegreen: 1, paleturquoise: 1, palevioletred: 1, papayawhip: 1, peachpuff: 1,\n peru: 1, pink: 1, plum: 1, powderblue: 1, purple: 1, rebeccapurple: 1, red: 1,\n rosybrown: 1, royalblue: 1, saddlebrown: 1, salmon: 1, sandybrown: 1, seagreen: 1,\n seashell: 1, sienna: 1, silver: 1, skyblue: 1, slateblue: 1, slategray: 1,\n slategrey: 1, snow: 1, springgreen: 1, steelblue: 1, tan: 1, teal: 1, thistle: 1,\n tomato: 1, turquoise: 1, violet: 1, wheat: 1, white: 1, whitesmoke: 1, yellow: 1,\n yellowgreen: 1, transparent: 1,\n}\nvar HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i\nvar FUNCTIONAL_COLOR_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\\((?:[0-9a-z%.,\\s/+-]+)\\)$/i\nvar COLOR_KEY_WORDS = {\n color: 1, colors: 1, colour: 1, colours: 1, shade: 1, shades: 1, hue: 1, hues: 1,\n tint: 1, tints: 1, tone: 1, tones: 1, swatch: 1, swatches: 1,\n}\nfunction splitKeyWords(key) {\n return key.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)\n}\nfunction keyIndicatesColor(key) {\n var normalized = key.toLowerCase()\n if (/colou?r/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!COLOR_KEY_WORDS[word] })\n}\nfunction resolvePropertyColor(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (HEX_COLOR_RE.test(v) || FUNCTIONAL_COLOR_RE.test(v)) return v\n if (CSS_NAMED_COLORS[v.toLowerCase()] && keyIndicatesColor(key)) return v\n return ''\n}\n\nvar DATA_IMAGE_URI_RE = /^data:image\\//i\nvar ABSOLUTE_URL_RE = /^(?:https?:)?\\/\\//i\nvar IMAGE_EXTENSION_RE = /\\.(?:png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?|heic|heif)$/i\nvar IMAGE_KEY_WORDS = {\n image: 1, images: 1, img: 1, imgs: 1, photo: 1, photos: 1, picture: 1, pictures: 1,\n pic: 1, pics: 1, thumbnail: 1, thumbnails: 1, thumb: 1, thumbs: 1, icon: 1, icons: 1,\n logo: 1, logos: 1, avatar: 1, avatars: 1, banner: 1, banners: 1, cover: 1, covers: 1,\n gallery: 1, screenshot: 1, screenshots: 1, poster: 1, posters: 1, artwork: 1,\n graphic: 1, graphics: 1, illustration: 1, wallpaper: 1, preview: 1, snapshot: 1,\n media: 1, visual: 1, visuals: 1,\n}\nfunction keyIndicatesImage(key) {\n var normalized = key.toLowerCase()\n if (/(?:image|img|photo|picture|thumb|icon|logo|avatar|banner|gallery|screenshot|poster|artwork|graphic|wallpaper)/.test(normalized)) return true\n return splitKeyWords(key).some(function(word) { return !!IMAGE_KEY_WORDS[word] })\n}\nfunction stripUrlQueryAndHash(url) {\n return url.split(/[?#]/)[0]\n}\nfunction resolvePropertyImage(key, value) {\n var v = value.trim()\n if (!v) return ''\n if (DATA_IMAGE_URI_RE.test(v)) return v\n var isAbsoluteUrl = ABSOLUTE_URL_RE.test(v)\n var isRootRelative = v.charAt(0) === '/' && !isAbsoluteUrl\n if (!isAbsoluteUrl && !isRootRelative) return ''\n if (IMAGE_EXTENSION_RE.test(stripUrlQueryAndHash(v))) return v\n if (isAbsoluteUrl && keyIndicatesImage(key)) return v\n return ''\n}\n\n// Image wins over colour (a URL can never be a CSS colour, but checking image\n// first keeps the precedence explicit), text is the fallback.\nfunction classifyProductProperty(key, value) {\n var imageUrl = resolvePropertyImage(key, value)\n if (imageUrl) return { key: key, value: value, kind: 'image', color: '', imageUrl: imageUrl }\n var color = resolvePropertyColor(key, value)\n if (color) return { key: key, value: value, kind: 'color', color: color, imageUrl: '' }\n return { key: key, value: value, kind: 'text', color: '', imageUrl: '' }\n}\n\nfunction transformEcommerceProducts(records, options) {\n if (!Array.isArray(records)) return []\n return records.map(function(record) { return buildEcommerceProduct(record, options) })\n}\n\n// Batched fetch of the purchasable variant rows for a set of product ids, keyed\n// by product_id, so buildEcommerceProduct can attach product.variants without a\n// per-product query. Best-effort: a store with no variants (or before the table\n// exists) yields an empty map and flat products keep variants: [].\n// Batched fetch of the product rows a set of products reference as \"related\",\n// keyed by id, so buildEcommerceProduct can attach product.relatedProducts\n// without a query per product.\n//\n// Best-effort by design: a store whose products table predates the column\n// yields no ids at all (the read below is of an absent property, not of a\n// column \u2014 the SQL never names it), the map stays empty, and the details page's\n// rail simply stays hidden behind its is_not_empty gate. Same for a failed\n// query.\nasync function getRelatedProductsMap(getClientFn, records) {\n var map = {}\n if (!Array.isArray(records) || records.length === 0) return map\n\n var wantedIds = []\n for (var i = 0; i < records.length; i++) {\n var ids = parseRelatedIds(records[i] && records[i].related_product_ids)\n for (var j = 0; j < ids.length; j++) {\n if (wantedIds.indexOf(ids[j]) === -1) wantedIds.push(ids[j])\n }\n }\n if (wantedIds.length === 0) return map\n\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query('SELECT * FROM teleport_products WHERE id = ANY($1)', [\n wantedIds,\n ])\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.id != null) map[String(row.id)] = row\n }\n }\n } catch (e) {\n // Leaves relatedProducts empty rather than failing the page.\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// Aggregate review rating for every product on the page, keyed by product id,\n// so a card can draw its star row without a query per product.\n//\n// APPROVED ONLY, and 'approved' is a fixed literal rather than a bound value\n// because nothing may ever widen it: a pending review is one the merchant has\n// not agreed to publish, and a rejected one is a review they actively removed.\n//\n// Unlike getVariantsMap this returns an EMPTY MAP on failure rather than null.\n// The two \"unknown\" answers differ because the consequences differ: an unknown\n// variant map must stay permissive (never make a catalogue unbuyable), whereas\n// an unknown rating has exactly one safe rendering \u2014 no stars \u2014 which is also\n// what a product with no reviews shows. Collapsing both onto {} means every\n// caller has one case to handle instead of two.\nasync function getRatingsMap(getClientFn, productIds) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n \"SELECT product_id, AVG(rating)::float AS average, COUNT(*)::int AS count \" +\n 'FROM teleport_product_reviews ' +\n \"WHERE status = 'approved' AND rating IS NOT NULL AND product_id = ANY($1) \" +\n 'GROUP BY product_id',\n [uniqueIds]\n )\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (row && row.product_id != null) {\n map[row.product_id] = { average: row.average, count: row.count }\n }\n }\n }\n } catch (e) {\n // teleport_product_reviews may not exist (reviews were never enabled), or\n // the query may have failed. Either way there is no rating to show, which\n // is exactly what an empty map produces.\n map = {}\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// The most recent APPROVED reviews for a product, for the product page's\n// structured data. Keyed by product id like every other batched lookup, though\n// in practice it is only ever called with one id.\n//\n// Three filters, and each one exists because of what the output is used for:\n// - APPROVED only, as a fixed literal, for the same reason the aggregate is:\n// a pending review is one the merchant has not agreed to publish.\n// - A NON-EMPTY reviewer name. Google requires author.name on a review\n// snippet, and an anonymous row would produce a Review object it rejects \u2014\n// taking the whole Product block down with it on some validators.\n// - A CAP. The reviews are inlined into the page's props and then into its\n// HTML twice over (once as data, once as JSON-LD); a product with four\n// hundred reviews would otherwise put all of them in both.\nasync function getProductReviewsMap(getClientFn, productIds, perProduct) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n 'SELECT product_id, reviewer_name, rating, content, created_at ' +\n 'FROM teleport_product_reviews ' +\n \"WHERE status = 'approved' AND rating IS NOT NULL \" +\n \"AND reviewer_name IS NOT NULL AND btrim(reviewer_name) <> '' \" +\n 'AND product_id = ANY($1) ' +\n 'ORDER BY created_at DESC',\n [uniqueIds]\n )\n var cap = typeof perProduct === 'number' && perProduct > 0 ? perProduct : 5\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n if (!row || row.product_id == null) continue\n var bucket = map[row.product_id] || (map[row.product_id] = [])\n if (bucket.length >= cap) continue\n bucket.push({\n author: String(row.reviewer_name).trim(),\n rating: Number(row.rating),\n body: row.content == null ? '' : String(row.content),\n datePublished: row.created_at ? new Date(row.created_at).toISOString() : null,\n })\n }\n }\n } catch (e) {\n // The reviews table may not exist (reviews were never enabled). No reviews\n // to show is both the empty answer and the safe one.\n map = {}\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n\n// Combinations for every product on the page, keyed by product id. Returns NULL\n// when the lookup could not run, which the transform reads as \"unknown\" and\n// stays permissive with; an empty map means the query ran and this catalogue\n// simply has no combinations.\nasync function getVariantsMap(getClientFn, productIds) {\n var map = {}\n if (!Array.isArray(productIds) || productIds.length === 0) return map\n var uniqueIds = []\n var seenIds = {}\n for (var u = 0; u < productIds.length; u++) {\n var pid0 = productIds[u]\n if (pid0 != null && !seenIds[pid0]) { seenIds[pid0] = true; uniqueIds.push(pid0) }\n }\n if (uniqueIds.length === 0) return map\n var client\n try {\n client = getClientFn()\n await client.connect()\n var result = await client.query(\n 'SELECT id, product_id, options, price, quantity, image_url FROM teleport_product_variants WHERE product_id = ANY($1) ORDER BY position ASC, id ASC',\n [uniqueIds]\n )\n if (result && result.rows) {\n for (var r = 0; r < result.rows.length; r++) {\n var row = result.rows[r]\n var pid = row.product_id\n if (!map[pid]) map[pid] = []\n map[pid].push(row)\n }\n }\n } catch (e) {\n // teleport_product_variants may not exist for a non-variant store, or the\n // query may have failed. Either way the combinations are UNKNOWN \u2014 null\n // keeps every picker selectable instead of declaring the catalogue unbuyable.\n map = null\n } finally {\n if (client) {\n try { await client.end() } catch (e) { /* ignore */ }\n }\n }\n return map\n}\n");
|
|
17
44
|
};
|
|
18
45
|
exports.generateEcommerceProductTransformationCode = generateEcommerceProductTransformationCode;
|
|
19
46
|
//# sourceMappingURL=ecommerce-product.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ecommerce-product.js","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":";;;AACA,+
|
|
1
|
+
{"version":3,"file":"ecommerce-product.js","sourceRoot":"","sources":["../../../src/transformations/ecommerce-product.ts"],"names":[],"mappings":";;;AACA,+DAKoC;AACpC,yDAAkE;AAqClE;;;;;;GAMG;AACH,IAAM,wBAAwB,GAAG,UAAC,YAAuC;IACvE,IAAM,QAAQ,GAAG,YAAY,CAAC,QAAQ,CAAA;IACtC,IAAM,KAAK,GAAG;QACZ,QAAQ,EACN,QAAQ,IAAI,OAAO,QAAQ,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,QAAQ,CAAC,SAAS,KAAK,QAAQ;YACtF,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,CAAC,SAAS,EAAE;YAC1D,CAAC,CAAC,IAAI;QACV,KAAK,EAAE,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CACjD,UAAC,GAAyD,EAAE,GAAG;YAC7D,IAAM,IAAI,GAAG,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YACpC,IAAI,IAAI,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,IAAI,CAAC,SAAS,KAAK,QAAQ,EAAE;gBAChF,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAA;aAC5D;YACD,OAAO,GAAG,CAAA;QACZ,CAAC,EACD,EAAE,CACH;KACF,CAAA;IACD,OAAO,6TAKa,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,y9BAoB1C,CAAA;AACD,CAAC,CAAA;AAED;;;;GAIG;AACI,IAAM,0CAA0C,GAAG,UACxD,OAA8C;;IAA9C,wBAAA,EAAA,YAA8C;IAE9C,IAAM,aAAa,GAAG,+BAAa,CAAC,+BAA+B,CACjE,MAAA,OAAO,CAAC,iBAAiB,mCAAI,CAAC,CAC/B,CAAA;IACD,IAAM,sBAAsB,GAAG,OAAO,CAAC,eAAe,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAA;IAElF,OAAO,YACP,aAAa,eACb,kCAAgB,CAAC,iCAAiC,EAAE,eACpD,gCAAc,CAAC,gCAAgC,EAAE,eACjD,gCAAc,CAAC,+BAA+B,EAAE,yRAKzB,sBAAsB,eAC7C,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,wBAAwB,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,eAC1E,IAAA,gDAA4B,EAAC,0BAA0B,EAAE,OAAO,CAAC,UAAU,CAAC,0kKAgH1E,OAAO,CAAC,YAAY;QAClB,CAAC,CAAC,oGACyF;QAC3F,CAAC,CAAC,EAAE,0+0BAkhBJ,OAAO,CAAC,YAAY;QAClB,CAAC,CAAC,iIAE+E;QACjF,CAAC,CAAC,EAAE,y/jCAuyBX,CAAA;AACD,CAAC,CAAA;AAp8CY,QAAA,0CAA0C,8CAo8CtD"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The class of the `#` link added to a post's content headings.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ PAIRED with `BLOG_HEADING_ANCHOR_CLASS` in teleport-gui
|
|
5
|
+
* `features/blog/constants/blog-heading-anchors.ts`, whose stylesheet (shipped
|
|
6
|
+
* on the post page) is what keeps the link out of sight until the heading is
|
|
7
|
+
* hovered or focused.
|
|
8
|
+
*/
|
|
9
|
+
export declare const BLOG_HEADING_ANCHOR_CLASS = "tq-heading-anchor";
|
|
10
|
+
/**
|
|
11
|
+
* Generates `addHeadingAnchors(html)`: every `<h2>`–`<h6>` of a post's content
|
|
12
|
+
* gets an `id` (kept when it already has one, otherwise a slug of its text,
|
|
13
|
+
* unique within the post) and a trailing `#` link to itself, so a section can
|
|
14
|
+
* be linked to (`/blog/post#setup`) and the link works on the very first load —
|
|
15
|
+
* the ids are in the server-rendered HTML.
|
|
16
|
+
*
|
|
17
|
+
* The link repeats the heading a screen reader already announces, so it is
|
|
18
|
+
* hidden from assistive technology and left out of the tab order; the heading
|
|
19
|
+
* stays the landmark.
|
|
20
|
+
*
|
|
21
|
+
* Idempotent: a heading that already carries the link is left alone. `h1` is
|
|
22
|
+
* the post title's level and is never touched.
|
|
23
|
+
*/
|
|
24
|
+
export declare const generateHeadingAnchorsCode: () => string;
|
|
25
|
+
//# sourceMappingURL=heading-anchors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"heading-anchors.d.ts","sourceRoot":"","sources":["../../../src/transformations/heading-anchors.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,yBAAyB,sBAAsB,CAAA;AAE5D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,QAAO,MA4D7C,CAAA"}
|