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.
- package/examples/muriel-blog-maker/blog.js +35 -777
- package/examples/muriel-blog-maker/lib.js +95 -0
- package/examples/muriel-blog-maker/transforms/collect-post/index.js +23 -0
- package/examples/muriel-blog-maker/transforms/copy-files/index.js +50 -0
- package/examples/muriel-blog-maker/transforms/homepage/index.js +91 -0
- package/examples/muriel-blog-maker/transforms/pagerizer/index.js +115 -0
- package/examples/muriel-blog-maker/transforms/post-scanner/index.js +49 -0
- package/examples/muriel-blog-maker/transforms/process-audio/index.js +67 -0
- package/examples/muriel-blog-maker/transforms/process-cover/index.js +45 -0
- package/examples/muriel-blog-maker/transforms/process-text/index.js +76 -0
- package/examples/muriel-blog-maker/transforms/rss-feed/index.js +57 -0
- package/examples/muriel-blog-maker/transforms/verify-post/index.js +35 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
6
|
-
import
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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">← Back</a>` : '<a href="index.html">← Home</a>'}
|
|
652
|
-
<span>Page ${pageNumber} of ${totalPages}</span>
|
|
653
|
-
${olderPageNumber ? `<a href="page-${olderPageNumber}.html">Next →</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, '&')
|
|
690
|
-
.replace(/</g, '<')
|
|
691
|
-
.replace(/>/g, '>')
|
|
692
|
-
.replace(/"/g, '"')
|
|
693
|
-
.replace(/'/g, ''');
|
|
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
|
-
|
|
793
|
-
processCover(profile.cover),
|
|
794
|
-
processAudio(profile.audio),
|
|
795
|
-
copyFiles()
|
|
796
|
-
], processText(), verify(), collector(), 'done'],
|
|
46
|
+
[postScanner(profile.src), 'post'],
|
|
797
47
|
|
|
798
|
-
|
|
48
|
+
['post',
|
|
49
|
+
[processCover(profile.cover), processAudio(profile.audio), copyFiles()],
|
|
50
|
+
processText(),
|
|
51
|
+
verifyPost(),
|
|
52
|
+
collectPost(),
|
|
53
|
+
'done'],
|
|
799
54
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
809
|
-
if (!
|
|
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`);
|