muriel 1.0.2 → 1.0.3

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.
@@ -2,9 +2,19 @@
2
2
  import { flow } from '../../index.js';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { readdir, readFile, mkdir, copyFile, writeFile } from 'node:fs/promises';
6
- import sharp from 'sharp';
7
- import { marked } from 'marked';
5
+
6
+ import { setup, processedPosts } from './lib.js';
7
+
8
+ import postScanner from './transforms/post-scanner/index.js';
9
+ import processCover from './transforms/process-cover/index.js';
10
+ import processAudio from './transforms/process-audio/index.js';
11
+ import processText from './transforms/process-text/index.js';
12
+ import copyFiles from './transforms/copy-files/index.js';
13
+ import verifyPost from './transforms/verify-post/index.js';
14
+ import collectPost from './transforms/collect-post/index.js';
15
+ import homepage from './transforms/homepage/index.js';
16
+ import pagerizer from './transforms/pagerizer/index.js';
17
+ import rssFeed from './transforms/rss-feed/index.js';
8
18
 
9
19
  // ─────────────────────────────────────────────
10
20
  // Configuration
@@ -16,768 +26,14 @@ if (!profilePath) {
16
26
  process.exit(1);
17
27
  }
18
28
 
19
- // Load and parse profile
20
29
  const profileFullPath = path.resolve(process.cwd(), profilePath);
21
- const profileRaw = fs.readFileSync(profileFullPath, 'utf-8');
22
- const profile = JSON.parse(profileRaw);
23
-
24
- // Base directory is the parent of 'examples' (project root)
30
+ const profile = JSON.parse(fs.readFileSync(profileFullPath, 'utf-8'));
25
31
  const baseDir = path.resolve(path.dirname(profileFullPath), '..');
26
32
 
