@terrymooreii/sia 1.0.1 → 2.0.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.
Files changed (62) hide show
  1. package/_config.yml +33 -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 +260 -19
  20. package/lib/collections.js +191 -0
  21. package/lib/config.js +114 -0
  22. package/lib/content.js +323 -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 +249 -0
  28. package/package.json +30 -15
  29. package/readme.md +216 -52
  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/.github/workflows/main.yml +0 -33
  37. package/.prettierignore +0 -3
  38. package/.prettierrc +0 -8
  39. package/lib/helpers.js +0 -37
  40. package/lib/markdown.js +0 -33
  41. package/lib/parse.js +0 -94
  42. package/lib/readconfig.js +0 -16
  43. package/lib/rss.js +0 -63
  44. package/templates/siarc-template.js +0 -53
  45. package/templates/src/_partials/_footer.njk +0 -1
  46. package/templates/src/_partials/_head.njk +0 -35
  47. package/templates/src/_partials/_header.njk +0 -1
  48. package/templates/src/_partials/_layout.njk +0 -12
  49. package/templates/src/_partials/_nav.njk +0 -12
  50. package/templates/src/_partials/page.njk +0 -5
  51. package/templates/src/_partials/post.njk +0 -13
  52. package/templates/src/_partials/posts.njk +0 -19
  53. package/templates/src/assets/android-chrome-192x192.png +0 -0
  54. package/templates/src/assets/android-chrome-512x512.png +0 -0
  55. package/templates/src/assets/apple-touch-icon.png +0 -0
  56. package/templates/src/assets/favicon-16x16.png +0 -0
  57. package/templates/src/assets/favicon-32x32.png +0 -0
  58. package/templates/src/assets/favicon.ico +0 -0
  59. package/templates/src/assets/site.webmanifest +0 -19
  60. package/templates/src/content/index.md +0 -7
  61. package/templates/src/css/markdown.css +0 -1210
  62. 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,261 @@
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
+
46
+ // Get all posts for the main blog listing
47
+ const posts = siteData.collections.posts || [];
48
+ const pages = paginate(posts, siteData.config.pagination.size);
49
+
50
+ for (const page of pages) {
51
+ const pagination = getPaginationUrls(baseUrl, page);
52
+
53
+ const html = renderTemplate(env, templateName, {
54
+ ...siteData,
55
+ pagination,
56
+ posts: page.items,
57
+ ...extraData
58
+ });
59
+
60
+ const outputPath = page.pageNumber === 1
61
+ ? join(outputBase, 'index.html')
62
+ : join(outputBase, 'page', String(page.pageNumber), 'index.html');
63
+
64
+ writeFile(outputPath, html);
65
+ }
66
+
67
+ return pages.length;
68
+ }
69
+
70
+ /**
71
+ * Render tag pages
72
+ */
73
+ function renderTagPages(env, siteData) {
74
+ const { tags, allTags, config } = siteData;
75
+
76
+ // Render main tags listing page
77
+ const tagsHtml = renderTemplate(env, 'tags.njk', {
78
+ ...siteData,
79
+ title: 'Tags'
80
+ });
81
+ writeFile(join(config.outputDir, 'tags', 'index.html'), tagsHtml);
82
+
83
+ // Render individual tag pages with pagination
84
+ for (const [tagSlug, tagData] of Object.entries(tags)) {
85
+ const tagPages = paginate(tagData.items, config.pagination.size);
86
+ const baseUrl = `/tags/${tagSlug}/`;
87
+
88
+ for (const page of tagPages) {
89
+ const pagination = getPaginationUrls(baseUrl, page);
90
+
91
+ const html = renderTemplate(env, 'tag.njk', {
92
+ ...siteData,
93
+ tag: tagData,
94
+ pagination,
95
+ posts: page.items,
96
+ title: `Tagged: ${tagData.name}`
97
+ });
98
+
99
+ const outputPath = page.pageNumber === 1
100
+ ? join(config.outputDir, 'tags', tagSlug, 'index.html')
101
+ : join(config.outputDir, 'tags', tagSlug, 'page', String(page.pageNumber), 'index.html');
102
+
103
+ writeFile(outputPath, html);
104
+ }
105
+ }
106
+
107
+ console.log(`🏷️ Generated ${Object.keys(tags).length} tag pages`);
108
+ }
109
+
110
+ /**
111
+ * Render the homepage
112
+ */
113
+ function renderHomepage(env, siteData) {
114
+ const html = renderTemplate(env, 'index.njk', {
115
+ ...siteData,
116
+ title: siteData.site.title
117
+ });
118
+
119
+ writeFile(join(siteData.config.outputDir, 'index.html'), html);
120
+ console.log('🏠 Generated homepage');
121
+ }
122
+
123
+ /**
124
+ * Render the blog listing with pagination
125
+ */
126
+ function renderBlogListing(env, siteData) {
127
+ const { config } = siteData;
128
+ const blogDir = join(config.outputDir, 'blog');
129
+
130
+ const pageCount = renderPaginatedPages(
131
+ env,
132
+ siteData,
133
+ '/blog/',
134
+ blogDir,
135
+ 'blog.njk'
136
+ );
137
+
138
+ console.log(`📝 Generated ${pageCount} blog listing pages`);
139
+ }
140
+
141
+ /**
142
+ * Render notes listing
143
+ */
144
+ function renderNotesListing(env, siteData) {
145
+ const { config, collections } = siteData;
146
+ const notes = collections.notes || [];
147
+
148
+ if (notes.length === 0) return;
149
+
150
+ const notesDir = join(config.outputDir, 'notes');
151
+ const pages = paginate(notes, config.pagination.size);
152
+
153
+ for (const page of pages) {
154
+ const pagination = getPaginationUrls('/notes/', page);
155
+
156
+ const html = renderTemplate(env, 'notes.njk', {
157
+ ...siteData,
158
+ pagination,
159
+ notes: page.items,
160
+ title: 'Notes'
161
+ });
162
+
163
+ const outputPath = page.pageNumber === 1
164
+ ? join(notesDir, 'index.html')
165
+ : join(notesDir, 'page', String(page.pageNumber), 'index.html');
166
+
167
+ writeFile(outputPath, html);
168
+ }
169
+
170
+ console.log(`📋 Generated ${pages.length} notes listing pages`);
20
171
  }
