wiki-plugin-shoppe 0.0.10 → 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.10",
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
  // ============================================================
@@ -280,7 +338,20 @@ async function processArchive(zipPath) {
280
338
  throw new Error('emojicode does not match registered tenant');
281
339
  }
282
340
 
283
- 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
+ }
284
355
 
285
356
  // ---- books/ ----
286
357
  // Each book is a subfolder containing the book file, cover.jpg, and info.json
@@ -290,21 +361,19 @@ async function processArchive(zipPath) {
290
361
  const entryPath = path.join(booksDir, entry);
291
362
  if (!fs.statSync(entryPath).isDirectory()) continue;
292
363
  try {
293
- const infoPath = path.join(entryPath, 'info.json');
294
- const info = fs.existsSync(infoPath)
295
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
296
- : {};
364
+ const info = readInfo(entryPath);
297
365
  const title = info.title || entry;
298
366
  const description = info.description || '';
299
367
  const price = info.price || 0;
300
368
 
301
369
  await sanoraCreateProduct(tenant, title, 'book', description, price, 0, 'book');
302
370
 
303
- // Cover image
371
+ // Cover image — use info.cover to pin a specific file, else first image found
304
372
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
305
- if (covers.length > 0) {
306
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
307
- 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);
308
377
  }
309
378
 
310
379
  // Book file
@@ -331,31 +400,44 @@ async function processArchive(zipPath) {
331
400
  const stat = fs.statSync(entryPath);
332
401
 
333
402
  if (stat.isDirectory()) {
334
- // Album
335
- const albumName = entry;
403
+ // Album — supports info.json: { title, description, price, cover }
404
+ const info = readInfo(entryPath);
405
+ const albumTitle = info.title || entry;
336
406
  const tracks = fs.readdirSync(entryPath).filter(f => MUSIC_EXTS.has(path.extname(f).toLowerCase()));
337
407
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
338
408
  try {
339
- await sanoraCreateProduct(tenant, albumName, 'music', `Album: ${albumName}`, 0, 0, 'music,album');
340
- if (covers.length > 0) {
341
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
342
- 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);
343
416
  }
344
417
  for (const track of tracks) {
345
418
  const buf = fs.readFileSync(path.join(entryPath, track));
346
- await sanoraUploadArtifact(tenant, albumName, buf, track, 'audio');
419
+ await sanoraUploadArtifact(tenant, albumTitle, buf, track, 'audio');
347
420
  }
348
- results.music.push({ title: albumName, type: 'album', tracks: tracks.length });
349
- 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)`);
350
423
  } catch (err) {
351
- console.warn(`[shoppe] ⚠️ album ${albumName}: ${err.message}`);
424
+ console.warn(`[shoppe] ⚠️ album ${entry}: ${err.message}`);
352
425
  }
353
426
  } else if (MUSIC_EXTS.has(path.extname(entry).toLowerCase())) {
354
- // Standalone track
355
- 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;
356
436
  try {
357
437
  const buf = fs.readFileSync(entryPath);
358
- 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');
359
441
  await sanoraUploadArtifact(tenant, title, buf, entry, 'audio');
360
442
  results.music.push({ title, type: 'track' });
361
443
  console.log(`[shoppe] 🎵 track: ${title}`);
@@ -381,10 +463,7 @@ async function processArchive(zipPath) {
381
463
  const entryPath = path.join(postsDir, entry);
382
464
  const folderTitle = entry.replace(/^\d+-/, '');
383
465
 
384
- const infoPath = path.join(entryPath, 'info.json');
385
- const info = fs.existsSync(infoPath)
386
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
387
- : {};
466
+ const info = readInfo(entryPath);
388
467
  const seriesTitle = info.title || folderTitle;
389
468
 
390
469
  // Check if this is a multi-part series (has numbered subdirectories)
@@ -423,13 +502,7 @@ async function processArchive(zipPath) {
423
502
  const partPath = path.join(entryPath, partEntry);
424
503
  const partFolderTitle = partEntry.replace(/^\d+-/, '');
425
504
 
426
- const partInfoPath = path.join(partPath, 'info.json');
427
- const partInfo = fs.existsSync(partInfoPath)
428
- ? JSON.parse(fs.readFileSync(partInfoPath, 'utf8'))
429
- : {};
430
- const partTitle = partInfo.title || partFolderTitle;
431
- // Sanora product title must be unique — namespace under series
432
- const productTitle = `${seriesTitle}: ${partTitle}`;
505
+ const partInfo = readInfo(partPath);
433
506
 
434
507
  try {
435
508
  const partMdFiles = fs.readdirSync(partPath).filter(f => f.endsWith('.md'));
@@ -439,8 +512,10 @@ async function processArchive(zipPath) {
439
512
  }
440
513
 
441
514
  const mdBuf = fs.readFileSync(path.join(partPath, partMdFiles[0]));
442
- const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
443
- 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;
444
519
 
445
520
  await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
446
521
  `post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`);
@@ -448,9 +523,10 @@ async function processArchive(zipPath) {
448
523
  await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
449
524
 
450
525
  const partCovers = fs.readdirSync(partPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
451
- if (partCovers.length > 0) {
452
- const coverBuf = fs.readFileSync(path.join(partPath, partCovers[0]));
453
- 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);
454
530
  }
455
531
 
456
532
  const partAssets = fs.readdirSync(partPath).filter(f =>
@@ -462,7 +538,7 @@ async function processArchive(zipPath) {
462
538
  await sanoraUploadArtifact(tenant, productTitle, buf, asset, 'image');
463
539
  }
464
540
 
465
- console.log(`[shoppe] part ${partIndex + 1}: ${partTitle}`);
541
+ console.log(`[shoppe] part ${partIndex + 1}: ${resolvedTitle}`);
466
542
  } catch (err) {
467
543
  console.warn(`[shoppe] ⚠️ part ${partEntry}: ${err.message}`);
468
544
  }
@@ -478,17 +554,19 @@ async function processArchive(zipPath) {
478
554
  continue;
479
555
  }
480
556
  const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
481
- const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
482
- const title = info.title || folderTitle;
483
- 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;
484
561
 
485
562
  await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
486
563
  await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
487
564
 
488
565
  const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
489
- if (covers.length > 0) {
490
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
491
- 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);
492
570
  }
493
571
 
494
572
  const assets = fs.readdirSync(entryPath).filter(f =>
@@ -536,32 +614,37 @@ async function processArchive(zipPath) {
536
614
  }
537
615
 
538
616
  // ---- products/ ----
539
- // 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, …).
540
619
  const productsDir = path.join(root, 'products');
541
620
  if (fs.existsSync(productsDir)) {
542
- 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];
543
627
  const entryPath = path.join(productsDir, entry);
544
- if (!fs.statSync(entryPath).isDirectory()) continue;
628
+ const folderTitle = entry.replace(/^\d+-/, '');
545
629
  try {
546
- const infoPath = path.join(entryPath, 'info.json');
547
- const info = fs.existsSync(infoPath)
548
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
549
- : {};
550
- const title = info.title || entry;
630
+ const info = readInfo(entryPath);
631
+ const title = info.title || folderTitle;
551
632
  const description = info.description || '';
552
633
  const price = info.price || 0;
553
634
  const shipping = info.shipping || 0;
554
635
 
555
- await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, 'product,physical');
636
+ await sanoraCreateProduct(tenant, title, 'product', description, price, shipping, `product,physical,order:${order}`);
556
637
 
638
+ // Hero image: prefer hero.jpg / hero.png, fall back to first image
557
639
  const images = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
558
- if (images.length > 0) {
559
- const coverBuf = fs.readFileSync(path.join(entryPath, images[0]));
560
- 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);
561
644
  }
562
645
 
563
- results.products.push({ title, price, shipping });
564
- 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)`);
565
648
  } catch (err) {
566
649
  console.warn(`[shoppe] ⚠️ product ${entry}: ${err.message}`);
567
650
  }
@@ -589,13 +672,16 @@ async function getShoppeGoods(tenant) {
589
672
  const goods = { books: [], music: [], posts: [], albums: [], products: [] };
590
673
 
591
674
  for (const [title, product] of Object.entries(products)) {
675
+ const isPost = product.category === 'post' || product.category === 'post-series';
592
676
  const item = {
593
677
  title: product.title || title,
594
678
  description: product.description || '',
595
679
  price: product.price || 0,
596
680
  shipping: product.shipping || 0,
597
681
  image: product.image ? `${getSanoraUrl()}/images/${product.image}` : null,
598
- 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)}`
599
685
  };
600
686
  const bucket = goods[product.category];
601
687
  if (bucket) bucket.push(item);
@@ -707,6 +793,49 @@ function generateShoppeHTML(tenant, goods) {
707
793
  </html>`;
708
794
  }
709
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
+
710
839
  // ============================================================
711
840
  // EXPRESS ROUTES
712
841
  // ============================================================
@@ -797,6 +926,39 @@ async function startServer(params) {
797
926
  res.json({ success: true });
798
927
  });
799
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
+
800
962
  // Goods JSON (public)
801
963
  app.get('/plugin/shoppe/:identifier/goods', async (req, res) => {
802
964
  try {