@teleporthq/teleport-plugin-next-data-source 0.43.74 → 0.44.0

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 (231) hide show
  1. package/__tests__/_helpers/pagination-widget-fixture.ts +95 -5
  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__/booking-item-transform.test.ts +120 -0
  8. package/__tests__/browser-row-policy.test.ts +13 -3
  9. package/__tests__/content-preset-wiring.test.ts +108 -0
  10. package/__tests__/fetchers.test.ts +1 -1
  11. package/__tests__/fixed-list-sort.test.ts +183 -0
  12. package/__tests__/infinite-scroll.test.ts +154 -5
  13. package/__tests__/list-sort-order.test.ts +61 -0
  14. package/__tests__/null-filter-where.test.ts +132 -0
  15. package/__tests__/pagination-filter-groups.test.ts +186 -0
  16. package/__tests__/pg-date-parser.test.ts +73 -0
  17. package/__tests__/plain-list-sort.test.ts +215 -0
  18. package/__tests__/product-price-sort.ts +58 -25
  19. package/__tests__/range-control.test.ts +159 -0
  20. package/__tests__/rest-api-key-header.test.ts +18 -0
  21. package/__tests__/search-input-marker.test.ts +86 -0
  22. package/__tests__/search-url-sync.test.ts +61 -16
  23. package/__tests__/shared-resource-prefetch.test.ts +65 -0
  24. package/__tests__/table-read-route-protected-tables.test.ts +33 -3
  25. package/dist/cjs/count-effect.d.ts +7 -1
  26. package/dist/cjs/count-effect.d.ts.map +1 -1
  27. package/dist/cjs/count-effect.js +15 -5
  28. package/dist/cjs/count-effect.js.map +1 -1
  29. package/dist/cjs/fetchers/pg-client-import.d.ts +12 -0
  30. package/dist/cjs/fetchers/pg-client-import.d.ts.map +1 -0
  31. package/dist/cjs/fetchers/pg-client-import.js +15 -0
  32. package/dist/cjs/fetchers/pg-client-import.js.map +1 -0
  33. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/postgresql.js +4 -1
  35. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  36. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/raw-query.js +2 -1
  38. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  39. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/rest-api.js +3 -1
  41. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  42. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  43. package/dist/cjs/fetchers/teleport.js +5 -2
  44. package/dist/cjs/fetchers/teleport.js.map +1 -1
  45. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +7 -6
  46. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  47. package/dist/cjs/fetchers/utils/browser-row-policy.js +7 -33
  48. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
  49. package/dist/cjs/filter-utils.d.ts +19 -7
  50. package/dist/cjs/filter-utils.d.ts.map +1 -1
  51. package/dist/cjs/filter-utils.js +49 -13
  52. package/dist/cjs/filter-utils.js.map +1 -1
  53. package/dist/cjs/infinite-scroll.d.ts +25 -2
  54. package/dist/cjs/infinite-scroll.d.ts.map +1 -1
  55. package/dist/cjs/infinite-scroll.js +144 -33
  56. package/dist/cjs/infinite-scroll.js.map +1 -1
  57. package/dist/cjs/list-sort-order.d.ts +14 -0
  58. package/dist/cjs/list-sort-order.d.ts.map +1 -0
  59. package/dist/cjs/list-sort-order.js +36 -0
  60. package/dist/cjs/list-sort-order.js.map +1 -0
  61. package/dist/cjs/pagination-controls.d.ts +22 -3
  62. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  63. package/dist/cjs/pagination-controls.js +29 -3
  64. package/dist/cjs/pagination-controls.js.map +1 -1
  65. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  66. package/dist/cjs/pagination-plugin.js +267 -212
  67. package/dist/cjs/pagination-plugin.js.map +1 -1
  68. package/dist/cjs/product-price-sort.d.ts +0 -9
  69. package/dist/cjs/product-price-sort.d.ts.map +1 -1
  70. package/dist/cjs/product-price-sort.js +1 -13
  71. package/dist/cjs/product-price-sort.js.map +1 -1
  72. package/dist/cjs/range-control.d.ts +45 -0
  73. package/dist/cjs/range-control.d.ts.map +1 -0
  74. package/dist/cjs/range-control.js +165 -0
  75. package/dist/cjs/range-control.js.map +1 -0
  76. package/dist/cjs/row-order.d.ts +20 -0
  77. package/dist/cjs/row-order.d.ts.map +1 -0
  78. package/dist/cjs/row-order.js +46 -0
  79. package/dist/cjs/row-order.js.map +1 -0
  80. package/dist/cjs/search-input.d.ts +42 -0
  81. package/dist/cjs/search-input.d.ts.map +1 -0
  82. package/dist/cjs/search-input.js +158 -0
  83. package/dist/cjs/search-input.js.map +1 -0
  84. package/dist/cjs/sort-utils.d.ts +10 -0
  85. package/dist/cjs/sort-utils.d.ts.map +1 -1
  86. package/dist/cjs/sort-utils.js +22 -1
  87. package/dist/cjs/sort-utils.js.map +1 -1
  88. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +11 -1
  89. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -1
  90. package/dist/cjs/transformations/blog-adjacent-posts.js +14 -1
  91. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -1
  92. package/dist/cjs/transformations/blog-comments.d.ts +1 -1
  93. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -1
  94. package/dist/cjs/transformations/blog-comments.js +1 -1
  95. package/dist/cjs/transformations/blog-comments.js.map +1 -1
  96. package/dist/cjs/transformations/blog-post.d.ts +14 -0
  97. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  98. package/dist/cjs/transformations/blog-post.js +4 -1
  99. package/dist/cjs/transformations/blog-post.js.map +1 -1
  100. package/dist/cjs/transformations/booking-item.d.ts +24 -0
  101. package/dist/cjs/transformations/booking-item.d.ts.map +1 -0
  102. package/dist/cjs/transformations/booking-item.js +27 -0
  103. package/dist/cjs/transformations/booking-item.js.map +1 -0
  104. package/dist/cjs/transformations/heading-anchors.d.ts +32 -8
  105. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -1
  106. package/dist/cjs/transformations/heading-anchors.js +34 -10
  107. package/dist/cjs/transformations/heading-anchors.js.map +1 -1
  108. package/dist/cjs/transformations/index.d.ts +27 -13
  109. package/dist/cjs/transformations/index.d.ts.map +1 -1
  110. package/dist/cjs/transformations/index.js +73 -16
  111. package/dist/cjs/transformations/index.js.map +1 -1
  112. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  113. package/dist/cjs/utils.d.ts.map +1 -1
  114. package/dist/cjs/utils.js +52 -3
  115. package/dist/cjs/utils.js.map +1 -1
  116. package/dist/esm/count-effect.d.ts +7 -1
  117. package/dist/esm/count-effect.d.ts.map +1 -1
  118. package/dist/esm/count-effect.js +15 -5
  119. package/dist/esm/count-effect.js.map +1 -1
  120. package/dist/esm/fetchers/pg-client-import.d.ts +12 -0
  121. package/dist/esm/fetchers/pg-client-import.d.ts.map +1 -0
  122. package/dist/esm/fetchers/pg-client-import.js +12 -0
  123. package/dist/esm/fetchers/pg-client-import.js.map +1 -0
  124. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  125. package/dist/esm/fetchers/postgresql.js +5 -2
  126. package/dist/esm/fetchers/postgresql.js.map +1 -1
  127. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  128. package/dist/esm/fetchers/raw-query.js +2 -1
  129. package/dist/esm/fetchers/raw-query.js.map +1 -1
  130. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  131. package/dist/esm/fetchers/rest-api.js +3 -1
  132. package/dist/esm/fetchers/rest-api.js.map +1 -1
  133. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  134. package/dist/esm/fetchers/teleport.js +6 -3
  135. package/dist/esm/fetchers/teleport.js.map +1 -1
  136. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +7 -6
  137. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
  138. package/dist/esm/fetchers/utils/browser-row-policy.js +5 -31
  139. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
  140. package/dist/esm/filter-utils.d.ts +19 -7
  141. package/dist/esm/filter-utils.d.ts.map +1 -1
  142. package/dist/esm/filter-utils.js +46 -12
  143. package/dist/esm/filter-utils.js.map +1 -1
  144. package/dist/esm/infinite-scroll.d.ts +25 -2
  145. package/dist/esm/infinite-scroll.d.ts.map +1 -1
  146. package/dist/esm/infinite-scroll.js +142 -32
  147. package/dist/esm/infinite-scroll.js.map +1 -1
  148. package/dist/esm/list-sort-order.d.ts +14 -0
  149. package/dist/esm/list-sort-order.d.ts.map +1 -0
  150. package/dist/esm/list-sort-order.js +32 -0
  151. package/dist/esm/list-sort-order.js.map +1 -0
  152. package/dist/esm/pagination-controls.d.ts +22 -3
  153. package/dist/esm/pagination-controls.d.ts.map +1 -1
  154. package/dist/esm/pagination-controls.js +28 -2
  155. package/dist/esm/pagination-controls.js.map +1 -1
  156. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  157. package/dist/esm/pagination-plugin.js +271 -216
  158. package/dist/esm/pagination-plugin.js.map +1 -1
  159. package/dist/esm/product-price-sort.d.ts +0 -9
  160. package/dist/esm/product-price-sort.d.ts.map +1 -1
  161. package/dist/esm/product-price-sort.js +0 -11
  162. package/dist/esm/product-price-sort.js.map +1 -1
  163. package/dist/esm/range-control.d.ts +45 -0
  164. package/dist/esm/range-control.d.ts.map +1 -0
  165. package/dist/esm/range-control.js +137 -0
  166. package/dist/esm/range-control.js.map +1 -0
  167. package/dist/esm/row-order.d.ts +20 -0
  168. package/dist/esm/row-order.d.ts.map +1 -0
  169. package/dist/esm/row-order.js +41 -0
  170. package/dist/esm/row-order.js.map +1 -0
  171. package/dist/esm/search-input.d.ts +42 -0
  172. package/dist/esm/search-input.d.ts.map +1 -0
  173. package/dist/esm/search-input.js +129 -0
  174. package/dist/esm/search-input.js.map +1 -0
  175. package/dist/esm/sort-utils.d.ts +10 -0
  176. package/dist/esm/sort-utils.d.ts.map +1 -1
  177. package/dist/esm/sort-utils.js +20 -0
  178. package/dist/esm/sort-utils.js.map +1 -1
  179. package/dist/esm/transformations/blog-adjacent-posts.d.ts +11 -1
  180. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -1
  181. package/dist/esm/transformations/blog-adjacent-posts.js +14 -1
  182. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -1
  183. package/dist/esm/transformations/blog-comments.d.ts +1 -1
  184. package/dist/esm/transformations/blog-comments.d.ts.map +1 -1
  185. package/dist/esm/transformations/blog-comments.js +1 -1
  186. package/dist/esm/transformations/blog-comments.js.map +1 -1
  187. package/dist/esm/transformations/blog-post.d.ts +14 -0
  188. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  189. package/dist/esm/transformations/blog-post.js +4 -1
  190. package/dist/esm/transformations/blog-post.js.map +1 -1
  191. package/dist/esm/transformations/booking-item.d.ts +24 -0
  192. package/dist/esm/transformations/booking-item.d.ts.map +1 -0
  193. package/dist/esm/transformations/booking-item.js +23 -0
  194. package/dist/esm/transformations/booking-item.js.map +1 -0
  195. package/dist/esm/transformations/heading-anchors.d.ts +32 -8
  196. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -1
  197. package/dist/esm/transformations/heading-anchors.js +33 -9
  198. package/dist/esm/transformations/heading-anchors.js.map +1 -1
  199. package/dist/esm/transformations/index.d.ts +27 -13
  200. package/dist/esm/transformations/index.d.ts.map +1 -1
  201. package/dist/esm/transformations/index.js +72 -16
  202. package/dist/esm/transformations/index.js.map +1 -1
  203. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  204. package/dist/esm/utils.d.ts.map +1 -1
  205. package/dist/esm/utils.js +52 -3
  206. package/dist/esm/utils.js.map +1 -1
  207. package/package.json +5 -5
  208. package/src/count-effect.ts +31 -8
  209. package/src/fetchers/pg-client-import.ts +16 -0
  210. package/src/fetchers/postgresql.ts +25 -23
  211. package/src/fetchers/raw-query.ts +2 -1
  212. package/src/fetchers/rest-api.ts +3 -1
  213. package/src/fetchers/teleport.ts +22 -23
  214. package/src/fetchers/utils/browser-row-policy.ts +10 -20
  215. package/src/filter-utils.ts +102 -28
  216. package/src/infinite-scroll.ts +240 -79
  217. package/src/list-sort-order.ts +34 -0
  218. package/src/pagination-controls.ts +26 -3
  219. package/src/pagination-plugin.ts +370 -306
  220. package/src/product-price-sort.ts +0 -11
  221. package/src/range-control.ts +207 -0
  222. package/src/row-order.ts +55 -0
  223. package/src/search-input.ts +190 -0
  224. package/src/sort-utils.ts +25 -0
  225. package/src/transformations/blog-adjacent-posts.ts +89 -3
  226. package/src/transformations/blog-comments.ts +5 -5
  227. package/src/transformations/blog-post.ts +64 -13
  228. package/src/transformations/booking-item.ts +71 -0
  229. package/src/transformations/heading-anchors.ts +79 -16
  230. package/src/transformations/index.ts +104 -14
  231. package/src/utils.ts +74 -3
