wiki-plugin-shoppe 0.0.9 → 0.0.11

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/CLAUDE.md CHANGED
@@ -65,8 +65,8 @@ my-shoppe.zip
65
65
  photo1.jpg
66
66
  photo2.jpg
67
67
  products/
68
- T-Shirt/ product = subfolder with cover + info.json
69
- cover.jpg
68
+ 01-T-Shirt/ numeric prefix sets display order
69
+ hero.jpg ← main product image (hero.jpg or hero.png)
70
70
  info.json
71
71
  ```
72
72
 
@@ -80,6 +80,54 @@ my-shoppe.zip
80
80
  }
81
81
  ```
82
82
 
83
+ ### books/*/info.json
84
+
85
+ ```json
86
+ {
87
+ "title": "My Novel",
88
+ "description": "A gripping tale",
89
+ "price": 9,
90
+ "cover": "front.jpg"
91
+ }
92
+ ```
93
+
94
+ `cover` pins a specific image file as the Sanora cover image. If omitted, the first image in the folder is used.
95
+
96
+ ### music/*/info.json (albums)
97
+
98
+ ```json
99
+ {
100
+ "title": "My Album",
101
+ "description": "Debut record",
102
+ "price": 10,
103
+ "cover": "artwork.jpg"
104
+ }
105
+ ```
106
+
107
+ ### music/*.json (standalone track sidecar)
108
+
109
+ A `.json` file with the same basename as the audio file:
110
+
111
+ ```
112
+ music/
113
+ my-track.mp3
114
+ my-track.json ← { "title": "…", "description": "…", "price": 0 }
115
+ ```
116
+
117
+ ### posts/*/index.md front matter
118
+
119
+ Posts support TOML (`+++ … +++`) or YAML (`--- … ---`) front matter:
120
+
121
+ ```toml
122
+ +++
123
+ title = "On boiling the ocean"
124
+ date = "2025-01-12"
125
+ preview = "ocean.jpg"
126
+ +++
127
+ ```
128
+
129
+ `preview` pins a specific image as the post cover. If omitted, the first image in the folder is used. `title` and `date` override folder name and `info.json`.
130
+
83
131
  ### products/*/info.json
84
132
 
85
133
  ```json
@@ -91,6 +139,8 @@ my-shoppe.zip
91
139
  }
92
140
  ```
93
141
 
142
+ The hero image is resolved automatically: `hero.jpg` or `hero.png` is used if present, otherwise the first image in the folder. Folder numeric prefix (`01-`, `02-`, …) sets display order.
143
+
94
144
  ## Routes
95
145
 
96
146
  | Method | Path | Auth | Description |
package/client/shoppe.js CHANGED
@@ -246,8 +246,11 @@
246
246
  r.products.length && `📦 ${r.products.length} product${r.products.length !== 1 ? 's' : ''}`
247
247
  ].filter(Boolean).join(' · ') || 'no items found';
248
248
 
