@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.
- package/__tests__/_helpers/pagination-widget-fixture.ts +8 -1
- package/__tests__/blog-adjacent-arranged.test.ts +176 -0
- package/__tests__/blog-post-contents.test.ts +212 -0
- package/__tests__/blog-post-page-enrichment.test.ts +28 -5
- package/__tests__/blog-post-picture-stand-in.test.ts +48 -0
- package/__tests__/blog-post-reading-time.test.ts +55 -0
- package/__tests__/browser-row-policy.test.ts +5 -1
- package/__tests__/content-preset-wiring.test.ts +108 -0
- package/__tests__/fixed-list-sort.test.ts +183 -0
- package/__tests__/pagination-filter-groups.test.ts +186 -0
- package/__tests__/plain-list-sort.test.ts +215 -0
- package/__tests__/product-price-sort.ts +47 -19
- package/__tests__/rest-api-key-header.test.ts +18 -0
- package/__tests__/search-url-sync.test.ts +34 -15
- package/__tests__/shared-resource-prefetch.test.ts +65 -0
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +2 -1
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +3 -1
- 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 +3 -2
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +7 -6
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.js +7 -33
- package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -1
- package/dist/cjs/filter-utils.d.ts +19 -7
- package/dist/cjs/filter-utils.d.ts.map +1 -1
- package/dist/cjs/filter-utils.js +49 -13
- package/dist/cjs/filter-utils.js.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +137 -118
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/product-price-sort.d.ts +0 -9
- package/dist/cjs/product-price-sort.d.ts.map +1 -1
- package/dist/cjs/product-price-sort.js +1 -13
- package/dist/cjs/product-price-sort.js.map +1 -1
- package/dist/cjs/row-order.d.ts +19 -0
- package/dist/cjs/row-order.d.ts.map +1 -0
- package/dist/cjs/row-order.js +45 -0
- package/dist/cjs/row-order.js.map +1 -0
- package/dist/cjs/sort-utils.d.ts +10 -0
- package/dist/cjs/sort-utils.d.ts.map +1 -1
- package/dist/cjs/sort-utils.js +22 -1
- package/dist/cjs/sort-utils.js.map +1 -1
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts +11 -1
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-adjacent-posts.js +14 -1
- package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -1
- package/dist/cjs/transformations/blog-comments.d.ts +1 -1
- package/dist/cjs/transformations/blog-comments.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-comments.js +1 -1
- package/dist/cjs/transformations/blog-comments.js.map +1 -1
- package/dist/cjs/transformations/blog-post.d.ts +14 -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/heading-anchors.d.ts +32 -8
- package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -1
- package/dist/cjs/transformations/heading-anchors.js +34 -10
- package/dist/cjs/transformations/heading-anchors.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +24 -12
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +59 -11
- package/dist/cjs/transformations/index.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 +52 -3
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +3 -2
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +3 -1
- 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 +4 -3
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts +7 -6
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -1
- package/dist/esm/fetchers/utils/browser-row-policy.js +5 -31
- package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -1
- package/dist/esm/filter-utils.d.ts +19 -7
- package/dist/esm/filter-utils.d.ts.map +1 -1
- package/dist/esm/filter-utils.js +46 -12
- package/dist/esm/filter-utils.js.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +140 -121
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/product-price-sort.d.ts +0 -9
- package/dist/esm/product-price-sort.d.ts.map +1 -1
- package/dist/esm/product-price-sort.js +0 -11
- package/dist/esm/product-price-sort.js.map +1 -1
- package/dist/esm/row-order.d.ts +19 -0
- package/dist/esm/row-order.d.ts.map +1 -0
- package/dist/esm/row-order.js +40 -0
- package/dist/esm/row-order.js.map +1 -0
- package/dist/esm/sort-utils.d.ts +10 -0
- package/dist/esm/sort-utils.d.ts.map +1 -1
- package/dist/esm/sort-utils.js +20 -0
- package/dist/esm/sort-utils.js.map +1 -1
- package/dist/esm/transformations/blog-adjacent-posts.d.ts +11 -1
- package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -1
- package/dist/esm/transformations/blog-adjacent-posts.js +14 -1
- package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -1
- package/dist/esm/transformations/blog-comments.d.ts +1 -1
- package/dist/esm/transformations/blog-comments.d.ts.map +1 -1
- package/dist/esm/transformations/blog-comments.js +1 -1
- package/dist/esm/transformations/blog-comments.js.map +1 -1
- package/dist/esm/transformations/blog-post.d.ts +14 -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/heading-anchors.d.ts +32 -8
- package/dist/esm/transformations/heading-anchors.d.ts.map +1 -1
- package/dist/esm/transformations/heading-anchors.js +33 -9
- package/dist/esm/transformations/heading-anchors.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +24 -12
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +58 -11
- package/dist/esm/transformations/index.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 +52 -3
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/fetchers/postgresql.ts +11 -15
- package/src/fetchers/rest-api.ts +3 -1
- package/src/fetchers/teleport.ts +12 -16
- package/src/fetchers/utils/browser-row-policy.ts +10 -20
- package/src/filter-utils.ts +102 -28
- package/src/pagination-plugin.ts +211 -175
- package/src/product-price-sort.ts +0 -11
- package/src/row-order.ts +54 -0
- package/src/sort-utils.ts +25 -0
- package/src/transformations/blog-adjacent-posts.ts +89 -3
- package/src/transformations/blog-comments.ts +5 -5
- package/src/transformations/blog-post.ts +64 -13
- package/src/transformations/heading-anchors.ts +79 -16
- package/src/transformations/index.ts +80 -13
- package/src/utils.ts +74 -3
|
@@ -187,6 +187,8 @@ export interface RepeaterOptions {
|
|
|
187
187
|
infiniteScroll?: boolean
|
|
188
188
|
infiniteScrollLoadMore?: boolean
|
|
189
189
|
perPage?: number
|
|
190
|
+
/** The list's filters, as the inspector authors them (conditions and groups). */
|
|
191
|
+
filters?: unknown[]
|
|
190
192
|
}
|
|
191
193
|
|
|
192
194
|
export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'items'): any => ({
|
|
@@ -198,7 +200,12 @@ export const makeUidlNode = (options: RepeaterOptions, identifier: string = 'ite
|
|
|
198
200
|
tableName: 'products',
|
|
199
201
|
dataSourceType: 'postgresql',
|
|
200
202
|
},
|
|
201
|
-
resource: {
|
|
203
|
+
resource: {
|
|
204
|
+
params: {
|
|
205
|
+
queryColumns: { content: ['name'] },
|
|
206
|
+
...(options.filters ? { filters: { type: 'static', content: options.filters } } : {}),
|
|
207
|
+
},
|
|
208
|
+
},
|
|
202
209
|
nodes: {
|
|
203
210
|
success: {
|
|
204
211
|
type: 'cms-list-repeater',
|
|
@@ -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é & 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> <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
|
|
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
|
-
'
|
|
166
|
-
'
|
|
167
|
-
'
|
|
178
|
+
anchored('h2', ' id="getting-started"', 'getting-started', 'Getting Started') +
|
|
179
|
+
anchored('h3', ' class="x" id="custom"', 'custom', 'Café & Crème') +
|
|
180
|
+
anchored('h2', ' id="getting-started-3"', 'getting-started-3', 'Getting started') +
|
|
168
181
|
'<h4><br></h4>' +
|
|
169
|
-
'
|
|
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
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { generateBlogPostTransformationCode } from '../src/transformations/blog-post'
|
|
2
|
+
import { generateSharedTransformationCode } from '../src/transformations/shared-utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A post that stores no reading time gets one from its body — its words at
|
|
6
|
+
* about 200 a minute, at least one, as the editor's post form computes it —
|
|
7
|
+
* so a card or post page never prints "min read" with no number before it.
|
|
8
|
+
* The SAME cases as teleport-gui `packages/renderer/src/utils/__tests__/
|
|
9
|
+
* blog-reading-time.test.ts`: the canvas and the site must agree.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const buildPost = (headingAnchors = false): ((record: unknown) => Record<string, unknown>) => {
|
|
13
|
+
const code =
|
|
14
|
+
generateSharedTransformationCode() +
|
|
15
|
+
'\n' +
|
|
16
|
+
generateBlogPostTransformationCode({ headingAnchors })
|
|
17
|
+
return new Function(code + '\nreturn buildBlogPost;')() as (
|
|
18
|
+
record: unknown
|
|
19
|
+
) => Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const words = (count: number) => `<p>${Array.from({ length: count }, () => 'word').join(' ')}</p>`
|
|
23
|
+
const POST = { id: 'p1', title: 'Connect a domain', slug: 'connect-a-domain', status: 'published' }
|
|
24
|
+
|
|
25
|
+
describe('a post’s reading time', () => {
|
|
26
|
+
it('is the one the post stores', () => {
|
|
27
|
+
expect(
|
|
28
|
+
buildPost()({ ...POST, reading_time_minutes: 7, content: words(401) }).readingTimeMinutes
|
|
29
|
+
).toBe(7)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('comes from the body when the post stores none', () => {
|
|
33
|
+
expect(buildPost()({ ...POST, content: words(401) }).readingTimeMinutes).toBe(3)
|
|
34
|
+
expect(
|
|
35
|
+
buildPost()({ ...POST, reading_time_minutes: null, content: words(12) }).readingTimeMinutes
|
|
36
|
+
).toBe(1)
|
|
37
|
+
expect(
|
|
38
|
+
buildPost()({
|
|
39
|
+
...POST,
|
|
40
|
+
content: '<h2>Steps</h2><ul><li>Open the panel</li><li>Click Save</li></ul>',
|
|
41
|
+
}).readingTimeMinutes
|
|
42
|
+
).toBe(1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('counts the words, not the heading anchors a site adds', () => {
|
|
46
|
+
const content = `<h2>One</h2>${words(399)}`
|
|
47
|
+
expect(buildPost(true)({ ...POST, content }).readingTimeMinutes).toBe(2)
|
|
48
|
+
expect(buildPost(false)({ ...POST, content }).readingTimeMinutes).toBe(2)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('is none for a post with no body', () => {
|
|
52
|
+
expect(buildPost()({ ...POST, content: '' }).readingTimeMinutes).toBeNull()
|
|
53
|
+
expect(buildPost()({ ...POST, content: '<p> </p>' }).readingTimeMinutes).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -233,7 +233,11 @@ describe('blog posts: a visitor reads the published posts, never an author email
|
|
|
233
233
|
it('serves the store everything: the admin, a server segment and getStaticProps', async () => {
|
|
234
234
|
const admin = await posts.run({ session: { role: 'admin' } })
|
|
235
235
|
expect(mainQuery(admin)).not.toContain("status = 'published'")
|
|
236
|
-
|
|
236
|
+
// The post view model carries no email for anyone — a page's data must not
|
|
237
|
+
// ship it (transformations/blog-post.ts); the admin form reads raw rows.
|
|
238
|
+
expect(JSON.stringify(admin.body.data)).not.toContain('ana@example.com')
|
|
239
|
+
const adminRaw = await posts.run({ session: { role: 'admin' }, query: { rawRows: 'true' } })
|
|
240
|
+
expect(JSON.stringify(adminRaw.body.data)).toContain('ana@example.com')
|
|
237
241
|
|
|
238
242
|
const server = await posts.run({ headers: internal })
|
|
239
243
|
expect(mainQuery(server)).not.toContain("status = 'published'")
|