@@ -1,6 +1,11 @@
1
1
  import * as types from '@babel/types'
2
2
  import { ChunkType, FileType, ChunkDefinition } from '@teleporthq/teleport-types'
3
- import { PAGINATION_CONTROL_ATTR } from '../../src/pagination-controls'
3
+ import {
4
+ PAGINATION_CONTROL_ATTR,
5
+ PAGINATION_FOR_ATTR,
6
+ RANGE_TEMPLATE_ATTR,
7
+ } from '../../src/pagination-controls'
8
+ import { SEARCH_FOR_ATTR } from '../../src/search-input'
4
9
 
5
10
  /**
6
11
  * A component shell carrying a `DataProvider` and the pagination controls a
@@ -20,9 +25,36 @@ interface ControlOptions {
20
25
  legacyOnly?: boolean
21
26
  /** Number of DataProvider + widget pairs to emit (order-based zip coverage). */
22
27
  mappers?: number
28
+ /** The element the providers render in: a table's body, a list. */
29
+ rootTag?: string
30
+ /**
31
+ * The `data-tq-range-template` of a `range` control in the widget. Defaults
32
+ * to the "Showing" wording; `null` authors the control with no template.
33
+ */
34
+ rangeTemplate?: string | null
35
+ /**
36
+ * A `range` text OUTSIDE every widget, placed BEFORE the first provider (a
37
+ * toolbar above the list), naming its mapper with `data-tq-pagination-for`.
38
+ */
39
+ standaloneRange?: { forNodeId: string; template?: string }
40
+ /**
41
+ * Search inputs OUTSIDE every widget, placed BEFORE the first provider, in
42
+ * this order: a page's own box naming its mapper with `data-tq-search-for`,
43
+ * or the platform's (`search-input` class) paired by order.
44
+ */
45
+ standaloneSearch?: Array<{ forNodeId?: string; name: string }>
23
46
  }
