@terrymooreii/sia 1.0.2 → 2.0.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.
Files changed (61) hide show
  1. package/_config.yml +32 -0
  2. package/bin/cli.js +51 -0
  3. package/defaults/includes/footer.njk +14 -0
  4. package/defaults/includes/header.njk +71 -0
  5. package/defaults/includes/pagination.njk +26 -0
  6. package/defaults/includes/tag-list.njk +11 -0
  7. package/defaults/layouts/base.njk +41 -0
  8. package/defaults/layouts/note.njk +25 -0
  9. package/defaults/layouts/page.njk +14 -0
  10. package/defaults/layouts/post.njk +43 -0
  11. package/defaults/pages/blog.njk +36 -0
  12. package/defaults/pages/feed.njk +28 -0
  13. package/defaults/pages/index.njk +60 -0
  14. package/defaults/pages/notes.njk +34 -0
  15. package/defaults/pages/tag.njk +41 -0
  16. package/defaults/pages/tags.njk +39 -0
  17. package/defaults/styles/main.css +1074 -0
  18. package/lib/assets.js +234 -0
  19. package/lib/build.js +263 -19
  20. package/lib/collections.js +195 -0
  21. package/lib/config.js +122 -0
  22. package/lib/content.js +325 -0
  23. package/lib/index.js +53 -18
  24. package/lib/init.js +555 -6
  25. package/lib/new.js +379 -41
  26. package/lib/server.js +257 -0
  27. package/lib/templates.js +268 -0
  28. package/package.json +30 -15
  29. package/readme.md +212 -63
  30. package/src/images/.gitkeep +3 -0
  31. package/src/notes/2024-12-17-first-note.md +6 -0
  32. package/src/pages/about.md +29 -0
  33. package/src/posts/2024-12-16-markdown-features.md +76 -0
  34. package/src/posts/2024-12-17-welcome-to-sia.md +78 -0
  35. package/src/posts/2024-12-17-welcome-to-static-forge.md +78 -0
  36. package/.prettierignore +0 -3
  37. package/.prettierrc +0 -8
  38. package/lib/helpers.js +0 -37
  39. package/lib/markdown.js +0 -33
  40. package/lib/parse.js +0 -100
  41. package/lib/readconfig.js +0 -18
  42. package/lib/rss.js +0 -63
  43. package/templates/siarc-template.js +0 -53
  44. package/templates/src/_partials/_footer.njk +0 -1
  45. package/templates/src/_partials/_head.njk +0 -35
  46. package/templates/src/_partials/_header.njk +0 -1
  47. package/templates/src/_partials/_layout.njk +0 -12
  48. package/templates/src/_partials/_nav.njk +0 -12
  49. package/templates/src/_partials/page.njk +0 -5
  50. package/templates/src/_partials/post.njk +0 -13
  51. package/templates/src/_partials/posts.njk +0 -19
  52. package/templates/src/assets/android-chrome-192x192.png +0 -0
  53. package/templates/src/assets/android-chrome-512x512.png +0 -0
  54. package/templates/src/assets/apple-touch-icon.png +0 -0
  55. package/templates/src/assets/favicon-16x16.png +0 -0
  56. package/templates/src/assets/favicon-32x32.png +0 -0
  57. package/templates/src/assets/favicon.ico +0 -0
  58. package/templates/src/assets/site.webmanifest +0 -19
  59. package/templates/src/content/index.md +0 -7
  60. package/templates/src/css/markdown.css +0 -1210
  61. package/templates/src/css/theme.css +0 -120