172
+
173
+ /**
174
+ * Generate RSS feed
175
+ */
176
+ function renderRSSFeed(env, siteData) {
177
+ const { config, collections } = siteData;
178
+ const posts = collections.posts || [];
179
+
180
+ const rss = renderTemplate(env, 'feed.njk', {
181
+ ...siteData,
182
+ posts,
183
+ buildDate: new Date().toUTCString()
184
+ });
185
+
186
+ writeFile(join(config.outputDir, 'feed.xml'), rss);
187
+ console.log('📡 Generated RSS feed');
188
+ }
189
+
190
+ /**
191
+ * Main build function
192
+ */
193
+ export async function build(options = {}) {
194
+ const startTime = Date.now();
195
+
196
+ console.log('\n⚡ Sia - Building site...\n');
197
+
198
+ // Load configuration
199
+ const config = loadConfig(options.rootDir || process.cwd());
200
+
201
+ // Clean output directory if requested
202
+ if (options.clean !== false) {
203
+ cleanOutput(config);
204
+ }
205
+
206
+ // Build site data (collections, tags, etc.)
207
+ const siteData = buildSiteData(config);
208
+
209
+ // Create template engine
210
+ const env = createTemplateEngine(config);
211
+
212
+ // Render all content items
213
+ let itemCount = 0;
214
+
215
+ for (const [collectionName, items] of Object.entries(siteData.collections)) {
216
+ for (const item of items) {
217
+ renderContentItem(env, item, siteData);
218
+ itemCount++;
219
+ }
220
+ }
221
+
222
+ console.log(`📄 Generated ${itemCount} content pages`);
223
+
224
+ // Render listing pages
225
+ renderHomepage(env, siteData);
226
+ renderBlogListing(env, siteData);
227
+ renderNotesListing(env, siteData);
228
+ renderTagPages(env, siteData);
229
+
230
+ // Generate RSS feed
231
+ renderRSSFeed(env, siteData);
232
+
233
+ // Copy assets
234
+ copyImages(config);
235
+ copyDefaultStyles(config, defaultsDir);
236
+ copyStaticAssets(config);
237
+
238
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
239
+ console.log(`\n✅ Build complete in ${duration}s`);
240
+ console.log(`📁 Output: ${config.outputDir}\n`);
241
+
242
+ return { config, siteData };
243
+ }
244
+
245
+ /**
246
+ * Build command handler for CLI
247
+ */
248
+ export async function buildCommand(options) {
249
+ try {
250
+ await build({
251
+ clean: options.clean !== false,
252
+ rootDir: process.cwd()
253
+ });
254
+ } catch (err) {
255
+ console.error('❌ Build failed:', err.message);
256
+ process.exit(1);
257
+ }
258
+ }
259
+
260
+ export default { build, buildCommand };
261
+
@@ -0,0 +1,191 @@
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
+ */
83
+ export function getPaginationUrls(baseUrl, pagination) {
84
+ return {
85
+ ...pagination,
86
+ url: pagination.pageNumber === 1
87
+ ? baseUrl
88
+ : `${baseUrl}page/${pagination.pageNumber}/`,
89
+ previousUrl: pagination.previousPage
90
+ ? (pagination.previousPage === 1 ? baseUrl : `${baseUrl}page/${pagination.previousPage}/`)
91
+ : null,
92
+ nextUrl: pagination.nextPage
93
+ ? `${baseUrl}page/${pagination.nextPage}/`
94
+ : null
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Build the complete site data structure
100
+ */
101
+ export function buildSiteData(config) {
102
+ // Load all content collections
103
+ const collections = loadAllCollections(config);
104
+
105
+ // Build tag collections
106
+ const tagCollections = buildTagCollections(collections);
107
+ const allTags = getAllTags(tagCollections);
108
+
109
+ console.log(`🏷️ Found ${allTags.length} unique tags`);
110
+
111
+ // Create paginated collections for listings
112
+ const paginatedCollections = {};
113
+
114
+ for (const [name, items] of Object.entries(collections)) {
115
+ paginatedCollections[name] = paginate(items, config.pagination.size);
116
+ }
117
+
118
+ // Create paginated tag pages
119
+ const paginatedTags = {};
120
+
121
+ for (const [tagSlug, tagData] of Object.entries(tagCollections)) {
122
+ paginatedTags[tagSlug] = paginate(tagData.items, config.pagination.size).map(page => ({
123
+ ...page,
124
+ tag: tagData
125
+ }));
126
+ }
127
+
128
+ return {
129
+ config,
130
+ site: config.site,
131
+ collections,
132
+ paginatedCollections,
133
+ tags: tagCollections,
134
+ allTags,
135
+ paginatedTags
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Get recent items across all collections
141
+ */
142
+ export function getRecentItems(collections, limit = 10) {
143
+ const allItems = [];
144
+
145
+ for (const items of Object.values(collections)) {
146
+ allItems.push(...items);
147
+ }
148
+
149
+ return allItems
150
+ .sort((a, b) => new Date(b.date) - new Date(a.date))
151
+ .slice(0, limit);
152
+ }
153
+
154
+ /**
155
+ * Get related items based on shared tags
156
+ */
157
+ export function getRelatedItems(item, collections, limit = 5) {
158
+ const related = [];
159
+ const itemTags = new Set(item.tags.map(t => t.toLowerCase()));
160
+
161
+ for (const items of Object.values(collections)) {
162
+ for (const candidate of items) {
163
+ if (candidate.slug === item.slug) continue;
164
+
165
+ const candidateTags = new Set(candidate.tags.map(t => t.toLowerCase()));
166
+ const sharedTags = [...itemTags].filter(t => candidateTags.has(t));
167
+
168
+ if (sharedTags.length > 0) {
169
+ related.push({
170
+ ...candidate,
171
+ sharedTags: sharedTags.length
172
+ });
173
+ }
174
+ }
175
+ }
176
+
177
+ return related
178
+ .sort((a, b) => b.sharedTags - a.sharedTags)
179
+ .slice(0, limit);
180
+ }
181
+
182
+ export default {
183
+ buildTagCollections,
184
+ getAllTags,
185
+ paginate,
186
+ getPaginationUrls,
187
+ buildSiteData,
188
+ getRecentItems,
189
+ getRelatedItems
190
+ };
191
+