24
47
 
25
- const control = (marker: string, children: types.JSXElement['children'] = []): types.JSXElement =>
48
+ // ASCII hyphen on purpose: the generator prints string literals through jsesc,
49
+ // which would turn an en dash into `–` and make every assertion unreadable.
50
+ export const RANGE_DRAWN_TEXT = 'Showing 1-8 of 12'
51
+ export const RANGE_TEMPLATE = 'Showing {from}–{to} of {total}'
52
+
53
+ const control = (
54
+ marker: string,
55
+ children: types.JSXElement['children'] = [],
56
+ extraAttrs: Array<[string, string]> = []
57
+ ): types.JSXElement =>
26
58
  types.jsxElement(
27
59
  types.jsxOpeningElement(
28
60
  types.jsxIdentifier('div'),
@@ -35,6 +67,9 @@ const control = (marker: string, children: types.JSXElement['children'] = []): t
35
67
  types.jsxIdentifier(PAGINATION_CONTROL_ATTR),
36
68
  types.stringLiteral(marker)
37
69
  ),
70
+ ...extraAttrs.map(([name, value]) =>
71
+ types.jsxAttribute(types.jsxIdentifier(name), types.stringLiteral(value))
72
+ ),
38
73
  ],
39
74
  false
40
75
  ),
@@ -43,6 +78,40 @@ const control = (marker: string, children: types.JSXElement['children'] = []): t
43
78
  false
44
79
  )
45
80
 
81
+ const rangeControl = (
82
+ template: string | null | undefined,
83
+ forNodeId?: string
84
+ ): types.JSXElement => {
85
+ const attrs: Array<[string, string]> = []
86
+ if (template !== null) {
87
+ attrs.push([RANGE_TEMPLATE_ATTR, template ?? RANGE_TEMPLATE])
88
+ }
89
+ if (forNodeId) {
90
+ attrs.push([PAGINATION_FOR_ATTR, forNodeId])
91
+ }
92
+ return control('range', [types.jsxText(RANGE_DRAWN_TEXT)], attrs)
93
+ }
94
+
95
+ const searchInput = (name: string, forNodeId?: string): types.JSXElement =>
96
+ types.jsxElement(
97
+ types.jsxOpeningElement(
98
+ types.jsxIdentifier('input'),
99
+ [
100
+ types.jsxAttribute(types.jsxIdentifier('name'), types.stringLiteral(name)),
101
+ forNodeId
102
+ ? types.jsxAttribute(types.jsxIdentifier(SEARCH_FOR_ATTR), types.stringLiteral(forNodeId))
103
+ : types.jsxAttribute(
104
+ types.jsxIdentifier('className'),
105
+ types.stringLiteral('products-list-search-input')
106
+ ),
107
+ ],
108
+ true
109
+ ),
110
+ null,
111
+ [],
112
+ true
113
+ )
114
+
46
115
  const legacyControl = (direction: 'previous' | 'next'): types.JSXElement =>
