coralite-plugin-aggregation 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ <template id="coralite-pagination">
2
+ <nav aria-label="{{ ariaLabel }}">
3
+ <ul class="pagination">
4
+ {{ paginationLinks }}
5
+ </ul>
6
+ </nav>
7
+ </template>
8
+
9
+ <script type="module">
10
+ import { defineComponent } from 'coralite/plugins'
11
+
12
+ export default defineComponent({
13
+ attributes: {
14
+ ariaLabel: { type: String, default: 'Page navigation' },
15
+ currentPage: { type: Number, default: 1 },
16
+ totalPages: { type: Number, default: 1 },
17
+ maxVisible: { type: Number, default: 5 },
18
+ baseUrl: { type: String, default: '' },
19
+ urlPrefix: { type: String, default: '' },
20
+ segment: { type: String, default: 'page' },
21
+ ellipsis: { type: String, default: '...' }
22
+ },
23
+ getters: {
24
+ paginationLinks: (state) => {
25
+ const getPageUrl = (page) => {
26
+ if (page === 1) {
27
+ return state.baseUrl;
28
+ }
29
+
30
+ const cleanPrefix = state.urlPrefix.endsWith('/') ? state.urlPrefix : `${state.urlPrefix}/`;
31
+ return `${cleanPrefix}${state.segment}/${page}.html`;
32
+ }
33
+
34
+ const createItem = (page, text, isActive, isDisabled) => {
35
+ let className = 'page-item';
36
+ if (isActive) className += ' active';
37
+ if (isDisabled) className += ' disabled';
38
+
39
+ let attr = '';
40
+ if (isActive) attr += ' aria-current="page"';
41
+ if (isDisabled) attr += ' tabindex="-1" aria-disabled="true"';
42
+
43
+ const href = isDisabled ? '#' : getPageUrl(page);
44
+
45
+ return `<li class="${className}"><a class="page-link" href="${href}"${attr}>${text}</a></li>`;
46
+ }
47
+
48
+ let links = '';
49
+
50
+ // Previous Link
51
+ links += createItem(state.currentPage - 1, 'Previous', false, state.currentPage <= 1);
52
+
53
+ // Calculate Window (Start/End)
54
+ const half = Math.floor(state.maxVisible / 2);
55
+ let start = state.currentPage - half;
56
+ let end = state.currentPage + half;
57
+
58
+ if (start < 1) {
59
+ start = 1;
60
+ end = Math.min(state.totalPages, state.maxVisible);
61
+ }
62
+
63
+ if (end > state.totalPages) {
64
+ end = state.totalPages;
65
+ start = Math.max(1, state.totalPages - state.maxVisible + 1);
66
+ }
67
+
68
+ const showFirst = start > 1;
69
+ const showLast = end < state.totalPages;
70
+
71
+ // First page + ellipsis
72
+ if (showFirst) {
73
+ links += createItem(1, '1', false, false);
74
+ if (start > 2) {
75
+ links += `<li class="page-item disabled"><span class="page-link">${state.ellipsis}</span></li>`;
76
+ }
77
+ }
78
+
79
+ // Main window loop
80
+ for (let i = start; i <= end; i++) {
81
+ links += createItem(i, i, i === state.currentPage, false);
82
+ }
83
+
84
+ // Last page + ellipsis
85
+ if (showLast) {
86
+ if (end < state.totalPages - 1) {
87
+ links += `<li class="page-item disabled"><span class="page-link">${state.ellipsis}</span></li>`;
88
+ }
89
+ links += createItem(state.totalPages, state.totalPages, false, false);
90
+ }
91
+
92
+ // Next Link
93
+ links += createItem(state.currentPage + 1, 'Next', false, state.currentPage >= state.totalPages);
94
+
95
+ return links;
96
+ }
97
+ }
98
+ })
99
+ </script>
package/lib/index.js CHANGED
@@ -1,286 +1,243 @@
1
1
  import { definePlugin } from 'coralite'
2
2
  import path from 'node:path'
3
3
 
