@teleporthq/teleport-plugin-next-data-source 0.43.71 → 0.43.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +8 -1
  2. package/__tests__/blog-adjacent-arranged.test.ts +176 -0
  3. package/__tests__/blog-post-contents.test.ts +212 -0
  4. package/__tests__/blog-post-page-enrichment.test.ts +28 -5
  5. package/__tests__/blog-post-picture-stand-in.test.ts +48 -0
  6. package/__tests__/blog-post-reading-time.test.ts +55 -0
  7. package/__tests__/browser-row-policy.test.ts +5 -1
  8. package/__tests__/content-preset-wiring.test.ts +108 -0
  9. package/__tests__/fixed-list-sort.test.ts +183 -0
  10. package/__tests__/pagination-filter-groups.test.ts +186 -0
  11. package/__tests__/plain-list-sort.test.ts +215 -0
  12. package/__tests__/product-price-sort.ts +47 -19
  13. package/__tests__/rest-api-key-header.test.ts +18 -0
  14. package/__tests__/search-url-sync.test.ts +34 -15
  15. package/__tests__/shared-resource-prefetch.test.ts +65 -0
  16. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  17. package/dist/cjs/fetchers/postgresql.js +2 -1
  18. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  19. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  20. package/dist/cjs/fetchers/rest-api.js +3 -1
  21. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  22. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  23. package/dist/cjs/fetchers/teleport.js +3 -2
  24. package/dist/cjs/fetchers/teleport.js.map +1 -1
  25. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +7 -6
  26. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  27. package/dist/cjs/fetchers/utils/browser-row-policy.js +7 -33
  28. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  29. package/dist/cjs/filter-utils.d.ts +19 -7
  30. package/dist/cjs/filter-utils.d.ts.map +1 -1
  31. package/dist/cjs/filter-utils.js +49 -13
  32. package/dist/cjs/filter-utils.js.map +1 -1
  33. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  34. package/dist/cjs/pagination-plugin.js +137 -118
  35. package/dist/cjs/pagination-plugin.js.map +1 -1
  36. package/dist/cjs/product-price-sort.d.ts +0 -9
  37. package/dist/cjs/product-price-sort.d.ts.map +1 -1
  38. package/dist/cjs/product-price-sort.js +1 -13
  39. package/dist/cjs/product-price-sort.js.map +1 -1
  40. package/dist/cjs/row-order.d.ts +19 -0
  41. package/dist/cjs/row-order.d.ts.map +1 -0
  42. package/dist/cjs/row-order.js +45 -0
  43. package/dist/cjs/row-order.js.map +1 -0
  44. package/dist/cjs/sort-utils.d.ts +10 -0
  45. package/dist/cjs/sort-utils.d.ts.map +1 -1
  46. package/dist/cjs/sort-utils.js +22 -1
  47. package/dist/cjs/sort-utils.js.map +1 -1
  48. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +11 -1
  49. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -1
  50. package/dist/cjs/transformations/blog-adjacent-posts.js +14 -1
  51. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -1
  52. package/dist/cjs/transformations/blog-comments.d.ts +1 -1
  53. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -1
  54. package/dist/cjs/transformations/blog-comments.js +1 -1
  55. package/dist/cjs/transformations/blog-comments.js.map +1 -1
  56. package/dist/cjs/transformations/blog-post.d.ts +14 -0
  57. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  58. package/dist/cjs/transformations/blog-post.js +4 -1
  59. package/dist/cjs/transformations/blog-post.js.map +1 -1
  60. package/dist/cjs/transformations/heading-anchors.d.ts +32 -8
  61. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -1
  62. package/dist/cjs/transformations/heading-anchors.js +34 -10
  63. package/dist/cjs/transformations/heading-anchors.js.map +1 -1
  64. package/dist/cjs/transformations/index.d.ts +24 -12
  65. package/dist/cjs/transformations/index.d.ts.map +1 -1
  66. package/dist/cjs/transformations/index.js +59 -11
  67. package/dist/cjs/transformations/index.js.map +1 -1
  68. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  69. package/dist/cjs/utils.d.ts.map +1 -1
  70. package/dist/cjs/utils.js +52 -3
  71. package/dist/cjs/utils.js.map +1 -1
  72. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  73. package/dist/esm/fetchers/postgresql.js +3 -2
  74. package/dist/esm/fetchers/postgresql.js.map +1 -1
  75. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  76. package/dist/esm/fetchers/rest-api.js +3 -1
  77. package/dist/esm/fetchers/rest-api.js.map +1 -1
  78. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  79. package/dist/esm/fetchers/teleport.js +4 -3
  80. package/dist/esm/fetchers/teleport.js.map +1 -1
  81. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +7 -6
  82. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  83. package/dist/esm/fetchers/utils/browser-row-policy.js +5 -31
  84. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  85. package/dist/esm/filter-utils.d.ts +19 -7
  86. package/dist/esm/filter-utils.d.ts.map +1 -1
  87. package/dist/esm/filter-utils.js +46 -12
  88. package/dist/esm/filter-utils.js.map +1 -1
  89. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  90. package/dist/esm/pagination-plugin.js +140 -121
  91. package/dist/esm/pagination-plugin.js.map +1 -1
  92. package/dist/esm/product-price-sort.d.ts +0 -9
  93. package/dist/esm/product-price-sort.d.ts.map +1 -1
  94. package/dist/esm/product-price-sort.js +0 -11
  95. package/dist/esm/product-price-sort.js.map +1 -1
  96. package/dist/esm/row-order.d.ts +19 -0
  97. package/dist/esm/row-order.d.ts.map +1 -0
  98. package/dist/esm/row-order.js +40 -0
  99. package/dist/esm/row-order.js.map +1 -0
  100. package/dist/esm/sort-utils.d.ts +10 -0
  101. package/dist/esm/sort-utils.d.ts.map +1 -1
  102. package/dist/esm/sort-utils.js +20 -0
  103. package/dist/esm/sort-utils.js.map +1 -1
  104. package/dist/esm/transformations/blog-adjacent-posts.d.ts +11 -1
  105. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -1
  106. package/dist/esm/transformations/blog-adjacent-posts.js +14 -1
  107. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -1
  108. package/dist/esm/transformations/blog-comments.d.ts +1 -1
  109. package/dist/esm/transformations/blog-comments.d.ts.map +1 -1
  110. package/dist/esm/transformations/blog-comments.js +1 -1
  111. package/dist/esm/transformations/blog-comments.js.map +1 -1
  112. package/dist/esm/transformations/blog-post.d.ts +14 -0
  113. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  114. package/dist/esm/transformations/blog-post.js +4 -1
  115. package/dist/esm/transformations/blog-post.js.map +1 -1
  116. package/dist/esm/transformations/heading-anchors.d.ts +32 -8
  117. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -1
  118. package/dist/esm/transformations/heading-anchors.js +33 -9
  119. package/dist/esm/transformations/heading-anchors.js.map +1 -1
  120. package/dist/esm/transformations/index.d.ts +24 -12
  121. package/dist/esm/transformations/index.d.ts.map +1 -1
  122. package/dist/esm/transformations/index.js +58 -11
  123. package/dist/esm/transformations/index.js.map +1 -1
  124. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  125. package/dist/esm/utils.d.ts.map +1 -1
  126. package/dist/esm/utils.js +52 -3
  127. package/dist/esm/utils.js.map +1 -1
  128. package/package.json +5 -5
  129. package/src/fetchers/postgresql.ts +11 -15
  130. package/src/fetchers/rest-api.ts +3 -1
  131. package/src/fetchers/teleport.ts +12 -16
  132. package/src/fetchers/utils/browser-row-policy.ts +10 -20
  133. package/src/filter-utils.ts +102 -28
  134. package/src/pagination-plugin.ts +211 -175
  135. package/src/product-price-sort.ts +0 -11
  136. package/src/row-order.ts +54 -0
  137. package/src/sort-utils.ts +25 -0
  138. package/src/transformations/blog-adjacent-posts.ts +89 -3
  139. package/src/transformations/blog-comments.ts +5 -5
  140. package/src/transformations/blog-post.ts +64 -13
  141. package/src/transformations/heading-anchors.ts +79 -16
  142. package/src/transformations/index.ts +80 -13
  143. package/src/utils.ts +74 -3