27
- function resolvePath(p) {
28
- return path.resolve(baseDir, p.replace('{profile}', profile.profile));
29
- }
30
-
31
- // ─────────────────────────────────────────────
32
- // Producer: Scan posts directory
33
- // ─────────────────────────────────────────────
34
-
35
- function scanner(postsDir) {
36
- return async send => {
37
- const srcDir = resolvePath(postsDir);
38
- console.log(`Scanning: ${srcDir}`);
39
-
40
- const entries = await readdir(srcDir, { withFileTypes: true });
41
- const postDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
42
-
43
- // Filter to valid posts (those with post.json)
44
- const validPostDirs = [];
45
- for (const postId of postDirs) {
46
- const postDir = path.join(srcDir, postId);
47
- const postJsonPath = path.join(postDir, 'post.json');
48
- if (fs.existsSync(postJsonPath)) {
49
- validPostDirs.push({ postId, postDir, postJsonPath });
50
- } else {
51
- console.log(` Skipping ${postId}: no post.json`);
52
- }
53
- }
54
-
55
- const totalPosts = validPostDirs.length;
56
- console.log(` Found ${totalPosts} posts`);
57
-
58
- for (const { postId, postDir, postJsonPath } of validPostDirs) {
59
- const postData = JSON.parse(await readFile(postJsonPath, 'utf-8'));
60
-
61
- send({
62
- postId,
63
- postDir,
64
- postData,
65
- guid: postData.guid,
66
- chapter: postData.chapter,
67
- _totalPosts: totalPosts,
68
- files: {
69
- cover: postData.image ? path.join(postDir, 'cover.jpg') : null,
70
- audio: postData.audio ? path.join(postDir, 'audio.mp3') : null,
71
- text: path.join(postDir, 'text.md'),
72
- filesDir: path.join(postDir, 'files')
73
- }
74
- });
75
- }
76
- };
77
- }
78
-
79
- // ─────────────────────────────────────────────
80
- // Transform: Process cover image → AVIF
81
- // ─────────────────────────────────────────────
82
-
83
- function processCover(config) {
84
- const { width = 1024, height = 1024, quality = 80, effort = 4 } = config;
85
-
86
- return async (send, packet) => {
87
- const { files, guid, postId } = packet;
88
-
89
- if (!files.cover || !fs.existsSync(files.cover)) {
90
- console.log(` [cover] ${postId}: No cover image`);
91
- send({ ...packet, coverResult: { skipped: true } });
92
- return;
93
- }
94
-
95
- const destPath = resolvePath(profile.cover.dest.replace('{guid}', guid));
96
- await mkdir(path.dirname(destPath), { recursive: true });
97
-
98
- try {
99
- await sharp(files.cover)
100
- .resize(width, height, { kernel: sharp.kernel.mitchell, fit: 'inside' })
101
- .avif({ quality, effort })
102
- .toFile(destPath);
103
-
104
- const stats = fs.statSync(destPath);
105
- console.log(` [cover] ${postId}: ${(stats.size / 1024).toFixed(1)}KB → ${path.basename(destPath)}`);
106
-
107
- send({
108
- ...packet,
109
- coverResult: {
110
- success: true,
111
- path: destPath,
112
- url: profile.cover.url.replace('{guid}', guid),
113
- size: stats.size
114
- }
115
- });
116
- } catch (err) {
117
- console.error(` [cover] ${postId}: Error - ${err.message}`);
118
- send({ ...packet, coverResult: { error: err.message } });
119
- }
120
- };
121
- }
122
-
123
- // ─────────────────────────────────────────────
124
- // Transform: Process audio → MP3 downsampling
125
- // ─────────────────────────────────────────────
126
-
127
- import { spawn } from 'node:child_process';
128
- import { once } from 'node:events';
129
-
130
- const mp3Presets = {
131
- highQuality: (src, out) => [
132
- '-hide_banner', '-loglevel', 'error', '-i', src,
133
- '-c:a', 'libmp3lame', '-q:a', '5', '-ar', '48000',
134
- '-af', 'aresample=resampler=soxr:precision=33:dither_method=triangular',
135
- '-y', out
136
- ],
137
- quality: (src, out) => [
138
- '-hide_banner', '-loglevel', 'error', '-i', src,
139
- '-c:a', 'libmp3lame', '-q:a', '6', '-b:a', '192k', '-ar', '44100',
140
- '-af', 'aresample=resampler=soxr:precision=28:dither_method=triangular',
141
- '-y', out
142
- ],
143
- balanced: (src, out) => [
144
- '-hide_banner', '-loglevel', 'error', '-i', src,
145
- '-c:a', 'libmp3lame', '-q:a', '7', '-ar', '44100',
146
- '-af', 'aresample=resampler=soxr:precision=24',
147
- '-y', out
148
- ],
149
- speed: (src, out) => [
150
- '-hide_banner', '-loglevel', 'error', '-i', src,
151
- '-c:a', 'libmp3lame', '-q:a', '7', '-b:a', '128k', '-ar', '44100',
152
- '-af', 'aresample=resampler=soxr:precision=20',
153
- '-y', out
154
- ],
155
- fast: (src, out) => [
156
- '-hide_banner', '-loglevel', 'error', '-i', src,
157
- '-c:a', 'libmp3lame', '-q:a', '8', '-b:a', '96k', '-ar', '22050',
158
- '-af', 'aresample=resampler=soxr',
159
- '-y', out
160
- ]
161
- };
162
-
163
- function processAudio(config) {
164
- const preset = config.preset || 'balanced';
165
-
166
- return async (send, packet) => {
167
- const { files, guid, postId, postData } = packet;
168
-
169
- if (!files.audio || !fs.existsSync(files.audio)) {
170
- console.log(` [audio] ${postId}: No audio file`);
171
- send({ ...packet, audioResult: { skipped: true } });
172
- return;
173
- }
174
-
175
- const destPath = resolvePath(
176
- profile.audio.dest
177
- .replace('{chapter}', postData.chapter || '0')
178
- .replace('{id}', postData.id)
179
- );
180
- await mkdir(path.dirname(destPath), { recursive: true });
181
-
182
- const presetFn = mp3Presets[preset];
183
- if (!presetFn) {
184
- console.error(` [audio] ${postId}: Unknown preset "${preset}"`);
185
- send({ ...packet, audioResult: { error: `Unknown preset: ${preset}` } });
186
- return;
187
- }
188
-
189
- try {
190
- const args = presetFn(files.audio, destPath);
191
- const ffmpeg = spawn('ffmpeg', args);
192
- let stderr = '';
193
- ffmpeg.stderr.on('data', data => stderr += data.toString());
194
-
195
- const [code] = await once(ffmpeg, 'close');
196
-
197
- if (code !== 0) {
198
- throw new Error(`FFmpeg exited with code ${code}: ${stderr}`);
199
- }
200
-
201
- const inputStats = fs.statSync(files.audio);
202
- const outputStats = fs.statSync(destPath);
203
- const reduction = ((1 - outputStats.size / inputStats.size) * 100).toFixed(1);
204
-
205
- console.log(` [audio] ${postId}: ${(inputStats.size / 1024 / 1024).toFixed(1)}MB → ${(outputStats.size / 1024 / 1024).toFixed(1)}MB (${reduction}% smaller)`);
206
-
207
- send({
208
- ...packet,
209
- audioResult: {
210
- success: true,
211
- path: destPath,
212
- url: profile.audio.url.replace('{id}', postData.id),
213
- size: outputStats.size,
214
- reduction: parseFloat(reduction)
215
- }
216
- });
217
- } catch (err) {
218
- console.error(` [audio] ${postId}: Error - ${err.message}`);
219
- send({ ...packet, audioResult: { error: err.message } });
220
- }
221
- };
222
- }
223
-
224
- // ─────────────────────────────────────────────
225
- // Transform: Process text → HTML
226
- // ─────────────────────────────────────────────
227
-
228
- function processText() {
229
- // This runs after parallel branches join, so we have access to cover/audio results
230
- return async (send, packet) => {
231
- const { branches, guid, postId, postData } = packet;
232
-
233
- // Extract results from joined branches
234
- const coverBranch = branches?.find(b => b.coverResult);
235
- const audioBranch = branches?.find(b => b.audioResult);
236
- const filesBranch = branches?.find(b => b.files);
237
-
238
- const files = filesBranch?.files || packet.files;
239
-
240
- if (!fs.existsSync(files.text)) {
241
- console.log(` [text] ${postId}: No text.md`);
242
- send({ ...packet, textResult: { skipped: true } });
243
- return;
244
- }
245
-
246
- try {
247
- const markdown = await readFile(files.text, 'utf-8');
248
- const html = marked(markdown);
249
-
250
- const destDir = resolvePath(`${profile.dest}/permalink/${guid}`);
251
- await mkdir(destDir, { recursive: true });
252
-
253
- const destPath = path.join(destDir, 'index.html');
254
-
255
- // Get cover and audio URLs from branches
256
- const coverUrl = coverBranch?.coverResult?.url;
257
- const audioUrl = audioBranch?.audioResult?.url;
258
-
259
- // Simple HTML template
260
- const fullHtml = `<!DOCTYPE html>
261
- <html lang="en">
262
- <head>
263
- <meta charset="UTF-8">
264
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
265
- <title>${postData.title || postId}</title>
266
- <style>
267
- body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 2rem; line-height: 1.6; }
268
- img { max-width: 100%; height: auto; }
269
- audio { width: 100%; }
270
- </style>
271
- </head>
272
- <body>
273
- <article>
274
- <h1>${postData.title || postId}</h1>
275
- <time>${postData.date ? new Date(postData.date).toLocaleDateString() : ''}</time>
276
- ${coverUrl ? `<img src="${coverUrl}" alt="Cover">` : ''}
277
- ${audioUrl ? `<audio controls src="${audioUrl}"></audio>` : ''}
278
- <div class="content">
279
- ${html}
280
- </div>
281
- </article>
282
- </body>
283
- </html>`;
284
-
285
- await writeFile(destPath, fullHtml);
286
- console.log(` [text] ${postId}: Generated index.html`);
287
-
288
- send({
289
- ...packet,
290
- textResult: {
291
- success: true,
292
- path: destPath,
293
- htmlLength: fullHtml.length
294
- }
295
- });
296
- } catch (err) {
297
- console.error(` [text] ${postId}: Error - ${err.message}`);
298
- send({ ...packet, textResult: { error: err.message } });
299
- }
300
- };
301
- }
302
-
303
- // ─────────────────────────────────────────────
304
- // Transform: Copy files directory
305
- // ─────────────────────────────────────────────
306
-
307
- function copyFiles() {
308
- return async (send, packet) => {
309
- const { files, guid, postId } = packet;
310
-
311
- if (!fs.existsSync(files.filesDir)) {
312
- send({ ...packet, filesResult: { skipped: true, reason: 'no files dir' } });
313
- return;
314
- }
315
-
316
- try {
317
- const entries = await readdir(files.filesDir, { withFileTypes: true });
318
- const fileList = entries.filter(e => e.isFile());
319
-
320
- if (fileList.length === 0) {
321
- send({ ...packet, filesResult: { skipped: true, reason: 'empty' } });
322
- return;
323
- }
324
-
325
- const destDir = resolvePath(`${profile.dest}/permalink/${guid}/files`);
326
- await mkdir(destDir, { recursive: true });
327
-
328
- let copiedCount = 0;
329
- for (const file of fileList) {
330
- const srcPath = path.join(files.filesDir, file.name);
331
- const destPath = path.join(destDir, file.name);
332
- await copyFile(srcPath, destPath);
333
- copiedCount++;
334
- }
335
-
336
- console.log(` [files] ${postId}: Copied ${copiedCount} file(s)`);
337
-
338
- send({
339
- ...packet,
340
- filesResult: {
341
- success: true,
342
- count: copiedCount,
343
- destDir
344
- }
345
- });
346
- } catch (err) {
347
- console.error(` [files] ${postId}: Error - ${err.message}`);
348
- send({ ...packet, filesResult: { error: err.message } });
349
- }
350
- };
351
- }
352
-
353
- // ─────────────────────────────────────────────
354
- // Transform: Verify all branches completed
355
- // ─────────────────────────────────────────────
356
-
357
- function verify() {
358
- return (send, packet) => {
359
- const { branches, postId, textResult } = packet;
360
-
361
- const coverBranch = branches?.find(b => b.coverResult);
362
- const audioBranch = branches?.find(b => b.audioResult);
363
- const filesBranch = branches?.find(b => b.filesResult);
364
-
365
- const results = {
366
- cover: coverBranch?.coverResult || { missing: true },
367
- audio: audioBranch?.audioResult || { missing: true },
368
- text: textResult || { missing: true },
369
- files: filesBranch?.filesResult || { missing: true }
370
- };
371
-
372
- const errors = Object.entries(results)
373
- .filter(([, r]) => r.error)
374
- .map(([k, r]) => `${k}: ${r.error}`);
375
-
376
- const valid = errors.length === 0;
377
-
378
- if (!valid) {
379
- console.log(` [verify] ${postId}: FAILED - ${errors.join(', ')}`);
380
- } else {
381
- console.log(` [verify] ${postId}: OK`);
382
- }
383
-
384
- send({
385
- ...packet,
386
- results,
387
- valid,
388
- errors
389
- });
390
- };
391
- }
392
-
393
- // ─────────────────────────────────────────────
394
- // Transform: Collect processed posts
395
- // ─────────────────────────────────────────────
396
-
397
- const processedPosts = [];
398
-
399
- function collector() {
400
- return (send, packet) => {
401
- const { branches, postData, postId, guid, valid, errors, textResult } = packet;
402
-
403
- // Extract results from branches
404
- const coverBranch = branches?.find(b => b.coverResult);
405
- const audioBranch = branches?.find(b => b.audioResult);
406
-
407
- processedPosts.push({
408
- postId,
409
- guid,
410
- valid,
411
- errors,
412
- postData,
413
- coverUrl: coverBranch?.coverResult?.url,
414
- audioUrl: audioBranch?.audioResult?.url,
415
- permalinkUrl: `/permalink/${guid}/`
416
- });
417
-
418
- send(packet);
419
- };
420
- }
421
-
422
- // ─────────────────────────────────────────────
423
- // Pagerizer: Generate paginated archive pages
424
- // page-0.html = oldest, page-N.html = newest
425
- // ─────────────────────────────────────────────
426
-
427
- class Page {
428
- constructor(posts, index, allChunks) {
429
- this.posts = posts;
430
- this.index = index;
431
- this.allChunks = allChunks;
432
- this.pagerRadius = 5;
433
- }
434
-
435
- get pageIndex() { return this.index; }
436
- get totalPages() { return this.allChunks.length; }
437
- get isFirstPage() { return this.index === 0; }
438
- get isLastPage() { return this.index === this.totalPages - 1; }
439
-
440
- get newerIndex() { return this.index + 1 > this.totalPages - 1 ? null : this.index + 1; }
441
- get olderIndex() { return this.index - 1 < 0 ? null : this.index - 1; }
442
-
443
- get fileName() { return this.toFileName(this.index); }
444
- get newerFileName() { return this.newerIndex !== null ? this.toFileName(this.newerIndex) : 'index.html'; }
445
- get olderFileName() { return this.olderIndex !== null ? this.toFileName(this.olderIndex) : null; }
446
-
447
- toFileName(index) {
448
- return `page-${index}.html`;
449
- }
450
-
451
- get pager() {
452
- return Array.from(
453
- { length: this.pagerRadius * 2 + 1 },
454
- (_, i) => (this.pageIndex - this.pagerRadius + i + this.totalPages) % this.totalPages
455
- ).map(index => ({
456
- text: `${index + 1}`,
457
- url: this.toFileName(index),
458
- ariaCurrent: this.pageIndex === index
459
- }));
460
- }
461
- }
462
-
463
- function chunk(array, size) {
464
- const chunks = [];
465
- for (let i = 0; i < array.length; i += size) {
466
- chunks.push(array.slice(i, i + size));
467
- }
468
- return chunks;
469
- }
470
-
471
- const pageStyles = `
472
- * { box-sizing: border-box; }
473
- body { font-family: system-ui, sans-serif; max-width: 1200px; margin: 0 auto; padding: 2rem; line-height: 1.6; background: #111; color: #eee; }
474
- a { color: #6af; text-decoration: none; }
475
- a:hover { text-decoration: underline; }
476
- h1 { border-bottom: 1px solid #333; padding-bottom: 0.5rem; }
477
- .posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
478
- .post { background: #1a1a1a; border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
479
- .post:hover { transform: translateY(-4px); }
480
- .post img { width: 100%; height: 180px; object-fit: cover; }
481
- .post-content { padding: 1rem; }
482
- .post h2 { margin: 0 0 0.5rem; font-size: 1.1rem; }
483
- .post time { color: #888; font-size: 0.85rem; }
484
- .pager { display: flex; justify-content: center; gap: 0.5rem; margin: 2rem 0; flex-wrap: wrap; }
485
- .pager a, .pager span { padding: 0.5rem 1rem; background: #222; border-radius: 4px; }
486
- .pager span[aria-current="true"] { background: #6af; color: #000; }
487
- nav.nav { display: flex; justify-content: space-between; margin-bottom: 1rem; }
488
- `;
489
-
490
- function renderPostCard(post) {
491
- return ` <article class="post">
492
- ${post.coverUrl ? `<a href="${post.permalinkUrl}"><img src="${post.coverUrl}" alt="" loading="lazy"></a>` : ''}
493
- <div class="post-content">
494
- <h2><a href="${post.permalinkUrl}">${post.postData.title || post.postId}</a></h2>
495
- <time>${post.postData.date ? new Date(post.postData.date).toLocaleDateString() : ''}</time>
496
- </div>
497
- </article>`;
498
- }
499
-
500
- // ─────────────────────────────────────────────
501
- // Homepage: index.html with latest posts
502
- // ─────────────────────────────────────────────
503
-
504
- async function generateHomepage(posts, totalPages, config) {
505
- const { pp = 12 } = config;
506
- const destDir = resolvePath(profile.pagerizer.dest);
507
- await mkdir(destDir, { recursive: true });
508
-
509
- // Get latest posts (already sorted newest first)
510
- const latestPosts = posts.slice(0, pp);
511
-
512
- // Pager links to archive pages (centered on highest page with wrap)
513
- // Shows: [wrapped pages descending] [main pages descending]
514
- const pagerRadius = 5;
515
- const centerPage = totalPages;
516
- const allHomePages = Array.from(
517
- { length: pagerRadius * 2 + 1 },
518
- (_, i) => {
519
- let pageNum = ((centerPage - pagerRadius + i - 1 + totalPages) % totalPages) + 1;
520
- return {
521
- text: `${pageNum}`,
522
- url: `page-${pageNum}.html`,
523
- pageNum
524
- };
525
- }
526
- );
527
-
528
- const lowBound = centerPage - pagerRadius;
529
- const highBound = centerPage + pagerRadius;
530
- const wrappedHome = allHomePages.filter(p => p.pageNum < lowBound || p.pageNum > highBound);
531
- const mainHome = allHomePages.filter(p => p.pageNum >= lowBound && p.pageNum <= highBound);
532
-
533
- const homePager = [
534
- ...wrappedHome.sort((a, b) => b.pageNum - a.pageNum),
535
- ...mainHome.sort((a, b) => b.pageNum - a.pageNum)
536
- ];
537
-
538
- const html = `<!DOCTYPE html>
539
- <html lang="en">
540
- <head>
541
- <meta charset="UTF-8">
542
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
543
- <title>${profile.title}</title>
544
- <link rel="alternate" type="application/rss+xml" title="${profile.title} Feed" href="/feed.xml">
545
- <style>${pageStyles}</style>
546
- </head>
547
- <body>
548
- <header>
549
- <h1>${profile.title}</h1>
550
- </header>
551
-
552
- <main class="posts">
553
- ${latestPosts.map(renderPostCard).join('\n')}
554
- </main>
555
-
556
- <nav class="pager">
557
- <a href="page-${totalPages}.html">Browse Archive</a>
558
- ${homePager.map(p => ` <a href="${p.url}">${p.text}</a>`).join('\n')}
559
- </nav>
560
-
561
- <footer>
562
- <p><a href="/feed.xml">RSS Feed</a></p>
563
- </footer>
564
- </body>
565
- </html>`;
566
-
567
- const filePath = path.join(destDir, 'index.html');
568
- await writeFile(filePath, html);
569
- console.log(` [homepage] Generated index.html with ${latestPosts.length} latest posts`);
570
- return { posts: latestPosts.length };
571
- }
33
+ setup(baseDir, profile);
572
34
 