249
+ const warnings = (r.warnings && r.warnings.length > 0)
250
+ ? `<br><br>⚠️ <strong>Warnings (${r.warnings.length}):</strong><br>${r.warnings.map(w => `• ${w}`).join('<br>')}`
251
+ : '';
249
252
  showStatus(container, '#sw-upload-status',
250
- `✅ <strong>${result.tenant.name}</strong> ${result.tenant.emojicode} updated — ${counts}<br>
253
+ `✅ <strong>${result.tenant.name}</strong> ${result.tenant.emojicode} updated — ${counts}${warnings}<br>
251
254
  <a href="/plugin/shoppe/${result.tenant.uuid}" target="_blank" class="sw-link" style="display:inline-block;margin-top:8px;">View your shoppe →</a>`,
252
255
  'success');
253
256
  loadDirectory(container);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Multi-tenant digital goods shoppe for federated wiki, powered by Sanora",
5
5
  "keywords": [
6
6
  "wiki",
package/server/server.js CHANGED
@@ -51,6 +51,64 @@ const BOOK_EXTS = new Set(['.epub', '.pdf', '.mobi', '.azw', '.azw3']);
51
51
  const MUSIC_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav']);
52
52
  const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
53
53
 
54
+ // ============================================================
55
+ // MARKDOWN / FRONT MATTER UTILITIES
56
+ // ============================================================
57
+
58
+ // Parse +++ TOML or --- YAML front matter from a markdown string.
59
+ // Returns { title, date, preview, body } — body is the content after the block.
60
+ function parseFrontMatter(content) {
61
+ const result = { title: null, date: null, preview: null, body: content };
62
+ const m = content.match(/^(\+\+\+|---)\s*\n([\s\S]*?)\n\1\s*\n?([\s\S]*)/);
63
+ if (!m) return result;
64
+ const fm = m[2];
65
+ result.body = m[3] || '';
66
+ const grab = key => { const r = fm.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm')); return r ? r[1] : null; };
67
+ result.title = grab('title');
68
+ result.date = grab('date') || grab('updated');
69
+ result.preview = grab('preview');
70
+ return result;
71
+ }
72
+
73
+ function escHtml(str) {
74
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
75
+ }
76
+
77
+ function renderMarkdown(md) {
78
+ // Process code blocks first to avoid mangling their contents
79
+ const codeBlocks = [];
80
+ let out = md.replace(/```[\s\S]*?```/g, m => {
81
+ const lang = m.match(/^```(\w*)/)?.[1] || '';
82
+ const code = m.replace(/^```[^\n]*\n?/, '').replace(/\n?```$/, '');
83
+ codeBlocks.push(`<pre><code class="lang-${lang}">${escHtml(code)}</code></pre>`);
84
+ return `\x00CODE${codeBlocks.length - 1}\x00`;
85
+ });
86
+
87
+ out = out
88
+ .replace(/^#{4} (.+)$/gm, '<h4>$1</h4>')
89
+ .replace(/^#{3} (.+)$/gm, '<h3>$1</h3>')
90
+ .replace(/^#{2} (.+)$/gm, '<h2>$1</h2>')
91
+ .replace(/^# (.+)$/gm, '<h1>$1</h1>')
92
+ .replace(/^---+$/gm, '<hr>')
93
+ .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
94
+ .replace(/\*([^*\n]+)\*/g, '<em>$1</em>')
95
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
96
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8px;margin:1em 0">')
97
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
98
+
99
+ // Paragraphs: split on blank lines, wrap non-block-level content
100
+ const blockRe = /^<(h[1-6]|hr|pre|ul|ol|li|blockquote)/;
101
+ out = out.split(/\n{2,}/).map(chunk => {
102
+ chunk = chunk.trim();
103
+ if (!chunk || blockRe.test(chunk) || chunk.startsWith('\x00CODE')) return chunk;
104
+ return '<p>' + chunk.replace(/\n/g, '<br>') + '</p>';
105
+ }).join('\n');
106
+
107
+ // Restore code blocks
108
+ codeBlocks.forEach((block, i) => { out = out.replace(`\x00CODE${i}\x00`, block); });
109
+ return out;
110
+ }
111
+
54
112
  // ============================================================
55
113
  // TENANT MANAGEMENT
56
114
  // ============================================================
@@ -247,19 +305,27 @@ async function processArchive(zipPath) {
247
305
  zip.extractAllTo(tmpDir, true);
248
306
 
249
307
  try {
250
- // Find manifest.json — handle zips that wrap everything in a top-level folder
251
- let root = tmpDir;
252
- let manifestPath = path.join(tmpDir, 'manifest.json');
253
- if (!fs.existsSync(manifestPath)) {
254
- const entries = fs.readdirSync(tmpDir);
255
- if (entries.length === 1 && fs.statSync(path.join(tmpDir, entries[0])).isDirectory()) {
256
- root = path.join(tmpDir, entries[0]);
257
- manifestPath = path.join(root, 'manifest.json');
308
+ // Find manifest.json — handle zips wrapped in a top-level folder and
309
+ // macOS zips that include a __MACOSX metadata folder alongside the content.
310
+ function findManifest(dir, depth = 0) {
311
+ const direct = path.join(dir, 'manifest.json');
312
+ if (fs.existsSync(direct)) return dir;
313
+ if (depth >= 2) return null;
314
+ const entries = fs.readdirSync(dir).filter(f =>
315
+ f !== '__MACOSX' && fs.statSync(path.join(dir, f)).isDirectory()
316
+ );
317
+ for (const entry of entries) {
318
+ const found = findManifest(path.join(dir, entry), depth + 1);
319
+ if (found) return found;
258
320
  }
321
+ return null;
259
322
  }
260
- if (!fs.existsSync(manifestPath)) {
323
+
324
+ const root = findManifest(tmpDir);
325
+ if (!root) {
261
326
  throw new Error('Archive is missing manifest.json');
262
327
  }
328
+ const manifestPath = path.join(root, 'manifest.json');
263
329
 
264
330
  const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
265
331
  if (!manifest.uuid || !manifest.emojicode) {
@@ -272,7 +338,20 @@ async function processArchive(zipPath) {
272
338
  throw new Error('emojicode does not match registered tenant');
273
339
  }
274
340
 
275
- const results = { books: [], music: [], posts: [], albums: [], products: [] };
341
+ const results = { books: [], music: [], posts: [], albums: [], products: [], warnings: [] };
342
+
343
+ function readInfo(entryPath) {
344
+ const infoPath = path.join(entryPath, 'info.json');
345
+ if (!fs.existsSync(infoPath)) return {};
346
+ try {
347
+ return JSON.parse(fs.readFileSync(infoPath, 'utf8'));
348
+ } catch (err) {
349
+ const msg = `info.json in "${path.basename(entryPath)}" is invalid JSON: ${err.message}`;
350
+ results.warnings.push(msg);
351
+ console.warn(`[shoppe] ⚠️ ${msg}`);
352
+ return {};
353
+ }
354
+ }
276
355
 
277
356
  // ---- books/ ----
278
357
  // Each book is a subfolder containing the book file, cover.jpg, and info.json
@@ -282,21 +361,19 @@ async function processArchive(zipPath) {
282
361
  const entryPath = path.join(booksDir, entry);
283
362
  if (!fs.statSync(entryPath).isDirectory()) continue;
284
363
  try {
285
- const infoPath = path.join(entryPath, 'info.json');
286
- const info = fs.existsSync(infoPath)
287
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
288
- : {};
364
+ const info = readInfo(entryPath);
289
365
  const title = info.title || entry;
290
366
  const description = info.description || '';
291
367
  const price = info.price || 0;
292
368
 
293
369
  await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
294
370
 
295
- // Cover image
371
+ // Cover image — use info.cover to pin a specific file, else first image found
296
372
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
297
- if (covers.length > 0) {
298
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
299
- await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
373
+ const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
374
+ if (coverFile) {
375
+ const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
376
+ await sanoraUploadImage(tenant, title, coverBuf, coverFile);
300
377
  }
301
378
 
302
379
  // Book file
@@ -323,31 +400,44 @@ async function processArchive(zipPath) {
323
400
  const stat = fs.statSync(entryPath);
324
401
 
325
402
  if (stat.isDirectory()) {
326
- // Album
327
- const albumName = entry;
403
+ // Album — supports info.json: { title, description, price, cover }
404
+ const info = readInfo(entryPath);
405
+ const albumTitle = info.title || entry;
328
406
  const tracks = fs.readdirSync(entryPath).filter(f => MUSIC_EXTS.has(path.extname(f).toLowerCase()));
329
407
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
330
408
  try {
331
- await sanoraCreateProduct(tenant, albumName, 'music', `Album: ${albumName}`, 0, 0, 'music,album');
332
- if (covers.length > 0) {
333
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
334
- await sanoraUploadImage(tenant, albumName, coverBuf, covers[0]);
409
+ const description = info.description || `Album: ${albumTitle}`;
410
+ const price = info.price || 0;
411
+ await sanoraCreateProduct(tenant, albumTitle, 'music', description, price, 0, 'music,album');
412
+ const coverFile = info.cover ? (covers.find(f => f === info.cover) || covers[0]) : covers[0];
413
+ if (coverFile) {
414
+ const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
415
+ await sanoraUploadImage(tenant, albumTitle, coverBuf, coverFile);
335
416
  }
336
417
  for (const track of tracks) {
337
418
  const buf = fs.readFileSync(path.join(entryPath, track));
338
- await sanoraUploadArtifact(tenant, albumName, buf, track, 'audio');
419
+ await sanoraUploadArtifact(tenant, albumTitle, buf, track, 'audio');
339
420
  }
340
- results.music.push({ title: albumName, type: 'album', tracks: tracks.length });
341
- console.log(`[shoppe] 🎵 album: ${albumName} (${tracks.length} tracks)`);
421
+ results.music.push({ title: albumTitle, type: 'album', tracks: tracks.length });
422
+ console.log(`[shoppe] 🎵 album: ${albumTitle} (${tracks.length} tracks)`);
342
423
  } catch (err) {
343
- console.warn(`[shoppe] ⚠️ album ${albumName}: ${err.message}`);
424
+ console.warn(`[shoppe] ⚠️ album ${entry}: ${err.message}`);
344
425
  }
345
426
  } else if (MUSIC_EXTS.has(path.extname(entry).toLowerCase())) {
346
- // Standalone track
347
- const title = path.basename(entry, path.extname(entry));
427
+ // Standalone track — supports a sidecar .json with same basename: { title, description, price }
428
+ const baseName = path.basename(entry, path.extname(entry));
429
+ const sidecarPath = path.join(musicDir, baseName + '.json');
430
+ let trackInfo = {};
431
+ if (fs.existsSync(sidecarPath)) {
432
+ try { trackInfo = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); }
433
+ catch (e) { results.warnings.push(`sidecar JSON for "${entry}" is invalid: ${e.message}`); }
434
+ }
435
+ const title = trackInfo.title || baseName;
348
436
  try {
349
437
  const buf = fs.readFileSync(entryPath);
350
- await sanoraCreateProduct(tenant, title, 'music', `Track: ${title}`, 0, 0, 'music,track');
438
+ const description = trackInfo.description || `Track: ${title}`;
439
+ const price = trackInfo.price || 0;
440
+ await sanoraCreateProduct(tenant, title, 'music', description, price, 0, 'music,track');
351
441
  await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
352
442
  results.music.push({ title, type: 'track' });
353
443
  console.log(`[shoppe] 🎵 track: ${title}`);
@@ -373,10 +463,7 @@ async function processArchive(zipPath) {
373
463
  const entryPath = path.join(postsDir, entry);
374
464
  const folderTitle = entry.replace(/^\d+-/, '');
375
465
 
376
- const infoPath = path.join(entryPath, 'info.json');
377
- const info = fs.existsSync(infoPath)
378
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
379
- : {};
466
+ const info = readInfo(entryPath);
380
467
  const seriesTitle = info.title || folderTitle;
381
468
 
382
469
  // Check if this is a multi-part series (has numbered subdirectories)
@@ -415,13 +502,7 @@ async function processArchive(zipPath) {
415
502
  const partPath = path.join(entryPath, partEntry);
416
503
  const partFolderTitle = partEntry.replace(/^\d+-/, '');
417
504
 
418
- const partInfoPath = path.join(partPath, 'info.json');
419
- const partInfo = fs.existsSync(partInfoPath)
420
- ? JSON.parse(fs.readFileSync(partInfoPath, 'utf8'))
421
- : {};
422
- const partTitle = partInfo.title || partFolderTitle;
423
- // Sanora product title must be unique — namespace under series
424
- const productTitle = `${seriesTitle}: ${partTitle}`;
505
+ const partInfo = readInfo(partPath);
425
506
 
426
507
  try {
427
508
  const partMdFiles = fs.readdirSync(partPath).filter(f => f.endsWith('.md'));
@@ -431,8 +512,10 @@ async function processArchive(zipPath) {
431
512
  }
432
513
 
433
514
  const mdBuf = fs.readFileSync(path.join(partPath, partMdFiles[0]));
434
- const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
435
- const description = partInfo.description || firstHeading || partTitle;
515
+ const partFm = parseFrontMatter(mdBuf.toString('utf8'));
516
+ const resolvedTitle = partFm.title || partInfo.title || partFolderTitle;
517
+ const productTitle = `${seriesTitle}: ${resolvedTitle}`;
518
+ const description = partInfo.description || partFm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || resolvedTitle;
436
519
 
437
520
  await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
438
521
  `post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`);
@@ -440,9 +523,10 @@ async function processArchive(zipPath) {
440
523
  await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
441
524
 
442
525
  const partCovers = fs.readdirSync(partPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
443
- if (partCovers.length > 0) {
444
- const coverBuf = fs.readFileSync(path.join(partPath, partCovers[0]));
445
- await sanoraUploadImage(tenant, productTitle, coverBuf, partCovers[0]);
526
+ const partCoverFile = partFm.preview ? (partCovers.find(f => f === partFm.preview) || partCovers[0]) : partCovers[0];
527
+ if (partCoverFile) {
528
+ const coverBuf = fs.readFileSync(path.join(partPath, partCoverFile));
529
+ await sanoraUploadImage(tenant, productTitle, coverBuf, partCoverFile);
446
530
  }
447
531
 
448
532
  const partAssets = fs.readdirSync(partPath).filter(f =>
@@ -454,7 +538,7 @@ async function processArchive(zipPath) {
454
538
  await sanoraUploadArtifact(tenant, productTitle, buf, asset, 'image');
455
539
  }
456
540
 
457
- console.log(`[shoppe] part ${partIndex + 1}: ${partTitle}`);
541
+ console.log(`[shoppe] part ${partIndex + 1}: ${resolvedTitle}`);
458
542
  } catch (err) {
459
543
  console.warn(`[shoppe] ⚠️ part ${partEntry}: ${err.message}`);
460
544
  }
@@ -470,17 +554,19 @@ async function processArchive(zipPath) {
470
554
  continue;
471
555
  }
472
556
  const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
473
- const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
474
- const title = info.title || folderTitle;
475
- const description = info.description || firstHeading || title;
557
+ const fm = parseFrontMatter(mdBuf.toString('utf8'));
558
+ const title = fm.title || info.title || folderTitle;
559
+ const firstLine = fm.body.split('\n').find(l => l.trim()).replace(/^#+\s*/, '');
560
+ const description = info.description || fm.body.split('\n\n')[0].replace(/^#+\s*/, '').trim() || firstLine || title;
476
561
 
477
562
  await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
478
563
  await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
479
564
 
480
565
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
481
- if (covers.length > 0) {
482
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
483
- await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
566
+ const coverFile = fm.preview ? (covers.find(f => f === fm.preview) || covers[0]) : covers[0];
567
+ if (coverFile) {
568
+ const coverBuf = fs.readFileSync(path.join(entryPath, coverFile));
569
+ await sanoraUploadImage(tenant, title, coverBuf, coverFile);
484
570
  }
485
571
 
486
572
  const assets = fs.readdirSync(entryPath).filter(f =>
@@ -528,32 +614,37 @@ async function processArchive(zipPath) {
528
614
  }
529
615
 
530
616
  // ---- products/ ----
531
- // Each subfolder is a physical product with cover.jpg + info.json
617
+ // Each subfolder is a physical product with hero.jpg/hero.png + info.json.
618
+ // Numeric prefix on folder name sets display order (01-T-Shirt, 02-Hat, …).
532
619
  const productsDir = path.join(root, 'products');
533
620
  if (fs.existsSync(productsDir)) {
534
- for (const entry of fs.readdirSync(productsDir)) {
621
+ const productFolders = fs.readdirSync(productsDir)
622
+ .filter(f => fs.statSync(path.join(productsDir, f)).isDirectory())
623
+ .sort();
624
+
625
+ for (let order = 0; order < productFolders.length; order++) {
626
+ const entry = productFolders[order];
535
627
  const entryPath = path.join(productsDir, entry);
536
- if (!fs.statSync(entryPath).isDirectory()) continue;
628
+ const folderTitle = entry.replace(/^\d+-/, '');
537
629
  try {
538
- const infoPath = path.join(entryPath, 'info.json');
539
- const info = fs.existsSync(infoPath)
540
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
541
- : {};
542
- const title = info.title || entry;
630
+ const info = readInfo(entryPath);
631
+ const title = info.title || folderTitle;
543
632
  const description = info.description || '';
544
633
  const price = info.price || 0;
545
634
  const shipping = info.shipping || 0;
546
635
 
547
- await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, 'product,physical');
636
+ await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, `product,physical,order:${order}`);
548
637
 
638
+ // Hero image: prefer hero.jpg / hero.png, fall back to first image
549
639
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
550
- if (images.length > 0) {
551
- const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
552
- await sanoraUploadImage(tenant, title, coverBuf, images[0]);
640
+ const heroFile = images.find(f => /^hero\.(jpg|jpeg|png|webp)$/i.test(f)) || images[0];
641
+ if (heroFile) {
642
+ const heroBuf = fs.readFileSync(path.join(entryPath, heroFile));
643
+ await sanoraUploadImage(tenant, title, heroBuf, heroFile);
553
644
  }
554
645
 
555
- results.products.push({ title, price, shipping });
556
- console.log(`[shoppe] 📦 product: ${title} ($${price} + $${shipping} shipping)`);
646
+ results.products.push({ title, order, price, shipping });
647
+ console.log(`[shoppe] 📦 product [${order + 1}]: ${title} ($${price} + $${shipping} shipping)`);
557
648
  } catch (err) {
558
649
  console.warn(`[shoppe] ⚠️ product ${entry}: ${err.message}`);
559
650
  }
@@ -581,13 +672,16 @@ async function getShoppeGoods(tenant) {
581
672
  const goods = { books: [], music: [], posts: [], albums: [], products: [] };
582
673
 
583
674
  for (const [title, product] of Object.entries(products)) {
675
+ const isPost = product.category === 'post' || product.category === 'post-series';
584
676
  const item = {
585
677
  title: product.title || title,
586
678
  description: product.description || '',
587
679
  price: product.price || 0,
588
680
  shipping: product.shipping || 0,
589
681
  image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
590
- url: `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
682
+ url: isPost
683
+ ? `/plugin/shoppe/${tenant.uuid}/post/${encodeURIComponent(title)}`
684
+ : `${getSanoraUrl()}/products/${tenant.uuid}/${encodeURIComponent(title)}`
591
685
  };
592
686
  const bucket = goods[product.category];
593
687
  if (bucket) bucket.push(item);
@@ -699,6 +793,49 @@ function generateShoppeHTML(tenant, goods) {
699
793
  </html>`;
700
794
  }
701
795
 
796
+ function generatePostHTML(tenant, title, date, imageUrl, markdownBody) {
797
+ const content = renderMarkdown(markdownBody);
798
+ return `<!DOCTYPE html>
799
+ <html lang="en">
800
+ <head>
801
+ <meta charset="UTF-8">
802
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
803
+ <title>${escHtml(title)} — ${escHtml(tenant.name)}</title>
804
+ <style>
805
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
806
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; }
807
+ .back-bar { background: #1a1a2e; padding: 12px 24px; }
808
+ .back-bar a { color: rgba(255,255,255,0.75); text-decoration: none; font-size: 14px; }
809
+ .back-bar a:hover { color: white; }
810
+ .hero { width: 100%; max-height: 420px; object-fit: cover; display: block; }
811
+ .post-header { max-width: 740px; margin: 48px auto 0; padding: 0 24px; }
812
+ .post-header h1 { font-size: 38px; font-weight: 800; line-height: 1.15; letter-spacing: -0.5px; }
813
+ .post-date { margin-top: 10px; font-size: 14px; color: #888; }
814
+ article { max-width: 740px; margin: 36px auto 80px; padding: 0 24px; line-height: 1.75; font-size: 17px; color: #2d2d2f; }
815
+ article h1,article h2,article h3,article h4 { margin: 2em 0 0.5em; line-height: 1.2; color: #1d1d1f; }
816
+ article h1 { font-size: 28px; } article h2 { font-size: 24px; } article h3 { font-size: 20px; }
817
+ article p { margin-bottom: 1.4em; }
818
+ article a { color: #0066cc; }
819
+ article code { background: #e8e8ed; border-radius: 4px; padding: 2px 6px; font-size: 14px; }
820
+ article pre { background: #1d1d1f; color: #a8f0a8; border-radius: 10px; padding: 20px; overflow-x: auto; margin: 1.5em 0; }
821
+ article pre code { background: none; padding: 0; font-size: 14px; color: inherit; }
822
+ article img { max-width: 100%; border-radius: 8px; margin: 1em 0; }
823
+ article hr { border: none; border-top: 1px solid #ddd; margin: 2.5em 0; }
824
+ article strong { color: #1d1d1f; }
825
+ </style>
826
+ </head>
827
+ <body>
828
+ <div class="back-bar"><a href="/plugin/shoppe/${tenant.uuid}">← ${escHtml(tenant.name)}</a></div>
829
+ ${imageUrl ? `<img class="hero" src="${imageUrl}" alt="">` : ''}
830
+ <div class="post-header">
831
+ <h1>${escHtml(title)}</h1>
832
+ ${date ? `<div class="post-date">${escHtml(date)}</div>` : ''}
833
+ </div>
834
+ <article>${content}</article>
835
+ </body>
836
+ </html>`;
837
+ }
838
+
702
839
  // ============================================================
703
840
  // EXPRESS ROUTES
704
841
  // ============================================================
@@ -789,6 +926,39 @@ async function startServer(params) {
789
926
  res.json({ success: true });
790
927
  });
791
928
 
929
+ // Post reader — fetches markdown from Sanora and renders it as HTML
930
+ app.get('/plugin/shoppe/:identifier/post/:title', async (req, res) => {
931
+ try {
932
+ const tenant = getTenantByIdentifier(req.params.identifier);
933
+ if (!tenant) return res.status(404).send('<h1>Shoppe not found</h1>');
934
+
935
+ const title = decodeURIComponent(req.params.title);
936
+ const productsResp = await fetch(`${getSanoraUrl()}/products/${tenant.uuid}`);
937
+ const products = await productsResp.json();
938
+ const product = products[title];
939
+ if (!product) return res.status(404).send('<h1>Post not found</h1>');
940
+
941
+ // Find the markdown artifact (UUID-named .md file)
942
+ const mdArtifact = (product.artifacts || []).find(a => a.endsWith('.md'));
943
+ let mdContent = '';
944
+ if (mdArtifact) {
945
+ const artResp = await fetch(`${getSanoraUrl()}/artifacts/${mdArtifact}`);
946
+ mdContent = await artResp.text();
947
+ }
948
+
949
+ const fm = parseFrontMatter(mdContent);
950
+ const postTitle = fm.title || title;
951
+ const postDate = fm.date || '';
952
+ const imageUrl = product.image ? `${getSanoraUrl()}/images/${product.image}` : null;
953
+
954
+ res.set('Content-Type', 'text/html');
955
+ res.send(generatePostHTML(tenant, postTitle, postDate, imageUrl, fm.body || mdContent));
956
+ } catch (err) {
957
+ console.error('[shoppe] post page error:', err);
958
+ res.status(500).send(`<h1>Error</h1><p>${err.message}</p>`);
959
+ }
960
+ });
961
+
792
962
  // Goods JSON (public)
793
963
  app.get('/plugin/shoppe/:identifier/goods', async (req, res) => {
794
964
  try {