@@ -17,14 +17,25 @@ const ADJACENT_POST_CANDIDATES = 5
17
17
  *
18
18
  * MUST mirror `findAdjacentBlogPosts` in teleport-gui
19
19
  * `packages/renderer/src/utils/blog-adjacent-posts.ts` (the canvas).
20
+ *
21
+ * In Manual Order (`arranged`) a post walks the published posts of its
22
+ * PRIMARY category (the first of `category_ids`) the way the author arranged
23
+ * them: the ones with a place (`sort_order`) by it, then the rest newest
24
+ * first, ties by id — "previous" the one above it, "next" the one below. The
25
+ * category is read off each row in code, since a list cell may be JSON or a
26
+ * comma-separated list (see `array-overlap-sql.ts`). A post without a
27
+ * category, or a table the query cannot read (a column it lacks), walks the
28
+ * newest-first order above. MUST mirror `findArrangedAdjacentPosts` in
29
+ * teleport-gui `apps/gui/app/project-page/features/blog/utils/arranged-order.ts`.
20
30
  */
21
- export const generateAdjacentPostsCode = (): string => `
31
+ export const generateAdjacentPostsCode = (postsTable: string, arranged = false): string => `
22
32
  var BLOG_ADJACENT_CANDIDATES = ${ADJACENT_POST_CANDIDATES}
33
+ var BLOG_ARRANGED_ORDER = ${arranged}
23
34
 
