coralite-plugin-aggregation 0.1.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.
package/README.md ADDED
@@ -0,0 +1,306 @@
1
+ # Coralite Aggregation Plugin
2
+
3
+ The **Coralite Aggregation Plugin** is a powerful tool designed to help developers dynamically collect, filter, sort, and display content across multiple sources within a Coralite project.
4
+
5
+
6
+ - [Installation](#installation)
7
+ - [Example](#example)
8
+ - [Type definition](#types)
9
+ - [Custom pager](#custom-pager)
10
+
11
+ ---
12
+
13
+ ## Features
14
+
15
+ - **Content Aggregation**: Gather content from multiple files or directories using path patterns (e.g., `blog/`, `['products/all','blog/']`).
16
+ - **Filtering & Sorting**: Use metadata-based filters and custom sort functions to refine results based on attributes like tags, categories, dates, or tokens.
17
+ - **Pagination Support**: Easily create paginated views with customizable templates for navigation controls and visible page links.
18
+ - **Token Handling**: Configure token aliases, defaults, and metadata mapping.
19
+
20
+ ---
21
+
22
+ ## Coralite Aggregation Plugin Guide
23
+
24
+ ### Installation {#installation}
25
+
26
+ ```bash
27
+ npm install coralite-plugin-aggregation
28
+ ```
29
+
30
+ ### Setup Configuration
31
+ First, enable the plugin in your `coralite.config.js`:
32
+
33
+ ```js
34
+ // coralite.config.js
35
+ import aggregation from 'coralite-plugin-aggregation'
36
+
37
+ export default {
38
+ plugins: [aggregation]
39
+ }
40
+ ```
41
+
42
+ > **Note**: The plugin must be imported as `'coralite-plugin-aggregation'` for compatibility with the core Coralite framework.
43
+
44
+ ---
45
+
46
+ ### Example Implementation {#example}
47
+
48
+ #### Entry Point: Displaying Aggregated Results
49
+ Create a file like `coralite-posts.html` to define your aggregation logic and rendering template:
50
+
51
+ ```html
52
+ <!-- templates/coralite-posts.html -->
53
+ <template id="coralite-posts">
54
+ <div>
55
+ {{ posts }}
56
+ <!-- Pagination controls will be injected automatically if enabled. -->
57
+ </div>
58
+ </template>
59
+
60
+ <script type="module">
61
+ import { defineComponent, aggregation } from 'coralite'
62
+
63
+ export default defineComponent({
64
+ tokens: {
65
+ // Aggregation function returns an array of content items.
66
+ posts() {
67
+ return aggregation({
68
+ path: ['products'], // Source directory.
69
+ template: 'coralite-post', // Template ID for individual items.
70
+ limit: 20, // Maximum number of results per page.
71
+ recursive: true, // Include subdirectories.
72
+ pagination: {
73
+ token: 'post_count', // Page size control token.
74
+ template: 'coralite-pagination', // Template for pagination controls.
75
+ path: 'page', // Infix for paginated URLs (e.g., `page/1`).
76
+ visible: 5 // Max number of visible page links.
77
+ },
78
+ filter(meta) {
79
+ return meta.name === 'category' && meta.content === 'tech'
80
+ },
81
+ sort(a, b) {
82
+ return new Date(b.date) - new Date(a.date)
83
+ },
84
+ tokens: {
85
+ default: {
86
+ author: 'Anonymous',
87
+ category: 'uncategorized'
88
+ },
89
+ aliases: {
90
+ tags: ['tech', 'news', 'tutorial']
91
+ }
92
+ }
93
+ })
94
+ }
95
+ }
96
+ })
97
+ </script>
98
+ ```
99
+
100
+ #### Aggregated Content Files
101
+ Each file to be aggregated must include metadata via `<meta>` elements. For example:
102
+
103
+ ```html
104
+ <!-- pages/products/product-1.html -->
105
+ <!DOCTYPE html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="UTF-8" />
109
+ <meta name="title" content="Great Barrier Reef" />
110
+ <meta name="description" content="The Great Barrier Reef—largest, comprising over 2,900 individual reefs and 900 islands stretching for over 2,600 kilometers." />
111
+ <meta name="price" content="1000" />
112
+ <meta name="published_time" content="2025-01-08T20:23:07.645Z" />
113
+ </head>
114
+ <body>
115
+ <coralite-header>
116
+ <h1>Great Barrier Reef</h1>
117
+ </coralite-header>
118
+ <coralite-author name="Nemo" datetime="2025-01-08T20:23:07.645Z"></coralite-author>
119
+ </body>
120
+ </html>
121
+ ```
122
+
123
+ #### Single Result Template
124
+ Define a `<template>` element for rendering individual items:
125
+
126
+ ```html
127
+ <!-- templates/coralite-post.html -->
128
+ <template id="coralite-post">
129
+ <div class="post-item">
130
+ <h2>{{ $title }}</h2>
131
+ <p>{{ $description }} - {{ formattedPrice }}</p>
132
+ </div>
133
+ </template>
134
+
135
+ <script type="module">
136
+ import { defineComponent } from 'coralite'
137
+
138
+ export default defineComponent({
139
+ tokens: {
140
+ // Custom token to format prices using Intl.NumberFormat.
141
+ formattedPrice(values) {
142
+ return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(
143
+ values.$price
144
+ )
145
+ }
146
+ }
147
+ })
148
+ </script>
149
+ ```
150
+
151
+ > **Token Syntax**: Metadata attributes are accessed in templates as `$<meta name>`. For example, the `<meta name="title">` element is referenced using `$title`.
152
+
153
+
154
+ ### Configuration Options {#types}
155
+
156
+ #### `CoraliteAggregate` {#coralite-aggregate}
157
+ Configuration object for content aggregation processes.
158
+
159
+ | Property | Type | Description | Reference |
160
+ |-----------------|----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|-----------|
161
+ | `path` | `string[]` | Array of paths relative to the pages directory (e.g., `['products', 'blog/']`). | - |
162
+ | `template` | `CoraliteAggregateTemplate` or `string` | Templates used to display the result. Must match an existing `<template>` element by ID. | [CoraliteAggregateTemplate](#coralite-aggregate-template) |
163
+ | `pagination` | `Object` | Pagination settings (optional). | - |
164
+ | `filter` | [`CoraliteAggregateFilter`](#coralite-aggregate-filter) | Callback to filter out unwanted elements from the aggregated content. | [CoraliteAggregateFilter](#coralite-aggregate-filter) |
165
+ | `recursive` | `boolean` | Whether to recursively search subdirectories. | - |
166
+ | `tokens` | [`CoraliteTokenOptions`](#coralite-token-options) | Token configuration options (optional). | [CoraliteTokenOptions](#coralite-token-options) |
167
+ | `sort` | [`CoraliteAggregateSort`](#coralite-aggregate-sort) | Sort aggregated pages. | [CoraliteAggregateSort](#coralite-aggregate-sort) |
168
+ | `limit` | `number` | Maximum number of results to retrieve (used with pagination). | - |
169
+ | `offset` | `number` | Starting index for the results list (used with pagination). | - |
170
+
171
+ ---
172
+
173
+ #### `CoraliteAggregateTemplate` {#coralite-aggregate-template}
174
+ Configuration for templates used to render aggregated results.
175
+
176
+ | Property | Type | Description | Reference |
177
+ |----------|-----------|-----------------------------------------------------------------------------|-----------|
178
+ | `item` | `string` | Unique identifier for the component used for each document (e.g., `'coralite-post'`). | - |
179
+
180
+ ---
181
+
182
+ #### `CoraliteTokenOptions` {#coralite-token-options}
183
+ Configuration options for token handling during processing.
184
+
185
+ | Property | Type | Description |
186
+ |--------------|-------------------------------------------------------|-----------------------------------------------------------------------------|
187
+ | `default` | `Object.<string, string>` | Default token values for properties not explicitly set (e.g., `{ author: 'Anonymous' }`). |
188
+ | `aliases` | `Object.<string, string[]>` | Token aliases and their possible values (e.g., `{ tags: ['tech', 'news'] }`). |
189
+
190
+ ---
191
+
192
+ #### `CoraliteAggregateFilter` {#coralite-aggregate-filter}
193
+ Callback function for filtering aggregated content based on metadata.
194
+
195
+ | Parameter | Type | Description |
196
+ |---------------|-------------------------------------|-----------------------------------------------------------------------------|
197
+ | `metadata` | [`CoraliteToken`](#coralite-token) | Aggregated HTML page metadata (e.g., `{ name: 'category', content: 'tech' }`). |
198
+
199
+ ---
200
+
201
+ #### `CoraliteAggregateSort` {#coralite-aggregate-sort}
202
+ Callback function for sorting aggregated results based on metadata.
203
+
204
+ | Parameter | Type | Description |
205
+ |-----------|-------------------------------------|-----------------------------------------------------------------------------|
206
+ | `a` | `Object.<string, string>` | Metadata of the first item being compared (e.g., `{ date: '2025-01-08' }`). |
207
+ | `b` | `Object.<string, string>` | Metadata of the second item being compared. |
208
+
209
+ ---
210
+
211
+ #### `CoraliteToken` {#coralite-token}
212
+ A representation of a token with name and value.
213
+
214
+ | Property | Type | Description |
215
+ |----------|--------|-----------------------------------------------------------------------------|
216
+ | `name` | `string` | Token identifier (e.g., `'title'`, `'category'`). |
217
+ | `content`| `string` | Token value or content (e.g., `'Great Barrier Reef'`, `'tech'`). |
218
+
219
+ ---
220
+
221
+ ## Custom Pager Template User Guide for Coralite Pagination Component {#custom-pager}
222
+
223
+ This guide explains how to create a custom pagination template using the existing `coralite-pagination` component as a reference. The goal is to define a new pager layout below the default implementation, preserving compatibility with the core logic while enabling customization.
224
+
225
+ ---
226
+
227
+ ### Create a New Template Element
228
+ Define a unique `<template>` element for your custom pager. Use an ID distinct from the default (`coralite-pagination`) to avoid conflicts:
229
+
230
+ ```html
231
+ <template id="coralite-pagination-custom">
232
+ {{ pagination_list }}
233
+ </template>
234
+ ```
235
+
236
+ ---
237
+
238
+ ### Implement Custom Logic in `<script type="module">`
239
+ Replace or extend the `pagination_list` token function with your custom logic. The core structure remains compatible with Coralite’s API, but you can modify rendering rules (e.g., ellipsis behavior, link formatting).
240
+
241
+ #### Example: Basic Custom Token Function
242
+ ```javascript
243
+ <script type="module">
244
+ import { defineComponent } from 'coralite'
245
+
246
+ export default defineComponent({
247
+ tokens: {
248
+ /**
249
+ * @param {Object} values
250
+ * @param {string} values.pagination_visible - Max number of visible pages items.
251
+ * @param {string} values.pagination_offset - Number of items to skip from the beginning (offset for pagination)
252
+ * @param {string} values.pagination_index - Base URL path for generating pagination links
253
+ * @param {string} values.pagination_dirname - Directory path component for routing context
254
+ * @param {string} values.pagination_length - Total number of items across all pages
255
+ * @param {string} values.pagination_current - Currently active page number or identifier
256
+ */
257
+ pagination_list (values) {
258
+ const length = parseInt(values.pagination_length)
259
+ if (!length) return ''
260
+
261
+ // Custom logic: render a simplified pager with only previous/next and current page
262
+ const currentPage = parseInt(values.pagination_current)
263
+ const dirname = values.pagination_dirname[0] === '/' ? values.pagination_dirname : '/' + values.pagination_dirname
264
+
265
+ let html = '<ul class="pagination">'
266
+
267
+ // Previous link
268
+ if (currentPage > 1) {
269
+ html += `<li class="page-item"><a class="page-link" href="${dirname}/${currentPage - 1}.html">Previous</a></li>`
270
+ } else {
271
+ html += '<li class="page-item disabled"><span class="page-link">Previous</span></li>'
272
+ }
273
+
274
+ // Current page
275
+ html += `<li class="page-item active"><span class="page-link">${currentPage}</span></li>`
276
+
277
+ // Next link
278
+ if (currentPage < length) {
279
+ html += `<li class="page-item"><a class="page-link" href="${dirname}/${currentPage + 1}.html">Next</a></li>`
280
+ } else {
281
+ html += '<li class="page-item disabled"><span class="page-link">Next</span></li>'
282
+ }
283
+
284
+ html += '</ul>'
285
+ return html
286
+ }
287
+ }
288
+ })
289
+ </script>
290
+ ```
291
+
292
+ ---
293
+
294
+ ### Key Parameters
295
+ The `pagination_list` token function receives these critical parameters from Coralite:
296
+
297
+ | Parameter | Description |
298
+ |-----------------------|-----------------------------------------------------------------------------|
299
+ | `values.pagination_length` | Total number of items across all pages (used to determine page count). |
300
+ | `values.pagination_current` | Currently active page number. |
301
+ | `values.pagination_dirname` | Base directory path for routing context (e.g., `/blog`). |
302
+ | `values.pagination_index` | Base URL path for pagination links (e.g., `/blog/index.html`). |
303
+
304
+ > **Note:** Avoid hardcoding values like `length`, `currentPage`, or `dirname`. Use the parameters provided by Coralite to ensure compatibility.
305
+
306
+ ---
package/changelog.md ADDED
@@ -0,0 +1,88 @@
1
+ # 🎁 Complete Release History
2
+
3
+ ## Initial Release: `v0.1.0`
4
+
5
+ ### Initial Commits
6
+
7
+ - 2b0f1dc (HEAD -> main, tag: v0.1.0, origin/main) feat: Implement dynamic pagination with visible items and ellipsis - ([Thomas David](https://codeberg.org/tjdavid))
8
+ - 6b4d75f test: add fixtures to cover visibility feature - ([Thomas David](https://codeberg.org/tjdavid))
9
+ - 70f5a4b add npm ignore list - ([Thomas David](https://codeberg.org/tjdavid))
10
+ - 48cf1e0 docs: Add custom pager template user guide - ([Thomas David](https://codeberg.org/tjdavid))
11
+ - 065ff1a feat(pagination): improve pagination handling with dynamic path and adjusted length calculation - ([Thomas David](https://codeberg.org/tjdavid))
12
+ - ea200fe types: add visible property to pagination configuration - ([Thomas David](https://codeberg.org/tjdavid))
13
+ - 4f68032 chore: update pnpm, coralite peer dep and rename build script - ([Thomas David](https://codeberg.org/tjdavid))
14
+ - 1a4ebc2 docs: add readme - ([Thomas David](https://codeberg.org/tjdavid))
15
+ - 762a507 fix: correct pagination length calculation and offset generation - ([Thomas David](https://codeberg.org/tjdavid))
16
+ Adjust pagination logic to use calculated `paginationLength` for determining
17
+ page counts and offsets, ensuring accurate rendering of paginated content.
18
+
19
+ - 74ba4a0 fix: simplify page limit handling by removing array check and using offset correctly - ([Thomas David](https://codeberg.org/tjdavid))
20
+ - 2cf82e6 refactor: Use tokens instead of values in component definitions - ([Thomas David](https://codeberg.org/tjdavid))
21
+ - b5fde9d fix: fix previous navigation link generation in pagination - ([Thomas David](https://codeberg.org/tjdavid))
22
+ Correctly adjust the "Previous" link href based on current index to ensure proper
23
+ navigation when on pages beyond the second one. The fix updates the logic to
24
+ reference the correct page number for the previous item, resolving invalid
25
+ navigation links in multi-page scenarios.
26
+
27
+ - Removed hardcoded index increments
28
+ - Added dynamic calculation based on current page position
29
+
30
+ - 8b50d9d fix(pagination): use correct length value and adjust skip condition - ([Thomas David](https://codeberg.org/tjdavid))
31
+ - 4879d44 refactor: rename values to tokens in pagination_list - ([Thomas David](https://codeberg.org/tjdavid))
32
+ - 3b06c3c chore: upgrade coralite to 0.11.1 - ([Thomas David](https://codeberg.org/tjdavid))
33
+ - cfa07cb chore: update coralite to 0.10.0 - ([Thomas David](https://codeberg.org/tjdavid))
34
+ - 320f665 chore: add "type": "module" to package.json - ([Thomas David](https://codeberg.org/tjdavid))
35
+ - 0ad0f60 chore: update repository and issues URLs in package.json - ([Thomas David](https://codeberg.org/tjdavid))
36
+ - 679b3cf fix: use import.meta.dirname for template path - ([Thomas David](https://codeberg.org/tjdavid))
37
+ - 093b1ea fix: ensure context scope for new components - ([Thomas David](https://codeberg.org/tjdavid))
38
+ - f118211 refactor: update metadata processing to use name and content in filter - ([Thomas David](https://codeberg.org/tjdavid))
39
+ - 8d2a0e4 refactor: ensure consistent path formatting for pagination links - ([Thomas David](https://codeberg.org/tjdavid))
40
+ - 58bd08f fix: update pagination logic to skip when no directory name - ([Thomas David](https://codeberg.org/tjdavid))
41
+ - 7af1188 fix: token name spacing conflict - ([Thomas David](https://codeberg.org/tjdavid))
42
+ - 27a448a types: update CoraliteAggregate type to use string[] for path and add pagination.id - ([Thomas David](https://codeberg.org/tjdavid))
43
+ - 26b8b9a refactor: remove webServer config and update type imports in index.js - ([Thomas David](https://codeberg.org/tjdavid))
44
+ - 5fdc416 refactor(test): update posts aggregation parameters and add pagination support - ([Thomas David](https://codeberg.org/tjdavid))
45
+ Refactor the posts aggregation logic to use `limit` instead of `pages`, split path into array,
46
+ and add optional pagination configuration. This improves consistency and enables pagination.
47
+
48
+ BREAKING CHANGE: The `pages` parameter has been replaced with `limit`. Existing code using `pages`
49
+ may need adjustment to work with this change.
50
+
51
+ - 1a1d9af chore: remove unused blog index.html test fixture - ([Thomas David](https://codeberg.org/tjdavid))
52
+ - 398b775 refactor: wrap post title in link and remove coralite-header - ([Thomas David](https://codeberg.org/tjdavid))
53
+ - 12f9e54 tests: add blog posts fixtures - ([Thomas David](https://codeberg.org/tjdavid))
54
+ - 314b7e6 test: add index.html test fixture for paginated blog posts - ([Thomas David](https://codeberg.org/tjdavid))
55
+ - 19eb540 feat: add default coralite-pagination template - ([Thomas David](https://codeberg.org/tjdavid))
56
+ - 165ec10 fix(pagination): correct path handling and context value processing - ([Thomas David](https://codeberg.org/tjdavid))
57
+ Use context.document.path instead of document.path to ensure accurate directory resolution.
58
+ Introduce processed flag to prevent redundant pagination value computation.
59
+ Update pagination template rendering to use context-aware values.
60
+
61
+ BREAKING CHANGE: Pagination template behavior may change due to updated value context
62
+
63
+ - 8c0ea5a refactor: use context values and document in createComponent - ([Thomas David](https://codeberg.org/tjdavid))
64
+ - 59bd962 chore: remove html meta data processing logic - ([Thomas David](https://codeberg.org/tjdavid))
65
+ - 78b3bb0 fix: use context values - ([Thomas David](https://codeberg.org/tjdavid))
66
+ - f2c32d1 feat: support multiple aggregate paths in plugin method - ([Thomas David](https://codeberg.org/tjdavid))
67
+ - 5703d63 refactor: use page.result.meta instead of parsing HTML meta tags - ([Thomas David](https://codeberg.org/tjdavid))
68
+ - 7dbcc2c fix: handle excluding self reference in aggregation - ([Thomas David](https://codeberg.org/tjdavid))
69
+ - ca2f228 feat(test): Update blog posts test to exclude current page and adjust component structure - ([Thomas David](https://codeberg.org/tjdavid))
70
+ - 487a0b0 chore: Update script paths, pnpm version, and move coralite to peerDependencies - ([Thomas David](https://codeberg.org/tjdavid))
71
+ - 86af8d5 chore: update package metadata - ([Thomas David](https://codeberg.org/tjdavid))
72
+ - 6e9d733 chore: add jsconfig.json for TypeScript configuration - ([Thomas David](https://codeberg.org/tjdavid))
73
+ - 4b1fd18 build: add Playwright E2E tests and update build tools - ([Thomas David](https://codeberg.org/tjdavid))
74
+ - 1a4e8a0 chore: add coralite config to enable aggregation plugin - ([Thomas David](https://codeberg.org/tjdavid))
75
+ - 85be5fd fix: Update coralite import path to utils module - ([Thomas David](https://codeberg.org/tjdavid))
76
+ - 1a46a51 chore: update .gitignore with additional patterns - ([Thomas David](https://codeberg.org/tjdavid))
77
+
78
+ ### Metadata
79
+ ```
80
+ First version ------- v0.1.0
81
+ Total commits ------- 47
82
+ ```
83
+
84
+ ## Summary
85
+ ```
86
+ Total releases ------ 1
87
+ Total commits ------- 47
88
+ ```
@@ -0,0 +1,5 @@
1
+ import aggregation from './src/index.js'
2
+
3
+ export default {
4
+ plugins: [aggregation]
5
+ }
@@ -0,0 +1,117 @@
1
+ import stylisticJs from '@stylistic/eslint-plugin-js'
2
+ import stylisticPlus from '@stylistic/eslint-plugin-plus'
3
+
4
+ export default [
5
+ {
6
+ plugins: {
7
+ '@stylistic/js': stylisticJs,
8
+ '@stylistic/plus': stylisticPlus
9
+ },
10
+ rules: {
11
+ '@stylistic/plus/curly-newline': ['error', 'always'],
12
+ '@stylistic/js/indent': [
13
+ 'error', 2, {
14
+ SwitchCase: 1,
15
+ VariableDeclarator: 1,
16
+ outerIIFEBody: 1,
17
+ MemberExpression: 1,
18
+ FunctionExpression: {
19
+ body: 1,
20
+ parameters: 1
21
+ },
22
+ CallExpression: {
23
+ arguments: 1
24
+ },
25
+ ArrayExpression: 1,
26
+ ObjectExpression: 1,
27
+ ImportDeclaration: 1,
28
+ flatTernaryExpressions: true,
29
+ ignoreComments: false
30
+ }
31
+ ],
32
+ '@stylistic/js/object-curly-spacing': ['error', 'always'],
33
+ '@stylistic/js/object-property-newline': 'error',
34
+ '@stylistic/js/object-curly-newline': ['error', {
35
+ ObjectExpression: {
36
+ multiline: true,
37
+ consistent: true
38
+ },
39
+ ObjectPattern: {
40
+ multiline: true,
41
+ consistent: true
42
+ },
43
+ ImportDeclaration: {
44
+ multiline: true,
45
+ consistent: true
46
+ },
47
+ ExportDeclaration: {
48
+ multiline: true,
49
+ consistent: true
50
+ }
51
+ }],
52
+ '@stylistic/js/quote-props': ['error', 'as-needed'],
53
+ '@stylistic/js/space-before-function-paren': ['error', 'always'],
54
+ '@stylistic/js/function-call-spacing': ['error', 'never'],
55
+ '@stylistic/js/implicit-arrow-linebreak': ['error', 'beside'],
56
+ '@stylistic/js/eol-last': ['error', 'always'],
57
+ '@stylistic/js/brace-style': [
58
+ 'error', '1tbs', {
59
+ allowSingleLine: false
60
+ }
61
+ ],
62
+ '@stylistic/js/semi': ['error', 'never'],
63
+ '@stylistic/js/quotes': [
64
+ 'error', 'single', {
65
+ avoidEscape: true,
66
+ allowTemplateLiterals: true
67
+ }
68
+ ],
69
+ '@stylistic/js/comma-dangle': ['error', 'never'],
70
+ '@stylistic/js/comma-spacing': [
71
+ 'error', {
72
+ before: false,
73
+ after: true
74
+ }
75
+ ],
76
+ '@stylistic/js/comma-style': ['error', 'last'],
77
+ '@stylistic/js/array-bracket-spacing': ['error', 'never'],
78
+ '@stylistic/js/array-bracket-newline': ['error', 'consistent'],
79
+ '@stylistic/js/array-element-newline': ['error', 'consistent'],
80
+ '@stylistic/js/computed-property-spacing': ['error', 'never'],
81
+ '@stylistic/js/no-mixed-operators': [
82
+ 'error',
83
+ {
84
+ groups: [
85
+ [
86
+ '+', '-', '*', '/', '%', '**'
87
+ ],
88
+ [
89
+ '&', '|', '^', '~', '<<', '>>', '>>>'
90
+ ],
91
+ [
92
+ '==', '!=', '===', '!==', '>', '>=', '<', '<='
93
+ ],
94
+ ['&&', '||'],
95
+ ['in', 'instanceof']
96
+ ],
97
+ allowSamePrecedence: false
98
+ }
99
+ ],
100
+ '@stylistic/js/key-spacing': [
101
+ 'error', {
102
+ mode: 'strict'
103
+ }
104
+ ],
105
+ '@stylistic/js/no-trailing-spaces': 'error',
106
+ '@stylistic/js/no-multi-spaces': 'error',
107
+ '@stylistic/js/no-confusing-arrow': 'error'
108
+ }
109
+ },
110
+ {
111
+ ignores: [
112
+ '**/dist/',
113
+ '**/.history/',
114
+ '**/playwright-report/'
115
+ ]
116
+ }
117
+ ]
package/jsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "compilerOptions": {
3
+ "checkJs": true,
4
+ "module": "NodeNext",
5
+ "target": "ES2022",
6
+ "moduleResolution": "nodenext",
7
+ }
8
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "coralite-plugin-aggregation",
3
+ "version": "0.1.0",
4
+ "description": "Build database free coralite websites",
5
+ "scripts": {
6
+ "build": "coralite -t tests/fixtures/templates -p tests/fixtures/pages -o dist",
7
+ "test-e2e": "playwright test",
8
+ "test-e2e-report": "playwright show-report",
9
+ "test-e2e-ui": "playwright test --ui",
10
+ "server": "sirv dist --dev --port 3000"
11
+ },
12
+ "type": "module",
13
+ "keywords": [],
14
+ "homepage": "https://coralite.io",
15
+ "author": {
16
+ "name": "Thomas David",
17
+ "url": "https://thomasjackdavid.com"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://codeberg.org/tjdavid/coralite-plugin-aggregation.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://codeberg.org/tjdavid/coralite-plugin-aggregation/issues"
25
+ },
26
+ "imports": {
27
+ "#types": "./types/index.js"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "default": "./src/index.js",
32
+ "types": "./types/index.js"
33
+ }
34
+ },
35
+ "license": "AGPL-3.0-only",
36
+ "packageManager": "pnpm@10.12.1",
37
+ "devDependencies": {
38
+ "@playwright/test": "^1.52.0",
39
+ "@stylistic/eslint-plugin-js": "^4.2.0",
40
+ "@stylistic/eslint-plugin-plus": "^4.2.0",
41
+ "coralite": "^0.11.1",
42
+ "sirv-cli": "^3.0.1"
43
+ },
44
+ "dependencies": {
45
+ "dom-serializer": "^2.0.0",
46
+ "htmlparser2": "^10.0.0"
47
+ },
48
+ "peerDependencies": {
49
+ "coralite": "^0.11.2"
50
+ }
51
+ }
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+ import { defineConfig, devices } from '@playwright/test'
3
+
4
+ /**
5
+ * Read environment variables from file.
6
+ * https://github.com/motdotla/dotenv
7
+ */
8
+ // import dotenv from 'dotenv';
9
+ // import path from 'path';
10
+ // dotenv.config({ path: path.resolve(__dirname, '.env') });
11
+
12
+ /**
13
+ * @see https://playwright.dev/docs/test-configuration
14
+ */
15
+ export default defineConfig({
16
+ testDir: './tests/e2e',
17
+ /* Run tests in files in parallel */
18
+ fullyParallel: true,
19
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
20
+ forbidOnly: !!process.env.CI,
21
+ /* Retry on CI only */
22
+ retries: process.env.CI ? 2 : 0,
23
+ /* Opt out of parallel tests on CI. */
24
+ workers: process.env.CI ? 1 : undefined,
25
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
26
+ reporter: 'html',
27
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
28
+ use: {
29
+ /* Base URL to use in actions like `await page.goto('/')`. */
30
+ baseURL: 'http://localhost:3000',
31
+
32
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
33
+ trace: 'on-first-retry'
34
+ },
35
+
36
+ /* Configure projects for major browsers */
37
+ projects: [
38
+ {
39
+ name: 'firefox',
40
+ use: { ...devices['Desktop Firefox'] }
41
+ }
42
+ ]
43
+ })
44
+
package/src/index.js ADDED
@@ -0,0 +1,373 @@
1
+ import { join } from 'node:path'
2
+ import { existsSync } from 'node:fs'
3
+ import { Parser } from 'htmlparser2'
4
+ import {
5
+ getHtmlFiles,
6
+ createElement,
7
+ createTextNode,
8
+ createPlugin,
9
+ } from 'coralite/utils'
10
+
11
+ /**
12
+ * @import {CoraliteAnyNode, CoraliteCollectionItem, CoraliteContentNode, CoraliteDocumentRoot} from 'coralite/types'
13
+ * @import {CoraliteAggregate} from '#types'
14
+ */
15
+
16
+ export default createPlugin({
17
+ name: 'aggregation',
18
+ /**
19
+ * Aggregates HTML content from specified paths into a single collection of components.
20
+ *
21
+ * @param {CoraliteAggregate} options - Configuration object defining the aggregation behavior
22
+ *
23
+ * @returns {Promise<CoraliteAnyNode[]>} Array of processed content nodes from aggregated documents
24
+ * @throws {Error} If pages directory path is undefined or aggregate path doesn't exist
25
+ *
26
+ */
27
+ async method (options, context) {
28
+ let templateId
29
+
30
+ // Determine template component ID from configuration
31
+ if (typeof options.template === 'string') {
32
+ templateId = options.template
33
+ } else if (typeof options.template === 'object') {
34
+ templateId = options.template.item
35
+ }
36
+
37
+ if (!templateId) {
38
+ /** @TODO Refer to documentation */
39
+ throw new Error('Aggregate template was undefined')
40
+ }
41
+
42
+ /** @type {CoraliteCollectionItem[]} */
43
+ let pages = []
44
+
45
+ for (let i = 0; i < options.path.length; i++) {
46
+ let path = options.path[i];
47
+ const dirname = join(context.path.pages, path)
48
+
49
+ if (!existsSync(dirname)) {
50
+ /** @TODO Refer to documentation */
51
+ throw new Error('Aggregate path does not exist: "' + dirname + '"')
52
+ }
53
+
54
+ if (path[0] !== '/') {
55
+ path = '/' + path
56
+ }
57
+
58
+ const cachePages = this.pages.getListByPath(path)
59
+
60
+ if (!cachePages || !cachePages.length) {
61
+ // Retrieve HTML pages from specified path
62
+ const collection = await getHtmlFiles({
63
+ type: 'page',
64
+ path: dirname
65
+ })
66
+
67
+ if (!collection.list.length) {
68
+ throw new Error('Aggregation found no documents in "' + dirname + '"')
69
+ }
70
+
71
+ pages = pages.concat(collection.list)
72
+ } else {
73
+ pages = pages.concat(cachePages)
74
+ }
75
+ }
76
+
77
+ let result = []
78
+ let startIndex = 0
79
+ let endIndex = pages.length
80
+ let paginationOffset = context.values.pagination_offset
81
+
82
+ // Sort results based on custom sort function
83
+ if (typeof options.sort === 'function') {
84
+ pages.sort((a, b) => {
85
+ const metaA = a.result.meta
86
+ const metaB = b.result.meta
87
+
88
+ return options.sort(metaA, metaB)
89
+ })
90
+ }
91
+
92
+ if (typeof options.filter === 'function') {
93
+ const filteredPages = []
94
+
95
+ for (let i = 0; i < pages.length; i++) {
96
+ const page = pages[i]
97
+ const metadata = page.result.values
98
+ let keepItem = false
99
+
100
+ // Process metadata and populate token values for rendering
101
+ for (const key in metadata) {
102
+ if (Object.prototype.hasOwnProperty.call(metadata, key)) {
103
+ const data = metadata[key]
104
+
105
+ if (Array.isArray(data)) {
106
+ for (let i = 0; i < data.length; i++) {
107
+
108
+ if (!keepItem) {
109
+ keepItem = options.filter({ name: key, content: data[i] })
110
+ }
111
+ }
112
+ } else {
113
+ // Handle single metadata item
114
+ if (!keepItem) {
115
+ keepItem = options.filter({
116
+ name: key,
117
+ content: data
118
+ })
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ if (keepItem) {
125
+ filteredPages.push(page)
126
+ }
127
+ }
128
+
129
+ pages = filteredPages
130
+ endIndex = pages.length
131
+ }
132
+
133
+ // Apply page offset
134
+ if (Object.prototype.hasOwnProperty.call(options, 'offset') || paginationOffset != null) {
135
+ let offset = paginationOffset || options.offset
136
+
137
+ if (!Array.isArray(offset)) {
138
+ if (typeof offset === 'string') {
139
+ offset = parseInt(offset)
140
+ }
141
+
142
+ if (offset > endIndex) {
143
+ startIndex = endIndex
144
+ } else {
145
+ startIndex = offset
146
+ }
147
+ }
148
+ }
149
+
150
+ // apply page limit
151
+ let limit
152
+ if (options.limit) {
153
+ limit = options.limit
154
+
155
+ if (typeof limit === 'string') {
156
+ limit = parseInt(limit)
157
+ }
158
+
159
+ const limitOffset = limit + startIndex
160
+
161
+ if (limitOffset < endIndex) {
162
+ endIndex = limitOffset
163
+ }
164
+ }
165
+
166
+ let pageLength = pages.length
167
+ for (let i = startIndex; i < endIndex; i++) {
168
+ let page = pages[i]
169
+
170
+ if (page.path.filename === context.document.path.filename) {
171
+ // skip to next page
172
+ page = pages[++i]
173
+ pageLength--
174
+
175
+ if (!page) {
176
+ // exit loop
177
+ break
178
+ }
179
+ }
180
+
181
+ // render component with current values and add to results
182
+ const component = await this.createComponent({
183
+ id: templateId,
184
+ values: { ...context.values, ...page.result.values },
185
+ document: context.document,
186
+ contextId: context.id + i + templateId
187
+ })
188
+
189
+ if (typeof component === 'object') {
190
+ // concat rendered components
191
+ result = result.concat(component.children)
192
+ }
193
+ }
194
+
195
+ if (options.pagination) {
196
+ const pagination = options.pagination
197
+ const paginationPath = context.values.pagination_path || pagination.path || 'page'
198
+ const paginationLength = Math.floor(pageLength / limit)
199
+ let processed = context.values.pagination_processed
200
+
201
+ if (!processed && paginationLength) {
202
+ const path = context.document.path
203
+ const nameSplit = context.document.path.filename.split('.')
204
+ const length = nameSplit.length - 1
205
+ let name = ''
206
+
207
+ for (let i = 0; i < length; i++) {
208
+ name += nameSplit[i]
209
+ }
210
+
211
+ if (name === 'index') {
212
+ name = ''
213
+ }
214
+
215
+ // @ts-ignore
216
+ let dirname = join(path.dirname, name, paginationPath)
217
+ let pageIndex = path.pathname
218
+ const indexPage = this.pages.getItem(path.pathname)
219
+ const maxVisiblePages = (pagination.visible || paginationLength).toString()
220
+
221
+ // check if we are currently not on a pager page
222
+ if (context.values.pagination_pager_dirname == null) {
223
+ for (let i = 1; i < paginationLength; i++) {
224
+ const currentPageIndex = i + 1
225
+ const filename = currentPageIndex + '.html'
226
+ const pathname = join(dirname, filename)
227
+ const contextId = pathname + context.id.substring(path.pathname.length)
228
+
229
+ // store global values for pagination page
230
+ this.values[contextId] = {
231
+ pagination_path: paginationPath,
232
+ pagination_visible: maxVisiblePages,
233
+ pagination_processed: 'true',
234
+ pagination_offset: (endIndex * i).toString(),
235
+ pagination_index: path.pathname,
236
+ pagination_dirname: dirname,
237
+ pagination_length: paginationLength.toString(),
238
+ pagination_current: currentPageIndex.toString(),
239
+ }
240
+
241
+ // add pagination page to render queue
242
+ this.addRenderQueue({
243
+ values: {
244
+ pagination_pager_dirname: path.dirname,
245
+ pagination_pager_index: pageIndex,
246
+ },
247
+ path: {
248
+ dirname,
249
+ pathname,
250
+ filename
251
+ },
252
+ content: indexPage.content
253
+ })
254
+ }
255
+ } else {
256
+ // @ts-ignore
257
+ dirname = join(context.values.pagination_pager_dirname, paginationPath)
258
+ // @ts-ignore
259
+ pageIndex = context.values.pagination_pager_index
260
+ }
261
+
262
+ // store pagination values for current page
263
+ context.values = {
264
+ ...context.values,
265
+ pagination_path: paginationPath,
266
+ pagination_visible: maxVisiblePages,
267
+ pagination_processed: 'true',
268
+ pagination_offset: endIndex.toString(),
269
+ pagination_index: pageIndex,
270
+ pagination_dirname: dirname,
271
+ pagination_length: paginationLength.toString(),
272
+ pagination_current: '1'
273
+ }
274
+ }
275
+
276
+ const contextId = context.id
277
+ let values = this.values[contextId]
278
+
279
+ if (!values || !processed) {
280
+ values = context.values
281
+ this.values[contextId] = values
282
+ }
283
+
284
+ const templateId = pagination.template || 'coralite-pagination'
285
+
286
+ const component = await this.createComponent({
287
+ id: templateId,
288
+ values,
289
+ document: context.document,
290
+ contextId: contextId + templateId
291
+ })
292
+
293
+ if (typeof component === 'object') {
294
+ result = result.concat(component.children)
295
+ }
296
+ }
297
+
298
+ return result
299
+ },
300
+ templates: [join(import.meta.dirname, 'templates/coralite-pagination.html')]
301
+ })
302
+
303
+ /**
304
+ * Parse HTML content and return a CoraliteDocument object representing the parsed document structure
305
+ *
306
+ * @param {string} string - The HTML content to parse. This should be a valid HTML string input.
307
+ * @param {Object} pagination - Configuration object for pagination templates
308
+ * @param {string} pagination.template - The template tag name used to identify pagination elements
309
+ * @param {Object.<string, string>} pagination.attributes - Additional attributes to merge into the template element
310
+ * @example parsePagination('<pagination-element></pagination-element>', {
311
+ * template: 'pagination-element',
312
+ * attributes: { class: 'custom-pagination' }
313
+ * })
314
+ */
315
+ export function parsePagination (string, meta) {
316
+ // root element reference
317
+ /** @type {CoraliteDocumentRoot} */
318
+ const root = {
319
+ type: 'root',
320
+ children: []
321
+ }
322
+
323
+ // stack to keep track of current element hierarchy
324
+ /** @type {CoraliteContentNode[]} */
325
+ const stack = [root]
326
+ const parser = new Parser({
327
+ onprocessinginstruction (name, data) {
328
+ root.children.push({
329
+ type: 'directive',
330
+ name,
331
+ data
332
+ })
333
+ },
334
+ onopentag (originalName, attributes) {
335
+ const parent = stack[stack.length - 1]
336
+ const element = createElement({
337
+ name: originalName,
338
+ attributes,
339
+ parent
340
+ })
341
+ // push element to stack as it may have children
342
+ stack.push(element)
343
+ },
344
+ ontext (text) {
345
+ const parent = stack[stack.length - 1]
346
+
347
+ createTextNode(text, parent)
348
+ },
349
+ onclosetag () {
350
+ const parent = stack[stack.length - 1]
351
+
352
+ if (parent.type === 'tag' && parent.name === 'head') {
353
+
354
+ }
355
+ // remove current element from stack as we're done with its children
356
+ stack.pop()
357
+ },
358
+ oncomment (data) {
359
+ const parent = stack[stack.length - 1]
360
+
361
+ parent.children.push({
362
+ type: 'comment',
363
+ data,
364
+ parent
365
+ })
366
+ }
367
+ })
368
+
369
+ parser.write(string)
370
+ parser.end()
371
+
372
+ return root
373
+ }
@@ -0,0 +1,123 @@
1
+ <template id="coralite-pagination">
2
+ {{ pagination_list }}
3
+ </template>
4
+
5
+ <script type="module">
6
+ import { defineComponent, document } from 'coralite'
7
+
8
+ export default defineComponent({
9
+ tokens: {
10
+ /**
11
+ * @param {Object} values
12
+ * @param {string} values.pagination_visible- Max number of visible pages items.
13
+ * @param {string} values.pagination_offset - Number of items to skip from the beginning (offset for pagination)
14
+ * @param {string} values.pagination_index - Base URL path for generating pagination links
15
+ * @param {string} values.pagination_dirname - Directory path component for routing context
16
+ * @param {string} values.pagination_length - Total number of items across all pages
17
+ * @param {string} values.pagination_current - Currently active page number or identifier
18
+ */
19
+ pagination_list (values) {
20
+ const length = parseInt(values.pagination_length)
21
+
22
+ // skip pagination if not needed
23
+ if (!length) {
24
+ return ''
25
+ }
26
+
27
+ const maxVisible = parseInt(values.pagination_visible)
28
+ const currentPage = parseInt(values.pagination_current)
29
+ const dirname = values.pagination_dirname[0] === '/' ? values.pagination_dirname : '/' + values.pagination_dirname
30
+ const indexPathname = values.pagination_index[0] === '/' ? values.pagination_index : '/' + values.pagination_index
31
+ const pages = []
32
+
33
+ if (maxVisible >= length) {
34
+ // show all pages when max visible is greater than or equal to total page count
35
+ for (let i = 1; i <= length; i++) {
36
+ pages.push(i)
37
+ }
38
+ } else {
39
+ // calculate start and end of visible range based on current page position
40
+ let start = Math.max(1, currentPage - Math.floor(maxVisible / 2));
41
+ let end = Math.min(length, start + maxVisible - 1);
42
+
43
+ if (start > 1) {
44
+ pages.push(1)
45
+
46
+ if (start > 2) {
47
+ // add ellipsis to indicate omitted pages before visible range
48
+ pages.push('...')
49
+ }
50
+ }
51
+
52
+ for (let i = start; i <= end; i++) {
53
+ pages.push(i)
54
+ }
55
+
56
+ if (end < length) {
57
+ if (length - end > 1) {
58
+ // add ellipsis to indicate omitted pages after visible range
59
+ pages.push('...')
60
+ }
61
+
62
+ pages.push(length)
63
+ }
64
+ }
65
+
66
+ let attributes = 'class="page-link"'
67
+
68
+ // highlight current page if it matches the document path
69
+ if (values.pagination_index === document.path.pathname) {
70
+ attributes = 'class="page-link active" aria-current="page"'
71
+ }
72
+
73
+ // add first page link
74
+ let items = `<li class="page-item"><a ${attributes} href="${indexPathname}">1</a></li>`
75
+
76
+ // generate page items
77
+ for (let i = 1; i < pages.length; i++) {
78
+ const pageNum = pages[i]
79
+
80
+ // check if current item an ellipsis
81
+ if (typeof pageNum === 'string') {
82
+ items += `<li class="page-item disabled"><span class="page-link">${pageNum}</span></li>`
83
+ } else {
84
+ // determine if this page number matches the current page
85
+ const isCurrent = currentPage === pageNum
86
+
87
+ let attributes = 'class="page-link"'
88
+
89
+ if (isCurrent) {
90
+ // add active state for current page
91
+ attributes = 'class="page-link active" aria-current="page"'
92
+ }
93
+
94
+ // construct link with dynamic page number and base directory path
95
+ items += `<li class="page-item"><a ${attributes} href="${dirname}/${pageNum}.html">${pageNum}</a></li>`
96
+ }
97
+ }
98
+
99
+ let nextItem = '<li class="page-item disabled"><span class="page-link">Next</span></li>'
100
+ let prevItem = '<li class="page-item disabled"><span class="page-link">Previous</span></li>'
101
+
102
+
103
+ // initialize previous/next items as disabled links
104
+ if (currentPage > 1) {
105
+ let href = `${dirname}/${currentPage - 1}.html`
106
+
107
+ if (currentPage === 2) {
108
+ // set index path
109
+ href = indexPathname
110
+ }
111
+
112
+ prevItem = `<li class="page-item"><a class="page-link" href="${href}">Previous</a></li>`;
113
+ }
114
+
115
+ if (currentPage < length) {
116
+ nextItem = `<li class="page-item"><a class="page-link" href="${dirname}/${currentPage + 1}.html">Next</a></li>`;
117
+ }
118
+
119
+ return '<ul class="pagination">' + prevItem + items + nextItem + '</ul>'
120
+ }
121
+ }
122
+ })
123
+ </script>
package/types/index.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @import {CoraliteToken, CoraliteTokenOptions, CoraliteDocument } from 'coralite/types'
3
+ */
4
+
5
+ /**
6
+ * Configuration for templates used to render aggregated results.
7
+ * @typedef {Object} CoraliteAggregateTemplate - Templates used to display the result
8
+ * @property {string} item - Unique identifier for the component used for each document
9
+ */
10
+
11
+ /**
12
+ * Callback function for filtering aggregated content based on metadata.
13
+ * @callback CoraliteAggregateFilter
14
+ * @param {CoraliteToken} metadata - Aggregated HTML page metadata
15
+ */
16
+
17
+ /**
18
+ * Callback function for sorting aggregated results based on metadata.
19
+ * @callback CoraliteAggregateSort
20
+ * @param {Object.<string, (string | CoraliteToken[])>} a - Aggregated HTML page metadata
21
+ * @param {Object.<string, (string | CoraliteToken[])>} b - Aggregated HTML page metadata
22
+ */
23
+
24
+ /**
25
+ * Configuration object for content aggregation processes.
26
+ * @typedef {Object} CoraliteAggregate – Configuration object for the aggregation process
27
+ * @property {string[]} path - The path to aggregate, relative to pages directory
28
+ * @property {CoraliteAggregateTemplate | string} template - Templates used to display the result
29
+ * @property {Object} [pagination]
30
+ * @property {CoraliteAggregateTemplate | string} pagination.template - Pagination template ID
31
+ * @property {string} pagination.path - Pagination page infix (e.g. 'page' will result in 'page/1')
32
+ * @property {number} pagination.visible - Maximum visible number of pages.
33
+ * @property {CoraliteAggregateFilter} [filter] - Callback to filter out unwanted elements from the aggregated content.
34
+ * @property {boolean} [recursive] - Whether to recursively search subdirectories
35
+ * @property {CoraliteTokenOptions} [tokens] - Token configuration options
36
+ * @property {CoraliteAggregateSort} [sort] - Sort aggregated pages
37
+ * @property {number} [limit] - Specifies the maximum number of results to retrieve.
38
+ * @property {number} [offset] - Specifies the starting index for the results list.
39
+ */