muriel 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,8 @@ No nodes. No operators. No DSL gymnastics.
17
17
  - **Everything else is series**
18
18
  - **Worker threads are optional**
19
19
 
20
+ NOTE: The worker pool is more suited for CPU-bound pure-JS transforms where you want to avoid blocking the main thread — e.g., heavy string processing, JSON transformations, or math-intensive filters across thousands of packets.
21
+
20
22
  ---
21
23
 
22
24
  ## Transform API
@@ -1,12 +1,30 @@
1
1
  {
2
+
3
+ "debug":{
4
+
5
+ "_processOnly": ["poem-2074", "poem-2077", "poem-2094", "poem-2095", "poem-2111"],
6
+
7
+
8
+ "mostRecent": 48,
9
+ "skipCovers": true,
10
+ "skipAudio": true
11
+ },
12
+
2
13
  "profile": "catpea_www",
3
14
  "title": "Cat Pea",
4
15
 
5
16
  "src": "examples/catpea-blog-sample-data/database/main-posts",
6
17
  "dest": "examples/catpea-blog-sample-data/dist/{profile}",
7
- "theme": "examples/catpea-blog-sample-data/theme",
18
+
19
+ "theme": {
20
+ "src": "examples/catpea-blog-sample-data/themes/striped-dark-solarize",
21
+ "dest": "examples/catpea-blog-sample-data/dist/{profile}"
22
+
23
+ },
24
+
8
25
 
9
26
  "pagerizer": {
27
+ "pp": 24,
10
28
  "dest": "examples/catpea-blog-sample-data/dist/{profile}"
11
29
  },
12
30
 
@@ -20,13 +38,24 @@
20
38
  "width": 1024,
21
39
  "height": 1024,
22
40
  "quality": 80,
23
- "effort": 4
41
+ "effort": 4,
42
+ "exif": {
43
+ "IFD0": {
44
+ "Copyright": "Cat Pea",
45
+ "ImageDescription": "Cat Pea Blog Post Cover"
46
+ }
47
+ }
24
48
  },
25
49
 
26
50
  "audio": {
27
- "dest": "examples/catpea-blog-sample-data/dist/audio/{chapter}/docs/{id}.mp3",
28
- "url": "https://catpea.github.io/chapter-15/{id}.mp3",
29
- "preset": "balanced"
51
+ "dest": "examples/catpea-blog-sample-data/dist/audio/chapter-{chapter}/docs/{id}.mp3",
52
+ "url": "https://catpea.github.io/chapter-{chapter}/{id}.mp3",
53
+ "preset": "balanced",
54
+ "id3": {
55
+ "artist": "Cat Pea",
56
+ "album_artist": "Cat Pea",
57
+ "publisher": "catpea.com"
58
+ }
30
59
  },
31
60
 
32
61
  "text": {}
@@ -3,7 +3,7 @@ import { flow } from '../../index.js';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
6
- import { setup, processedPosts, manifestUpdates, loadManifest, saveManifest, computeConfigHash } from './lib.js';
6
+ import { setup, resolvePath, processedPosts, manifestUpdates, loadManifest, saveManifest, computeConfigHash } from './lib.js';
7
7
 
8
8
  import postScanner from './transforms/post-scanner/index.js';
9
9
  import skipUnchanged from './transforms/skip-unchanged/index.js';
@@ -16,6 +16,7 @@ import collectPost from './transforms/collect-post/index.js';
16
16
  import homepage from './transforms/homepage/index.js';
17
17
  import pagerizer from './transforms/pagerizer/index.js';
18
18
  import rssFeed from './transforms/rss-feed/index.js';
19
+ import useTheme from './transforms/use-theme/index.js';
19
20
 
20
21
  // ─────────────────────────────────────────────
21
22
  // Configuration
@@ -37,7 +38,7 @@ setup(baseDir, profile);
37
38
  // Manifest
38
39
  // ─────────────────────────────────────────────
39
40
 
40
- const manifestPath = path.join(baseDir, profile.profile, '.muriel-manifest.json');
41
+ const manifestPath = path.join(resolvePath(profile.dest), '.muriel-manifest.json');
41
42
  const manifest = await loadManifest(manifestPath);
42
43
 
43
44
  const configHash = computeConfigHash(profile);
