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 +52 -2
- package/client/shoppe.js +4 -1
- package/package.json +1 -1
- package/server/server.js +222 -60
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/
|
|
69
|
-
|
|
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
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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,
|
|
419
|
+
await sanoraUploadArtifact(tenant, albumTitle, buf, track, 'audio');
|
|
347
420
|
}
|
|
348
|
-
results.music.push({ title:
|
|
349
|
-
console.log(`[shoppe] 🎵 album: ${
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
443
|
-
const
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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}: ${
|
|
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
|
|
482
|
-
const title = info.title || folderTitle;
|
|
483
|
-
const
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
const folderTitle = entry.replace(/^\d+-/, '');
|
|
545
629
|
try {
|
|
546
|
-
const
|
|
547
|
-
const
|
|
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,
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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:
|
|
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 {
|