47
116
  types.jsxElement(
48
117
  types.jsxOpeningElement(
@@ -72,6 +141,10 @@ const buildWidget = (options: ControlOptions): types.JSXElement => {
72
141
  children.push(legacyControl(marker))
73
142
  continue
74
143
  }
144
+ if (marker === 'range') {
145
+ children.push(rangeControl(options.rangeTemplate))
146
+ continue
147
+ }
75
148
  children.push(control(marker))
76
149
  }
77
150
 
@@ -148,6 +221,12 @@ const buildDataProvider = (name: string, rowIdentifier: string): types.JSXElemen
148
221
 
149
222
  export const makeWidgetComponentChunk = (options: ControlOptions): ChunkDefinition => {
150
223
  const sections: types.JSXElement[] = []
224
+ if (options.standaloneRange) {
225
+ sections.push(rangeControl(options.standaloneRange.template, options.standaloneRange.forNodeId))
226
+ }
227
+ for (const search of options.standaloneSearch ?? []) {
228
+ sections.push(searchInput(search.name, search.forNodeId))
229
+ }
151
230
  const mappers = options.mappers ?? 1
152
231
  for (let i = 0; i < mappers; i += 1) {
153
232
  const identifier = `items${i === 0 ? '' : i}`
@@ -155,9 +234,10 @@ export const makeWidgetComponentChunk = (options: ControlOptions): ChunkDefiniti
155
234
  sections.push(buildWidget(options))
156
235
  }
157
236
 
237
+ const rootTag = options.rootTag ?? 'div'
158
238
  const root = types.jsxElement(
159
- types.jsxOpeningElement(types.jsxIdentifier('div'), [], false),
160
- types.jsxClosingElement(types.jsxIdentifier('div')),
239
+ types.jsxOpeningElement(types.jsxIdentifier(rootTag), [], false),
240
+ types.jsxClosingElement(types.jsxIdentifier(rootTag)),
161
241
  sections,
162
242
  false
163
243
  )
@@ -187,6 +267,10 @@ export interface RepeaterOptions {
187
267
  infiniteScroll?: boolean
188
268
  infiniteScrollLoadMore?: boolean
189
269
  perPage?: number
270
+ /** The list's filters, as the inspector authors them (conditions and groups). */
271
+ filters?: unknown[]
272
+ /** The mapper's editor node id, as the UIDL carries it on the repeater. */
273
+ nodeId?: string
190
274
  }
191
275
 
192
276
  export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'items'): any => ({
@@ -198,7 +282,12 @@ export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'ite
198
282
  tableName: 'products',
199
283
  dataSourceType: 'postgresql',
200
284
  },
201
- resource: { params: { queryColumns: { content: ['name'] } } },
285
+ resource: {
286
+ params: {
287
+ queryColumns: { content: ['name'] },
288
+ ...(options.filters ? { filters: { type: 'static', content: options.filters } } : {}),
289
+ },
290
+ },
202
291
  nodes: {
203
292
  success: {
204
293
  type: 'cms-list-repeater',
@@ -211,6 +300,7 @@ export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'ite
211
300
  ...(options.paginationMode ? { paginationMode: options.paginationMode } : {}),
212
301
  ...(options.infiniteScroll ? { infiniteScroll: true } : {}),
213
302
  ...(options.infiniteScrollLoadMore ? { infiniteScrollLoadMore: true } : {}),
303
+ ...(options.nodeId ? { nodeId: options.nodeId } : {}),
214
304
  nodes: { list: { type: 'element', content: { elementType: 'div' } } },
215
305
  },
216
306
  },
@@ -0,0 +1,176 @@
1
+ import { generateBlogPostTransformationCode } from '../src/transformations/blog-post'
2
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
3
+ import { getTransformationCode } from '../src/transformations'
4
+ import { ContentTables } from '@teleporthq/teleport-shared'
5
+
6
+ /**
7
+ * Previous / Next in Manual Order: a post walks the published posts of its
8
+ * primary category the way the author arranged them. These evaluate the
9
+ * EMITTED code against a fake database client, the way the runtime calls it.
10
+ *
11
+ * The fixture and the expectations are the SAME as teleport-gui
12
+ * `features/blog/utils/__tests__/arranged-order.spec.ts` ("Previous / Next the
13
+ * published site mirrors"): the canvas and the site must agree.
14
+ */
15
+
16
+ interface Row {
17
+ id: string
18
+ status: string
19
+ category_ids: string | null
20
+ sort_order: number | null
21
+ created_at: string
22
+ redirect_url?: string | null
23
+ }
24
+
25
+ type AdjacentMap = Record<string, { previous: Row | null; next: Row | null }>
26
+ type GetAdjacent = (getClient: () => unknown, records: unknown[]) => Promise<AdjacentMap>
27
+
28
+ const HELP_TABLE = ContentTables.contentTablesByKey('help').posts
29
+
30
+ const load = (arranged: boolean): GetAdjacent =>
31
+ // tslint:disable-next-line:function-constructor
32
+ new Function(
33
+ `${generateSharedTransformationCode()}\n${generateBlogPostTransformationCode({
34
+ tables: ContentTables.contentTablesByKey('help'),
35
+ arranged,
36
+ })}\nreturn getAdjacentPostsMap;`
37
+ )() as GetAdjacent
38
+
39
+ const row = (
40
+ id: string,
41
+ categories: string[] | null,
42
+ sortOrder: number | null,
43
+ createdAt: string,
44
+ overrides: Partial<Row> = {}
45
+ ): Row => ({
46
+ id,
47
+ status: 'published',
48
+ category_ids: categories ? JSON.stringify(categories) : null,
49
+ sort_order: sortOrder,
50
+ created_at: createdAt,
51
+ ...overrides,
52
+ })
53
+
54
+ const TABLE: Row[] = [
55
+ row('a1', ['guides'], 1, '2026-01-01T00:00:00.000Z'),
56
+ row('a2', ['guides'], 2, '2026-01-05T00:00:00.000Z'),
57
+ row('a3', ['guides'], 3, '2026-01-03T00:00:00.000Z', { redirect_url: '/help/a1' }),
58
+ row('a4', ['guides'], 4, '2026-01-02T00:00:00.000Z'),
59
+ row('a5', ['guides'], null, '2026-02-01T00:00:00.000Z'),
60
+ row('a6', ['guides'], null, '2026-01-20T00:00:00.000Z'),
61
+ row('draft', ['guides'], 2, '2026-03-01T00:00:00.000Z', { status: 'draft' }),
62
+ // Filed under guides as a second category: not on guides' path.
63
+ row('b1', ['billing', 'guides'], 1, '2026-01-04T00:00:00.000Z'),
64
+ // A comma-separated cell, as the generated admin panel's text field writes it.
65
+ row('c1', null, 1, '2026-01-06T00:00:00.000Z', { category_ids: 'guides, billing' }),
66
+ row('lone', null, null, '2026-01-07T00:00:00.000Z'),
67
+ ]
68
+
69
+ /** A client over `TABLE` that answers the statements the code sends, and records them. */
70
+ const fakeClient = (options: { failArranged?: boolean } = {}) => {
71
+ const statements: string[] = []
72
+ const client = {
73
+ connect: async (): Promise<void> => undefined,
74
+ end: async (): Promise<void> => undefined,
75
+ query: async (sql: string, params: unknown[]) => {
76
+ statements.push(sql)
77
+ if (sql.startsWith('SELECT id, category_ids, sort_order, created_at FROM')) {
78
+ if (options.failArranged) {
79
+ throw new Error('column "sort_order" does not exist')
80
+ }
81
+ return {
82
+ rows: TABLE.filter((entry) => entry.status === 'published' || entry.id === params[0]),
83
+ }
84
+ }
85
+ if (sql.includes('= ANY($1::text[])')) {
86
+ const ids = params[0] as string[]
87
+ return { rows: TABLE.filter((entry) => ids.includes(entry.id)) }
88
+ }
89
+ // The newest-first lookups: (created_at, id) before / after the post's.
90
+ const current = TABLE.find((entry) => entry.id === params[0]) as Row
91
+ const older = sql.includes(') < (cur.created_at')
92
+ const rows = TABLE.filter((entry) => entry.status === 'published')
93
+ .filter((entry) =>
94
+ older ? entry.created_at < current.created_at : entry.created_at > current.created_at
95
+ )
96
+ .sort((a, b) =>
97
+ older
98
+ ? b.created_at.localeCompare(a.created_at)
99
+ : a.created_at.localeCompare(b.created_at)
100
+ )
101
+ return { rows }
102
+ },
103
+ }
104
+ return { client, statements }
105
+ }
106
+
107
+ const neighbours = async (postId: string, arranged = true, failArranged = false) => {
108
+ const { client, statements } = fakeClient({ failArranged })
109
+ const map = await load(arranged)(
110
+ () => client,
111
+ [TABLE.find((candidate) => candidate.id === postId)]
112
+ )
113
+ const found = map[postId]
114
+ return {
115
+ previous: found?.previous?.id ?? null,
116
+ next: found?.next?.id ?? null,
117
+ statements,
118
+ }
119
+ }
120
+
121
+ describe('Previous / Next in Manual Order', () => {
122
+ it('walks the primary category as arranged: places first, then the rest newest first', async () => {
123
+ // c1 shares place 1 and is newer, so it comes first.
124
+ expect(await neighbours('a1')).toMatchObject({ previous: 'c1', next: 'a2' })
125
+ // a3 redirects elsewhere: stepped over both ways.
126
+ expect(await neighbours('a2')).toMatchObject({ previous: 'a1', next: 'a4' })
127
+ expect(await neighbours('a4')).toMatchObject({ previous: 'a2', next: 'a5' })
128
+ expect(await neighbours('a5')).toMatchObject({ previous: 'a4', next: 'a6' })
129
+ expect(await neighbours('a6')).toMatchObject({ previous: 'a5', next: null })
130
+ })
131
+
132
+ it('leaves out drafts and posts filed there through a second category, yet places a draft it shows', async () => {
133
+ const draft = await neighbours('draft')
134
+ expect(draft).toMatchObject({ previous: 'a1', next: 'a2' })
135
+ expect(await neighbours('b1')).toMatchObject({ previous: null, next: null })
136
+ })
137
+
138
+ it('reads a comma-separated category cell like a JSON one', async () => {
139
+ // c1's primary is guides; place 1 ties with a1, the newer first.
140
+ expect(await neighbours('c1')).toMatchObject({ previous: null, next: 'a1' })
141
+ })
142
+
143
+ it('walks the newest-first order for a post without a category, or a table it cannot read', async () => {
144
+ const lone = await neighbours('lone')
145
+ expect(lone.statements.some((sql) => sql.includes('(cur.created_at, cur.id)'))).toBe(true)
146
+ expect(lone).toMatchObject({ previous: 'c1', next: 'a6' })
147
+
148
+ const unreadable = await neighbours('a2', true, true)
149
+ expect(unreadable).toMatchObject({ previous: 'b1', next: 'c1' })
150
+ })
151
+
152
+ it('never asks for the arranged order when the pages are newest first', async () => {
153
+ const plain = await neighbours('a2', false)
154
+ expect(plain.statements.some((sql) => sql.includes('sort_order'))).toBe(false)
155
+ expect(plain).toMatchObject({ previous: 'b1', next: 'c1' })
156
+ })
157
+
158
+ it('is emitted for a table whose preset is in Manual Order, and only then', () => {
159
+ const arranged = getTransformationCode(HELP_TABLE, {
160
+ contentSettings: { help: { order: 'manual' } },
161
+ })
162
+ expect(arranged).toContain('var BLOG_ARRANGED_ORDER = true')
163
+ expect(getTransformationCode(HELP_TABLE, {})).toContain('var BLOG_ARRANGED_ORDER = false')
164
+ })
165
+ })
166
+
167
+ describe('the post a page is given', () => {
168
+ it('carries its place, or null', () => {
169
+ const build = new Function(
170
+ `${generateSharedTransformationCode()}\n${generateBlogPostTransformationCode()}\nreturn buildBlogPost;`
171
+ )() as (record: unknown) => Record<string, unknown>
172
+ expect(build(row('a', ['guides'], 3, '2026-01-01T00:00:00.000Z')).sortOrder).toBe(3)
173
+ expect(build(row('a', ['guides'], null, '2026-01-01T00:00:00.000Z')).sortOrder).toBeNull()
174
+ expect(build({ id: 'old', title: 'Old', slug: 'old' }).sortOrder).toBeNull()
175
+ })
176
+ })
@@ -0,0 +1,212 @@
1
+ import { generateBlogPostTransformationCode } from '../src/transformations/blog-post'
2
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
3
+ import {
4
+ buildProductTransformOptions,
5
+ getTransformationCode,
6
+ getTransformWrapperCode,
7
+ } from '../src/transformations'
8
+
9
+ /**
10
+ * A post page's Contents list: the post's sections — its headings of the
11
+ * topmost level it uses — each with the address that jumps to it, drawn for a
12
+ * post of three sections or more. The headings get their ids for it even with
13
+ * the heading anchors off, and only a post page's own fetch carries the list.
14
+ *
15
+ * `CONTENTS_FIXTURES` is the SAME table as teleport-gui `packages/renderer/src/
16
+ * utils/__tests__/blog-contents-sections.test.ts`: the canvas and the site must
17
+ * list the same sections under the same addresses.
18
+ */
19
+
20
+ type Build = (record: unknown, options?: Record<string, unknown>) => Record<string, unknown>
21
+
22
+ const buildWith = (options: { headingAnchors?: boolean; contents?: boolean } = {}): Build =>
23
+ new Function(
24
+ generateSharedTransformationCode() +
25
+ '\n' +
26
+ generateBlogPostTransformationCode(options) +
27
+ '\nreturn buildBlogPost;'
28
+ )() as Build
29
+
30
+ const POST = { id: 'p1', title: 'Connect a domain', slug: 'connect-a-domain', status: 'published' }
31
+ const DETAILS = { details: true }
32
+
33
+ const CONTENTS_FIXTURES: Array<{ name: string; html: string; sections: string[][] }> = [
34
+ {
35
+ name: 'a help article: its h2 sections, not the h3 under them, never the h1',
36
+ html:
37
+ '<h1>Title</h1><p>Intro</p><h2>Before you start</h2><p>x</p><h2>Steps</h2>' +
38
+ '<h3>Open the panel</h3><h2>Check that it worked</h2><h2>Troubleshooting</h2>',
39
+ sections: [
40
+ ['before-you-start', 'Before you start'],
41
+ ['steps', 'Steps'],
42
+ ['check-that-it-worked', 'Check that it worked'],
43
+ ['troubleshooting', 'Troubleshooting'],
44
+ ],
45
+ },
46
+ {
47
+ name: 'fewer than three sections: no list',
48
+ html: '<h2>One</h2><p>x</p><h2>Two</h2><h3>Two and a half</h3><h3>Two and three quarters</h3>',
49
+ sections: [],
50
+ },
51
+ {
52
+ name: 'the topmost level the post uses',
53
+ html: '<h3>Alpha</h3><h4>Under alpha</h4><h3>Beta</h3><h3>Gamma</h3>',
54
+ sections: [
55
+ ['alpha', 'Alpha'],
56
+ ['beta', 'Beta'],
57
+ ['gamma', 'Gamma'],
58
+ ],
59
+ },
60
+ {
61
+ name: 'ids the content has are kept, repeated titles numbered, entities decoded',
62
+ html:
63
+ '<h2 id="custom">Café &amp; Crème</h2><h2>Setup</h2><h2>Setup</h2>' +
64
+ '<h2 id="setup-2">Clash</h2>',
65
+ sections: [
66
+ ['custom', 'Café & Crème'],
67
+ ['setup', 'Setup'],
68
+ ['setup-3', 'Setup'],
69
+ ['setup-2', 'Clash'],
70
+ ],
71
+ },
72
+ {
73
+ name: 'an empty heading is no section',
74
+ html: '<h2><br></h2><h2>A</h2><h2>B</h2><h2>C</h2>',
75
+ sections: [
76
+ ['a', 'A'],
77
+ ['b', 'B'],
78
+ ['c', 'C'],
79
+ ],
80
+ },
81
+ {
82
+ name: 'accented, non-Latin and symbol-only titles',
83
+ html: '<h2>Crème brûlée</h2><h2>Привет мир</h2><h2>!!!</h2>',
84
+ sections: [
85
+ ['creme-brulee', 'Crème brûlée'],
86
+ ['привет-мир', 'Привет мир'],
87
+ ['section', '!!!'],
88
+ ],
89
+ },
90
+ {
91
+ name: 'a title that names a built-in property',
92
+ html: '<h2>Constructor</h2><h2>Prototype</h2><h2>Has own property</h2>',
93
+ sections: [
94
+ ['constructor', 'Constructor'],
95
+ ['prototype', 'Prototype'],
96
+ ['has-own-property', 'Has own property'],
97
+ ],
98
+ },
99
+ {
100
+ name: 'inline markup inside a heading',
101
+ html: '<h2>Use <code>npm</code> &lt;3</h2><h2><strong>Bold</strong> move</h2><h2>Last</h2>',
102
+ sections: [
103
+ ['use-npm-3', 'Use npm <3'],
104
+ ['bold-move', 'Bold move'],
105
+ ['last', 'Last'],
106
+ ],
107
+ },
108
+ ]
109
+
110
+ const asPairs = (sections: unknown) =>
111
+ (sections as Array<{ id: string; title: string; href: string }>).map((section) => {
112
+ expect(section.href).toBe(`#${section.id}`)
113
+ return [section.id, section.title]
114
+ })
115
+
116
+ describe('a post page’s Contents list', () => {
117
+ it.each(CONTENTS_FIXTURES)('$name', ({ html, sections }) => {
118
+ const built = buildWith({ contents: true })({ ...POST, content: html }, DETAILS)
119
+ expect(asPairs(built.sections)).toEqual(sections)
120
+ // Every section the list links to is a heading of the page, under that id.
121
+ for (const [id] of sections) {
122
+ expect(built.content as string).toContain(`id="${id}"`)
123
+ }
124
+ })
125
+
126
+ it('gives the headings their ids without the # links while the heading anchors are off', () => {
127
+ const built = buildWith({ contents: true })(
128
+ { ...POST, content: '<h2>One</h2><h2>Two</h2><h2>Three</h2>' },
129
+ DETAILS
130
+ )
131
+ expect(built.content).toBe(
132
+ '<h2 id="one">One</h2><h2 id="two">Two</h2><h2 id="three">Three</h2>'
133
+ )
134
+ })
135
+
136
+ it('links the same ids the heading anchors give, with the anchors on', () => {
137
+ const built = buildWith({ contents: true, headingAnchors: true })(
138
+ { ...POST, content: '<h2>One</h2><h2>Two</h2><h2>Three</h2>' },
139
+ DETAILS
140
+ )
141
+ expect(built.content).toContain(
142
+ '<h2 id="one"><a class="tq-heading-link" href="#one">One</a><a class="tq-heading-anchor" href="#one" aria-hidden="true" tabindex="-1">#</a></h2>'
143
+ )
144
+ expect(asPairs(built.sections)).toEqual([
145
+ ['one', 'One'],
146
+ ['two', 'Two'],
147
+ ['three', 'Three'],
148
+ ])
149
+ })
150
+
151
+ it('reads a heading stored as its own link with the # after it, without the #', () => {
152
+ const stored =
153
+ '<h2 id="one"><a class="tq-heading-link" href="#one">One</a><a class="tq-heading-anchor" href="#one" aria-hidden="true" tabindex="-1">#</a></h2>' +
154
+ '<h2>Two</h2><h2>Three</h2>'
155
+ const built = buildWith({ contents: true, headingAnchors: true })(
156
+ { ...POST, content: stored },
157
+ DETAILS
158
+ )
159
+ expect(asPairs(built.sections)).toEqual([
160
+ ['one', 'One'],
161
+ ['two', 'Two'],
162
+ ['three', 'Three'],
163
+ ])
164
+ })
165
+
166
+ it('reads a heading stored with its # link already, without the #', () => {
167
+ const stored =
168
+ '<h2 id="one">One<a class="tq-heading-anchor" href="#one" aria-hidden="true" tabindex="-1">#</a></h2>' +
169
+ '<h2>Two</h2><h2>Three</h2>'
170
+ const built = buildWith({ contents: true, headingAnchors: true })(
171
+ { ...POST, content: stored },
172
+ DETAILS
173
+ )
174
+ expect(asPairs(built.sections)).toEqual([
175
+ ['one', 'One'],
176
+ ['two', 'Two'],
177
+ ['three', 'Three'],
178
+ ])
179
+ })
180
+
181
+ it('is left off a listing’s cards and off a post without the list', () => {
182
+ const content = '<h2>One</h2><h2>Two</h2><h2>Three</h2>'
183
+ const card = buildWith({ contents: true })({ ...POST, content })
184
+ expect(card.sections).toEqual([])
185
+ expect(card.content).toContain('id="one"')
186
+
187
+ const withoutList = buildWith()({ ...POST, content }, DETAILS)
188
+ expect(withoutList.sections).toEqual([])
189
+ expect(withoutList.content).toBe(content)
190
+ })
191
+ })
192
+
193
+ describe('the Contents setting, from the project to the fetcher', () => {
194
+ const HELP_TABLE = 'teleport_help_articles'
195
+
196
+ it('reaches the transform of its own preset only', () => {
197
+ const options = buildProductTransformOptions({
198
+ helpCenterSettings: { headingAnchors: false, contents: true },
199
+ blogSettings: { headingAnchors: true },
200
+ })
201
+ expect(getTransformationCode(HELP_TABLE, options)).toContain('var BLOG_CONTENTS = true')
202
+ expect(getTransformationCode('teleport_blog_posts', options)).toContain(
203
+ 'var BLOG_CONTENTS = false'
204
+ )
205
+ })
206
+
207
+ it('tells the transform when the fetch is a post page’s own', () => {
208
+ expect(getTransformWrapperCode(HELP_TABLE)).toContain(
209
+ 'details: Array.isArray(records) && records.length === 1'
210
+ )
211
+ })
212
+ })
@@ -29,6 +29,16 @@ const post = (id: string, overrides: Record<string, unknown> = {}) => ({
29
29
  })
30
30
 
31
31
  describe('blog post transform — author box fields', () => {
32
+ it('keeps the author email out of the post a page is given, under both spellings', () => {
33
+ const built = buildBlogPost(
34
+ post('a', { author_name: 'Ana', author_email: 'ana@example.com' })
35
+ ) as Record<string, unknown>
36
+ expect(built.authorName).toBe('Ana')
37
+ expect('authorEmail' in built).toBe(false)
38
+ expect('author_email' in built).toBe(false)
39
+ expect(JSON.stringify(built)).not.toContain('ana@example.com')
40
+ })
41
+
32
42
  it('resolves the bio in the request language, falling back to the main one', () => {
33
43
  const record = post('a', {
34
44
  author_bio: 'Writes about tea.',
@@ -152,7 +162,7 @@ describe('blog post transform — heading anchors', () => {
152
162
  expect(buildBlogPost(post('a', { content })).content).toBe(content)
153
163
  })
154
164
 
155
- it('gives every h2-h6 a unique id and a hidden # link, keeping ids it already has', () => {
165
+ it('gives every h2-h6 a unique id, makes its text a link to itself with a # after it, keeping ids it already has', () => {
156
166
  const built = buildWith({ headingAnchors: true })(
157
167
  post('a', {
158
168
  content:
@@ -160,13 +170,26 @@ describe('blog post transform — heading anchors', () => {
160
170
  '<h2>Getting started</h2><h4><br></h4><h2 id="getting-started-2">Clash</h2>',
161
171
  })
162
172
  )
173
+ const anchored = (tag: string, attrs: string, id: string, text: string) =>
174
+ `<${tag}${attrs}><a class="tq-heading-link" href="#${id}">${text}</a>` +
175
+ `<a class="tq-heading-anchor" href="#${id}" aria-hidden="true" tabindex="-1">#</a></${tag}>`
163
176
  expect(built.content).toBe(
164
177
  '<h1>Title</h1>' +
165
- '<h2 id="getting-started">Getting Started<a class="tq-heading-anchor" href="#getting-started" aria-hidden="true" tabindex="-1">#</a></h2>' +
166
- '<h3 class="x" id="custom">Café &amp; Crème<a class="tq-heading-anchor" href="#custom" aria-hidden="true" tabindex="-1">#</a></h3>' +
167
- '<h2 id="getting-started-3">Getting started<a class="tq-heading-anchor" href="#getting-started-3" aria-hidden="true" tabindex="-1">#</a></h2>' +
178
+ anchored('h2', ' id="getting-started"', 'getting-started', 'Getting Started') +
179
+ anchored('h3', ' class="x" id="custom"', 'custom', 'Café &amp; Crème') +
180
+ anchored('h2', ' id="getting-started-3"', 'getting-started-3', 'Getting started') +
168
181
  '<h4><br></h4>' +
169
- '<h2 id="getting-started-2">Clash<a class="tq-heading-anchor" href="#getting-started-2" aria-hidden="true" tabindex="-1">#</a></h2>'
182
+ anchored('h2', ' id="getting-started-2"', 'getting-started-2', 'Clash')
183
+ )
184
+ })
185
+
186
+ it('links a heading that holds a link of its own through the # alone', () => {
187
+ const built = buildWith({ headingAnchors: true })(
188
+ post('a', { content: '<h2>See <a href="/pricing">pricing</a></h2>' })
189
+ )
190
+ expect(built.content).toBe(
191
+ '<h2 id="see-pricing">See <a href="/pricing">pricing</a>' +
192
+ '<a class="tq-heading-anchor" href="#see-pricing" aria-hidden="true" tabindex="-1">#</a></h2>'
170
193
  )
171
194
  })
172
195
 
@@ -0,0 +1,48 @@
1
+ import { ContentTables } from '@teleporthq/teleport-shared'
2
+ import { generateBlogPostTransformationCode } from '../src/transformations/blog-post'
3
+ import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
4
+
5
+ /**
6
+ * What a post without a picture carries to its card and post page. The Blog
7
+ * shows the grey "No image" stand-in; the Help Center's articles are text, so
8
+ * an article without a picture carries none and its card closes up (the card
9
+ * draws the picture only when there is one). The editor reads the same
10
+ * `ContentTables.pictureStandIn`, so the canvas and the site agree.
11
+ */
12
+
13
+ const buildPost = (
14
+ key: ContentTables.ContentPresetKey
15
+ ): ((record: unknown) => Record<string, unknown>) => {
16
+ const code =
17
+ generateSharedTransformationCode() +
18
+ '\n' +
19
+ generateBlogPostTransformationCode({ tables: ContentTables.contentTablesByKey(key) })
20
+ return new Function(code + '\nreturn buildBlogPost;')() as (
21
+ record: unknown
22
+ ) => Record<string, unknown>
23
+ }
24
+
25
+ const POST = { id: 'p1', title: 'Connect a domain', slug: 'connect-a-domain', status: 'published' }
26
+ const DEFAULT_PICTURE = 'https://play.teleporthq.io/static/svg/default-img.svg'
27
+
28
+ describe('a post without a picture', () => {
29
+ it('gets the grey stand-in on the Blog', () => {
30
+ expect(buildPost('blog')(POST).featuredImageUrl).toMatch(/^data:image\/svg\+xml/)
31
+ })
32
+
33
+ it('carries none in the Help Center, so its card closes up', () => {
34
+ expect(buildPost('help')(POST).featuredImageUrl).toBeNull()
35
+ })
36
+
37
+ it('counts the platform default picture as none', () => {
38
+ const record = { ...POST, featured_image_url: DEFAULT_PICTURE }
39
+ expect(buildPost('help')(record).featuredImageUrl).toBeNull()
40
+ expect(buildPost('blog')(record).featuredImageUrl).toMatch(/^data:image\/svg\+xml/)
41
+ })
42
+
43
+ it('keeps a picture of its own in both', () => {
44
+ const record = { ...POST, featured_image_url: 'https://cdn.example.com/domains.png' }
45
+ expect(buildPost('help')(record).featuredImageUrl).toBe('https://cdn.example.com/domains.png')
46
+ expect(buildPost('blog')(record).featuredImageUrl).toBe('https://cdn.example.com/domains.png')
47
+ })
48
+ })