573
35
  // ─────────────────────────────────────────────
574
- // Pagerizer: Generate archive pages
575
- // Page N (highest) = newest posts (full 24)
576
- // Page 1 (lowest) = oldest posts (remainder)
577
- // ─────────────────────────────────────────────
578
-
579
- async function generatePaginatedPages(posts, config) {
580
- const { pp = 24 } = config;
581
- const destDir = resolvePath(profile.pagerizer.dest);
582
- await mkdir(destDir, { recursive: true });
583
-
584
- // Sort posts by date (newest first)
585
- const sortedPosts = [...posts].sort((a, b) =>
586
- new Date(b.postData.date) - new Date(a.postData.date)
587
- );
588
-
589
- // Chunk newest first: chunk[0] = newest 24, chunk[N] = oldest (remainder)
590
- const chunks = chunk(sortedPosts, pp);
591
- if (chunks.length === 0) chunks.push([]);
592
-
593
- const totalPages = chunks.length;
594
-
595
- for (let i = 0; i < chunks.length; i++) {
596
- // Page number: chunk 0 = Page N (highest), chunk N-1 = Page 1 (lowest)
597
- const pageNumber = totalPages - i;
598
- const chunkPosts = chunks[i];
599
-
600
- // Navigation indices (in chunk space)
601
- const olderChunkIndex = i + 1 < chunks.length ? i + 1 : null;
602
- const newerChunkIndex = i - 1 >= 0 ? i - 1 : null;
603
-
604
- const olderPageNumber = olderChunkIndex !== null ? totalPages - olderChunkIndex : null;
605
- const newerPageNumber = newerChunkIndex !== null ? totalPages - newerChunkIndex : null;
606
-
607
- // Generate pager centered on current page with circular wrap
608
- // Shows: [wrapped pages descending] [main pages descending]
609
- // e.g., page 91: [5][4][3][2][1] [91][90][89][88][87][86]
610
- const pagerRadius = 5;
611
- const allPages = Array.from(
612
- { length: pagerRadius * 2 + 1 },
613
- (_, i) => {
614
- let pn = ((pageNumber - pagerRadius + i - 1 + totalPages) % totalPages) + 1;
615
- return {
616
- text: `${pn}`,
617
- url: `page-${pn}.html`,
618
- ariaCurrent: pn === pageNumber,
619
- pageNum: pn
620
- };
621
- }
622
- );
623
-
624
- // Split into wrapped (far from current) and main (near current)
625
- const lowBound = pageNumber - pagerRadius;
626
- const highBound = pageNumber + pagerRadius;
627
- const wrapped = allPages.filter(p => p.pageNum < lowBound || p.pageNum > highBound);
628
- const main = allPages.filter(p => p.pageNum >= lowBound && p.pageNum <= highBound);
629
-
630
- // Wrapped pages descending, then main pages descending
631
- const pager = [
632
- ...wrapped.sort((a, b) => b.pageNum - a.pageNum),
633
- ...main.sort((a, b) => b.pageNum - a.pageNum)
634
- ];
635
-
636
- const html = `<!DOCTYPE html>
637
- <html lang="en">
638
- <head>
639
- <meta charset="UTF-8">
640
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
641
- <title>${profile.title} - Page ${pageNumber}</title>
642
- <link rel="alternate" type="application/rss+xml" title="${profile.title} Feed" href="/feed.xml">
643
- <style>${pageStyles}</style>
644
- </head>
645
- <body>
646
- <header>
647
- <h1>${profile.title}</h1>
648
- </header>
649
-
650
- <nav class="nav">
651
- ${newerPageNumber ? `<a href="page-${newerPageNumber}.html">&larr; Back</a>` : '<a href="index.html">&larr; Home</a>'}
652
- <span>Page ${pageNumber} of ${totalPages}</span>
653
- ${olderPageNumber ? `<a href="page-${olderPageNumber}.html">Next &rarr;</a>` : '<span></span>'}
654
- </nav>
655
-
656
- <main class="posts">
657
- ${chunkPosts.map(renderPostCard).join('\n')}
658
- </main>
659
-
660
- <nav class="pager">
661
- <a href="index.html">Home</a>
662
- ${pager.map(p => p.ariaCurrent
663
- ? ` <span aria-current="true">${p.text}</span>`
664
- : ` <a href="${p.url}">${p.text}</a>`
665
- ).join('\n')}
666
- </nav>
667
-
668
- <footer>
669
- <p><a href="/feed.xml">RSS Feed</a></p>
670
- </footer>
671
- </body>
672
- </html>`;
673
-
674
- const filePath = path.join(destDir, `page-${pageNumber}.html`);
675
- await writeFile(filePath, html);
676
- }
677
-
678
- console.log(` [pagerizer] Generated ${chunks.length} archive page(s)`);
679
- return { pages: chunks.length, postsPerPage: pp };
680
- }
681
-
682
- // ─────────────────────────────────────────────
683
- // Feed: Generate RSS feed.xml
684
- // ─────────────────────────────────────────────
685
-
686
- function escapeXml(str) {
687
- if (!str) return '';
688
- return str
689
- .replace(/&/g, '&amp;')
690
- .replace(/</g, '&lt;')
691
- .replace(/>/g, '&gt;')
692
- .replace(/"/g, '&quot;')
693
- .replace(/'/g, '&apos;');
694
- }
695
-
696
- async function generateFeed(posts) {
697
- const destPath = resolvePath(profile.feed.dest);
698
- await mkdir(path.dirname(destPath), { recursive: true });
699
-
700
- // Sort posts by date (newest first)
701
- const sortedPosts = [...posts].sort((a, b) =>
702
- new Date(b.postData.date) - new Date(a.postData.date)
703
- );
704
-
705
- const buildDate = new Date().toUTCString();
706
-
707
- const xml = `<?xml version="1.0" encoding="UTF-8"?>
708
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
709
- <channel>
710
- <title>${escapeXml(profile.title)}</title>
711
- <link>https://catpea.com/</link>
712
- <description>${escapeXml(profile.title)} - Latest Posts</description>
713
- <language>en-us</language>
714
- <lastBuildDate>${buildDate}</lastBuildDate>
715
- <atom:link href="https://catpea.com/feed.xml" rel="self" type="application/rss+xml"/>
716
- ${sortedPosts.slice(0, 50).map(post => ` <item>
717
- <title>${escapeXml(post.postData.title || post.postId)}</title>
718
- <link>https://catpea.com/permalink/${post.guid}/</link>
719
- <guid isPermaLink="true">https://catpea.com/permalink/${post.guid}/</guid>
720
- <pubDate>${new Date(post.postData.date).toUTCString()}</pubDate>
721
- ${post.postData.description ? `<description>${escapeXml(post.postData.description)}</description>` : ''}
722
- ${post.coverUrl ? `<enclosure url="https://catpea.com${post.coverUrl}" type="image/avif"/>` : ''}
723
- </item>`).join('\n')}
724
- </channel>
725
- </rss>`;
726
-
727
- await writeFile(destPath, xml);
728
- console.log(` [feed] Generated feed.xml with ${Math.min(sortedPosts.length, 50)} items`);
729
- return { items: Math.min(sortedPosts.length, 50) };
730
- }
731
-
732
- // ─────────────────────────────────────────────
733
- // Finalize transform (homepage + pagerizer + feed)
734
- // ─────────────────────────────────────────────
735
-
736
- function finalize(config) {
737
- const { homePP = 12, pagerPP = 24 } = config;
738
- let expectedTotal = null;
739
- const collected = [];
740
-
741
- return async (send, packet) => {
742
- // Track total from scanner
743
- if (packet._totalPosts !== undefined) {
744
- expectedTotal = packet._totalPosts;
745
- }
746
-
747
- collected.push(packet);
748
-
749
- // Check if we've collected all posts
750
- if (expectedTotal !== null && collected.length >= expectedTotal) {
751
- // Get valid posts sorted newest first for homepage
752
- const validPosts = processedPosts.filter(p => p.valid);
753
- const sortedNewestFirst = [...validPosts].sort((a, b) =>
754
- new Date(b.postData.date) - new Date(a.postData.date)
755
- );
756
-
757
- // Generate archive pages (returns page count)
758
- const pagerResult = await generatePaginatedPages(validPosts, { pp: pagerPP });
759
-
760
- // Generate homepage with link to archive
761
- await generateHomepage(sortedNewestFirst, pagerResult.pages, { pp: homePP });
762
-
763
- // Generate RSS feed
764
- await generateFeed(sortedNewestFirst);
765
-
766
- send({
767
- _complete: true,
768
- pagerized: true,
769
- homepageGenerated: true,
770
- feedGenerated: true,
771
- totalPages: pagerResult.pages
772
- });
773
- } else {
774
- send({ ...packet, _complete: false });
775
- }
776
- };
777
- }
778
-
779
- // ─────────────────────────────────────────────
780
- // Build the filtergraph
36
+ // Filtergraph
781
37
  // ─────────────────────────────────────────────
782
38
 
783
39
  console.log(`\nMuriel Blog Builder`);
@@ -785,28 +41,30 @@ console.log(`Profile: ${profile.profile}`);
785
41
  console.log(`Title: ${profile.title}`);
786
42
  console.log(`─────────────────────────────────────────────\n`);
787
43
 
788
- const blog = flow(
789
- [
790
- [scanner(profile.src), 'post'],
44
+ const blog = flow([
791
45
 
792
- ['post', [
793
- processCover(profile.cover),
794
- processAudio(profile.audio),
795
- copyFiles()
796
- ], processText(), verify(), collector(), 'done'],
46
+ [postScanner(profile.src), 'post'],
797
47
 
798
- ['done', finalize({ homePP: 12, pagerPP: 24 }), 'finished']
48
+ ['post',
49
+ [processCover(profile.cover), processAudio(profile.audio), copyFiles()],
50
+ processText(),
51
+ verifyPost(),
52
+ collectPost(),
53
+ 'done'],
799
54
 
800
- ],
801
- {
802
- context: { profile }
803
- }
804
- );
55
+ ['done',
56
+ [homepage({ pp: 12 }), pagerizer({ pp: 24 }), rssFeed()],
57
+ 'finished'],
58
+
59
+ ], { context: { profile } });
60
+
61
+ // ─────────────────────────────────────────────
62
+ // Completion
63
+ // ─────────────────────────────────────────────
805
64
 
806
- // Listen for completion on the 'finished' pipe
807
65
  blog.on('finished', packet => {
808
- // Only handle the final completion packet
809
- if (!packet._complete) return;
66
+ const allComplete = packet.branches?.every(b => b._complete);
67
+ if (!allComplete) return;
810
68
 
811
69
  console.log(`\n─────────────────────────────────────────────`);
812
70
  console.log(`Summary: ${processedPosts.length} posts processed`);