24
35
  function blogAdjacentPostsSql(comparison, direction) {
25
36
  return (
26
- 'SELECT p.* FROM teleport_blog_posts p,' +
27
- ' (SELECT created_at, id FROM teleport_blog_posts WHERE id = $1) cur' +
37
+ 'SELECT p.* FROM ${postsTable} p,' +
38
+ ' (SELECT created_at, id FROM ${postsTable} WHERE id = $1) cur' +
28
39
  " WHERE p.status = 'published' AND (p.created_at, p.id) " + comparison + ' (cur.created_at, cur.id)' +
29
40
  ' ORDER BY p.created_at ' + direction + ', p.id ' + direction +
30
41
  ' LIMIT ' + BLOG_ADJACENT_CANDIDATES
@@ -44,6 +55,69 @@ function firstLinkableBlogPost(result) {
44
55
  return null
45
56
  }
46
57
 
58
+ function blogPlaceOf(row) {
59
+ var raw = pickFirst(row.sort_order, row.sortOrder)
60
+ if (raw === null || raw === undefined || raw === '') return null
61
+ var value = Number(raw)
62
+ return isFinite(value) ? value : null
63
+ }
64
+
65
+ // The arranged posts by their place, then the rest newest first, ties by id descending.
66
+ function compareBlogPlaces(a, b) {
67
+ var placeA = blogPlaceOf(a)
68
+ var placeB = blogPlaceOf(b)
69
+ if (placeA !== placeB) {
70
+ if (placeA === null) return 1
71
+ if (placeB === null) return -1
72
+ return placeA - placeB
73
+ }
74
+ var byDate =
75
+ normalizeTimestamp(pickFirst(b.created_at, b.createdAt)) -
76
+ normalizeTimestamp(pickFirst(a.created_at, a.createdAt))
77
+ if (byDate !== 0) return byDate
78
+ var idA = String(a.id)
79
+ var idB = String(b.id)
80
+ return idA < idB ? 1 : idA > idB ? -1 : 0
81
+ }
82
+
83
+ // { previous, next } along the post's primary category in Manual Order, or
84
+ // null when the post has none (the caller then walks the newest-first order).
85
+ async function getArrangedAdjacentPosts(client, record) {
86
+ var primary = parseJsonArray(record.category_ids)[0]
87
+ if (!primary) return null
88
+ var postId = String(record.id)
89
+ var listed = await client.query(
90
+ "SELECT id, category_ids, sort_order, created_at FROM ${postsTable} WHERE status = 'published' OR id = $1",
91
+ [postId]
92
+ )
93
+ var chain = (listed && Array.isArray(listed.rows) ? listed.rows : [])
94
+ .filter(function (row) {
95
+ return String(row.id) === postId || parseJsonArray(row.category_ids)[0] === primary
96
+ })
97
+ .sort(compareBlogPlaces)
98
+ var at = -1
99
+ for (var i = 0; i < chain.length; i++) {
100
+ if (String(chain[i].id) === postId) at = i
101
+ }
102
+ if (at < 0) return null
103
+ var above = chain.slice(Math.max(0, at - BLOG_ADJACENT_CANDIDATES), at).reverse()
104
+ var below = chain.slice(at + 1, at + 1 + BLOG_ADJACENT_CANDIDATES)
105
+ var ids = above.concat(below).map(function (row) { return String(row.id) })
106
+ if (ids.length === 0) return { previous: null, next: null }
107
+ var full = await client.query(
108
+ 'SELECT p.* FROM ${postsTable} p WHERE p.id::text = ANY($1::text[])',
109
+ [ids]
110
+ )
111
+ var byId = {}
112
+ ;(full && Array.isArray(full.rows) ? full.rows : []).forEach(function (row) {
113
+ byId[String(row.id)] = row
114
+ })
115
+ var rowsOf = function (entries) {
116
+ return { rows: entries.map(function (row) { return byId[String(row.id)] }) }
117
+ }
118
+ return { previous: firstLinkableBlogPost(rowsOf(above)), next: firstLinkableBlogPost(rowsOf(below)) }
119
+ }
120
+
47
121
  // { [postId]: { previous, next } } — raw rows or null — for a single-record
48
122
  // fetch; {} otherwise and on any failure (the navigation then hides).
49
123
  async function getAdjacentPostsMap(getClientFn, records) {
@@ -56,6 +130,18 @@ async function getAdjacentPostsMap(getClientFn, records) {
56
130
  try {
57
131
  client = getClientFn()
58
132
  await client.connect()
133
+ var arranged = null
134
+ if (BLOG_ARRANGED_ORDER) {
135
+ try {
136
+ arranged = await getArrangedAdjacentPosts(client, records[0])
137
+ } catch (e) {
138
+ // A table without the columns: the newest-first order below.
139
+ }
140
+ }
141
+ if (arranged) {
142
+ map[String(postId)] = arranged
143
+ return map
144
+ }
59
145
  var older = await client.query(blogAdjacentPostsSql('<', 'DESC'), [String(postId)])
60
146
  var newer = await client.query(blogAdjacentPostsSql('>', 'ASC'), [String(postId)])
61
147
  map[String(postId)] = {
@@ -27,17 +27,17 @@ export const BLOG_COMMENTS_RENDER_LIMIT = 200
27
27
  * MUST mirror `buildBlogComments` in teleport-gui
28
28
  * `packages/renderer/src/utils/blog-comments.ts` (the canvas).
29
29
  */
30
- export const generateBlogCommentsCode = (): string => `
30
+ export const generateBlogCommentsCode = (commentsTable: string): string => `
31
31
  var BLOG_COMMENTS_RENDER_LIMIT = ${BLOG_COMMENTS_RENDER_LIMIT}
32
32
 
33
33
  var BLOG_COMMENTS_SQL =
34
34
  'WITH top AS (' +
35
- ' SELECT id FROM teleport_blog_comments' +
35
+ ' SELECT id FROM ${commentsTable}' +
36
36
  " WHERE post_id = $1 AND parent_id IS NULL AND status = 'approved'" +
37
37
  ' ORDER BY created_at DESC, id DESC LIMIT ' + BLOG_COMMENTS_RENDER_LIMIT +
38
38
  ')' +
39
39
  ' SELECT c.id, c.parent_id, c.author_name, c.content, c.is_author_reply, c.created_at' +
40
- ' FROM teleport_blog_comments c' +
40
+ ' FROM ${commentsTable} c' +
41
41
  " WHERE c.post_id = $1 AND c.status = 'approved'" +
42
42
  ' AND (c.id IN (SELECT id FROM top) OR c.parent_id IN (SELECT id FROM top))' +
43
43
  ' ORDER BY c.created_at ASC, c.id ASC'
@@ -45,10 +45,10 @@ var BLOG_COMMENTS_SQL =
45
45
  // Every approved comment the page could show — a reply counts only while the
46
46
  // comment it answers is approved too.
47
47
  var BLOG_COMMENTS_COUNT_SQL =
48
- 'SELECT COUNT(*) AS n FROM teleport_blog_comments c' +
48
+ 'SELECT COUNT(*) AS n FROM ${commentsTable} c' +
49
49
  " WHERE c.post_id = $1 AND c.status = 'approved'" +
50
50
  ' AND (c.parent_id IS NULL OR EXISTS (' +
51
- ' SELECT 1 FROM teleport_blog_comments p' +
51
+ ' SELECT 1 FROM ${commentsTable} p' +
52
52
  " WHERE p.id = c.parent_id AND p.status = 'approved'))"
53
53
 
54
54
  function blogAuthorInitials(name) {
@@ -1,4 +1,5 @@
1
1
  import type { UIDLEcommerceCategory } from '@teleporthq/teleport-types'
2
+ import { ContentTables, RichTextContentCodegen } from '@teleporthq/teleport-shared'
2
3
  import { generateCategoryTaxonomyCode } from './category-taxonomy'
3
4
  import { generateAdjacentPostsCode } from './blog-adjacent-posts'
4
5
  import { generateBlogCommentsCode } from './blog-comments'
@@ -6,10 +7,23 @@ import { generateHeadingAnchorsCode } from './heading-anchors'
6
7
 
7
8
  /** What the blog-post transform bakes in at export time beyond the row itself. */
8
9
  export interface BlogPostTransformOptions {
10
+ /**
11
+ * The preset's tables (the Blog's by default): the posts table the related
12
+ * and adjacent posts are read from, the comments table a post page counts.
13
+ */
14
+ tables?: ContentTables.ContentTables
9
15
  /** Category taxonomy — lives only in the UIDL, there is no DB table for it. */
10
16
  categories?: UIDLEcommerceCategory[]
11
17
  /** Content headings get ids and `#` links — see `heading-anchors.ts`. */
12
18
  headingAnchors?: boolean
19
+ /**
20
+ * The post pages carry a Contents list: the content's headings get their ids
21
+ * (the `#` links only with `headingAnchors`) and a post page's post its
22
+ * `sections` — see `blogContentsSectionsOf`.
23
+ */
24
+ contents?: boolean
25
+ /** Manual Order: Previous / Next walk the post's category as arranged — see `blog-adjacent-posts.ts`. */
26
+ arranged?: boolean
13
27
  }
14
28
 
15
29
  /**
@@ -20,15 +34,28 @@ export interface BlogPostTransformOptions {
20
34
  export const generateBlogPostTransformationCode = (
21
35
  options: BlogPostTransformOptions = {}
22
36
  ): string => {
37
+ const tables = options.tables ?? ContentTables.contentTablesByKey('blog')
23
38
  return `
24
39
  // ============================================================
25
40
  // Blog Post Transformation
26
41
  // ============================================================
27
42
  ${generateCategoryTaxonomyCode('BLOG_CATEGORIES_BY_ID', options.categories)}
28
- ${generateBlogCommentsCode()}
29
- ${generateAdjacentPostsCode()}
43
+ ${generateBlogCommentsCode(tables.comments ?? `${tables.posts}_comments`)}
44
+ ${generateAdjacentPostsCode(tables.posts, options.arranged === true)}
30
45
  ${generateHeadingAnchorsCode()}
46
+ ${RichTextContentCodegen.generateLegacyRichTextNormalizerCode()}
31
47
  var BLOG_HEADING_ANCHORS = ${options.headingAnchors === true}
48
+ var BLOG_CONTENTS = ${options.contents === true}
49
+ var BLOG_PICTURE_STAND_IN = ${tables.pictureStandIn !== false}
50
+
51
+ // A post's reading time from its body, for one that stores none: its words at
52
+ // about 200 a minute, at least one — what the editor's post form computes. A
53
+ // card or post page otherwise printed "min read" with no number before it.
54
+ // MUST mirror readingTimeOf in teleport-gui packages/renderer/src/utils/blog-posts.ts.
55
+ function blogReadingTimeOf(html) {
56
+ var text = String(html || '').replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim()
57
+ return text ? Math.max(1, Math.ceil(text.split(' ').length / 200)) : null
58
+ }
32
59
 
33
60
  function buildBlogPost(record, options) {
34
61
  if (!record || typeof record !== 'object') return record
@@ -42,8 +69,21 @@ function buildBlogPost(record, options) {
42
69
  // i18n-resolved text fields
43
70
  var title = resolveI18nField(record, 'title', 'title', currentLang, mainLang) || ''
44
71
  var slug = resolveI18nField(record, 'slug', 'slug', currentLang, mainLang) || ''
45
- var content = resolveI18nField(record, 'content', 'content', currentLang, mainLang) || ''
46
- if (BLOG_HEADING_ANCHORS) content = addHeadingAnchors(content)
72
+ // A body stored before the rich-text contract (the studio's flat list lines,
73
+ // the admin panel's no-break spaces) reads as stored HTML from here on.
74
+ var content = normalizeLegacyRichTextHtml(
75
+ resolveI18nField(record, 'content', 'content', currentLang, mainLang) || ''
76
+ )
77
+ // Counted before the heading anchors add their marks.
78
+ var bodyReadingTime = blogReadingTimeOf(content)
79
+ // The Contents list links to the headings, so they get their ids for it too
80
+ // — the # links only with the heading anchors on. Its sections ride on a
81
+ // post page's own fetch only (options.details): a listing draws no list, and
82
+ // every card of it would otherwise carry one.
83
+ var contentsHeadings = BLOG_CONTENTS && options.details === true ? [] : null
84
+ if (BLOG_HEADING_ANCHORS || BLOG_CONTENTS) {
85
+ content = addHeadingAnchors(content, { links: BLOG_HEADING_ANCHORS, sections: contentsHeadings })
86
+ }
47
87
  var excerpt = resolveI18nField(record, 'excerpt', 'excerpt', currentLang, mainLang) || ''
48
88
  var category = resolveI18nField(record, 'category', 'category', currentLang, mainLang) || null
49
89
 
@@ -77,11 +117,15 @@ function buildBlogPost(record, options) {
77
117
  rawFeaturedImage === TELEPORT_DEFAULT_FEATURED_IMG ||
78
118
  featuredImageUrl === TELEPORT_DEFAULT_FEATURED_IMG
79
119
  ) {
80
- featuredImageUrl =
81
- 'data:image/svg+xml;charset=utf-8,' +
82
- encodeURIComponent(
83
- '<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>'
84
- )
120
+ // No picture of its own: the grey "No image" stand-in, or none at all for a
121
+ // preset whose posts are text (ContentTables.pictureStandIn) — its card and
122
+ // post page show the picture only when there is one.
123
+ featuredImageUrl = BLOG_PICTURE_STAND_IN
124
+ ? 'data:image/svg+xml;charset=utf-8,' +
125
+ encodeURIComponent(
126
+ '<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>'
127
+ )
128
+ : null
85
129
  }
86
130
 
87
131
  var rawAuthorAvatar = pickFirst(record.author_avatar_url, record.authorAvatarUrl)
@@ -90,8 +134,11 @@ function buildBlogPost(record, options) {
90
134
  // Simple pass-through fields
91
135
  var authorName = pickFirst(record.author_name, record.authorName)
92
136
  var authorInitials = blogAuthorInitials(authorName)
93
- var authorEmail = pickFirst(record.author_email, record.authorEmail)
137
+ // The author's email never leaves the server: a visitor's API read hides it
138
+ // (browser-row-policy.ts), and the page data a post or listing page ships
139
+ // is built here, so it is not part of the post a page sees either.
94
140
  var readingTimeMinutes = safeNumber(pickFirst(record.reading_time_minutes, record.readingTimeMinutes), null)
141
+ if (!(readingTimeMinutes > 0)) readingTimeMinutes = bodyReadingTime
95
142
 
96
143
  // Tags - i18n-resolved then parsed as JSON array
97
144
  var rawTags = resolveI18nField(record, 'tags', 'tags', currentLang, mainLang)
@@ -207,15 +254,16 @@ function buildBlogPost(record, options) {
207
254
  galleryImages: galleryImages,
208
255
  allImages: allImages,
209
256
  authorName: authorName,
210
- authorEmail: authorEmail,
211
257
  author_name: authorName,
212
- author_email: authorEmail,
213
258
  authorAvatarUrl: authorAvatarUrl,
214
259
  authorBio: authorBio,
215
260
  authorInitials: authorInitials,
216
261
  metaTitle: metaTitle,
217
262
  metaDescription: metaDescription,
218
263
  readingTimeMinutes: readingTimeMinutes,
264
+ // The post's sections, what its page's Contents list links to — see
265
+ // blogContentsSectionsOf; empty off a post page or under three sections.
266
+ sections: contentsHeadings ? blogContentsSectionsOf(contentsHeadings) : [],
219
267
  isFeatured: isFeatured,
220
268
  allowComments: allowComments,
221
269
  comments: comments,
@@ -231,6 +279,9 @@ function buildBlogPost(record, options) {
231
279
  createdAt: createdAt,
232
280
  updatedAt: updatedAt,
233
281
  created: created,
282
+ // The post's place among its primary category's posts (Manual Order), or
283
+ // null: what the category pages order by.
284
+ sortOrder: safeNumber(pickFirst(record.sort_order, record.sortOrder), null),
234
285
  }
235
286
  }
236
287
 
@@ -261,7 +312,7 @@ async function getRelatedPostsMap(getClientFn, records) {
261
312
  try {
262
313
  client = getClientFn()
263
314
  await client.connect()
264
- var result = await client.query('SELECT * FROM teleport_blog_posts WHERE id = ANY($1)', [
315
+ var result = await client.query('SELECT * FROM ${tables.posts} WHERE id = ANY($1)', [
265
316
  wantedIds,
266
317
  ])
267
318
  if (result && result.rows) {
@@ -9,22 +9,51 @@
9
9
  export const BLOG_HEADING_ANCHOR_CLASS = 'tq-heading-anchor'
10
10
 
11
11
  /**
12
- * Generates `addHeadingAnchors(html)`: every `<h2>`–`<h6>` of a post's content
13
- * gets an `id` (kept when it already has one, otherwise a slug of its text,
14
- * unique within the post) and a trailing `#` link to itself, so a section can
15
- * be linked to (`/blog/post#setup`) and the link works on the very first load —
16
- * the ids are in the server-rendered HTML.
12
+ * The class of the link a post's content heading's own text becomes.
17
13
  *
18
- * The link repeats the heading a screen reader already announces, so it is
19
- * hidden from assistive technology and left out of the tab order; the heading
20
- * stays the landmark.
14
+ * ⛔ PAIRED with `BLOG_HEADING_LINK_CLASS` in teleport-gui
15
+ * `features/blog/utils/elements/heading-anchor-styles.ts`, whose stylesheet
16
+ * keeps the heading looking like a heading rather than like a link.
17
+ */
18
+ export const BLOG_HEADING_LINK_CLASS = 'tq-heading-link'
19
+
20
+ /**
21
+ * The fewest sections a post's Contents list is drawn for: a shorter post is
22
+ * taken in at a glance.
23
+ *
24
+ * ⛔ PAIRED with `CONTENTS_MIN_SECTIONS` in teleport-gui
25
+ * `packages/renderer/src/utils/blog-posts.ts`, the canvas's copy.
26
+ */
27
+ export const CONTENTS_MIN_SECTIONS = 3
28
+
29
+ /**
30
+ * Generates `addHeadingAnchors(html, options)`: every `<h2>`–`<h6>` of a post's
31
+ * content gets an `id` (kept when it already has one, otherwise a slug of its
32
+ * text, unique within the post), its text becomes a link to itself and a `#`
33
+ * follows it, shown on hover, so a section can be linked to
34
+ * (`/blog/post#setup`) by clicking its heading, and the link works on the very
35
+ * first load — the ids are in the server-rendered HTML. A heading that holds a
36
+ * link of its own keeps its text as it is (a link cannot hold another) and
37
+ * links through the `#` alone.
38
+ *
39
+ * `options.links: false` gives the headings their ids without the links —
40
+ * what a post page's Contents list needs when the heading anchors are off.
41
+ * `options.sections`, an array, collects every heading as `{ level, id, text }`
42
+ * in reading order, and `blogContentsSectionsOf` turns those into the list.
43
+ *
44
+ * The heading's text is the link a screen reader announces and the keyboard
45
+ * reaches; the `#` repeats it, so it is hidden from assistive technology and
46
+ * left out of the tab order.
21
47
  *
22
48
  * Idempotent: a heading that already carries the link is left alone. `h1` is
23
49
  * the post title's level and is never touched.
24
50
  */
25
51
  export const generateHeadingAnchorsCode = (): string => `
26
52
  var BLOG_HEADING_ANCHOR_CLASS = '${BLOG_HEADING_ANCHOR_CLASS}'
53
+ var BLOG_HEADING_LINK_CLASS = '${BLOG_HEADING_LINK_CLASS}'
27
54
  var BLOG_HEADING_ID_PATTERN = /\\sid\\s*=\\s*("([^"]*)"|'([^']*)')/i
55
+ var BLOG_HEADING_ANCHOR_LINK_PATTERN = /<a\\s[^>]*class="${BLOG_HEADING_ANCHOR_CLASS}"[^>]*>[\\s\\S]*?<\\/a>/gi
56
+ var BLOG_CONTENTS_MIN_SECTIONS = ${CONTENTS_MIN_SECTIONS}
28
57
 
29
58
  function escapeHeadingAttribute(value) {
30
59
  return String(value)
@@ -52,11 +81,15 @@ function blogHeadingSlug(text, used) {
52
81
  return slug
53
82
  }
54
83
 
55
- function addHeadingAnchors(html) {
84
+ function addHeadingAnchors(html, options) {
85
+ options = options || {}
86
+ var withLinks = options.links !== false
87
+ var sections = Array.isArray(options.sections) ? options.sections : null
56
88
  if (typeof html !== 'string' || !/<h[2-6][\\s>]/i.test(html)) return html
57
89
  // Every id the content already uses is taken, so a generated one never
58
- // duplicates it.
59
- var used = {}
90
+ // duplicates it. No prototype: a heading titled "Constructor" is free to be
91
+ // #constructor.
92
+ var used = Object.create(null)
60
93
  var existingIds = new RegExp(BLOG_HEADING_ID_PATTERN.source, 'gi')
61
94
  var existing
62
95
  while ((existing = existingIds.exec(html)) !== null) {
@@ -64,22 +97,52 @@ function addHeadingAnchors(html) {
64
97
  }
65
98
 
66
99
  return html.replace(/<h([2-6])(\\s[^>]*)?>([\\s\\S]*?)<\\/h\\1>/gi, function(match, level, rawAttrs, inner) {
67
- if (inner.indexOf(BLOG_HEADING_ANCHOR_CLASS) !== -1) return match
68
- var text = stripHtmlTags(inner)
69
- if (!text) return match
70
100
  var attrs = rawAttrs || ''
71
101
  var idMatch = attrs.match(BLOG_HEADING_ID_PATTERN)
72
102
  var id = idMatch ? decodeHtmlEntities(idMatch[2] || idMatch[3] || '') : ''
103
+ if (inner.indexOf(BLOG_HEADING_ANCHOR_CLASS) !== -1) {
104
+ if (sections && id) {
105
+ var anchoredText = stripHtmlTags(inner.replace(BLOG_HEADING_ANCHOR_LINK_PATTERN, ''))
106
+ if (anchoredText) sections.push({ level: Number(level), id: id, text: anchoredText })
107
+ }
108
+ return match
109
+ }
110
+ var text = stripHtmlTags(inner)
111
+ if (!text) return match
73
112
  if (!id) {
74
113
  id = blogHeadingSlug(text, used)
75
114
  attrs = ' id="' + escapeHeadingAttribute(id) + '"' + attrs
76
115
  }
116
+ if (sections) sections.push({ level: Number(level), id: id, text: text })
117
+ if (!withLinks) return '<h' + level + attrs + '>' + inner + '</h' + level + '>'
118
+ var href = '#' + escapeHeadingAttribute(id)
119
+ var ownText = /<a[\\s>]/i.test(inner)
120
+ ? inner
121
+ : '<a class="' + BLOG_HEADING_LINK_CLASS + '" href="' + href + '">' + inner + '</a>'
77
122
  return (
78
- '<h' + level + attrs + '>' + inner +
79
- '<a class="' + BLOG_HEADING_ANCHOR_CLASS + '" href="#' + escapeHeadingAttribute(id) +
123
+ '<h' + level + attrs + '>' + ownText +
124
+ '<a class="' + BLOG_HEADING_ANCHOR_CLASS + '" href="' + href +
80
125
  '" aria-hidden="true" tabindex="-1">#</a>' +
81
126
  '</h' + level + '>'
82
127
  )
83
128
  })
84
129
  }
130
+
131
+ // A post page's Contents list: the post's sections, i.e. its headings of the
132
+ // topmost level it uses, each with the address that jumps to it; none for a
133
+ // post of fewer than BLOG_CONTENTS_MIN_SECTIONS sections.
134
+ // MUST mirror contentsSectionsOf in teleport-gui packages/renderer/src/utils/blog-posts.ts.
135
+ function blogContentsSectionsOf(headings) {
136
+ if (!Array.isArray(headings) || headings.length === 0) return []
137
+ var top = 6
138
+ for (var i = 0; i < headings.length; i++) {
139
+ if (headings[i].level < top) top = headings[i].level
140
+ }
141
+ var sections = []
142
+ for (var j = 0; j < headings.length; j++) {
143
+ if (headings[j].level !== top) continue
144
+ sections.push({ id: headings[j].id, title: headings[j].text, href: '#' + headings[j].id })
145
+ }
146
+ return sections.length >= BLOG_CONTENTS_MIN_SECTIONS ? sections : []
147
+ }
85
148
  `
@@ -1,9 +1,10 @@
1
1
  import type {
2
2
  GeneratorOptions,
3
+ UIDLBlogSettings,
3
4
  UIDLAuthentication,
4
5
  UIDLEcommerceCategory,
5
6
  } from '@teleporthq/teleport-types'
6
- import { StorefrontTax, TableAccess } from '@teleporthq/teleport-shared'
7
+ import { ContentTables, StorefrontTax, TableAccess } from '@teleporthq/teleport-shared'
7
8
  import { generateSharedTransformationCode } from './shared-utils'
8
9
  import { generateBlogPostTransformationCode } from './blog-post'
9
10
  import { generateCustomPageTransformationCode } from './custom-page'
@@ -26,7 +27,22 @@ export type { EcommerceProductTransformOptions }
26
27
  * to serve whichever transform that resolves to — the product fields are ignored
27
28
  * for a blog table and vice versa.
28
29
  */
30
+ /** What a content preset bakes into its post transform — see `UIDLBlogSettings`. */
31
+ export interface ContentPresetTransformSettings {
32
+ categories?: UIDLEcommerceCategory[]
33
+ headingAnchors?: boolean
34
+ contents?: boolean
35
+ comments?: boolean
36
+ order?: 'manual'
37
+ }
38
+
29
39
  export interface EntityTransformOptions extends EcommerceProductTransformOptions {
40
+ /**
41
+ * The settings of every content preset, by preset key — the post transform
42
+ * of a table reads its own preset's (`contentSettingsFor`). The `blog*`
43
+ * fields below are the Blog's, kept for the callers that name them.
44
+ */
45
+ contentSettings?: Partial<Record<ContentTables.ContentPresetKey, ContentPresetTransformSettings>>
30
46
  /** Blog post-category taxonomy — see `blogSettings.categories`. */
31
47
  blogCategories?: UIDLEcommerceCategory[]
32
48
  /** Post content headings get ids and `#` links — see `blogSettings.headingAnchors`. */
@@ -77,10 +93,50 @@ const resolveAllowBackorders = (
77
93
  * tax-inclusive or untaxed stores. `allowBackorders` is the effective
78
94
  * "stock never blocks a purchase" flag — see `resolveAllowBackorders`.
79
95
  */
96
+ const contentPresetTransformSettings = (
97
+ settings: UIDLBlogSettings | undefined
98
+ ): ContentPresetTransformSettings => ({
99
+ categories: settings?.categories,
100
+ headingAnchors: settings?.headingAnchors === true,
101
+ ...(settings?.contents === true && { contents: true }),
102
+ comments: settings?.comments === true,
103
+ ...(settings?.order === 'manual' && { order: 'manual' as const }),
104
+ })
105
+
106
+ /**
107
+ * The settings the transform of `tableName` bakes in: its preset's entry in
108
+ * `contentSettings`, or — for the Blog, and for a caller that only set the
109
+ * `blog*` fields — those fields. Empty for a table no preset owns.
110
+ */
111
+ export const contentSettingsFor = (
112
+ options: Pick<
113
+ EntityTransformOptions,
114
+ 'contentSettings' | 'blogCategories' | 'blogHeadingAnchors' | 'blogComments'
115
+ >,
116
+ tableName: string
117
+ ): ContentPresetTransformSettings => {
118
+ const role = ContentTables.contentTableRole(stripSchemaQualifier(tableName))
119
+ if (!role) {
120
+ return {}
121
+ }
122
+ const own = options.contentSettings?.[role.key]
123
+ if (own) {
124
+ return own
125
+ }
126
+ if (role.key === 'blog') {
127
+ return {
128
+ categories: options.blogCategories,
129
+ headingAnchors: options.blogHeadingAnchors,
130
+ comments: options.blogComments,
131
+ }
132
+ }
133
+ return {}
134
+ }
135
+
80
136
  export const buildProductTransformOptions = (
81
137
  options: Pick<
82
138
  GeneratorOptions,
83
- 'ecommerceSettings' | 'invoiceSettings' | 'blogSettings' | 'auth'
139
+ 'ecommerceSettings' | 'invoiceSettings' | 'blogSettings' | 'helpCenterSettings' | 'auth'
84
140
  > &
85
141
  LocalizedProjectOptions & {
86
142
  /**
@@ -96,6 +152,10 @@ export const buildProductTransformOptions = (
96
152
  }
97
153
  ): EntityTransformOptions => ({
98
154
  categories: options.ecommerceSettings?.categories,
155
+ contentSettings: {
156
+ blog: contentPresetTransformSettings(options.blogSettings),
157
+ help: contentPresetTransformSettings(options.helpCenterSettings),
158
+ },
99
159
  blogCategories: options.blogSettings?.categories,
100
160
  blogHeadingAnchors: options.blogSettings?.headingAnchors === true,
101
161
  blogComments: options.blogSettings?.comments === true,
@@ -125,9 +185,9 @@ const stripSchemaQualifier = (tableName: string): string => {
125
185
  * Detects which transformation type to apply based on the table name.
126
186
  * Returns null if no transformation is needed.
127
187
  *
128
- * IMPORTANT: Only the real platform-managed tables (`teleport_products`,
129
- * `teleport_blog_posts`) are routed through the e-commerce/blog view-model
130
- * transforms. A previous loose substring match (`lower.includes('products')`)
188
+ * IMPORTANT: Only the real platform-managed tables (`teleport_products`, the
189
+ * content presets' posts tables) are routed through the e-commerce/blog
190
+ * view-model transforms. A previous loose substring match (`lower.includes('products')`)
131
191
  * incorrectly routed CUSTOM tables such as `products`, `store_products` or
132
192
  * `wholesale_products` through `buildEcommerceProduct`, which emits a fixed set
133
193
  * of platform product fields and silently drops all custom columns. Matching the
@@ -138,7 +198,7 @@ export const detectTransformationType = (tableName: string): TransformationType
138
198
  return null
139
199
  }
140
200
  const bare = stripSchemaQualifier(tableName)
141
- if (bare === 'teleport_blog_posts') {
201
+ if (ContentTables.isContentPostsTable(bare)) {
142
202
  return 'blog-post'
143
203
  }
144
204
  if (bare === 'teleport_products') {
@@ -167,14 +227,20 @@ export const getTransformationCode = (
167
227
  const shared = generateSharedTransformationCode()
168
228
 
169
229
  switch (type) {
170
- case 'blog-post':
230
+ case 'blog-post': {
231
+ const role = ContentTables.contentTableRole(stripSchemaQualifier(tableName))
232
+ const settings = contentSettingsFor(options, tableName)
171
233
  return (
172
234
  shared +
173
235
  generateBlogPostTransformationCode({
174
- categories: options.blogCategories,
175
- headingAnchors: options.blogHeadingAnchors,
236
+ tables: role ? ContentTables.contentTablesByKey(role.key) : undefined,
237
+ categories: settings.categories,
238
+ headingAnchors: settings.headingAnchors,
239
+ contents: settings.contents === true,
240
+ arranged: settings.order === 'manual',
176
241
  })
177
242
  )
243
+ }
178
244
  case 'ecommerce-product':
179
245
  return shared + generateEcommerceProductTransformationCode(options)
180
246
  case 'custom-page':
@@ -300,7 +366,7 @@ export const REVIEWS_PER_PRODUCT = 5
300
366
  */
301
367
  export const getTransformWrapperCode = (
302
368
  tableName: string,
303
- options: Pick<EntityTransformOptions, 'localization' | 'blogComments'> = {}
369
+ options: Pick<EntityTransformOptions, 'localization' | 'blogComments' | 'contentSettings'> = {}
304
370
  ): string => {
305
371
  const type = detectTransformationType(tableName)
306
372
  if (!type) {
@@ -422,7 +488,8 @@ export const getTransformWrapperCode = (
422
488
 
423
489
  // A post page's neighbours and comments, on the same single-record heuristic
424
490
  // as the related rail. The comments are only asked for when the blog takes
425
- // them — see `EntityTransformOptions.blogComments`.
491
+ // them — see `EntityTransformOptions.blogComments`. `details` tells the
492
+ // transform the same thing: what only a post page draws (its Contents list).
426
493
  const blogPageEnrichment =
427
494
  type === 'blog-post'
428
495
  ? `
@@ -434,7 +501,7 @@ export const getTransformWrapperCode = (
434
501
  } catch (e) {
435
502
  // Best-effort; the post navigation stays hidden.
436
503
  }${
437
- options.blogComments
504
+ contentSettingsFor(options, tableName).comments === true
438
505
  ? `
439
506
  try {
440
507
  commentsByPostId = await getBlogCommentsMap(getClientFn, records)
@@ -447,7 +514,7 @@ export const getTransformWrapperCode = (
447
514
  : ''
448
515
  const blogPageOption =
449
516
  type === 'blog-post'
450
- ? ', adjacentPostsById: adjacentPostsById, commentsByPostId: commentsByPostId'
517
+ ? ', adjacentPostsById: adjacentPostsById, commentsByPostId: commentsByPostId, details: Array.isArray(records) && records.length === 1'
451
518
  : ''
452
519
 
453
520
  return `${RAW_ROWS_CODE}