coralite-plugin-aggregation 0.6.0 → 0.7.1

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/lib/index.js CHANGED
@@ -1,366 +1,289 @@
1
+ import { createPlugin } from 'coralite'
1
2
  import path from 'node:path'
2
- import { access } from 'node:fs/promises'
3
- import { getHtmlFiles, createPlugin } from 'coralite'
4
- import { pathToFileURL } from 'node:url'
5
3
 
6
4
  /**
7
- * @import {CoraliteAnyNode, CoraliteCollectionItem} from 'coralite/types'
8
- * @import {CoraliteAggregate} from '../types/index.js'
5
+ * Aggregates content based on configuration
6
+ * @param {import('../types/index.js').AggregationOptions} options
7
+ * @param {Object} contextInstance
8
+ * @returns {Promise<any[]>}
9
9
  */
10
-
11
- export default createPlugin({
12
- name: 'aggregation',
13
- /**
14
- * Aggregates HTML content from specified paths into a single collection of components.
15
- *
16
- * @param {CoraliteAggregate} options - Configuration object defining the aggregation behavior
17
- *
18
- * @returns {Promise<CoraliteAnyNode[]>} Array of processed content nodes from aggregated documents
19
- * @throws {Error} If pages directory path is undefined or aggregate path doesn't exist
20
- *
21
- */
22
- async method (options, context) {
23
- let templateId
24
-
25
- // Determine template component ID from configuration
26
- if (typeof options.template === 'string') {
27
- templateId = options.template
28
- } else if (typeof options.template === 'object') {
29
- templateId = options.template.item
30
- }
31
-
32
- if (!templateId) {
33
- /** @TODO Refer to documentation */
34
- throw new Error('Aggregate template was undefined')
10
+ async function aggregationMethod (options, contextInstance) {
11
+ const {
12
+ path: paths = [],
13
+ template,
14
+ pagination,
15
+ filter,
16
+ sort,
17
+ limit,
18
+ offset = 0,
19
+ recursive = false,
20
+ tokens
21
+ } = options
22
+
23
+ const contextValues = contextInstance.values
24
+ const pagesRoot = this.options.pages
25
+
26
+ // 1. Collect pages
27
+ let allPages = []
28
+ const uniquePaths = new Set()
29
+
30
+ for (const relativePath of paths) {
31
+ const targetPath = path.join(pagesRoot, relativePath)
32
+
33
+ // Check direct path match in listByPath (non-recursive)
34
+ if (!recursive) {
35
+ const pagesInDir = this.pages.getListByPath(targetPath)
36
+ if (pagesInDir) {
37
+ for (const page of pagesInDir) {
38
+ if (!uniquePaths.has(page.path.pathname)) {
39
+ uniquePaths.add(page.path.pathname)
40
+ allPages.push(page)
41
+ }
42
+ }
43
+ }
44
+ } else {
45
+ // Recursive search
46
+ for (const page of this.pages.list) {
47
+ const dirname = page.path.dirname
48
+ // Check if dirname is targetPath or a subdirectory of targetPath
49
+ if (dirname === targetPath || dirname.startsWith(targetPath + path.sep)) {
50
+ if (!uniquePaths.has(page.path.pathname)) {
51
+ uniquePaths.add(page.path.pathname)
52
+ allPages.push(page)
53
+ }
54
+ }
55
+ }
35
56
  }
57
+ }
58
+
59
+ // 2. Filter
60
+ if (typeof filter === 'function') {
61
+ allPages = allPages.filter(page => {
62
+ // Access values from result property
63
+ const values = page.result && page.result.values ? page.result.values : page.values
64
+ return filter(values)
65
+ })
66
+ }
67
+
68
+ // 3. Sort
69
+ if (typeof sort === 'function') {
70
+ allPages.sort((a, b) => {
71
+ const valA = a.result && a.result.values ? a.result.values : a.values
72
+ const valB = b.result && b.result.values ? b.result.values : b.values
73
+ return sort(valA, valB)
74
+ })
75
+ }
36
76
 
37
- /** @type {CoraliteCollectionItem[]} */
38
- let pages = []
77
+ // 4. Pagination
78
+ let startIndex = offset
79
+ let endIndex = allPages.length
39
80
 
40
- for (let i = 0; i < options.path.length; i++) {
41
- const optionPath = options.path[i];
42
- const dirname = path.join(context.path.pages, optionPath)
81
+ let currentPage = 1
82
+ let totalPages = 1
43
83
 
44
- try {
45
- await access(dirname)
46
- } catch (error) {
47
- throw new Error('Aggregate path does not exist: "' + dirname + '"')
48
- }
84
+ // Ensure renderContext is available
85
+ const currentRenderContext = contextInstance.renderContext
86
+ const buildId = currentRenderContext && currentRenderContext.buildId
49
87
 
50
- const cachePages = this.pages.getListByPath(dirname)
88
+ if (limit) {
89
+ if (pagination) {
90
+ const segment = pagination.segment || 'page'
91
+ const urlPathname = contextValues.$urlPathname || ''
51
92
 
52
- if (!cachePages || !cachePages.length) {
53
- // Retrieve HTML pages from specified path
54
- const collection = await getHtmlFiles({
55
- type: 'page',
56
- path: dirname
57
- })
93
+ const escapedSegment = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
58
94
 
59
- if (!collection.list.length) {
60
- throw new Error('Aggregation found no documents in "' + dirname + '"')
61
- }
95
+ // Try to match segment in URL: /.../segment/Number
96
+ const segmentRegex = new RegExp(`/${escapedSegment}/(\\d+)`)
97
+ const match = urlPathname.match(segmentRegex)
62
98
 
63
- pages = pages.concat(collection.list)
64
- } else {
65
- pages = pages.concat(cachePages)
99
+ if (match) {
100
+ currentPage = parseInt(match[1], 10)
66
101
  }
67
- }
68
-
69
- let result = []
70
- let startIndex = 0
71
- let endIndex = pages.length
72
- let paginationOffset = context.values.paginationOffset
73
102
 
74
- // sort results based on custom sort function
75
- if (typeof options.sort === 'function') {
76
- pages.sort((a, b) => {
77
- const metaA = a.result.values
78
- const metaB = b.result.values
79
-
80
- return options.sort(metaA, metaB)
81
- })
82
- }
103
+ startIndex = offset + (currentPage - 1) * limit
104
+ endIndex = startIndex + limit
105
+
106
+ totalPages = Math.ceil(allPages.length / limit)
107
+
108
+ // Automatically generate subsequent pagination pages if we are on the "root" page
109
+ // e.g., if we are on /blog/index.html (page 1), queue up /blog/page/2, /blog/page/3...
110
+ if (!match && currentPage === 1 && totalPages > 1 && buildId) {
111
+ const currentDocument = contextInstance.document
112
+ const currentPathname = currentDocument.path.pathname
113
+ const currentFilename = currentDocument.path.filename
114
+ const currentDirname = currentDocument.path.dirname
115
+
116
+ // Determine the output path structure based on rules
117
+ let targetDir = currentDirname
118
+ let urlPrefixBase = ''
119
+
120
+ // Rules:
121
+ // /index.html -> /page/1.html (relative to currentDirname)
122
+ // /blog.html -> /blog/page/1.html (creates subdirectory)
123
+ // /blog/index.html -> /blog/page/1.html (relative to currentDirname)
124
+ // /blog/today.html -> /blog/today/page/1.html (creates subdirectory)
125
+
126
+ if (currentFilename === 'index.html') {
127
+ // Standard case: keep in same directory
128
+ targetDir = currentDirname
129
+ // Prefix for URL generation in child
130
+ urlPrefixBase = path.dirname(urlPathname) // e.g. /blog/
131
+ } else {
132
+ // Named file: create subdirectory with same name (minus extension)
133
+ const basename = path.basename(currentFilename, path.extname(currentFilename))
134
+ targetDir = path.join(currentDirname, basename)
83
135
 
84
- if (typeof options.filter === 'function') {
85
- const filteredPages = []
86
-
87
- for (let i = 0; i < pages.length; i++) {
88
- const page = pages[i]
89
- const metadata = page.result.values
90
- let keepItem = false
91
-
92
- // process metadata and populate token values for rendering
93
- for (const key in metadata) {
94
- if (Object.prototype.hasOwnProperty.call(metadata, key)) {
95
- const data = metadata[key]
96
-
97
- if (Array.isArray(data)) {
98
- for (let i = 0; i < data.length; i++) {
99
-
100
- if (!keepItem) {
101
- keepItem = options.filter({ name: key, content: data[i] })
102
- }
103
- }
104
- } else {
105
- // handle single metadata item
106
- if (!keepItem) {
107
- keepItem = options.filter({
108
- name: key,
109
- content: data
110
- })
111
- }
112
- }
113
- }
136
+ // e.g. /blog.html -> /blog/
137
+ urlPrefixBase = urlPathname.replace(path.extname(currentFilename), '')
114
138
  }
115
139
 
116
- if (keepItem) {
117
- filteredPages.push(page)
140
+ // Ensure trailing slash for URL prefix
141
+ if (!urlPrefixBase.endsWith('/')) urlPrefixBase += '/'
142
+ // path.dirname returns / for /index.html, but /blog for /blog/index.html.
143
+ // If /blog/index.html, urlPathname is /blog/index.html. dirname is /blog.
144
+
145
+ if (currentFilename === 'index.html') {
146
+ // Correct logic for index.html url prefix
147
+ // If /index.html, urlPathname /index.html. dirname /. prefix /.
148
+ // If /blog/index.html, urlPathname /blog/index.html. dirname /blog. prefix /blog/.
149
+ urlPrefixBase = path.dirname(urlPathname)
150
+ if (!urlPrefixBase.endsWith('/')) urlPrefixBase += '/'
118
151
  }
119
- }
120
152
 
121
- pages = filteredPages
122
- endIndex = pages.length
123
- }
124
-
125
- // apply page offset
126
- if (Object.prototype.hasOwnProperty.call(options, 'offset') || paginationOffset != null) {
127
- let offset = paginationOffset || options.offset
128
-
129
- if (!Array.isArray(offset)) {
130
- if (typeof offset === 'string') {
131
- offset = parseInt(offset)
132
- }
153
+ // Retrieve the original item to get content
154
+ const currentItem = this.pages.getItem(currentDocument.path.pathname)
155
+
156
+ for (let i = 2; i <= totalPages; i++) {
157
+ const newPathname = path.join(targetDir, segment, `${i}.html`)
158
+
159
+ const virtualItem = {
160
+ content: currentItem ? currentItem.content : '', // Use original content
161
+ path: {
162
+ pathname: newPathname,
163
+ dirname: path.dirname(newPathname),
164
+ filename: path.basename(newPathname)
165
+ },
166
+ values: {
167
+ // Pass metadata to help resolve paths in children
168
+ meta_pagination_base_url: urlPathname,
169
+ // Calculate prefix once and pass it down
170
+ meta_pagination_url_prefix: urlPrefixBase
171
+ },
172
+ type: 'page'
173
+ }
133
174
 
134
- if (offset > endIndex) {
135
- startIndex = endIndex
136
- } else {
137
- startIndex = offset
175
+ // Add to queue
176
+ await this.addRenderQueue(virtualItem, buildId)
138
177
  }
139
178
  }
179
+ } else {
180
+ // Simple limit/offset without pagination logic
181
+ endIndex = Math.min(startIndex + limit, allPages.length)
140
182
  }
141
-
142
- // apply page limit
143
- let limit
144
- if (options.limit) {
145
- limit = options.limit
146
-
147
- if (typeof limit === 'string') {
148
- limit = parseInt(limit)
149
- }
150
-
151
- const limitOffset = limit + startIndex
152
-
153
- if (limitOffset < endIndex) {
154
- endIndex = limitOffset
183
+ }
184
+
185
+ const paginatedPages = allPages.slice(startIndex, endIndex)
186
+
187
+ // 5. Render Items
188
+ const resultNodes = []
189
+
190
+ for (const page of paginatedPages) {
191
+ const pageValues = page.result && page.result.values ? page.result.values : page.values
192
+ let itemValues = { ...pageValues }
193
+
194
+ // Apply token transformations
195
+ if (tokens && typeof tokens === 'object') {
196
+ for (const key in tokens) {
197
+ if (Object.prototype.hasOwnProperty.call(tokens, key)) {
198
+ const transform = tokens[key]
199
+ if (typeof transform === 'string') {
200
+ itemValues[key] = pageValues[transform]
201
+ } else if (typeof transform === 'function') {
202
+ itemValues[key] = transform(pageValues)
203
+ }
204
+ }
155
205
  }
156
206
  }
157
207
 
158
- // Filter out index.html and current page from pages array
159
- pages = pages.filter(p => {
160
- // Always exclude index.html from pagination
161
- if (p.path.filename === 'index.html') return false
162
-
163
- // Exclude current page if it matches
164
- if (p.path.filename === context.document.path.filename) return false
165
-
166
- return true
167
- })
168
-
169
- // Update endIndex after filtering
170
- endIndex = Math.min(endIndex, pages.length)
171
-
172
- // Render content
173
- for (let i = startIndex; i < endIndex; i++) {
174
- let page = pages[i]
175
-
176
- // render component with current values and add to results
208
+ // Render the item template
209
+ if (template) {
177
210
  const component = await this.createComponent({
178
- id: templateId,
179
- values: { ...context.values, ...page.result.values },
180
- document: context.document,
181
- contextId: context.id + i + templateId,
182
- index: i
211
+ id: template,
212
+ values: itemValues,
213
+ document: contextInstance.document,
214
+ renderContext: currentRenderContext
183
215
  })
184
216
 
185
- if (typeof component === 'object') {
186
- // concat rendered components
187
- result = result.concat(component.children)
217
+ if (component && component.children) {
218
+ resultNodes.push(...component.children)
188
219
  }
189
220
  }
221
+ }
190
222
 
191
- // process pagination
192
- if (options.pagination && limit) {
193
- const pagination = options.pagination
194
- const paginationSegment = context.values.paginationSegment || pagination.segment || 'page'
195
-
196
- // Calculate pagination length based on the filtered pages array
197
- // Each page shows 'limit' items, so total pages = ceil(filteredPagesCount / limit)
198
- const filteredPagesCount = pages.length
199
- const paginationLength = Math.ceil(filteredPagesCount / limit)
200
-
201
- let processed = context.values.paginationProcessed
202
-
203
- if (!processed && paginationLength > 1) {
204
- const documentPath = context.document.path
205
-
206
- // remove file extension
207
- let name = context.document.path.filename.replace(path.extname(context.document.path.filename), '')
208
-
209
- if (name === 'index') {
210
- name = ''
211
- }
223
+ // 6. Render Pagination Controls
224
+ if (pagination) {
225
+ const segment = pagination.segment || 'page'
226
+ const escapedSegment = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
212
227
 
213
- // Base directory for pagination pages
214
- let baseDirname = documentPath.dirname
215
- if (name) {
216
- baseDirname = path.join(baseDirname, name)
217
- }
218
-
219
- const paginationDirname = path.join(baseDirname, paginationSegment.toString())
220
-
221
- // Index page path - always the base index.html
222
- const indexPathname = context.values.paginationIndexPathname || documentPath.pathname
223
- // For URL calculation, we need to find the index page in the same directory structure
224
- // If we're on /blog/page/2.html, the index is at /blog/index.html
225
- let indexURLPathname = context.values.paginationIndexURLPathname
226
- let indexURLDirname = context.values.paginationIndexURLDirname
227
-
228
- if (!indexURLDirname || !indexURLPathname) {
229
- if (name) {
230
- // Non-index page: index is in parent directory
231
- const parentDir = path.dirname(documentPath.dirname)
232
- const indexFile = path.join(parentDir, context.document.path.filename)
233
- indexURLPathname = pathToFileURL(path.join('/', path.relative(this.options.path.pages, indexFile))).pathname
234
- indexURLDirname = pathToFileURL(path.dirname(indexURLPathname)).pathname
235
- } else {
236
- // Index page
237
- indexURLPathname = pathToFileURL(path.join('/', path.relative(this.options.path.pages, indexPathname))).pathname
238
- indexURLDirname = pathToFileURL(path.dirname(indexURLPathname)).pathname
239
- }
240
- }
241
-
242
- const indexPage = this.pages.getItem(documentPath.pathname)
243
- const maxVisiblePages = (pagination.maxVisible || paginationLength).toString()
244
-
245
- // Generate pagination pages (page 2, 3, 4, etc.)
246
- if (context.values.paginationFileDirname == null) {
247
- for (let i = 1; i < paginationLength; i++) {
248
- const currentPageIndex = i + 1
249
- const filename = currentPageIndex + '.html'
250
- const pathname = path.join(paginationDirname, filename)
251
- const contextId = pathname + context.id.substring(documentPath.pathname.length)
252
-
253
- // Calculate offset for this page
254
- const pageOffset = i * limit
255
-
256
- // URL for this pagination page
257
- const urlPathname = pathToFileURL(path.join('/', path.relative(this.options.path.pages, pathname))).pathname
258
- const urlDirname = pathToFileURL(path.dirname(urlPathname)).pathname
259
-
260
- // Store context values for pagination page
261
- // For pagination pages, use nested URL structure
262
- const nestedURLPathname = pathToFileURL(path.join('/', path.relative(this.options.path.pages, pathname))).pathname
263
- // For pagination pages, the URL dirname should be the full path without .html
264
- // e.g., /blog/page/2.html -> /blog/page/2
265
- const nestedURLDirname = nestedURLPathname.replace(/\.html$/, '')
266
-
267
- this.values[contextId] = {
268
- ...context.values,
269
- paginationIndexPathname: context.values.paginationIndexPathname,
270
- paginationIndexURLPathname: context.values.paginationIndexURLPathname,
271
- paginationIndexURLDirname: context.values.paginationIndexURLDirname,
272
- paginationSegment: paginationSegment,
273
- paginationMaxVisible: maxVisiblePages,
274
- paginationProcessed: 'true',
275
- paginationOffset: pageOffset.toString(),
276
- paginationFilePathname: pathname,
277
- paginationFileDirname: paginationDirname,
278
- paginationURLPathname: nestedURLPathname,
279
- paginationURLDirname: nestedURLDirname,
280
- paginationLength: paginationLength.toString(),
281
- paginationCurrent: currentPageIndex.toString(),
282
- }
283
-
284
- // Add pagination page to render queue
285
- await this.addRenderQueue({
286
- values: {
287
- paginationIndexPathname: indexPathname,
288
- paginationIndexURLPathname: indexURLPathname,
289
- paginationIndexURLDirname: indexURLDirname,
290
- paginationFileDirname: documentPath.dirname,
291
- },
292
- path: {
293
- dirname: paginationDirname,
294
- pathname,
295
- filename
296
- },
297
- content: indexPage.content
298
- })
299
- }
300
- }
228
+ const paginationTemplateId = pagination.template || 'coralite-pagination'
301
229
 
302
- // Store pagination values for current page
303
- // For non-index pages, use nested URL structure
304
- let currentPaginationURLDirname
305
- let currentPaginationURLPathname
306
-
307
- if (name) {
308
- // Non-index page: use nested structure
309
- // e.g., /blog/page/2 for page 2
310
- currentPaginationURLPathname = indexURLPathname
311
- // Calculate the full nested path
312
- // For page 2: /blog/page/2
313
- const relativePath = path.relative(this.options.path.pages, documentPath.dirname)
314
- currentPaginationURLDirname = pathToFileURL(path.join('/', relativePath, name)).pathname
315
- } else {
316
- // Index page: use flat structure
317
- currentPaginationURLPathname = indexURLPathname
318
- currentPaginationURLDirname = indexURLDirname
319
- }
320
- context.values = {
321
- ...context.values,
322
- paginationIndexPathname: indexPathname,
323
- paginationIndexURLPathname: indexURLPathname,
324
- paginationIndexURLDirname: indexURLDirname,
325
- paginationSegment: paginationSegment,
326
- paginationMaxVisible: maxVisiblePages,
327
- paginationProcessed: 'true',
328
- paginationOffset: limit.toString(),
329
- paginationFilePathname: indexPathname,
330
- paginationFileDirname: baseDirname,
331
- paginationURLPathname: currentPaginationURLPathname,
332
- paginationURLDirname: currentPaginationURLDirname,
333
- paginationLength: paginationLength.toString(),
334
- paginationCurrent: '1'
335
- }
336
- }
230
+ // Construct baseUrl and urlPrefix for template
231
+ const urlPathname = contextValues.$urlPathname
232
+ let baseUrl = urlPathname
233
+ let urlPrefix = ''
337
234
 
338
- // Handle pagination pages (non-index pages)
339
- const contextId = context.id
340
- let values = this.values[contextId]
235
+ if (contextValues.meta_pagination_base_url) {
236
+ baseUrl = contextValues.meta_pagination_base_url
237
+ }
341
238
 
342
- if (!values || !processed) {
343
- values = context.values
344
- this.values[contextId] = values
345
- }
346
-
347
- const paginationTemplateId = pagination.template || 'coralite-pagination'
348
-
349
- if (typeof paginationTemplateId === 'string') {
350
- const component = await this.createComponent({
351
- id: paginationTemplateId,
352
- values,
353
- document: context.document,
354
- contextId: contextId + paginationTemplateId
355
- })
356
-
357
- if (typeof component === 'object') {
358
- result = result.concat(component.children)
359
- }
239
+ if (contextValues.meta_pagination_url_prefix) {
240
+ urlPrefix = contextValues.meta_pagination_url_prefix
241
+ } else {
242
+ // Page 1 logic calculation if not passed
243
+ if (baseUrl.endsWith('/index.html') || baseUrl.endsWith('/')) {
244
+ urlPrefix = path.dirname(baseUrl)
245
+ } else {
246
+ // Named file
247
+ const basename = path.basename(baseUrl, '.html')
248
+ urlPrefix = path.join(path.dirname(baseUrl), basename)
360
249
  }
361
250
  }
362
251
 
363
- return result
364
- },
365
- templates: [path.join(import.meta.dirname, 'templates/coralite-pagination.html')]
252
+ // Normalize urlPrefix to ensure trailing slash
253
+ if (!urlPrefix.endsWith('/')) urlPrefix += '/'
254
+
255
+ const paginationValues = {
256
+ 'current-page': String(currentPage),
257
+ 'total-pages': String(totalPages),
258
+ 'base-url': baseUrl,
259
+ 'url-prefix': urlPrefix,
260
+ segment: pagination.segment || 'page',
261
+ 'max-visible': String(pagination.maxVisible || 5),
262
+ 'aria-label': pagination.ariaLabel || 'Pagination',
263
+ ellipsis: pagination.ellipsis || '...'
264
+ }
265
+
266
+ const component = await this.createComponent({
267
+ id: paginationTemplateId,
268
+ values: paginationValues,
269
+ document: contextInstance.document,
270
+ renderContext: currentRenderContext
271
+ })
272
+
273
+ if (component && component.children) {
274
+ resultNodes.push(...component.children)
275
+ }
276
+ }
277
+
278
+ return resultNodes
279
+ }
280
+
281
+ export const aggregation = createPlugin({
282
+ name: 'aggregation',
283
+ method: aggregationMethod,
284
+ templates: [
285
+ path.join(import.meta.dirname, 'templates/coralite-pagination.html')
286
+ ]
366
287
  })
288
+
289
+ export default aggregation