@@ -58,19 +59,21 @@ console.log(`──────────────────────
58
59
 
59
60
  const blog = flow([
60
61
 
61
- [postScanner(profile.src), 'scanned'],
62
- ['scanned', skipUnchanged(manifest), 'post'],
62
+ [ postScanner({ src: profile.src }, profile.debug), skipUnchanged(manifest), 'post' ],
63
63
 
64
64
  ['post',
65
- [processCover(profile.cover), processAudio(profile.audio), copyFiles()],
66
- processText(),
67
- verifyPost(),
68
- collectPost(),
65
+
66
+ [
67
+ processCover(profile.cover, profile.debug),
68
+ processAudio(profile.audio, profile.debug),
69
+ copyFiles()
70
+ ],
71
+
72
+ processText(), verifyPost(), collectPost(),
73
+
69
74
  'done'],
70
75
 
71
- ['done',
72
- [homepage({ pp: 12 }), pagerizer({ pp: 24 }), rssFeed()],
73
- 'finished'],
76
+ ['done', [homepage(profile.pagerizer), pagerizer(profile.pagerizer), rssFeed()], useTheme({ ...profile.theme, dest: profile.dest }), 'finished'],
74
77
 
75
78
  ], { context: { profile } });
76
79
 
@@ -37,9 +37,37 @@ export function escapeXml(str) {
37
37
  .replace(/'/g, ''');
38
38
  }
39
39
 
40
+ export function buildPager(currentPage, totalPages, radius = 5) {
41
+ if (totalPages <= 1) return [];
42
+
43
+ // Small page count: list all pages descending
44
+ const window = radius * 2 + 1;
45
+ if (totalPages <= window) {
46
+ return Array.from({ length: totalPages }, (_, i) => {
47
+ const pn = totalPages - i;
48
+ return { text: `${pn}`, url: `page-${pn}.html`, ariaCurrent: pn === currentPage, pageNum: pn };
49
+ });
50
+ }
51
+
52
+ // Large page count: circular window centered on currentPage
53
+ const pages = [];
54
+ for (let offset = -radius; offset <= radius; offset++) {
55
+ const pn = ((currentPage - 1 + offset + totalPages) % totalPages) + 1;
56
+ pages.push({ text: `${pn}`, url: `page-${pn}.html`, ariaCurrent: pn === currentPage, pageNum: pn });
57
+ }
58
+ const low = currentPage - radius;
59
+ const high = currentPage + radius;
60
+ const wrapped = pages.filter(p => p.pageNum < low || p.pageNum > high);
61
+ const main = pages.filter(p => p.pageNum >= low && p.pageNum <= high);
62
+ return [
63
+ ...wrapped.sort((a, b) => b.pageNum - a.pageNum),
64
+ ...main.sort((a, b) => b.pageNum - a.pageNum)
65
+ ];
66
+ }
67
+
40
68
  export function renderPostCard(post) {
41
69
  return ` <article class="post">
42
- ${post.coverUrl ? `<a href="${post.permalinkUrl}"><img src="${post.coverUrl}" alt="" loading="lazy"></a>` : ''}
70
+ ${post.coverUrl ? `<a href="${post.permalinkUrl}"><img src="${post.coverUrl}" alt="" loading="lazy"></a>` : ``}
43
71
  <div class="post-content">
44
72
  <h2><a href="${post.permalinkUrl}">${post.postData.title || post.postId}</a></h2>
45
73
  <time>${post.postData.date ? new Date(post.postData.date).toLocaleDateString() : ''}</time>
@@ -47,25 +75,6 @@ export function renderPostCard(post) {
47
75
  </article>`;
48
76
  }
49
77
 
50
- export const pageStyles = `
51
- * { box-sizing: border-box; }
52
- body { font-family: system-ui, sans-serif; max-width: 1200px; margin: 0 auto; padding: 2rem; line-height: 1.6; background: #111; color: #eee; }
53
- a { color: #6af; text-decoration: none; }
54
- a:hover { text-decoration: underline; }
55
- h1 { border-bottom: 1px solid #333; padding-bottom: 0.5rem; }
56
- .posts { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
57
- .post { background: #1a1a1a; border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
58
- .post:hover { transform: translateY(-4px); }
59
- .post img { width: 100%; height: 180px; object-fit: cover; }
60
- .post-content { padding: 1rem; }
61
- .post h2 { margin: 0 0 0.5rem; font-size: 1.1rem; }
62
- .post time { color: #888; font-size: 0.85rem; }
63
- .pager { display: flex; justify-content: center; gap: 0.5rem; margin: 2rem 0; flex-wrap: wrap; }
64
- .pager a, .pager span { padding: 0.5rem 1rem; background: #222; border-radius: 4px; }
65
- .pager span[aria-current="true"] { background: #6af; color: #000; }
66
- nav.nav { display: flex; justify-content: space-between; margin-bottom: 1rem; }
67
- `;
68
-
69
78
  export const mp3Presets = {
70
79
  highQuality: (src, out) => [
71
80
  '-hide_banner', '-loglevel', 'error', '-threads', '0', '-i', src,
@@ -0,0 +1,8 @@
1
+ const pagerHtml = totalPages > 1
2
+ ? ` <nav class="pager">
3
+ <a href="page-${totalPages}.html">Browse Archive</a>
4
+ ${homePager.map(p => p.ariaCurrent
5
+ ? ` <a aria-current="true"href="${p.url}">${p.text}</a>`
6
+ : ` <a href="${p.url}">${p.text}</a>`
7
+ ).join('\n')}
8
+ </nav>`
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { mkdir } from 'node:fs/promises';
3
- import { resolvePath, processedPosts, renderPostCard, pageStyles, atomicWriteFile } from '../../lib.js';
3
+ import { resolvePath, processedPosts, renderPostCard, buildPager, atomicWriteFile } from '../../lib.js';
4
4
 
5
5
  export default function homepage({ pp = 12 } = {}) {
6
6
  let expectedTotal = null;
@@ -22,33 +22,22 @@ export default function homepage({ pp = 12 } = {}) {
22
22
 
23
23
  const latestPosts = sortedNewestFirst.slice(0, pp);
24
24
 
25
- // Calculate archive page count for pager links
26
25
  const archivePP = 24;
27
26
  const totalPages = Math.ceil(validPosts.length / archivePP) || 1;
28
27
 
29
28
  const destDir = resolvePath(profile.pagerizer.dest);
30
29
  await mkdir(destDir, { recursive: true });
31
30
 
32
- // Pager links centered on highest page with circular wrap
33
- const pagerRadius = 5;
34
- const centerPage = totalPages;
35
- const allPages = Array.from(
36
- { length: pagerRadius * 2 + 1 },
37
- (_, i) => {
38
- let pageNum = ((centerPage - pagerRadius + i - 1 + totalPages) % totalPages) + 1;
39
- return { text: `${pageNum}`, url: `page-${pageNum}.html`, pageNum };
40
- }
41
- );
42
-
43
- const lowBound = centerPage - pagerRadius;
44
- const highBound = centerPage + pagerRadius;
45
- const wrapped = allPages.filter(p => p.pageNum < lowBound || p.pageNum > highBound);
46
- const main = allPages.filter(p => p.pageNum >= lowBound && p.pageNum <= highBound);
31
+ const homePager = buildPager(totalPages, totalPages);
47
32
 
48
- const homePager = [
49
- ...wrapped.sort((a, b) => b.pageNum - a.pageNum),
50
- ...main.sort((a, b) => b.pageNum - a.pageNum)
51
- ];
33
+ const pagerHtml = totalPages > 1
34
+ ? ` <nav class="pager">
35
+ ${homePager.map(p => p.ariaCurrent
36
+ ? ` <a aria-current="true"href="${p.url}">${p.text}</a>`
37
+ : ` <a href="${p.url}">${p.text}</a>`
38
+ ).join('\n')}
39
+ </nav>`
40
+ : '';
52
41
 
53
42
  const html = `<!DOCTYPE html>
54
43
  <html lang="en">
@@ -57,24 +46,18 @@ export default function homepage({ pp = 12 } = {}) {
57
46
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
58
47
  <title>${profile.title}</title>
59
48
  <link rel="alternate" type="application/rss+xml" title="${profile.title} Feed" href="/feed.xml">
60
- <style>${pageStyles}</style>
49
+ <link rel="stylesheet" href="/style.css">
61
50
  </head>
62
51
  <body>
63
- <header>
64
- <h1>${profile.title}</h1>
65
- </header>
66
52
 
67
53
  <main class="posts">
68
54
  ${latestPosts.map(renderPostCard).join('\n')}
69
55
  </main>
70
56
 
71
- <nav class="pager">
72
- <a href="page-${totalPages}.html">Browse Archive</a>
73
- ${homePager.map(p => ` <a href="${p.url}">${p.text}</a>`).join('\n')}
74
- </nav>
57
+ ${pagerHtml}
75
58
 
76
59
  <footer>
77
- <p><a href="/feed.xml">RSS Feed</a></p>
60
+ <p><a href="/feed.xml">RSS</a></p>
78
61
  </footer>
79
62
  </body>
80
63
  </html>`;
@@ -1,5 +1,5 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
- import { resolvePath, processedPosts, renderPostCard, pageStyles, chunk, atomicWriteFile } from '../../lib.js';
2
+ import { resolvePath, processedPosts, renderPostCard, chunk, buildPager, atomicWriteFile } from '../../lib.js';
3
3
 
4
4
  export default function pagerizer({ pp = 24 } = {}) {
5
5
  let expectedTotal = null;
@@ -39,30 +39,19 @@ export default function pagerizer({ pp = 24 } = {}) {
39
39
  const olderPageNumber = olderChunkIndex !== null ? totalPages - olderChunkIndex : null;
40
40
  const newerPageNumber = newerChunkIndex !== null ? totalPages - newerChunkIndex : null;
41
41
 
42
- // Circular pager: [wrapped pages descending] [main pages descending]
43
- const pagerRadius = 5;
44
- const allPages = Array.from(
45
- { length: pagerRadius * 2 + 1 },
46
- (_, j) => {
47
- let pn = ((pageNumber - pagerRadius + j - 1 + totalPages) % totalPages) + 1;
48
- return {
49
- text: `${pn}`,
50
- url: `page-${pn}.html`,
51
- ariaCurrent: pn === pageNumber,
52
- pageNum: pn
53
- };
54
- }
55
- );
56
-
57
- const lowBound = pageNumber - pagerRadius;
58
- const highBound = pageNumber + pagerRadius;
59
- const wrapped = allPages.filter(p => p.pageNum < lowBound || p.pageNum > highBound);
60
- const main = allPages.filter(p => p.pageNum >= lowBound && p.pageNum <= highBound);
61
-
62
- const pager = [
63
- ...wrapped.sort((a, b) => b.pageNum - a.pageNum),
64
- ...main.sort((a, b) => b.pageNum - a.pageNum)
65
- ];
42
+ const pager = buildPager(pageNumber, totalPages);
43
+
44
+ const pagerHtml = totalPages > 1
45
+ ? ` <nav class="pager">
46
+ <a href="index.html">Home</a>
47
+ ${pager.map(p => p.ariaCurrent
48
+ ? ` <span aria-current="true">${p.text}</span>`
49
+ : ` <a href="${p.url}">${p.text}</a>`
50
+ ).join('\n')}
51
+ </nav>`
52
+ : ` <nav class="pager">
53
+ <a href="index.html">Home</a>
54
+ </nav>`;
66
55
 
67
56
  const html = `<!DOCTYPE html>
68
57
  <html lang="en">
@@ -71,31 +60,23 @@ export default function pagerizer({ pp = 24 } = {}) {
71
60
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
72
61
  <title>${profile.title} - Page ${pageNumber}</title>
73
62
  <link rel="alternate" type="application/rss+xml" title="${profile.title} Feed" href="/feed.xml">
74
- <style>${pageStyles}</style>
63
+ <link rel="stylesheet" href="/style.css">
75
64
  </head>
76
65
  <body>
77
- <header>
78
- <h1>${profile.title}</h1>
79
- </header>
80
66
 
81
- <nav class="nav">
82
- ${newerPageNumber ? `<a href="page-${newerPageNumber}.html">&larr; Back</a>` : '<a href="index.html">&larr; Home</a>'}
83
- <span>Page ${pageNumber} of ${totalPages}</span>
84
- ${olderPageNumber ? `<a href="page-${olderPageNumber}.html">Next &rarr;</a>` : '<span></span>'}
85
- </nav>
86
67
 
87
68
  <main class="posts">
88
69
  ${chunkPosts.map(renderPostCard).join('\n')}
89
70
  </main>
90
71
 
91
- <nav class="pager">
92
- <a href="index.html">Home</a>
93
- ${pager.map(p => p.ariaCurrent
94
- ? ` <span aria-current="true">${p.text}</span>`
95
- : ` <a href="${p.url}">${p.text}</a>`
96
- ).join('\n')}
72
+ <nav class="nav">
73
+ ${newerPageNumber ? `<a href="page-${newerPageNumber}.html">&larr; Newer</a>` : '<a href="index.html">&larr; Home</a>'}
74
+ <span>Page ${pageNumber} of ${totalPages}</span>
75
+ ${olderPageNumber ? `<a href="page-${olderPageNumber}.html">Older &rarr;</a>` : '<span></span>'}
97
76
  </nav>
98
77
 
78
+ ${pagerHtml}
79
+
99
80
  <footer>
100
81
  <p><a href="/feed.xml">RSS Feed</a></p>
101
82
  </footer>
@@ -2,9 +2,10 @@ import path from 'node:path';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { resolvePath } from '../../lib.js';
4
4
 
5
- export default function postScanner(postsDir) {
5
+ export default function postScanner({src}, debug) {
6
+
6
7
  return async send => {
7
- const srcDir = resolvePath(postsDir);
8
+ const srcDir = resolvePath(src);
8
9
  console.log(`Scanning: ${srcDir}`);
9
10
 
10
11
  const entries = await readdir(srcDir, { withFileTypes: true });
@@ -22,13 +23,21 @@ export default function postScanner(postsDir) {
22
23
  }
23
24
 
24
25
 
25
- const selectedPostDirs = validPostDirs.slice(1_000, 1_005)
26
+
27
+ let selectedPostDirs = validPostDirs;
28
+ if (debug?.processOnly?.length) {
29
+ const allowed = new Set(debug.processOnly);
30
+ selectedPostDirs = validPostDirs.filter(p => allowed.has(p.postId));
31
+ } else if (debug?.mostRecent) {
32
+ selectedPostDirs = validPostDirs.slice(validPostDirs.length - debug.mostRecent);
33
+ }
34
+
26
35
  const totalPosts = selectedPostDirs.length;
27
36
  console.log(` Found ${totalPosts} posts`);
28
37
 
29
38
  for (const { postId, postDir, dirFiles } of selectedPostDirs) {
30
- const postData = JSON.parse(await readFile(path.join(postDir, 'post.json'), 'utf-8'));
31
39
 
40
+ const postData = JSON.parse(await readFile(path.join(postDir, 'post.json'), 'utf-8'));
32
41
  const cover = dirFiles.find(f => f.startsWith('cover.'));
33
42
  const audio = dirFiles.find(f => f.startsWith('audio.'));
34
43
 
@@ -5,8 +5,9 @@ import { spawn } from 'node:child_process';
5
5
  import { once } from 'node:events';
6
6
  import { resolvePath, mp3Presets, encodingSemaphore } from '../../lib.js';
7
7
 
8
- export default function processAudio(config) {
8
+ export default function processAudio(config, debug) {
9
9
  const preset = config.preset || 'balanced';
10
+ const id3 = config.id3 || {};
10
11
 
11
12
  return async (send, packet) => {
12
13
  if (packet._cached) {
@@ -16,7 +17,7 @@ export default function processAudio(config) {
16
17
 
17
18
  const { files, guid, postId, postData } = packet;
18
19
 
19
- if (!files.audio || !fs.existsSync(files.audio)) {
20
+ if (!files.audio || !fs.existsSync(files.audio) || debug.skipAudio) {
20
21
  console.log(` [audio] ${postId}: No audio file`);
21
22
  send({ ...packet, audioResult: { skipped: true } });
22
23
  return;
@@ -27,6 +28,23 @@ export default function processAudio(config) {
27
28
  .replace('{chapter}', postData.chapter || '0')
28
29
  .replace('{id}', postData.id)
29
30
  );
31
+
32
+ // If output already exists, skip encoding (delete file to force rebuild)
33
+ if (fs.existsSync(destPath)) {
34
+ const outputStats = fs.statSync(destPath);
35
+ console.log(` [audio] ${postId}: exists ${(outputStats.size / 1024 / 1024).toFixed(1)}MB`);
36
+ send({
37
+ ...packet,
38
+ audioResult: {
39
+ success: true,
40
+ path: destPath,
41
+ url: config.url.replace('{chapter}', postData.chapter || '0').replace('{id}', postData.id),
42
+ size: outputStats.size
43
+ }
44
+ });
45
+ return;
46
+ }
47
+
30
48
  await mkdir(path.dirname(destPath), { recursive: true });
31
49
 
32
50
  const presetFn = mp3Presets[preset];
@@ -40,6 +58,11 @@ export default function processAudio(config) {
40
58
  const tmpPath = destPath + '.tmp';
41
59
  const args = presetFn(files.audio, tmpPath);
42
60
 
61
+ // Append id3 metadata flags
62
+ for (const [key, value] of Object.entries(id3)) {
63
+ args.splice(args.indexOf('-f'), 0, '-metadata', `${key}=${value}`);
64
+ }
65
+
43
66
  await encodingSemaphore.acquire();
44
67
  let code, stderr = '';
45
68
  try {
@@ -67,7 +90,7 @@ export default function processAudio(config) {
67
90
  audioResult: {
68
91
  success: true,
69
92
  path: destPath,
70
- url: config.url.replace('{id}', postData.id),
93
+ url: config.url.replace('{chapter}', postData.chapter || '0').replace('{id}', postData.id),
71
94
  size: outputStats.size,
72
95
  reduction: parseFloat(reduction)
73
96
  }
@@ -2,12 +2,12 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { mkdir, rename } from 'node:fs/promises';
4
4
  import sharp from 'sharp';
5
- import { resolvePath, encodingSemaphore } from '../../lib.js';
5
+ import { resolvePath, encodingSemaphore, atomicCopyFile } from '../../lib.js';
6
6
 
7
7
  sharp.concurrency(1);
8
8
 
9
- export default function processCover(config) {
10
- const { width = 1024, height = 1024, quality = 80, effort = 4 } = config;
9
+ export default function processCover(config, debug) {
10
+ const { width = 1024, height = 1024, quality = 80, effort = 4, exif = {} } = config;
11
11
 
12
12
  return async (send, packet) => {
13
13
  if (packet._cached) {
@@ -17,27 +17,59 @@ export default function processCover(config) {
17
17
 
18
18
  const { files, guid, postId } = packet;
19
19
 
20
- if (!files.cover || !fs.existsSync(files.cover)) {
20
+ if (!files.cover || !fs.existsSync(files.cover) || debug.skipCovers) {
21
21
  console.log(` [cover] ${postId}: No cover image`);
22
22
  send({ ...packet, coverResult: { skipped: true } });
23
23
  return;
24
24
  }
25
25
 
26
26
  const destPath = resolvePath(config.dest.replace('{guid}', guid));
27
+
28
+ // If output already exists, skip encoding (delete file to force rebuild)
29
+ if (fs.existsSync(destPath)) {
30
+ const stats = fs.statSync(destPath);
31
+ console.log(` [cover] ${postId}: exists ${(stats.size / 1024).toFixed(1)}KB`);
32
+ send({
33
+ ...packet,
34
+ coverResult: {
35
+ success: true,
36
+ path: destPath,
37
+ url: config.url.replace('{guid}', guid),
38
+ size: stats.size
39
+ }
40
+ });
41
+ return;
42
+ }
43
+
27
44
  await mkdir(path.dirname(destPath), { recursive: true });
28
45
 
29
46
  try {
30
- const tmpPath = destPath + '.tmp';
31
- await encodingSemaphore.acquire();
32
- try {
33
- await sharp(files.cover)
34
- .resize(width, height, { kernel: sharp.kernel.mitchell, fit: 'inside' })
35
- .avif({ quality, effort })
36
- .toFile(tmpPath);
37
- } finally {
38
- encodingSemaphore.release();
47
+ if (files.cover.endsWith('.avif')) {
48
+ await atomicCopyFile(files.cover, destPath);
49
+ console.log(` [cover] ${postId}: copied (already AVIF)`);
50
+ } else {
51
+ const tmpPath = destPath + '.tmp';
52
+ await encodingSemaphore.acquire();
53
+ try {
54
+ let pipeline = sharp(files.cover)
55
+ .resize(width, height, { kernel: sharp.kernel.mitchell, fit: 'inside' })
56
+ .avif({ quality, effort });
57
+ if (Object.keys(exif).length > 0) {
58
+ pipeline = pipeline.withExif(exif);
59
+ }
60
+ await pipeline.toFile(tmpPath);
61
+ } catch (sharpErr) {
62
+ if (sharpErr.message.includes('unsupported image format')) {
63
+ console.log(` [cover] ${postId}: sharp can't decode, copying as-is`);
64
+ await atomicCopyFile(files.cover, destPath);
65
+ } else {
66
+ throw sharpErr;
67
+ }
68
+ } finally {
69
+ encodingSemaphore.release();
70
+ }
71
+ if (fs.existsSync(tmpPath)) await rename(tmpPath, destPath);
39
72
  }
40
- await rename(tmpPath, destPath);
41
73
 
42
74
  const stats = fs.statSync(destPath);
43
75
  console.log(` [cover] ${postId}: ${(stats.size / 1024).toFixed(1)}KB → ${path.basename(destPath)}`);
@@ -7,6 +7,7 @@ import { resolvePath, manifestUpdates, hashFileContent } from '../../lib.js';
7
7
  export default function skipUnchanged(manifest) {
8
8
 
9
9
  return async (send, packet) => {
10
+
10
11
  const { postId, postDir, guid, profile } = packet;
11
12
  const entry = manifest.posts[postId];
12
13
 
@@ -0,0 +1,42 @@
1
+ import path from 'node:path';
2
+ import { readdir, mkdir } from 'node:fs/promises';
3
+ import { resolvePath, atomicCopyFile } from '../../lib.js';
4
+
5
+ export default function useTheme(config) {
6
+ const themeSrc = resolvePath(config.src);
7
+ const destDir = resolvePath(config.dest);
8
+
9
+ return async (send, packet) => {
10
+ const allComplete = packet.branches?.every(b => b._complete);
11
+ if (!allComplete) {
12
+ send(packet);
13
+ return;
14
+ }
15
+
16
+ try {
17
+ const count = await copyRecursive(themeSrc, destDir);
18
+ console.log(` [theme] Installed ${count} file(s) from ${path.basename(themeSrc)}`);
19
+ } catch (err) {
20
+ console.error(` [theme] Error - ${err.message}`);
21
+ }
22
+
23
+ send(packet);
24
+ };
25
+ }
26
+
27
+ async function copyRecursive(src, dest) {
28
+ await mkdir(dest, { recursive: true });
29
+ const entries = await readdir(src, { withFileTypes: true });
30
+ let count = 0;
31
+ for (const entry of entries) {
32
+ const srcPath = path.join(src, entry.name);
33
+ const destPath = path.join(dest, entry.name);
34
+ if (entry.isDirectory()) {
35
+ count += await copyRecursive(srcPath, destPath);
36
+ } else {
37
+ await atomicCopyFile(srcPath, destPath);
38
+ count++;
39
+ }
40
+ }
41
+ return count;
42
+ }
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  //NOTE: this file will be executed by a human
4
- process.exit();
5
4
 
6
5
  import { readdir, readFile, writeFile, copyFile, mkdir, access } from "fs/promises";
7
6
  import { constants as fsConstants } from "fs";
package/index.js CHANGED
@@ -27,6 +27,19 @@ function createPipe(name) {
27
27
  * Worker pool (optional)
28
28
  * ───────────────────────────────────────────── */
29
29
 
30
+ /*
31
+ NOTE: About createWorkerPool
32
+ You do need to pass a size explicitly, but it doesn't have to be CPU count. Here's how it works:
33
+ - Activation: Pass workers in options — flow([...], { workers: 4 }) or flow([...], { workers: os.cpus().length }). If omitted, size is undefined and it returns null (disabled).
34
+ - Mechanism: Creates size Worker threads, distributes work round-robin via cursor++ % workers.length.
35
+ - Serialization: The filter function is sent as filter.toString() and eval()'d inside the worker. The packet is passed via postMessage (structured clone).
36
+ Important limitation: Because filters are serialized via toString() + eval(), the worker pool only works with pure, self-contained transforms — functions that don't import external modules or close over external variables. A function
37
+ like (send, packet) => { send({ ...packet, x: packet.y * 2 }) } works fine. But your blog builder transforms (importing sharp, ffmpeg, fs, etc.) would not work with it.
38
+ For the blog builder, the encodingSemaphore is the right approach. The heavy work (sharp, ffmpeg) happens in external native processes anyway — worker threads wouldn't add anything. The semaphore limits concurrency to os.cpus().length
39
+ without the serialization constraint.
40
+ The worker pool is more suited for CPU-bound pure-JS transforms where you want to avoid blocking the main thread — e.g., heavy string processing, JSON transformations, or math-intensive filters across thousands of packets.
41
+ */
42
+
30
43
  function createWorkerPool(size) {
31
44
  if (size === undefined) return null;
32
45
 
@@ -207,11 +220,20 @@ export function flow(definition, options = {}) {
207
220
 
208
221
  // First pass: set up all edges (listeners)
209
222
  for (const edge of definition) {
210
- // Producer → pipe (defer execution)
223
+ // Producer → [stages] → pipe (defer execution)
211
224
  if (typeof edge[0] === 'function') {
212
225
  const producer = edge[0];
213
- const pipe = getPipe(edge[1]);
214
- producers.push(() => producer(packet => pipe.send({ ...packet, ...ctx })));
226
+ const outputName = edge[edge.length - 1];
227
+ const stages = compileStages(edge.slice(1, -1));
228
+
229
+ if (stages.length === 0) {
230
+ const pipe = getPipe(outputName);
231
+ producers.push(() => producer(packet => pipe.send({ ...packet, ...ctx })));
232
+ } else {
233
+ const producerPipe = createPipe(`_producer_${producers.length}`);
234
+ connectEdge(producerPipe, stages, getPipe(outputName), ctx, pool);
235
+ producers.push(() => producer(packet => producerPipe.send({ ...packet, ...ctx })));
236
+ }
215
237
  continue;
216
238
  }
217
239
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "muriel",
4
- "version": "1.0.4",
4
+ "version": "1.0.5",
5
5
  "description": "Lightweight Filtergraph Flow Engine",
6
6
  "main": "index.js",
7
7
  "scripts": {
@@ -1,366 +0,0 @@
1
- {
2
- "version": 1,
3
- "configHash": "4334d284663296411f31a0421e4d6b6c0511ee0430a1a9db86e1cfa2872cd911",
4
- "posts": {
5
- "poem-1001": {
6
- "compositeHash": "6715a83bc29b60037c7cf96e946a86d2b62f551d06e8e75da3f66eed281c4513",
7
- "files": {
8
- "audio.mp3": {
9
- "mtime": 1770321388663.5708,
10
- "size": 3947444,
11
- "hash": "1ac432bdc39fd1fd56e60db15e6c09b7b12d2014e5e646c1757b83901783abec"
12
- },
13
- "cover.jpg": {
14
- "mtime": 1770321388663.7854,
15
- "size": 541318,
16
- "hash": "d33e976401350485b33b744580cff2039defccc9fa3c4e06cf91f7d2163a1d48"
17
- },
18
- "post.json": {
19
- "mtime": 1770321388663.9695,
20
- "size": 311,
21
- "hash": "25686c8858fa7350f8b2d5f72b03c64eaefd3e4e8f1c6f37350e3055c121a80c"
22
- },
23
- "text.md": {
24
- "mtime": 1770321388663.9695,
25
- "size": 1283,
26
- "hash": "ff971ec162528681d1ab586f50b3ec194385809d368dae2194e2c5e9a5e25cd6"
27
- }
28
- },
29
- "results": {
30
- "coverResult": {
31
- "success": true,
32
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/d0ee9d67-040d-41b6-8ee6-0913cd195b2d/cover.avif",
33
- "url": "/permalink/d0ee9d67-040d-41b6-8ee6-0913cd195b2d/cover.avif",
34
- "size": 109011
35
- },
36
- "audioResult": {
37
- "success": true,
38
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/audio/5/docs/poem-1001.mp3",
39
- "url": "https://catpea.github.io/chapter-15/poem-1001.mp3",
40
- "size": 1325895,
41
- "reduction": 66.4
42
- },
43
- "textResult": {
44
- "success": true,
45
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/d0ee9d67-040d-41b6-8ee6-0913cd195b2d/index.html",
46
- "htmlLength": 2238
47
- },
48
- "filesResult": {
49
- "skipped": true,
50
- "reason": "no files dir"
51
- },
52
- "valid": true,
53
- "errors": [],
54
- "collectedPost": {
55
- "postId": "poem-1001",
56
- "guid": "d0ee9d67-040d-41b6-8ee6-0913cd195b2d",
57
- "valid": true,
58
- "errors": [],
59
- "postData": {
60
- "id": "poem-1001",
61
- "guid": "d0ee9d67-040d-41b6-8ee6-0913cd195b2d",
62
- "chapter": 5,
63
- "title": "Little By Little; Or, To Live Above The Common Levels Of Life",
64
- "description": null,
65
- "date": "2022-11-27T04:25:38.628Z",
66
- "lastmod": null,
67
- "artwork": [
68
- "https://unsplash.com/photos/TvpTk9m5H1E"
69
- ]
70
- },
71
- "coverUrl": "/permalink/d0ee9d67-040d-41b6-8ee6-0913cd195b2d/cover.avif",
72
- "audioUrl": "https://catpea.github.io/chapter-15/poem-1001.mp3",
73
- "permalinkUrl": "/permalink/d0ee9d67-040d-41b6-8ee6-0913cd195b2d/"
74
- }
75
- }
76
- },
77
- "poem-1004": {
78
- "compositeHash": "0e094d783dd770333fc2ad7315e6971b3ff2925e7895555025b62a619b094f40",
79
- "files": {
80
- "audio.mp3": {
81
- "mtime": 1770321388671.8887,
82
- "size": 2972368,
83
- "hash": "2c279e07095e0c1cf88dcfa82220e4976ba76380e747a66851f878f9fdf5b952"
84
- },
85
- "cover.jpg": {
86
- "mtime": 1770321388672.2324,
87
- "size": 310641,
88
- "hash": "3d78f4cce735ec0cd2d4acff69cfaa070c1b2a941370781edad791017492cf51"
89
- },
90
- "post.json": {
91
- "mtime": 1770321388672.7017,
92
- "size": 353,
93
- "hash": "69de9df29ae2f8a3c577dab12cae142d0317d44abfc4e7e2131de1a819786223"
94
- },
95
- "text.md": {
96
- "mtime": 1770321388672.7017,
97
- "size": 1862,
98
- "hash": "6cf8f0ad86dc0299aaab5d7f0c261da9606661fb93e086bb85b814bd6545cdfa"
99
- }
100
- },
101
- "results": {
102
- "coverResult": {
103
- "success": true,
104
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/17f655e8-1a02-4040-94da-7e9ff2da9bb1/cover.avif",
105
- "url": "/permalink/17f655e8-1a02-4040-94da-7e9ff2da9bb1/cover.avif",
106
- "size": 274128
107
- },
108
- "audioResult": {
109
- "success": true,
110
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/audio/5/docs/poem-1004.mp3",
111
- "url": "https://catpea.github.io/chapter-15/poem-1004.mp3",
112
- "size": 1043753,
113
- "reduction": 64.9
114
- },
115
- "textResult": {
116
- "success": true,
117
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/17f655e8-1a02-4040-94da-7e9ff2da9bb1/index.html",
118
- "htmlLength": 2898
119
- },
120
- "filesResult": {
121
- "skipped": true,
122
- "reason": "no files dir"
123
- },
124
- "valid": true,
125
- "errors": [],
126
- "collectedPost": {
127
- "postId": "poem-1004",
128
- "guid": "17f655e8-1a02-4040-94da-7e9ff2da9bb1",
129
- "valid": true,
130
- "errors": [],
131
- "postData": {
132
- "id": "poem-1004",
133
- "guid": "17f655e8-1a02-4040-94da-7e9ff2da9bb1",
134
- "chapter": 5,
135
- "title": "Confusing Programming Can Be Pretty Colorful If You Build Everything Out Of Interesting Little Machines",
136
- "description": null,
137
- "date": "2022-11-30T04:11:09.289Z",
138
- "lastmod": null,
139
- "artwork": [
140
- "https://unsplash.com/photos/vuAkRNH4sGA"
141
- ]
142
- },
143
- "coverUrl": "/permalink/17f655e8-1a02-4040-94da-7e9ff2da9bb1/cover.avif",
144
- "audioUrl": "https://catpea.github.io/chapter-15/poem-1004.mp3",
145
- "permalinkUrl": "/permalink/17f655e8-1a02-4040-94da-7e9ff2da9bb1/"
146
- }
147
- }
148
- },
149
- "poem-1005": {
150
- "compositeHash": "9e61d9adcdbf0f511eaf5cc44810e274e7cc57f7d99c389b095732677296f0f9",
151
- "files": {
152
- "audio.mp3": {
153
- "mtime": 1770321388674.134,
154
- "size": 7287177,
155
- "hash": "861bbcc081f4a378b86184cffe4bfe7632be7ca724b84d6242774666ef51add1"
156
- },
157
- "cover.jpg": {
158
- "mtime": 1770321388674.279,
159
- "size": 509184,
160
- "hash": "5e4317a39b4855cbb144fdc599dc71795770779d1f706525cb14525e479a5fb4"
161
- },
162
- "post.json": {
163
- "mtime": 1770321388674.4119,
164
- "size": 320,
165
- "hash": "093c9a24e6a7b9a654958fa0b8ede2b6d8be75114336909a2cd2061146626eb7"
166
- },
167
- "text.md": {
168
- "mtime": 1770321388674.4119,
169
- "size": 2540,
170
- "hash": "54588e268459278b034c697f2b29399eff2b8f1b82e0b080b80ab1e4044e7a2e"
171
- }
172
- },
173
- "results": {
174
- "coverResult": {
175
- "success": true,
176
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/3c110103-9606-43fe-ba72-75a5045f8a0a/cover.avif",
177
- "url": "/permalink/3c110103-9606-43fe-ba72-75a5045f8a0a/cover.avif",
178
- "size": 54218
179
- },
180
- "audioResult": {
181
- "success": true,
182
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/audio/5/docs/poem-1005.mp3",
183
- "url": "https://catpea.github.io/chapter-15/poem-1005.mp3",
184
- "size": 2555933,
185
- "reduction": 64.9
186
- },
187
- "textResult": {
188
- "success": true,
189
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/3c110103-9606-43fe-ba72-75a5045f8a0a/index.html",
190
- "htmlLength": 3569
191
- },
192
- "filesResult": {
193
- "skipped": true,
194
- "reason": "no files dir"
195
- },
196
- "valid": true,
197
- "errors": [],
198
- "collectedPost": {
199
- "postId": "poem-1005",
200
- "guid": "3c110103-9606-43fe-ba72-75a5045f8a0a",
201
- "valid": true,
202
- "errors": [],
203
- "postData": {
204
- "id": "poem-1005",
205
- "guid": "3c110103-9606-43fe-ba72-75a5045f8a0a",
206
- "chapter": 5,
207
- "title": "Prototyping And Coding Your First Web Operating System and Web Desktop",
208
- "description": null,
209
- "date": "2022-12-01T04:06:47.823Z",
210
- "lastmod": null,
211
- "artwork": [
212
- "https://unsplash.com/photos/64YrPKiguAE"
213
- ]
214
- },
215
- "coverUrl": "/permalink/3c110103-9606-43fe-ba72-75a5045f8a0a/cover.avif",
216
- "audioUrl": "https://catpea.github.io/chapter-15/poem-1005.mp3",
217
- "permalinkUrl": "/permalink/3c110103-9606-43fe-ba72-75a5045f8a0a/"
218
- }
219
- }
220
- },
221
- "poem-1003": {
222
- "compositeHash": "e41d8865afd9bc0dd3d41dea6d944d56c62f8ad5f897392973521babc82ab6a0",
223
- "files": {
224
- "audio.mp3": {
225
- "mtime": 1770321388668.4753,
226
- "size": 8635341,
227
- "hash": "0fd28140c8a085308635b72332a3994665805898a0f904acad7f560d0ac1f93c"
228
- },
229
- "cover.jpg": {
230
- "mtime": 1770321388668.6865,
231
- "size": 215096,
232
- "hash": "37c0d2e353f85f436a877af2856b88fa1e17875efe8e13d9e7e08fb338720e24"
233
- },
234
- "post.json": {
235
- "mtime": 1770321388668.875,
236
- "size": 277,
237
- "hash": "3f72a82af449174047a1338f5cd80e45f8b4da70a48e16370c6c7ac4191b2a17"
238
- },
239
- "text.md": {
240
- "mtime": 1770321388668.875,
241
- "size": 3545,
242
- "hash": "acdf38771363c2eb5bcd6967d97d600b79ff2787d5efa59546f46ff39850dd36"
243
- }
244
- },
245
- "results": {
246
- "coverResult": {
247
- "success": true,
248
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/7c262035-1b1e-4b9c-ac3e-c1f9b2583538/cover.avif",
249
- "url": "/permalink/7c262035-1b1e-4b9c-ac3e-c1f9b2583538/cover.avif",
250
- "size": 104642
251
- },
252
- "audioResult": {
253
- "success": true,
254
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/audio/5/docs/poem-1003.mp3",
255
- "url": "https://catpea.github.io/chapter-15/poem-1003.mp3",
256
- "size": 2986536,
257
- "reduction": 65.4
258
- },
259
- "textResult": {
260
- "success": true,
261
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/7c262035-1b1e-4b9c-ac3e-c1f9b2583538/index.html",
262
- "htmlLength": 4501
263
- },
264
- "filesResult": {
265
- "skipped": true,
266
- "reason": "no files dir"
267
- },
268
- "valid": true,
269
- "errors": [],
270
- "collectedPost": {
271
- "postId": "poem-1003",
272
- "guid": "7c262035-1b1e-4b9c-ac3e-c1f9b2583538",
273
- "valid": true,
274
- "errors": [],
275
- "postData": {
276
- "id": "poem-1003",
277
- "guid": "7c262035-1b1e-4b9c-ac3e-c1f9b2583538",
278
- "chapter": 5,
279
- "title": "You Must Unlock Your Genius",
280
- "description": null,
281
- "date": "2022-11-29T04:28:54.843Z",
282
- "lastmod": null,
283
- "artwork": [
284
- "https://unsplash.com/photos/6EdFFNU4Qlw"
285
- ]
286
- },
287
- "coverUrl": "/permalink/7c262035-1b1e-4b9c-ac3e-c1f9b2583538/cover.avif",
288
- "audioUrl": "https://catpea.github.io/chapter-15/poem-1003.mp3",
289
- "permalinkUrl": "/permalink/7c262035-1b1e-4b9c-ac3e-c1f9b2583538/"
290
- }
291
- }
292
- },
293
- "poem-1002": {
294
- "compositeHash": "282c701c825d371d946258a0b1bf9dbf77057fdcdc377cd2ee8d1dcd4954173e",
295
- "files": {
296
- "audio.mp3": {
297
- "mtime": 1770321388665.9543,
298
- "size": 24097197,
299
- "hash": "e32492dddd818331cabbd7d5e2f8eda3d780a92d04cf6dfe99d05727440030a7"
300
- },
301
- "cover.jpg": {
302
- "mtime": 1770321388666.1982,
303
- "size": 327475,
304
- "hash": "46d2aad492fa446624bf30ed89f56f888ea79ca682e2686bb7b1b27b6942e414"
305
- },
306
- "post.json": {
307
- "mtime": 1770321388666.4639,
308
- "size": 315,
309
- "hash": "f2a340e7ce7b23b5ccfe53cf4cc25a98886ac8ecc2e9839ca704386869a6ab72"
310
- },
311
- "text.md": {
312
- "mtime": 1770321388666.4639,
313
- "size": 2924,
314
- "hash": "545f1a5b98b1a5b4267198c14b6b3becdb4c1ea662a5fcc794d0745e9999325b"
315
- }
316
- },
317
- "results": {
318
- "coverResult": {
319
- "success": true,
320
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/93be5330-7fc4-4bd2-b7e4-1d5efc024cca/cover.avif",
321
- "url": "/permalink/93be5330-7fc4-4bd2-b7e4-1d5efc024cca/cover.avif",
322
- "size": 185760
323
- },
324
- "audioResult": {
325
- "success": true,
326
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/audio/5/docs/poem-1002.mp3",
327
- "url": "https://catpea.github.io/chapter-15/poem-1002.mp3",
328
- "size": 8285538,
329
- "reduction": 65.6
330
- },
331
- "textResult": {
332
- "success": true,
333
- "path": "/home/meow/Universe/Development/npm/muriel/examples/catpea-blog-sample-data/dist/catpea_www/permalink/93be5330-7fc4-4bd2-b7e4-1d5efc024cca/index.html",
334
- "htmlLength": 4023
335
- },
336
- "filesResult": {
337
- "skipped": true,
338
- "reason": "no files dir"
339
- },
340
- "valid": true,
341
- "errors": [],
342
- "collectedPost": {
343
- "postId": "poem-1002",
344
- "guid": "93be5330-7fc4-4bd2-b7e4-1d5efc024cca",
345
- "valid": true,
346
- "errors": [],
347
- "postData": {
348
- "id": "poem-1002",
349
- "guid": "93be5330-7fc4-4bd2-b7e4-1d5efc024cca",
350
- "chapter": 5,
351
- "title": "We Are Star Babies; Or, The World Needs You To Unlock Your Genius",
352
- "description": null,
353
- "date": "2022-11-28T00:43:31.853Z",
354
- "lastmod": null,
355
- "artwork": [
356
- "https://unsplash.com/photos/AmkpzghJ5eo"
357
- ]
358
- },
359
- "coverUrl": "/permalink/93be5330-7fc4-4bd2-b7e4-1d5efc024cca/cover.avif",
360
- "audioUrl": "https://catpea.github.io/chapter-15/poem-1002.mp3",
361
- "permalinkUrl": "/permalink/93be5330-7fc4-4bd2-b7e4-1d5efc024cca/"
362
- }
363
- }
364
- }
365
- }
366
- }