4
- /**
5
- * Aggregates content based on configuration
6
- * @param {import('../types/index.js').AggregationOptions} options
7
- * @param {Object} contextInstance
8
- * @returns {Promise<any[]>}
9
- */
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
- // 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)
4
+ export const aggregation = definePlugin({
5
+ name: 'aggregation',
6
+
7
+ async exports(options) {
8
+ const {
9
+ path: paths = [],
10
+ component,
11
+ pagination,
12
+ filter,
13
+ sort,
14
+ limit,
15
+ offset = 0,
16
+ recursive = false,
17
+ transformState
18
+ } = options
19
+ const state = this.state || {}
20
+ const app = this.app
21
+ const pagesRoot = app.options.pages
22
+
23
+ // Collect pages
24
+ let allPages = []
25
+ const uniquePaths = new Set()
26
+
27
+ for (const relativePath of paths) {
28
+ const targetPath = path.join(pagesRoot, relativePath)
29
+
30
+ if (!recursive) {
31
+ const pagesInDir = app.pages.getListByPath(targetPath)
32
+
33
+ if (pagesInDir) {
34
+ for (const page of pagesInDir) {
35
+ const pagePath = page.path.pathname
36
+
37
+ if (!uniquePaths.has(pagePath)) {
38
+ uniquePaths.add(pagePath)
39
+ allPages.push(page)
40
+ }
41
41
  }
42
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)
43
+ } else {
44
+ // Recursive search
45
+ for (const page of app.pages.list) {
46
+ const dirname = page.path.dirname
47
+
48
+ if (dirname === targetPath || dirname.startsWith(targetPath + path.sep)) {
49
+ const pagePath = page.path.pathname
50
+
51
+ if (!uniquePaths.has(pagePath)) {
52
+ uniquePaths.add(pagePath)
53
+ allPages.push(page)
54
+ }
53
55
  }
54
56
  }
55
57
  }
56
58
  }
57
- }
58
-
59
- // 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
- // 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
- }
76
-
77
- // Pagination
78
- let startIndex = offset
79
- let endIndex = allPages.length
80
-
81
- let currentPage = 1
82
- let totalPages = 1
83
-
84
- // Ensure renderContext is available
85
- const currentRenderContext = contextInstance.renderContext
86
- const buildId = currentRenderContext && currentRenderContext.buildId
87
-
88
- if (limit) {
89
- if (pagination) {
90
- const segment = pagination.segment || 'page'
91
- const urlPathname = contextValues.page_url_pathname || ''
92
59
 
93
- const escapedSegment = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
60
+ // Filter
61
+ if (typeof filter === 'function') {
62
+ allPages = allPages.filter(page => {
63
+ return filter(page.result.state)
64
+ })
65
+ }
94
66
 
95
- // Try to match segment in URL: /.../segment/Number
96
- const segmentRegex = new RegExp(`/${escapedSegment}/(\\d+)`)
97
- const match = urlPathname.match(segmentRegex)
67
+ // Sort
68
+ if (typeof sort === 'function') {
69
+ allPages.sort((a, b) => {
70
+ const propsA = (a.result && a.result.state) ? a.result.state : a.state
71
+ const propsB = (b.result && b.result.state) ? b.result.state : b.state
72
+ return sort(propsA, propsB)
73
+ })
74
+ }
98
75
 
99
- if (match) {
100
- currentPage = parseInt(match[1], 10)
101
- }
76
+ // Pagination
77
+ let startIndex = offset
78
+ let endIndex = allPages.length
102
79
 
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.component
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)
80
+ let currentPage = 1
81
+ let totalPages = 1
82
+
83
+ const currentRenderContext = this.renderContext
84
+ const buildId = currentRenderContext && currentRenderContext.buildId
85
+
86
+ if (limit) {
87
+ if (pagination) {
88
+ const segment = pagination.segment || 'page'
89
+ const urlPathname = this.page.url.pathname
135
90
 
136
- // e.g. /blog.html -> /blog/
137
- urlPrefixBase = urlPathname.replace(path.extname(currentFilename), '')
91
+ const escapedSegment = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92
+ const segmentRegex = new RegExp(`/${escapedSegment}/(\\d+)`)
93
+ const match = urlPathname.match(segmentRegex)
94
+
95
+ if (match) {
96
+ currentPage = parseInt(match[1], 10)
138
97
  }
139
98
 
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.
99
+ startIndex = offset + (currentPage - 1) * limit
100
+ endIndex = startIndex + limit
101
+ totalPages = Math.ceil(allPages.length / limit)
102
+
103
+ if (!match && currentPage === 1 && totalPages > 1 && buildId) {
104
+ const currentPathname = this.page.file.pathname
105
+ const currentFilename = this.page.file.filename
106
+ const currentDirname = this.page.file.dirname
107
+
108
+ let targetDir = currentDirname
109
+ let urlPrefixBase = ''
110
+
111
+ if (currentFilename === 'index.html') {
112
+ targetDir = currentDirname
113
+ urlPrefixBase = path.dirname(urlPathname)
114
+ } else {
115
+ const basename = path.basename(currentFilename, path.extname(currentFilename))
116
+ targetDir = path.join(currentDirname, basename)
117
+ urlPrefixBase = urlPathname.replace(path.extname(currentFilename), '')
118
+ }
144
119
 
145
- if (currentFilename === 'index.html') {
146
- // If /index.html, urlPathname /index.html. dirname /. prefix /.
147
- // If /blog/index.html, urlPathname /blog/index.html. dirname /blog. prefix /blog/.
148
- urlPrefixBase = path.dirname(urlPathname)
149
120
  if (!urlPrefixBase.endsWith('/')) urlPrefixBase += '/'
150
- }
151
121
 
152
- // Retrieve the original item to get content
153
- const currentItem = this.pages.getItem(currentDocument.path.pathname)
154
-
155
- for (let i = 2; i <= totalPages; i++) {
156
- const newPathname = path.join(targetDir, segment, `${i}.html`)
157
-
158
- const virtualItem = {
159
- content: currentItem ? currentItem.content : '', // Use original content
160
- path: {
161
- pathname: newPathname,
162
- dirname: path.dirname(newPathname),
163
- filename: path.basename(newPathname)
164
- },
165
- values: {
166
- // Pass metadata to help resolve paths in children
167
- meta_pagination_base_url: urlPathname,
168
- // Calculate prefix once and pass it down
169
- meta_pagination_url_prefix: urlPrefixBase
170
- },
171
- type: 'page'
122
+ if (currentFilename === 'index.html') {
123
+ urlPrefixBase = path.dirname(urlPathname)
124
+ if (!urlPrefixBase.endsWith('/')) urlPrefixBase += '/'
172
125
  }
173
126
 
174
- // Add to queue
175
- await this.addRenderQueue(virtualItem, buildId)
127
+ const currentItem = app.pages.getItem(currentPathname)
128
+
129
+ for (let i = 2; i <= totalPages; i++) {
130
+ const newPathname = path.join(targetDir, segment, `${i}.html`)
131
+
132
+ const virtualItem = {
133
+ content: currentItem ? currentItem.content : '',
134
+ path: {
135
+ pathname: newPathname,
136
+ dirname: path.dirname(newPathname),
137
+ filename: path.basename(newPathname)
138
+ },
139
+ state: {
140
+ paginationBaseUrl: urlPathname,
141
+ paginationUrlPrefix: urlPrefixBase
142
+ },
143
+ type: 'page'
144
+ }
145
+
146
+ await app.addRenderQueue(virtualItem, buildId)
147
+ }
176
148
  }
149
+ } else {
150
+ endIndex = Math.min(startIndex + limit, allPages.length)
177
151
  }
178
- } else {
179
- // Simple limit/offset without pagination logic
180
- endIndex = Math.min(startIndex + limit, allPages.length)
181
152
  }
182
- }
183
-
184
- const paginatedPages = allPages.slice(startIndex, endIndex)
185
- const resultNodes = []
186
-
187
- for (const page of paginatedPages) {
188
- const pageValues = page.result && page.result.values ? page.result.values : page.values
189
- let itemValues = { ...pageValues }
190
-
191
- // Apply token transformations
192
- if (tokens && typeof tokens === 'object') {
193
- for (const key in tokens) {
194
- if (Object.prototype.hasOwnProperty.call(tokens, key)) {
195
- const transform = tokens[key]
196
- if (typeof transform === 'string') {
197
- itemValues[key] = pageValues[transform]
198
- } else if (typeof transform === 'function') {
199
- itemValues[key] = transform(pageValues)
153
+
154
+ const paginatedPages = allPages.slice(startIndex, endIndex)
155
+ const resultNodes = []
156
+
157
+ for (const page of paginatedPages) {
158
+ const pageProps = (page.result && page.result.state) ? page.result.state : page.state
159
+ let itemProps = { ...pageProps }
160
+
161
+ // Apply properties transformations
162
+ if (transformState && typeof transformState === 'object') {
163
+ for (const key in transformState) {
164
+ if (Object.prototype.hasOwnProperty.call(transformState, key)) {
165
+ const transform = transformState[key]
166
+ if (typeof transform === 'string') {
167
+ itemProps[key] = pageProps[transform]
168
+ } else if (typeof transform === 'function') {
169
+ itemProps[key] = transform(pageProps)
170
+ }
200
171
  }
201
172
  }
202
173
  }
203
- }
204
174
 
205
- // Render the item template
206
- if (template) {
207
- const component = await this.createComponentElement({
208
- id: template,
209
- values: itemValues,
210
- component: contextInstance.component,
211
- renderContext: currentRenderContext
212
- })
175
+ if (component) {
176
+ const componentElement = await app.createComponentElement({
177
+ id: component,
178
+ state: itemProps,
179
+ page: this.page,
180
+ renderContext: currentRenderContext
181
+ })
213
182
 
214
- if (component && component.children) {
215
- resultNodes.push(...component.children)
183
+ if (componentElement && componentElement.children) {
184
+ resultNodes.push(...componentElement.children)
185
+ }
216
186
  }
217
187
  }
218
- }
219
188
 
220
- // Render pagination controls
221
- if (pagination) {
222
- const segment = pagination.segment || 'page'
223
- const escapedSegment = segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
224
-
225
- const paginationTemplateId = pagination.template || 'coralite-pagination'
189
+ if (pagination) {
190
+ const paginationComponentId = pagination.component || 'coralite-pagination'
191
+ const urlPathname = this.page.url.pathname
226
192
 
227
- // Construct baseUrl and urlPrefix for template
228
- const urlPathname = contextValues.page_url_pathname
229
- let baseUrl = urlPathname
230
- let urlPrefix = ''
193
+ let baseUrl = urlPathname
194
+ let urlPrefix = ''
231
195
 
232
- if (contextValues.meta_pagination_base_url) {
233
- baseUrl = contextValues.meta_pagination_base_url
234
- }
196
+ if (state.paginationBaseUrl) {
197
+ baseUrl = state.paginationBaseUrl
198
+ }
235
199
 
236
- if (contextValues.meta_pagination_url_prefix) {
237
- urlPrefix = contextValues.meta_pagination_url_prefix
238
- } else {
239
- // Page 1 logic calculation if not passed
240
- if (baseUrl.endsWith('/index.html') || baseUrl.endsWith('/')) {
241
- urlPrefix = path.dirname(baseUrl)
200
+ if (state.paginationUrlPrefix) {
201
+ urlPrefix = state.paginationUrlPrefix
242
202
  } else {
243
- // Named file
244
- const basename = path.basename(baseUrl, '.html')
245
- urlPrefix = path.join(path.dirname(baseUrl), basename)
203
+ if (baseUrl.endsWith('/index.html') || baseUrl.endsWith('/')) {
204
+ urlPrefix = path.dirname(baseUrl)
205
+ } else {
206
+ const basename = path.basename(baseUrl, '.html')
207
+ urlPrefix = path.join(path.dirname(baseUrl), basename)
208
+ }
246
209
  }
247
- }
248
210
 
249
- // Normalize urlPrefix to ensure trailing slash
250
- if (!urlPrefix.endsWith('/')) urlPrefix += '/'
251
-
252
- const paginationValues = {
253
- 'current-page': String(currentPage),
254
- 'total-pages': String(totalPages),
255
- 'base-url': baseUrl,
256
- 'url-prefix': urlPrefix,
257
- segment: pagination.segment || 'page',
258
- 'max-visible': String(pagination.maxVisible || 5),
259
- 'aria-label': pagination.ariaLabel || 'Pagination',
260
- ellipsis: pagination.ellipsis || '...'
261
- }
211
+ if (!urlPrefix.endsWith('/')) urlPrefix += '/'
212
+
213
+ const paginationProps = {
214
+ 'current-page': String(currentPage),
215
+ 'total-pages': String(totalPages),
216
+ 'base-url': baseUrl,
217
+ 'url-prefix': urlPrefix,
218
+ segment: pagination.segment || 'page',
219
+ 'max-visible': String(pagination.maxVisible || 5),
220
+ 'aria-label': pagination.ariaLabel || 'Pagination',
221
+ ellipsis: pagination.ellipsis || '...'
222
+ }
262
223
 
263
- const component = await this.createComponentElement({
264
- id: paginationTemplateId,
265
- values: paginationValues,
266
- component: contextInstance.component,
267
- renderContext: currentRenderContext
268
- })
224
+ const componentElement = await app.createComponentElement({
225
+ id: paginationComponentId,
226
+ state: paginationProps,
227
+ page: this.page,
228
+ renderContext: currentRenderContext
229
+ })
269
230
 
270
- if (component && component.children) {
271
- resultNodes.push(...component.children)
231
+ if (componentElement && componentElement.children) {
232
+ resultNodes.push(...componentElement.children)
233
+ }
272
234
  }
273
- }
274
-
275
- return resultNodes
276
- }
277
235
 
278
- export const aggregation = definePlugin({
279
- name: 'aggregation',
280
- method: aggregationMethod,
236
+ return resultNodes
237
+ },
281
238
  components: [
282
239
  // @ts-ignore
283
- path.join(import.meta.dirname, 'templates/coralite-pagination.html')
240
+ path.join(import.meta.dirname, 'components/coralite-pagination.html')
284
241
  ]
285
242
  })
286
243
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coralite-plugin-aggregation",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "A Coralite plugin for dynamically collecting, filtering, sorting, and displaying content across multiple sources. Build database-free Coralite websites with automated content aggregation.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -39,14 +39,11 @@
39
39
  "lib",
40
40
  "types"
41
41
  ],
42
- "peerDependencies": {
43
- "coralite": "^0.32.0"
44
- },
45
- "license": "AGPL-3.0-only",
42
+ "license": "MPL-2.0",
46
43
  "devDependencies": {
47
44
  "@stylistic/eslint-plugin-js": "^4.2.0",
48
45
  "@stylistic/eslint-plugin-plus": "^4.2.0",
49
- "coralite": "^0.32.0"
46
+ "coralite": "^0.34.0"
50
47
  },
51
48
  "scripts": {
52
49
  "test": "node --test ./tests/index.spec.js"
package/types/index.js CHANGED
@@ -10,14 +10,14 @@
10
10
  /**
11
11
  * @typedef {Object} AggregationOptions
12
12
  * @property {string[]} [path=[]] - An array of relative paths to search for pages within `pagesRoot`.
13
- * @property {string} [template] - The component ID to use for rendering each item found.
13
+ * @property {string} [component] - The component ID to use for rendering each item found.
14
14
  * @property {AggregationPaginationOptions} [pagination] - Configuration for pagination logic and controls. If present, pagination logic is enabled.
15
15
  * @property {function(Object): boolean} [filter] - A callback function to filter pages. It receives the page values object and should return `true` to keep the item.
16
16
  * @property {function(Object, Object): number} [sort] - A comparison function for sorting pages. It receives two page value objects (a, b) and should return a number.
17
17
  * @property {number} [limit] - The maximum number of items to return (or items per page if pagination is used).
18
18
  * @property {number} [offset=0] - The starting index for fetching items.
19
19
  * @property {boolean} [recursive=false] - If true, searches subdirectories of the specified paths.
20
- * @property {Object.<string, (string|function(Object): *)>} [tokens] - A map of key transformations. Keys are the new property names. Values can be a string (source property name) or a function (receiving page values and returning the new value).
20
+ * @property {Object.<string, (string|function(Object): *)>} [transformState] - A map of key transformations. Keys are the new property names. Values can be a string (source property name) or a function (receiving page values and returning the new value).
21
21
  */
22
22
 
23
23
  export default {}