package/lib/assets.js ADDED
@@ -0,0 +1,234 @@
1
+ import {
2
+ readdirSync,
3
+ statSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ copyFileSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ unlinkSync,
10
+ rmdirSync
11
+ } from 'fs';
12
+ import { join, dirname, relative, extname } from 'path';
13
+
14
+ // Supported asset extensions
15
+ const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif'];
16
+ const STATIC_EXTENSIONS = ['.css', '.js', '.json', '.xml', '.txt', '.pdf', '.woff', '.woff2', '.ttf', '.eot'];
17
+ const ALL_ASSET_EXTENSIONS = [...IMAGE_EXTENSIONS, ...STATIC_EXTENSIONS];
18
+
19
+ /**
20
+ * Check if a file is an asset
21
+ */
22
+ export function isAsset(filePath) {
23
+ const ext = extname(filePath).toLowerCase();
24
+ return ALL_ASSET_EXTENSIONS.includes(ext);
25
+ }
26
+
27
+ /**
28
+ * Check if a file is an image
29
+ */
30
+ export function isImage(filePath) {
31
+ const ext = extname(filePath).toLowerCase();
32
+ return IMAGE_EXTENSIONS.includes(ext);
33
+ }
34
+
35
+ /**
36
+ * Recursively get all files in a directory
37
+ */
38
+ function getAllFiles(dir, fileList = []) {
39
+ if (!existsSync(dir)) {
40
+ return fileList;
41
+ }
42
+
43
+ const items = readdirSync(dir);
44
+
45
+ for (const item of items) {
46
+ const fullPath = join(dir, item);
47
+ const stat = statSync(fullPath);
48
+
49
+ if (stat.isDirectory()) {
50
+ getAllFiles(fullPath, fileList);
51
+ } else {
52
+ fileList.push(fullPath);
53
+ }
54
+ }
55
+
56
+ return fileList;
57
+ }
58
+
59
+ /**
60
+ * Ensure a directory exists
61
+ */
62
+ export function ensureDir(dirPath) {
63
+ if (!existsSync(dirPath)) {
64
+ mkdirSync(dirPath, { recursive: true });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Copy a single file
70
+ */
71
+ export function copyFile(src, dest) {
72
+ ensureDir(dirname(dest));
73
+ copyFileSync(src, dest);
74
+ }
75
+
76
+ /**
77
+ * Copy all assets from source to destination
78
+ */
79
+ export function copyAssets(srcDir, destDir) {
80
+ const copied = [];
81
+
82
+ if (!existsSync(srcDir)) {
83
+ return copied;
84
+ }
85
+
86
+ const files = getAllFiles(srcDir);
87
+
88
+ for (const file of files) {
89
+ if (isAsset(file)) {
90
+ const relativePath = relative(srcDir, file);
91
+ const destPath = join(destDir, relativePath);
92
+
93
+ copyFile(file, destPath);
94
+ copied.push(relativePath);
95
+ }
96
+ }
97
+
98
+ return copied;
99
+ }
100
+
101
+ /**
102
+ * Copy images from the images folder
103
+ */
104
+ export function copyImages(config) {
105
+ const imagesDir = join(config.inputDir, 'images');
106
+ const outputImagesDir = join(config.outputDir, 'images');
107
+
108
+ const copied = copyAssets(imagesDir, outputImagesDir);
109
+
110
+ if (copied.length > 0) {
111
+ console.log(`🖼️ Copied ${copied.length} images`);
112
+ }
113
+
114
+ return copied;
115
+ }
116
+
117
+ /**
118
+ * Check if directory has CSS files
119
+ */
120
+ function hasCssFiles(dir) {
121
+ if (!existsSync(dir)) return false;
122
+
123
+ const files = getAllFiles(dir);
124
+ return files.some(f => extname(f).toLowerCase() === '.css');
125
+ }
126
+
127
+ /**
128
+ * Copy default styles to output
129
+ */
130
+ export function copyDefaultStyles(config, defaultsDir) {
131
+ const defaultStylesDir = join(defaultsDir, 'styles');
132
+ const outputStylesDir = join(config.outputDir, 'styles');
133
+
134
+ // Check if user has custom styles (must actually have CSS files)
135
+ const userStylesDir = join(config.rootDir, 'styles');
136
+
137
+ if (hasCssFiles(userStylesDir)) {
138
+ // Copy user styles
139
+ const copied = copyAssets(userStylesDir, outputStylesDir);
140
+ console.log(`🎨 Copied ${copied.length} custom style files`);
141
+ return copied;
142
+ }
143
+
144
+ // Copy default styles from the package
145
+ if (existsSync(defaultStylesDir)) {
146
+ const copied = copyAssets(defaultStylesDir, outputStylesDir);
147
+ console.log(`🎨 Copied ${copied.length} default style files`);
148
+ return copied;
149
+ }
150
+
151
+ console.log('⚠️ No styles found');
152
+ return [];
153
+ }
154
+
155
+ /**
156
+ * Copy all static assets (non-image files from root)
157
+ */
158
+ export function copyStaticAssets(config) {
159
+ const staticDirs = ['assets', 'static', 'public'];
160
+ let totalCopied = 0;
161
+
162
+ for (const dirName of staticDirs) {
163
+ const srcDir = join(config.rootDir, dirName);
164
+
165
+ if (existsSync(srcDir)) {
166
+ const destDir = join(config.outputDir, dirName);
167
+ const copied = copyAssets(srcDir, destDir);
168
+ totalCopied += copied.length;
169
+ }
170
+ }
171
+
172
+ // Also copy favicon if it exists in root
173
+ const faviconSrc = join(config.rootDir, 'favicon.ico');
174
+ if (existsSync(faviconSrc)) {
175
+ copyFile(faviconSrc, join(config.outputDir, 'favicon.ico'));
176
+ totalCopied++;
177
+ }
178
+
179
+ if (totalCopied > 0) {
180
+ console.log(`📁 Copied ${totalCopied} static assets`);
181
+ }
182
+
183
+ return totalCopied;
184
+ }
185
+
186
+ /**
187
+ * Write a file with directory creation
188
+ */
189
+ export function writeFile(filePath, content) {
190
+ ensureDir(dirname(filePath));
191
+ writeFileSync(filePath, content, 'utf-8');
192
+ }
193
+
194
+ /**
195
+ * Clean a directory (remove all contents)
196
+ */
197
+ export function cleanDir(dirPath) {
198
+ if (!existsSync(dirPath)) {
199
+ return;
200
+ }
201
+
202
+ const items = readdirSync(dirPath);
203
+
204
+ for (const item of items) {
205
+ const fullPath = join(dirPath, item);
206
+ const stat = statSync(fullPath);
207
+
208
+ if (stat.isDirectory()) {
209
+ cleanDir(fullPath);
210
+ // Remove empty directory
211
+ try {
212
+ rmdirSync(fullPath);
213
+ } catch (e) {
214
+ // Ignore if directory not empty
215
+ }
216
+ } else {
217
+ unlinkSync(fullPath);
218
+ }
219
+ }
220
+ }
221
+
222
+ export default {
223
+ isAsset,
224
+ isImage,
225
+ ensureDir,
226
+ copyFile,
227
+ copyAssets,
228
+ copyImages,
229
+ copyDefaultStyles,
230
+ copyStaticAssets,
231
+ writeFile,
232
+ cleanDir
233
+ };
234
+
package/lib/build.js CHANGED
@@ -1,20 +1,264 @@
1
- import config from './readconfig.js'
2
- import { parseContent, generateBlogListPage } from './parse.js'
3
- import { mkdir, cpdir } from './helpers.js'
4
- import path from 'path'
5
- import { generateRSS } from './rss.js'
6
- const { app } = config
7
-
8
- export const build = () => {
9
- mkdir(path.join(app.public))
10
-
11
- const posts = parseContent()
12
- generateBlogListPage(posts)
13
- generateRSS(posts, app.feed.count)
14
-
15
- cpdir(path.join(app.src, app.css), path.join(app.public, app.css))
16
- cpdir(path.join(app.src, app.js), path.join(app.public, app.js))
17
- cpdir(path.join(app.src, app.images), path.join(app.public, app.images))
18
- cpdir(path.join(app.src, app.assets), path.join(app.public, app.assets))
19
- console.log('Build success')
1
+ import { existsSync, mkdirSync, rmSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { loadConfig } from './config.js';
5
+ import { buildSiteData, paginate, getPaginationUrls } from './collections.js';
6
+ import { createTemplateEngine, renderTemplate } from './templates.js';
7
+ import { copyImages, copyDefaultStyles, copyStaticAssets, writeFile, ensureDir } from './assets.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const defaultsDir = join(__dirname, '..', 'defaults');
12
+
13
+ /**
14
+ * Clean the output directory
15
+ */
16
+ function cleanOutput(config) {
17
+ if (existsSync(config.outputDir)) {
18
+ rmSync(config.outputDir, { recursive: true, force: true });
19
+ }
20
+ mkdirSync(config.outputDir, { recursive: true });
21
+ console.log('🧹 Cleaned output directory');
22
+ }
23
+
24
+ /**
25
+ * Render and write a single content item
26
+ */
27
+ function renderContentItem(env, item, siteData) {
28
+ const templateName = `${item.layout}.njk`;
29
+
30
+ const html = renderTemplate(env, templateName, {
31
+ ...siteData,
32
+ page: item,
33
+ content: item.content,
34
+ title: item.title
35
+ });
36
+
37
+ writeFile(item.outputPath, html);
38
+ }
39
+
40
+ /**
41
+ * Render paginated listing pages
42
+ */
43
+ function renderPaginatedPages(env, siteData, baseUrl, outputBase, templateName, extraData = {}) {
44
+ const { paginatedCollections } = siteData;
45
+ const basePath = siteData.config.site.basePath || '';
46
+
47
+ // Get all posts for the main blog listing
48
+ const posts = siteData.collections.posts || [];
49
+ const pages = paginate(posts, siteData.config.pagination.size);
50
+
51
+ for (const page of pages) {
52
+ const pagination = getPaginationUrls(baseUrl, page, basePath);
53
+
54
+ const html = renderTemplate(env, templateName, {
55
+ ...siteData,
56
+ pagination,
57
+ posts: page.items,
58
+ ...extraData
59
+ });
60
+
61
+ const outputPath = page.pageNumber === 1
62
+ ? join(outputBase, 'index.html')
63
+ : join(outputBase, 'page', String(page.pageNumber), 'index.html');
64
+
65
+ writeFile(outputPath, html);
66
+ }
67
+
68
+ return pages.length;
69
+ }
70
+
71
+ /**
72
+ * Render tag pages
73
+ */
74
+ function renderTagPages(env, siteData) {
75
+ const { tags, allTags, config } = siteData;
76
+ const basePath = config.site.basePath || '';
77
+
78
+ // Render main tags listing page
79
+ const tagsHtml = renderTemplate(env, 'tags.njk', {
80
+ ...siteData,
81
+ title: 'Tags'
82
+ });
83
+ writeFile(join(config.outputDir, 'tags', 'index.html'), tagsHtml);
84
+
85
+ // Render individual tag pages with pagination
86
+ for (const [tagSlug, tagData] of Object.entries(tags)) {
87
+ const tagPages = paginate(tagData.items, config.pagination.size);
88
+ const baseUrl = `/tags/${tagSlug}/`;
89
+
90
+ for (const page of tagPages) {
91
+ const pagination = getPaginationUrls(baseUrl, page, basePath);
92
+
93
+ const html = renderTemplate(env, 'tag.njk', {
94
+ ...siteData,
95
+ tag: tagData,
96
+ pagination,
97
+ posts: page.items,
98
+ title: `Tagged: ${tagData.name}`
99
+ });
100
+
101
+ const outputPath = page.pageNumber === 1
102
+ ? join(config.outputDir, 'tags', tagSlug, 'index.html')
103
+ : join(config.outputDir, 'tags', tagSlug, 'page', String(page.pageNumber), 'index.html');
104
+
105
+ writeFile(outputPath, html);
106
+ }
107
+ }
108
+
109
+ console.log(`🏷️ Generated ${Object.keys(tags).length} tag pages`);
110
+ }
111
+
112
+ /**
113
+ * Render the homepage
114
+ */
115
+ function renderHomepage(env, siteData) {
116
+ const html = renderTemplate(env, 'index.njk', {
117
+ ...siteData,
118
+ title: siteData.site.title
119
+ });
120
+
121
+ writeFile(join(siteData.config.outputDir, 'index.html'), html);
122
+ console.log('🏠 Generated homepage');
123
+ }
124
+
125
+ /**
126
+ * Render the blog listing with pagination
127
+ */
128
+ function renderBlogListing(env, siteData) {
129
+ const { config } = siteData;
130
+ const blogDir = join(config.outputDir, 'blog');
131
+
132
+ const pageCount = renderPaginatedPages(
133
+ env,
134
+ siteData,
135
+ '/blog/',
136
+ blogDir,
137
+ 'blog.njk'
138
+ );
139
+
140
+ console.log(`📝 Generated ${pageCount} blog listing pages`);
141
+ }
142
+
143
+ /**
144
+ * Render notes listing
145
+ */
146
+ function renderNotesListing(env, siteData) {
147
+ const { config, collections } = siteData;
148
+ const notes = collections.notes || [];
149
+ const basePath = config.site.basePath || '';
150
+
151
+ if (notes.length === 0) return;
152
+
153
+ const notesDir = join(config.outputDir, 'notes');
154
+ const pages = paginate(notes, config.pagination.size);
155
+
156
+ for (const page of pages) {
157
+ const pagination = getPaginationUrls('/notes/', page, basePath);
158
+
159
+ const html = renderTemplate(env, 'notes.njk', {
160
+ ...siteData,
161
+ pagination,
162
+ notes: page.items,
163
+ title: 'Notes'
164
+ });
165
+
166
+ const outputPath = page.pageNumber === 1
167
+ ? join(notesDir, 'index.html')
168
+ : join(notesDir, 'page', String(page.pageNumber), 'index.html');
169
+
170
+ writeFile(outputPath, html);
171
+ }
172
+
173
+ console.log(`📋 Generated ${pages.length} notes listing pages`);
20
174
  }
175
+
176
+ /**
177
+ * Generate RSS feed
178
+ */
179
+ function renderRSSFeed(env, siteData) {
180
+ const { config, collections } = siteData;
181
+ const posts = collections.posts || [];
182
+
183
+ const rss = renderTemplate(env, 'feed.njk', {
184
+ ...siteData,
185
+ posts,
186
+ buildDate: new Date().toUTCString()
187
+ });
188
+
189
+ writeFile(join(config.outputDir, 'feed.xml'), rss);
190
+ console.log('📡 Generated RSS feed');
191
+ }
192
+
193
+ /**
194
+ * Main build function
195
+ */
196
+ export async function build(options = {}) {
197
+ const startTime = Date.now();
198
+
199
+ console.log('\n⚡ Sia - Building site...\n');
200
+
201
+ // Load configuration
202
+ const config = loadConfig(options.rootDir || process.cwd());
203
+
204
+ // Clean output directory if requested
205
+ if (options.clean !== false) {
206
+ cleanOutput(config);
207
+ }
208
+
209
+ // Build site data (collections, tags, etc.)
210
+ const siteData = buildSiteData(config);
211
+
212
+ // Create template engine
213
+ const env = createTemplateEngine(config);
214
+
215
+ // Render all content items
216
+ let itemCount = 0;
217
+
218
+ for (const [collectionName, items] of Object.entries(siteData.collections)) {
219
+ for (const item of items) {
220
+ renderContentItem(env, item, siteData);
221
+ itemCount++;
222
+ }
223
+ }
224
+
225
+ console.log(`📄 Generated ${itemCount} content pages`);
226
+
227
+ // Render listing pages
228
+ renderHomepage(env, siteData);
229
+ renderBlogListing(env, siteData);
230
+ renderNotesListing(env, siteData);
231
+ renderTagPages(env, siteData);
232
+
233
+ // Generate RSS feed
234
+ renderRSSFeed(env, siteData);
235
+
236
+ // Copy assets
237
+ copyImages(config);
238
+ copyDefaultStyles(config, defaultsDir);
239
+ copyStaticAssets(config);
240
+
241
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
242
+ console.log(`\n✅ Build complete in ${duration}s`);
243
+ console.log(`📁 Output: ${config.outputDir}\n`);
244
+
245
+ return { config, siteData };
246
+ }
247
+
248
+ /**
249
+ * Build command handler for CLI
250
+ */
251
+ export async function buildCommand(options) {
252
+ try {
253
+ await build({
254
+ clean: options.clean !== false,
255
+ rootDir: process.cwd()
256
+ });
257
+ } catch (err) {
258
+ console.error('❌ Build failed:', err.message);
259
+ process.exit(1);
260
+ }
261
+ }
262
+
263
+ export default { build, buildCommand };
264
+
@@ -0,0 +1,195 @@
1
+ import { loadAllCollections } from './content.js';
2
+
3
+ /**
4
+ * Build tag collections from all content
5
+ */
6
+ export function buildTagCollections(collections) {
7
+ const tags = {};
8
+
9
+ // Iterate through all collections and gather tags
10
+ for (const [collectionName, items] of Object.entries(collections)) {
11
+ for (const item of items) {
12
+ if (item.tags && Array.isArray(item.tags)) {
13
+ for (const tag of item.tags) {
14
+ const normalizedTag = tag.toLowerCase().trim();
15
+
16
+ if (!tags[normalizedTag]) {
17
+ tags[normalizedTag] = {
18
+ name: tag,
19
+ slug: normalizedTag.replace(/\s+/g, '-'),
20
+ items: [],
21
+ count: 0
22
+ };
23
+ }
24
+
25
+ tags[normalizedTag].items.push({
26
+ ...item,
27
+ collection: collectionName
28
+ });
29
+ tags[normalizedTag].count++;
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ // Sort items within each tag by date (newest first)
36
+ for (const tag of Object.values(tags)) {
37
+ tag.items.sort((a, b) => new Date(b.date) - new Date(a.date));
38
+ }
39
+
40
+ return tags;
41
+ }
42
+
43
+ /**
44
+ * Get all unique tags sorted by count
45
+ */
46
+ export function getAllTags(tagCollections) {
47
+ return Object.values(tagCollections)
48
+ .sort((a, b) => b.count - a.count);
49
+ }
50
+
51
+ /**
52
+ * Paginate an array of items
53
+ */
54
+ export function paginate(items, pageSize = 10) {
55
+ const pages = [];
56
+ const totalPages = Math.ceil(items.length / pageSize);
57
+
58
+ for (let i = 0; i < totalPages; i++) {
59
+ const start = i * pageSize;
60
+ const end = start + pageSize;
61
+ const pageItems = items.slice(start, end);
62
+
63
+ pages.push({
64
+ items: pageItems,
65
+ pageNumber: i + 1,
66
+ totalPages,
67
+ totalItems: items.length,
68
+ isFirst: i === 0,
69
+ isLast: i === totalPages - 1,
70
+ previousPage: i > 0 ? i : null,
71
+ nextPage: i < totalPages - 1 ? i + 2 : null,
72
+ startIndex: start,
73
+ endIndex: Math.min(end, items.length)
74
+ });
75
+ }
76
+
77
+ return pages;
78
+ }
79
+
80
+ /**
81
+ * Generate pagination URLs
82
+ * @param {string} baseUrl - The base URL path (without basePath)
83
+ * @param {object} pagination - Pagination object
84
+ * @param {string} basePath - Optional basePath for subpath hosting
85
+ */
86
+ export function getPaginationUrls(baseUrl, pagination, basePath = '') {
87
+ const prefixedBaseUrl = basePath + baseUrl;
88
+ return {
89
+ ...pagination,
90
+ url: pagination.pageNumber === 1
91
+ ? prefixedBaseUrl
92
+ : `${prefixedBaseUrl}page/${pagination.pageNumber}/`,
93
+ previousUrl: pagination.previousPage
94
+ ? (pagination.previousPage === 1 ? prefixedBaseUrl : `${prefixedBaseUrl}page/${pagination.previousPage}/`)
95
+ : null,
96
+ nextUrl: pagination.nextPage
97
+ ? `${prefixedBaseUrl}page/${pagination.nextPage}/`
98
+ : null
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Build the complete site data structure
104
+ */
105
+ export function buildSiteData(config) {
106
+ // Load all content collections
107
+ const collections = loadAllCollections(config);
108
+
109
+ // Build tag collections
110
+ const tagCollections = buildTagCollections(collections);
111
+ const allTags = getAllTags(tagCollections);
112
+
113
+ console.log(`🏷️ Found ${allTags.length} unique tags`);
114
+
115
+ // Create paginated collections for listings
116
+ const paginatedCollections = {};
117
+
118
+ for (const [name, items] of Object.entries(collections)) {
119
+ paginatedCollections[name] = paginate(items, config.pagination.size);
120
+ }
121
+
122
+ // Create paginated tag pages
123
+ const paginatedTags = {};
124
+
125
+ for (const [tagSlug, tagData] of Object.entries(tagCollections)) {
126
+ paginatedTags[tagSlug] = paginate(tagData.items, config.pagination.size).map(page => ({
127
+ ...page,
128
+ tag: tagData
129
+ }));
130
+ }
131
+
132
+ return {
133
+ config,
134
+ site: config.site,
135
+ collections,
136
+ paginatedCollections,
137
+ tags: tagCollections,
138
+ allTags,
139
+ paginatedTags
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Get recent items across all collections
145
+ */
146
+ export function getRecentItems(collections, limit = 10) {
147
+ const allItems = [];
148
+
149
+ for (const items of Object.values(collections)) {
150
+ allItems.push(...items);
151
+ }
152
+
153
+ return allItems
154
+ .sort((a, b) => new Date(b.date) - new Date(a.date))
155
+ .slice(0, limit);
156
+ }
157
+
158
+ /**
159
+ * Get related items based on shared tags
160
+ */
161
+ export function getRelatedItems(item, collections, limit = 5) {
162
+ const related = [];
163
+ const itemTags = new Set(item.tags.map(t => t.toLowerCase()));
164
+
165
+ for (const items of Object.values(collections)) {
166
+ for (const candidate of items) {
167
+ if (candidate.slug === item.slug) continue;
168
+
169
+ const candidateTags = new Set(candidate.tags.map(t => t.toLowerCase()));
170
+ const sharedTags = [...itemTags].filter(t => candidateTags.has(t));
171
+
172
+ if (sharedTags.length > 0) {
173
+ related.push({
174
+ ...candidate,
175
+ sharedTags: sharedTags.length
176
+ });
177
+ }
178
+ }
179
+ }
180
+
181
+ return related
182
+ .sort((a, b) => b.sharedTags - a.sharedTags)
183
+ .slice(0, limit);
184
+ }
185
+
186
+ export default {
187
+ buildTagCollections,
188
+ getAllTags,
189
+ paginate,
190
+ getPaginationUrls,
191
+ buildSiteData,
192
+ getRecentItems,
193
+ getRelatedItems
194
+ };
195
+