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 +52 -2
- package/client/shoppe.js +4 -1
- package/package.json +1 -1
- package/server/server.js +239 -69
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
|
// ============================================================
|
|
@@ -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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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,
|
|
419
|
+
await sanoraUploadArtifact(tenant, albumTitle, buf, track, 'audio');
|
|
339
420
|
}
|
|
340
|
-
results.music.push({ title:
|
|
341
|
-
console.log(`[shoppe] 🎵 album: ${
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
435
|
-
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;
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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}: ${
|
|
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
|
|
474
|
-
const title = info.title || folderTitle;
|
|
475
|
-
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;
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
628
|
+
const folderTitle = entry.replace(/^\d+-/, '');
|
|
537
629
|
try {
|
|
538
|
-
const
|
|
539
|
-
const
|
|
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,
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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:
|
|
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 {
|