@teleporthq/teleport-plugin-next-data-source 0.43.65 → 0.43.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/array-mapper-cache-wiring.test.ts +11 -0
- package/__tests__/data-source-request-locale.test.ts +313 -0
- package/__tests__/gated-data-provider.test.ts +88 -0
- package/__tests__/localized-columns.test.ts +217 -0
- package/__tests__/numbered-pagination.test.ts +15 -0
- package/__tests__/product-filter-fields.ts +230 -0
- package/__tests__/product-filter-sql.fixture.json +7 -0
- package/__tests__/refetch-loading-state.test.ts +23 -0
- package/__tests__/search-clear-button.test.ts +122 -0
- package/dist/cjs/content-localization.d.ts +37 -0
- package/dist/cjs/content-localization.d.ts.map +1 -0
- package/dist/cjs/content-localization.js +29 -0
- package/dist/cjs/content-localization.js.map +1 -0
- package/dist/cjs/count-effect.d.ts +11 -1
- package/dist/cjs/count-effect.d.ts.map +1 -1
- package/dist/cjs/count-effect.js +30 -2
- package/dist/cjs/count-effect.js.map +1 -1
- package/dist/cjs/fetchers/airtable.d.ts.map +1 -1
- package/dist/cjs/fetchers/airtable.js +1 -1
- package/dist/cjs/fetchers/airtable.js.map +1 -1
- package/dist/cjs/fetchers/clickhouse.d.ts.map +1 -1
- package/dist/cjs/fetchers/clickhouse.js +1 -1
- package/dist/cjs/fetchers/clickhouse.js.map +1 -1
- package/dist/cjs/fetchers/firestore.d.ts.map +1 -1
- package/dist/cjs/fetchers/firestore.js +1 -1
- package/dist/cjs/fetchers/firestore.js.map +1 -1
- package/dist/cjs/fetchers/mariadb.d.ts.map +1 -1
- package/dist/cjs/fetchers/mariadb.js +1 -1
- package/dist/cjs/fetchers/mariadb.js.map +1 -1
- package/dist/cjs/fetchers/mongodb.d.ts.map +1 -1
- package/dist/cjs/fetchers/mongodb.js +1 -1
- package/dist/cjs/fetchers/mongodb.js.map +1 -1
- package/dist/cjs/fetchers/mysql.d.ts.map +1 -1
- package/dist/cjs/fetchers/mysql.js +1 -1
- package/dist/cjs/fetchers/mysql.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +2 -1
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
- package/dist/cjs/fetchers/redshift.js +1 -1
- package/dist/cjs/fetchers/redshift.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +10 -3
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/turso.d.ts.map +1 -1
- package/dist/cjs/fetchers/turso.js +1 -1
- package/dist/cjs/fetchers/turso.js.map +1 -1
- package/dist/cjs/index.d.ts +5 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +60 -8
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/loading-state.d.ts +16 -1
- package/dist/cjs/loading-state.d.ts.map +1 -1
- package/dist/cjs/loading-state.js +69 -3
- package/dist/cjs/loading-state.js.map +1 -1
- package/dist/cjs/localized-columns.d.ts +56 -0
- package/dist/cjs/localized-columns.d.ts.map +1 -0
- package/dist/cjs/localized-columns.js +124 -0
- package/dist/cjs/localized-columns.js.map +1 -0
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +123 -45
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/product-filter-fields.d.ts +87 -0
- package/dist/cjs/product-filter-fields.d.ts.map +1 -0
- package/dist/cjs/product-filter-fields.js +161 -0
- package/dist/cjs/product-filter-fields.js.map +1 -0
- package/dist/cjs/request-locale.d.ts +49 -0
- package/dist/cjs/request-locale.d.ts.map +1 -0
- package/dist/cjs/request-locale.js +130 -0
- package/dist/cjs/request-locale.js.map +1 -0
- package/dist/cjs/search-clear.d.ts +31 -0
- package/dist/cjs/search-clear.d.ts.map +1 -0
- package/dist/cjs/search-clear.js +103 -0
- package/dist/cjs/search-clear.js.map +1 -0
- package/dist/cjs/transformations/index.d.ts +16 -2
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +14 -2
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils.d.ts +11 -1
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +59 -15
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/content-localization.d.ts +37 -0
- package/dist/esm/content-localization.d.ts.map +1 -0
- package/dist/esm/content-localization.js +24 -0
- package/dist/esm/content-localization.js.map +1 -0
- package/dist/esm/count-effect.d.ts +11 -1
- package/dist/esm/count-effect.d.ts.map +1 -1
- package/dist/esm/count-effect.js +28 -1
- package/dist/esm/count-effect.js.map +1 -1
- package/dist/esm/fetchers/airtable.d.ts.map +1 -1
- package/dist/esm/fetchers/airtable.js +1 -1
- package/dist/esm/fetchers/airtable.js.map +1 -1
- package/dist/esm/fetchers/clickhouse.d.ts.map +1 -1
- package/dist/esm/fetchers/clickhouse.js +1 -1
- package/dist/esm/fetchers/clickhouse.js.map +1 -1
- package/dist/esm/fetchers/firestore.d.ts.map +1 -1
- package/dist/esm/fetchers/firestore.js +1 -1
- package/dist/esm/fetchers/firestore.js.map +1 -1
- package/dist/esm/fetchers/mariadb.d.ts.map +1 -1
- package/dist/esm/fetchers/mariadb.js +1 -1
- package/dist/esm/fetchers/mariadb.js.map +1 -1
- package/dist/esm/fetchers/mongodb.d.ts.map +1 -1
- package/dist/esm/fetchers/mongodb.js +1 -1
- package/dist/esm/fetchers/mongodb.js.map +1 -1
- package/dist/esm/fetchers/mysql.d.ts.map +1 -1
- package/dist/esm/fetchers/mysql.js +1 -1
- package/dist/esm/fetchers/mysql.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +2 -1
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/redshift.d.ts.map +1 -1
- package/dist/esm/fetchers/redshift.js +1 -1
- package/dist/esm/fetchers/redshift.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +10 -3
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/turso.d.ts.map +1 -1
- package/dist/esm/fetchers/turso.js +1 -1
- package/dist/esm/fetchers/turso.js.map +1 -1
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +55 -7
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/loading-state.d.ts +16 -1
- package/dist/esm/loading-state.d.ts.map +1 -1
- package/dist/esm/loading-state.js +69 -3
- package/dist/esm/loading-state.js.map +1 -1
- package/dist/esm/localized-columns.d.ts +56 -0
- package/dist/esm/localized-columns.d.ts.map +1 -0
- package/dist/esm/localized-columns.js +119 -0
- package/dist/esm/localized-columns.js.map +1 -0
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +124 -46
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/product-filter-fields.d.ts +87 -0
- package/dist/esm/product-filter-fields.d.ts.map +1 -0
- package/dist/esm/product-filter-fields.js +154 -0
- package/dist/esm/product-filter-fields.js.map +1 -0
- package/dist/esm/request-locale.d.ts +49 -0
- package/dist/esm/request-locale.d.ts.map +1 -0
- package/dist/esm/request-locale.js +98 -0
- package/dist/esm/request-locale.js.map +1 -0
- package/dist/esm/search-clear.d.ts +31 -0
- package/dist/esm/search-clear.d.ts.map +1 -0
- package/dist/esm/search-clear.js +74 -0
- package/dist/esm/search-clear.js.map +1 -0
- package/dist/esm/transformations/index.d.ts +16 -2
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +14 -2
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils.d.ts +11 -1
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js +59 -15
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/content-localization.ts +53 -0
- package/src/count-effect.ts +53 -2
- package/src/fetchers/airtable.ts +4 -2
- package/src/fetchers/clickhouse.ts +4 -2
- package/src/fetchers/firestore.ts +4 -2
- package/src/fetchers/mariadb.ts +4 -2
- package/src/fetchers/mongodb.ts +4 -2
- package/src/fetchers/mysql.ts +4 -2
- package/src/fetchers/postgresql.ts +16 -2
- package/src/fetchers/redshift.ts +4 -2
- package/src/fetchers/teleport.ts +48 -9
- package/src/fetchers/turso.ts +4 -2
- package/src/index.ts +79 -11
- package/src/loading-state.ts +134 -3
- package/src/localized-columns.ts +133 -0
- package/src/pagination-plugin.ts +177 -53
- package/src/product-filter-fields.ts +173 -0
- package/src/request-locale.ts +113 -0
- package/src/search-clear.ts +102 -0
- package/src/transformations/index.ts +30 -5
- package/src/utils.ts +83 -15
|
@@ -285,6 +285,17 @@ describe('array-mapper cache — client wiring', () => {
|
|
|
285
285
|
expect(hitReturn).toBeLessThan(raise)
|
|
286
286
|
})
|
|
287
287
|
|
|
288
|
+
it('counts a cache hit as the newest settled result', async () => {
|
|
289
|
+
const { code } = await runPlugin({ cache: CACHE })
|
|
290
|
+
|
|
291
|
+
// A hit answers synchronously with THIS request's rows; without advancing
|
|
292
|
+
// the sequence, an older network request still in flight would resolve
|
|
293
|
+
// after it and paint over it.
|
|
294
|
+
expect(code).toMatch(
|
|
295
|
+
/if \(__tqHit !== undefined\) \{\s*\+\+ds_0_fetchSeq\.current;?\s*ds_0_latestData\.current = __tqHit;?\s*return Promise\.resolve\(__tqHit\)/
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
288
299
|
it('still tracks in-flight fetches on a cache MISS', async () => {
|
|
289
300
|
const { code } = await runPlugin({ cache: CACHE })
|
|
290
301
|
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import * as types from '@babel/types'
|
|
2
|
+
import generator from '@babel/generator'
|
|
3
|
+
import {
|
|
4
|
+
ChunkType,
|
|
5
|
+
FileType,
|
|
6
|
+
ComponentStructure,
|
|
7
|
+
ChunkDefinition,
|
|
8
|
+
GeneratorOptions,
|
|
9
|
+
UIDLDataSource,
|
|
10
|
+
UIDLDependency,
|
|
11
|
+
} from '@teleporthq/teleport-types'
|
|
12
|
+
import { createNextPagesDataSourcePlugin, createNextComponentDataSourcePlugin } from '../src/index'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A page in a multi-language project renders rows in ITS language: every
|
|
16
|
+
* data-source fetch — the build-time prefetch in getStaticProps and the
|
|
17
|
+
* client-side refetch behind pagination, search and filters — says which
|
|
18
|
+
* locale it is for, and a locale switch (a client-side transition on the same
|
|
19
|
+
* page instance) refetches. A single-language project generates exactly what
|
|
20
|
+
* it generated before: no locale, no router.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DS_ID = 'ds1'
|
|
24
|
+
const TABLE = 'teleport_products'
|
|
25
|
+
/** A page writes the fetcher to `utils/`, a component straight into `api/`. */
|
|
26
|
+
const FETCHER_KEYS = ['utils/teleport-teleport_products-ds1', 'api/teleport-teleport_products-ds1']
|
|
27
|
+
|
|
28
|
+
const I18N: GeneratorOptions['internationalization'] = {
|
|
29
|
+
main: { name: 'English', locale: 'en' },
|
|
30
|
+
languages: { en: 'English', es: 'Spanish' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const dataSource: UIDLDataSource = {
|
|
34
|
+
id: DS_ID,
|
|
35
|
+
name: 'TeleportHQ database',
|
|
36
|
+
type: 'teleport',
|
|
37
|
+
config: {
|
|
38
|
+
selectedTables: {
|
|
39
|
+
[TABLE]: {
|
|
40
|
+
columns: [{ name: 'id' }, { name: 'name' }, { name: 'slug' }, { name: 'es_name' }],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const jsx = (name: string, attrs: types.JSXAttribute[], children: types.JSXElement[] = []) =>
|
|
47
|
+
types.jsxElement(
|
|
48
|
+
types.jsxOpeningElement(types.jsxIdentifier(name), attrs, children.length === 0),
|
|
49
|
+
children.length === 0 ? null : types.jsxClosingElement(types.jsxIdentifier(name)),
|
|
50
|
+
children,
|
|
51
|
+
children.length === 0
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const resourceDefinitionAttr = () =>
|
|
55
|
+
types.jsxAttribute(
|
|
56
|
+
types.jsxIdentifier('resourceDefinition'),
|
|
57
|
+
types.jsxExpressionContainer(
|
|
58
|
+
types.objectExpression([
|
|
59
|
+
types.objectProperty(types.stringLiteral('dataSourceId'), types.stringLiteral(DS_ID)),
|
|
60
|
+
types.objectProperty(types.stringLiteral('tableName'), types.stringLiteral(TABLE)),
|
|
61
|
+
types.objectProperty(
|
|
62
|
+
types.stringLiteral('dataSourceType'),
|
|
63
|
+
types.stringLiteral('teleport')
|
|
64
|
+
),
|
|
65
|
+
])
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
/** `<DataProvider name={'items'} resourceDefinition={…} renderSuccess={(items) => <Repeater …/>} />` */
|
|
70
|
+
const makeListProvider = (): types.JSXElement => {
|
|
71
|
+
const repeater = jsx('Repeater', [
|
|
72
|
+
types.jsxAttribute(
|
|
73
|
+
types.jsxIdentifier('items'),
|
|
74
|
+
types.jsxExpressionContainer(types.identifier('items'))
|
|
75
|
+
),
|
|
76
|
+
types.jsxAttribute(
|
|
77
|
+
types.jsxIdentifier('renderItem'),
|
|
78
|
+
types.jsxExpressionContainer(
|
|
79
|
+
types.arrowFunctionExpression([types.identifier('product')], jsx('div', []))
|
|
80
|
+
)
|
|
81
|
+
),
|
|
82
|
+
])
|
|
83
|
+
return jsx('DataProvider', [
|
|
84
|
+
types.jsxAttribute(
|
|
85
|
+
types.jsxIdentifier('name'),
|
|
86
|
+
types.jsxExpressionContainer(types.stringLiteral('items'))
|
|
87
|
+
),
|
|
88
|
+
resourceDefinitionAttr(),
|
|
89
|
+
types.jsxAttribute(
|
|
90
|
+
types.jsxIdentifier('renderSuccess'),
|
|
91
|
+
types.jsxExpressionContainer(
|
|
92
|
+
types.arrowFunctionExpression([types.identifier('items')], repeater)
|
|
93
|
+
)
|
|
94
|
+
),
|
|
95
|
+
])
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `<DataProvider name={'item'} resourceDefinition={…} renderSuccess={(item) => <div/>} />` */
|
|
99
|
+
const makeItemProvider = (): types.JSXElement =>
|
|
100
|
+
jsx('DataProvider', [
|
|
101
|
+
types.jsxAttribute(
|
|
102
|
+
types.jsxIdentifier('name'),
|
|
103
|
+
types.jsxExpressionContainer(types.stringLiteral('item'))
|
|
104
|
+
),
|
|
105
|
+
resourceDefinitionAttr(),
|
|
106
|
+
types.jsxAttribute(
|
|
107
|
+
types.jsxIdentifier('renderSuccess'),
|
|
108
|
+
types.jsxExpressionContainer(
|
|
109
|
+
types.arrowFunctionExpression([types.identifier('item')], jsx('div', []))
|
|
110
|
+
)
|
|
111
|
+
),
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
const makeComponentChunk = (provider: types.JSXElement): ChunkDefinition => ({
|
|
115
|
+
name: 'jsx-component',
|
|
116
|
+
type: ChunkType.AST,
|
|
117
|
+
fileType: FileType.JS,
|
|
118
|
+
linkAfter: [],
|
|
119
|
+
content: types.variableDeclaration('const', [
|
|
120
|
+
types.variableDeclarator(
|
|
121
|
+
types.identifier('ProductsList'),
|
|
122
|
+
types.arrowFunctionExpression(
|
|
123
|
+
[types.identifier('props')],
|
|
124
|
+
types.blockStatement([types.returnStatement(jsx('div', [], [provider]))])
|
|
125
|
+
)
|
|
126
|
+
),
|
|
127
|
+
]),
|
|
128
|
+
meta: { nodesLookup: { provider } },
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// tslint:disable-next-line:no-any
|
|
132
|
+
const makeListNode = (paginated: boolean): any => ({
|
|
133
|
+
type: 'data-source-list',
|
|
134
|
+
content: {
|
|
135
|
+
renderPropIdentifier: 'items',
|
|
136
|
+
resourceDefinition: { dataSourceId: DS_ID, tableName: TABLE, dataSourceType: 'teleport' },
|
|
137
|
+
resource: {
|
|
138
|
+
id: 'TQ_products',
|
|
139
|
+
params: { queryColumns: { type: 'static', content: ['name', 'es_name'] } },
|
|
140
|
+
},
|
|
141
|
+
nodes: {
|
|
142
|
+
success: {
|
|
143
|
+
type: 'cms-list-repeater',
|
|
144
|
+
content: {
|
|
145
|
+
renderPropIdentifier: 'product',
|
|
146
|
+
paginated,
|
|
147
|
+
perPage: 20,
|
|
148
|
+
searchEnabled: false,
|
|
149
|
+
nodes: { list: { type: 'element', content: { elementType: 'div' } } },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/** An item whose params cannot be resolved at build time, so it fetches client-side. */
|
|
157
|
+
// tslint:disable-next-line:no-any
|
|
158
|
+
const makeDynamicItemNode = (withParams: boolean): any => ({
|
|
159
|
+
type: 'data-source-item',
|
|
160
|
+
content: {
|
|
161
|
+
renderPropIdentifier: 'item',
|
|
162
|
+
resourceDefinition: { dataSourceId: DS_ID, tableName: TABLE, dataSourceType: 'teleport' },
|
|
163
|
+
resource: {
|
|
164
|
+
params: withParams
|
|
165
|
+
? { limit: { type: 'dynamic', content: { referenceType: 'prop', id: 'limit' } } }
|
|
166
|
+
: {},
|
|
167
|
+
},
|
|
168
|
+
children: [{ type: 'element', content: { elementType: 'div' } }],
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const run = async (
|
|
173
|
+
plugin: ReturnType<typeof createNextPagesDataSourcePlugin>,
|
|
174
|
+
// tslint:disable-next-line:no-any
|
|
175
|
+
node: any,
|
|
176
|
+
provider: types.JSXElement,
|
|
177
|
+
localized: boolean
|
|
178
|
+
) => {
|
|
179
|
+
const chunk = makeComponentChunk(provider)
|
|
180
|
+
const dependencies: Record<string, UIDLDependency> = {}
|
|
181
|
+
// tslint:disable-next-line:no-any
|
|
182
|
+
const extractedResources: Record<string, any> = {}
|
|
183
|
+
const structure: ComponentStructure = {
|
|
184
|
+
uidl: { name: 'ProductsList', node, outputOptions: { folderPath: [] } },
|
|
185
|
+
chunks: [chunk],
|
|
186
|
+
dependencies,
|
|
187
|
+
options: {
|
|
188
|
+
dataSources: { [DS_ID]: dataSource },
|
|
189
|
+
extractedResources,
|
|
190
|
+
...(localized ? { internationalization: I18N } : {}),
|
|
191
|
+
},
|
|
192
|
+
} as never
|
|
193
|
+
await plugin(structure)
|
|
194
|
+
const getStaticProps = structure.chunks.find((c) => c.name === 'getStaticProps')
|
|
195
|
+
return {
|
|
196
|
+
code: generator(chunk.content as types.Node).code,
|
|
197
|
+
staticProps: getStaticProps ? generator(getStaticProps.content as types.Node).code : '',
|
|
198
|
+
dependencies,
|
|
199
|
+
fetcher: FETCHER_KEYS.map((key) => extractedResources[key]?.content).find(Boolean) as string,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const collapse = (code: string): string => code.replace(/\s+/g, ' ')
|
|
204
|
+
|
|
205
|
+
describe('data-source fetches in a multi-language project', () => {
|
|
206
|
+
const pagesPlugin = createNextPagesDataSourcePlugin()
|
|
207
|
+
const componentPlugin = createNextComponentDataSourcePlugin()
|
|
208
|
+
|
|
209
|
+
describe('a paginated list page', () => {
|
|
210
|
+
it('fetches the first page for the language getStaticProps runs for', async () => {
|
|
211
|
+
const { staticProps } = await run(pagesPlugin, makeListNode(true), makeListProvider(), true)
|
|
212
|
+
expect(collapse(staticProps)).toContain(
|
|
213
|
+
'fetchData({ page: 1, perPage: 20, queryColumns: JSON.stringify(["name", "es_name"]), locale: context.locale })'
|
|
214
|
+
)
|
|
215
|
+
// The count is the same in every language.
|
|
216
|
+
expect(collapse(staticProps)).toContain('fetchCount().catch')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('sends the client locale with every refetch and refetches when it changes', async () => {
|
|
220
|
+
const { code, dependencies } = await run(
|
|
221
|
+
pagesPlugin,
|
|
222
|
+
makeListNode(true),
|
|
223
|
+
makeListProvider(),
|
|
224
|
+
true
|
|
225
|
+
)
|
|
226
|
+
const flat = collapse(code)
|
|
227
|
+
expect(flat).toContain(
|
|
228
|
+
'params={useMemo(() => ({ page: ds_0_page, perPage: 20, locale: router?.locale }), [ds_0_page, router?.locale])}'
|
|
229
|
+
)
|
|
230
|
+
expect(code).toContain('const router = useRouter()')
|
|
231
|
+
expect(dependencies.useRouter).toMatchObject({ type: 'library', path: 'next/router' })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('bakes the per-language columns into the fetcher it emits', async () => {
|
|
235
|
+
const { fetcher } = await run(pagesPlugin, makeListNode(true), makeListProvider(), true)
|
|
236
|
+
expect(fetcher).toContain('var LOCALIZED_COLUMNS = {"name":{"es":"es_name"}}')
|
|
237
|
+
expect(fetcher).toContain('mainLanguage: "en"')
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('a plain list page', () => {
|
|
242
|
+
it('prefetches and refetches in the page language', async () => {
|
|
243
|
+
const { code, staticProps } = await run(
|
|
244
|
+
pagesPlugin,
|
|
245
|
+
makeListNode(false),
|
|
246
|
+
makeListProvider(),
|
|
247
|
+
true
|
|
248
|
+
)
|
|
249
|
+
expect(collapse(staticProps)).toContain(
|
|
250
|
+
'fetchData({ "queryColumns": JSON.stringify(["name", "es_name"]), locale: context.locale })'
|
|
251
|
+
)
|
|
252
|
+
expect(collapse(code)).toContain(
|
|
253
|
+
'params={useMemo(() => ({ locale: router?.locale }), [router?.locale])}'
|
|
254
|
+
)
|
|
255
|
+
expect(code).toContain('const router = useRouter()')
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('a component item that fetches client-side', () => {
|
|
260
|
+
it('adds the locale to the params it already sends', async () => {
|
|
261
|
+
const { code, dependencies } = await run(
|
|
262
|
+
componentPlugin,
|
|
263
|
+
makeDynamicItemNode(true),
|
|
264
|
+
makeItemProvider(),
|
|
265
|
+
true
|
|
266
|
+
)
|
|
267
|
+
const flat = collapse(code)
|
|
268
|
+
expect(flat).toContain('locale: router?.locale')
|
|
269
|
+
// The route is a template literal that interpolates the params.
|
|
270
|
+
expect(flat).toContain(
|
|
271
|
+
'fetch(`/api/teleport-teleport_products-ds1?$' + '{new URLSearchParams(params)}`'
|
|
272
|
+
)
|
|
273
|
+
expect(code).toContain('const router = useRouter()')
|
|
274
|
+
expect(dependencies.useRouter).toBeDefined()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('creates the params when the node declared none', async () => {
|
|
278
|
+
const { code } = await run(
|
|
279
|
+
componentPlugin,
|
|
280
|
+
makeDynamicItemNode(false),
|
|
281
|
+
makeItemProvider(),
|
|
282
|
+
true
|
|
283
|
+
)
|
|
284
|
+
const flat = collapse(code)
|
|
285
|
+
expect(flat).toContain('params={{ locale: router?.locale }}')
|
|
286
|
+
expect(flat).toContain('new URLSearchParams(params)')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('a single-language project', () => {
|
|
291
|
+
it.each([
|
|
292
|
+
['a paginated list page', pagesPlugin, makeListNode(true), makeListProvider],
|
|
293
|
+
['a plain list page', pagesPlugin, makeListNode(false), makeListProvider],
|
|
294
|
+
['a component item', componentPlugin, makeDynamicItemNode(true), makeItemProvider],
|
|
295
|
+
])(
|
|
296
|
+
'generates %s exactly as before — no locale, no router',
|
|
297
|
+
async (_, plugin, node, provider) => {
|
|
298
|
+
const { code, staticProps, dependencies, fetcher } = await run(
|
|
299
|
+
plugin,
|
|
300
|
+
node,
|
|
301
|
+
provider(),
|
|
302
|
+
false
|
|
303
|
+
)
|
|
304
|
+
expect(code).not.toContain('locale')
|
|
305
|
+
expect(code).not.toContain('useRouter')
|
|
306
|
+
expect(staticProps).not.toContain('locale')
|
|
307
|
+
expect(dependencies.useRouter).toBeUndefined()
|
|
308
|
+
expect(fetcher).toContain('var LOCALIZED_COLUMNS = {}')
|
|
309
|
+
expect(fetcher).toContain('mainLanguage: null')
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
})
|
|
313
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as types from '@babel/types'
|
|
2
|
+
import generator from '@babel/generator'
|
|
3
|
+
import { parse } from '@babel/parser'
|
|
4
|
+
import {
|
|
5
|
+
ChunkType,
|
|
6
|
+
FileType,
|
|
7
|
+
ComponentStructure,
|
|
8
|
+
ChunkDefinition,
|
|
9
|
+
} from '@teleporthq/teleport-types'
|
|
10
|
+
import { createNextArrayMapperPaginationPlugin } from '../src/pagination-plugin'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A paginated list under a tab panel — `{activeTabIndex === 0 && (…)}` — is a
|
|
14
|
+
* DataProvider on the RIGHT of a logical expression. The plugin's JSX walkers
|
|
15
|
+
* visited children, bodies, arguments and ternary branches, never `left` /
|
|
16
|
+
* `right`, so the provider was never wired: no page params, no fetcher, and a
|
|
17
|
+
* shop whose grid sat under its first tab rendered nothing.
|
|
18
|
+
*/
|
|
19
|
+
const makeComponentChunk = (): ChunkDefinition => {
|
|
20
|
+
const program = parse(
|
|
21
|
+
`const TestComponent = (props) => {
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
{activeTabIndex === 0 && (
|
|
25
|
+
<div className="panel">
|
|
26
|
+
<DataProvider name={'items'} renderSuccess={(items) => (
|
|
27
|
+
<Repeater items={items || []} renderItem={(product) => <div>{product.name}</div>} />
|
|
28
|
+
)} />
|
|
29
|
+
</div>
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}`,
|
|
34
|
+
{ sourceType: 'module', plugins: ['jsx'] }
|
|
35
|
+
)
|
|
36
|
+
return {
|
|
37
|
+
name: 'jsx-component',
|
|
38
|
+
type: ChunkType.AST,
|
|
39
|
+
fileType: FileType.JS,
|
|
40
|
+
linkAfter: [],
|
|
41
|
+
content: program.program.body[0] as types.VariableDeclaration,
|
|
42
|
+
meta: {},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// tslint:disable-next-line:no-any
|
|
47
|
+
const makeUidlNode = (): any => ({
|
|
48
|
+
type: 'data-source-list',
|
|
49
|
+
content: {
|
|
50
|
+
renderPropIdentifier: 'items',
|
|
51
|
+
resourceDefinition: {
|
|
52
|
+
dataSourceId: 'ds1',
|
|
53
|
+
tableName: 'products',
|
|
54
|
+
dataSourceType: 'postgresql',
|
|
55
|
+
},
|
|
56
|
+
resource: { params: { queryColumns: { content: ['name'] } } },
|
|
57
|
+
nodes: {
|
|
58
|
+
success: {
|
|
59
|
+
type: 'cms-list-repeater',
|
|
60
|
+
content: {
|
|
61
|
+
renderPropIdentifier: 'product',
|
|
62
|
+
paginated: true,
|
|
63
|
+
perPage: 20,
|
|
64
|
+
searchEnabled: false,
|
|
65
|
+
nodes: { list: { type: 'element', content: { elementType: 'div' } } },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('pagination plugin — a provider gated by a logical expression', () => {
|
|
73
|
+
it('wires the page params and the fetcher onto a DataProvider under `cond && (…)`', async () => {
|
|
74
|
+
const chunk = makeComponentChunk()
|
|
75
|
+
const structure: ComponentStructure = {
|
|
76
|
+
uidl: { name: 'TestComponent', node: makeUidlNode() },
|
|
77
|
+
chunks: [chunk],
|
|
78
|
+
dependencies: {},
|
|
79
|
+
options: { dataSources: {}, extractedResources: {} },
|
|
80
|
+
} as never
|
|
81
|
+
await createNextArrayMapperPaginationPlugin()(structure)
|
|
82
|
+
const code = generator(chunk.content as types.Node).code.replace(/\s+/g, ' ')
|
|
83
|
+
|
|
84
|
+
expect(code).toContain('page: ds_0_page')
|
|
85
|
+
expect(code).toContain('perPage: 20')
|
|
86
|
+
expect(code).toContain('fetchData={')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { parse } from '@babel/parser'
|
|
2
|
+
import {
|
|
3
|
+
collectLocalizedColumns,
|
|
4
|
+
generateLocalizedColumnsHelper,
|
|
5
|
+
type LocalizedColumnMap,
|
|
6
|
+
} from '../src/localized-columns'
|
|
7
|
+
import { resolveContentLocalization, isLocalizedProject } from '../src/content-localization'
|
|
8
|
+
import { buildProductTransformOptions, getTransformWrapperCode } from '../src/transformations'
|
|
9
|
+
import { generateTeleportFetcher } from '../src/fetchers/teleport'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A Spanish listing links to `es_slug` and sorts by `es_name`, so the SQL a
|
|
13
|
+
* fetch runs has to know about the per-language columns — otherwise the
|
|
14
|
+
* details page 404s on the very slug the card linked to. The helper is pinned
|
|
15
|
+
* here by RUNNING the emitted JavaScript, the same way the runtime does.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const LOCALIZATION = { mainLocale: 'en', secondaryLocales: ['es', 'fr'] }
|
|
19
|
+
|
|
20
|
+
const PRODUCT_COLUMNS = [
|
|
21
|
+
'id',
|
|
22
|
+
'name',
|
|
23
|
+
'slug',
|
|
24
|
+
'description',
|
|
25
|
+
'price',
|
|
26
|
+
'og_title',
|
|
27
|
+
'es_name',
|
|
28
|
+
'es_slug',
|
|
29
|
+
'es_description',
|
|
30
|
+
'fr_name',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
interface Helper {
|
|
34
|
+
localizedEqualityClause: (
|
|
35
|
+
field: string,
|
|
36
|
+
operand: string,
|
|
37
|
+
value: unknown,
|
|
38
|
+
pushParam: (param: unknown) => string
|
|
39
|
+
) => string | null
|
|
40
|
+
localizedSortFieldSql: (field: string, locale: unknown) => string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const loadHelper = (families: LocalizedColumnMap): Helper => {
|
|
44
|
+
// tslint:disable-next-line:function-constructor
|
|
45
|
+
const factory = new Function(
|
|
46
|
+
`${generateLocalizedColumnsHelper(
|
|
47
|
+
families
|
|
48
|
+
)}\nreturn { localizedEqualityClause, localizedSortFieldSql }`
|
|
49
|
+
)
|
|
50
|
+
return factory()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const bindParams = () => {
|
|
54
|
+
const params: unknown[] = []
|
|
55
|
+
const pushParam = (param: unknown) => {
|
|
56
|
+
params.push(param)
|
|
57
|
+
return '$' + params.length
|
|
58
|
+
}
|
|
59
|
+
return { params, pushParam }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('resolveContentLocalization', () => {
|
|
63
|
+
it('splits the project languages into the main locale and the copies', () => {
|
|
64
|
+
expect(
|
|
65
|
+
resolveContentLocalization({
|
|
66
|
+
internationalization: {
|
|
67
|
+
main: { name: 'English', locale: 'en' },
|
|
68
|
+
languages: { en: 'English', es: 'Spanish', fr: 'French' },
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
).toEqual({ mainLocale: 'en', secondaryLocales: ['es', 'fr'] })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('is absent for a single-language project', () => {
|
|
75
|
+
expect(resolveContentLocalization({})).toBeUndefined()
|
|
76
|
+
expect(isLocalizedProject({})).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reaches the fetcher through the shared transform options', () => {
|
|
80
|
+
const options = buildProductTransformOptions({
|
|
81
|
+
internationalization: {
|
|
82
|
+
main: { name: 'English', locale: 'en' },
|
|
83
|
+
languages: { en: 'English', es: 'Spanish' },
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
expect(options.localization).toEqual({ mainLocale: 'en', secondaryLocales: ['es'] })
|
|
87
|
+
expect(buildProductTransformOptions({}).localization).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('collectLocalizedColumns', () => {
|
|
92
|
+
it('pairs a column with the copies the schema actually holds', () => {
|
|
93
|
+
expect(collectLocalizedColumns(PRODUCT_COLUMNS, LOCALIZATION)).toEqual({
|
|
94
|
+
name: { es: 'es_name', fr: 'fr_name' },
|
|
95
|
+
slug: { es: 'es_slug' },
|
|
96
|
+
description: { es: 'es_description' },
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('never mistakes a prefix for a language', () => {
|
|
101
|
+
// `og_title` is Open Graph, and `og` is not a project language.
|
|
102
|
+
const families = collectLocalizedColumns(['title', 'og_title'], LOCALIZATION)
|
|
103
|
+
expect(families).toEqual({})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('has nothing to say for a single-language project', () => {
|
|
107
|
+
expect(collectLocalizedColumns(PRODUCT_COLUMNS, undefined)).toEqual({})
|
|
108
|
+
expect(
|
|
109
|
+
collectLocalizedColumns(PRODUCT_COLUMNS, { mainLocale: 'en', secondaryLocales: [] })
|
|
110
|
+
).toEqual({})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('refuses a column it could not quote', () => {
|
|
114
|
+
const families = collectLocalizedColumns(['slug', 'es_slug', 'pt-BR_slug'], {
|
|
115
|
+
mainLocale: 'en',
|
|
116
|
+
secondaryLocales: ['es', 'pt-BR'],
|
|
117
|
+
})
|
|
118
|
+
expect(families).toEqual({ slug: { es: 'es_slug' } })
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('generateLocalizedColumnsHelper', () => {
|
|
123
|
+
const families = collectLocalizedColumns(PRODUCT_COLUMNS, LOCALIZATION)
|
|
124
|
+
const helper = loadHelper(families)
|
|
125
|
+
|
|
126
|
+
it('emits plain source the fetcher template can embed', () => {
|
|
127
|
+
const source = generateLocalizedColumnsHelper(families)
|
|
128
|
+
expect(source).not.toContain('`')
|
|
129
|
+
expect(source).not.toContain('${')
|
|
130
|
+
expect(() => parse(source, { sourceType: 'script' })).not.toThrow()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('matches equality on a translatable column in every language, binding once', () => {
|
|
134
|
+
const { params, pushParam } = bindParams()
|
|
135
|
+
const clause = helper.localizedEqualityClause('slug', '=', 'bolso-tote', pushParam)
|
|
136
|
+
expect(clause).toBe('("slug" = $1 OR "es_slug" = $1)')
|
|
137
|
+
expect(params).toEqual(['bolso-tote'])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('answers an `in` set (already normalised to an array) the same way', () => {
|
|
141
|
+
const { params, pushParam } = bindParams()
|
|
142
|
+
const clause = helper.localizedEqualityClause('name', '=', ['A', 'B'], pushParam)
|
|
143
|
+
expect(clause).toBe(
|
|
144
|
+
'("name"::text = ANY($1::text[]) OR "es_name"::text = ANY($1::text[]) OR "fr_name"::text = ANY($1::text[]))'
|
|
145
|
+
)
|
|
146
|
+
expect(params).toEqual([['A', 'B']])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('leaves every other column and operand to the ordinary branches', () => {
|
|
150
|
+
const { params, pushParam } = bindParams()
|
|
151
|
+
expect(helper.localizedEqualityClause('price', '=', '10', pushParam)).toBeNull()
|
|
152
|
+
expect(helper.localizedEqualityClause('slug', '!=', 'x', pushParam)).toBeNull()
|
|
153
|
+
expect(helper.localizedEqualityClause('slug', '=', null, pushParam)).toBeNull()
|
|
154
|
+
// An empty set means "no filter", as it does in the ordinary array branch.
|
|
155
|
+
expect(helper.localizedEqualityClause('slug', '=', [], pushParam)).toBeNull()
|
|
156
|
+
expect(params).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("orders by the request's language, falling back to the base column", () => {
|
|
160
|
+
expect(helper.localizedSortFieldSql('name', 'es')).toBe(
|
|
161
|
+
`COALESCE(NULLIF("es_name", ''), "name")`
|
|
162
|
+
)
|
|
163
|
+
// The main language and a language without a copy order by the column itself.
|
|
164
|
+
expect(helper.localizedSortFieldSql('name', 'en')).toBeNull()
|
|
165
|
+
expect(helper.localizedSortFieldSql('slug', 'fr')).toBeNull()
|
|
166
|
+
expect(helper.localizedSortFieldSql('price', 'es')).toBeNull()
|
|
167
|
+
expect(helper.localizedSortFieldSql('name', null)).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('is inert without families', () => {
|
|
171
|
+
const inert = loadHelper({})
|
|
172
|
+
const { pushParam } = bindParams()
|
|
173
|
+
expect(inert.localizedEqualityClause('slug', '=', 'x', pushParam)).toBeNull()
|
|
174
|
+
expect(inert.localizedSortFieldSql('name', 'es')).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('teleport fetcher — localized columns', () => {
|
|
179
|
+
const config = {
|
|
180
|
+
selectedTables: {
|
|
181
|
+
teleport_products: { columns: PRODUCT_COLUMNS.map((name) => ({ name })) },
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it('bakes the column map from the table schema and the project languages', () => {
|
|
186
|
+
const route = generateTeleportFetcher(config, 'teleport_products', {
|
|
187
|
+
localization: LOCALIZATION,
|
|
188
|
+
})
|
|
189
|
+
expect(route).toContain(
|
|
190
|
+
'var LOCALIZED_COLUMNS = {"name":{"es":"es_name","fr":"fr_name"},"slug":{"es":"es_slug"},"description":{"es":"es_description"}}'
|
|
191
|
+
)
|
|
192
|
+
expect(route).toContain('const localizedClause = localizedEqualityClause(field, operand, value')
|
|
193
|
+
expect(route).toContain(
|
|
194
|
+
"const requestLocale = typeof req.query.locale === 'string' ? req.query.locale : null"
|
|
195
|
+
)
|
|
196
|
+
expect(route).toContain(
|
|
197
|
+
'localizedSortFieldSql(sort.field, requestLocale) || sortFieldSql(sort.field)'
|
|
198
|
+
)
|
|
199
|
+
expect(route).toContain('localizedSortFieldSql(sortBy, requestLocale) || sortBy')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('bakes an empty map for a single-language project', () => {
|
|
203
|
+
const route = generateTeleportFetcher(config, 'teleport_products')
|
|
204
|
+
expect(route).toContain('var LOCALIZED_COLUMNS = {}')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('resolves the row transform against the request locale and the baked main locale', () => {
|
|
208
|
+
const wrapper = getTransformWrapperCode('teleport_products', { localization: LOCALIZATION })
|
|
209
|
+
expect(wrapper).toContain(
|
|
210
|
+
"var currentLanguage = reqQuery && typeof reqQuery.locale === 'string' && reqQuery.locale ? reqQuery.locale : null"
|
|
211
|
+
)
|
|
212
|
+
expect(wrapper).toContain('mainLanguage: "en"')
|
|
213
|
+
expect(wrapper).not.toContain('reqQuery.lang')
|
|
214
|
+
|
|
215
|
+
expect(getTransformWrapperCode('teleport_products')).toContain('mainLanguage: null')
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -89,6 +89,21 @@ describe('pagination plugin — numbered mode', () => {
|
|
|
89
89
|
expect(code).toContain('setDs_0_maxPages(data.count === 0 ? 0 : Math.ceil(data.count / 20))')
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
+
it('drops a count response a newer count request has overtaken', async () => {
|
|
93
|
+
// Two filter changes in quick succession start two counts; the older,
|
|
94
|
+
// slower one landing last must not paint a strip for rows no longer shown.
|
|
95
|
+
const code = await runPlugin({ paginationMode: 'numbered' }, NUMBERED_CONTROLS)
|
|
96
|
+
const flat = collapse(code)
|
|
97
|
+
|
|
98
|
+
expect(flat).toContain('const ds_0_countSeq = useRef(0)')
|
|
99
|
+
expect(flat).toContain('const __tqCountSeq = ++ds_0_countSeq.current')
|
|
100
|
+
expect(flat).toContain('if (__tqCountSeq !== ds_0_countSeq.current) return;')
|
|
101
|
+
// The guard sits before the setter, inside the response handler.
|
|
102
|
+
expect(flat.indexOf('if (__tqCountSeq !== ds_0_countSeq.current) return;')).toBeLessThan(
|
|
103
|
+
flat.indexOf('setDs_0_maxPages(data.count === 0')
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
92
107
|
it('removes a numbered strip left over from a mode switch', async () => {
|
|
93
108
|
// The builder never deletes controls on a mode switch (that would discard
|
|
94
109
|
// the author's styling), so a Previous/Next list can still carry them.
|