wiki-plugin-shoppe 0.0.7 → 0.0.8

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
@@ -51,8 +51,15 @@ my-shoppe.zip
51
51
  cover.jpg ← optional cover image
52
52
  screenshot.png ← any assets referenced in the markdown
53
53
  info.json ← optional: { "title": "…", "description": "…" }
54
- 02-Another Post/
55
- post.md
54
+ 02-My Series/ ← subdirectories = multi-part series
55
+ cover.jpg ← optional series cover
56
+ intro.md ← optional series intro
57
+ info.json ← optional: { "title": "…", "description": "…" }
58
+ 01-Part One/
59
+ post.md
60
+ diagram.png
61
+ 02-Part Two/
62
+ post.md
56
63
  albums/
57
64
  Vacation 2025/ ← photo album = subfolder
58
65
  photo1.jpg
package/client/shoppe.js CHANGED
@@ -78,10 +78,16 @@
78
78
  01-Hello World/ ← number prefix sets table of contents order
79
79
  post.md ← the post content
80
80
  cover.jpg ← optional cover image
81
- screenshot.png ← any other assets referenced in the markdown
81
+ screenshot.png ← any assets referenced in the markdown
82
82
  info.json ← optional: { "title": "…", "description": "…" }
83
- 02-Another Post/
84
- post.md
83
+ 02-My Series/ ← subdirectories = multi-part series
84
+ cover.jpg ← optional series cover
85
+ info.json ← optional: { "title": "…", "description": "…" }
86
+ 01-Part One/
87
+ post.md
88
+ diagram.png
89
+ 02-Part Two/
90
+ post.md
85
91
  albums/
86
92
  Vacation 2025/ ← subfolder of images = photo album
87
93
  photo1.jpg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-plugin-shoppe",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
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
@@ -246,8 +246,16 @@ async function processArchive(zipPath) {
246
246
  zip.extractAllTo(tmpDir, true);
247
247
 
248
248
  try {
249
- // Read and validate manifest
250
- const manifestPath = path.join(tmpDir, 'manifest.json');
249
+ // Find manifest.json handle zips that wrap everything in a top-level folder
250
+ let root = tmpDir;
251
+ let manifestPath = path.join(tmpDir, 'manifest.json');
252
+ if (!fs.existsSync(manifestPath)) {
253
+ const entries = fs.readdirSync(tmpDir);
254
+ if (entries.length === 1 && fs.statSync(path.join(tmpDir, entries[0])).isDirectory()) {
255
+ root = path.join(tmpDir, entries[0]);
256
+ manifestPath = path.join(root, 'manifest.json');
257
+ }
258
+ }
251
259
  if (!fs.existsSync(manifestPath)) {
252
260
  throw new Error('Archive is missing manifest.json');
253
261
  }
@@ -267,7 +275,7 @@ async function processArchive(zipPath) {
267
275
 
268
276
  // ---- books/ ----
269
277
  // Each book is a subfolder containing the book file, cover.jpg, and info.json
270
- const booksDir = path.join(tmpDir, 'books');
278
+ const booksDir = path.join(root, 'books');
271
279
  if (fs.existsSync(booksDir)) {
272
280
  for (const entry of fs.readdirSync(booksDir)) {
273
281
  const entryPath = path.join(booksDir, entry);
@@ -307,7 +315,7 @@ async function processArchive(zipPath) {
307
315
 
308
316
  // ---- music/ ----
309
317
  // Albums are subfolders; standalone files are individual tracks
310
- const musicDir = path.join(tmpDir, 'music');
318
+ const musicDir = path.join(root, 'music');
311
319
  if (fs.existsSync(musicDir)) {
312
320
  for (const entry of fs.readdirSync(musicDir)) {
313
321
  const entryPath = path.join(musicDir, entry);
@@ -353,7 +361,7 @@ async function processArchive(zipPath) {
353
361
  // Each post is a numbered subfolder: "01-My Title/" containing post.md,
354
362
  // optional assets (images etc.), and optional info.json for metadata overrides.
355
363
  // Folders are sorted by their numeric prefix to build the table of contents.
356
- const postsDir = path.join(tmpDir, 'posts');
364
+ const postsDir = path.join(root, 'posts');
357
365
  if (fs.existsSync(postsDir)) {
358
366
  const postFolders = fs.readdirSync(postsDir)
359
367
  .filter(f => fs.statSync(path.join(postsDir, f)).isDirectory())
@@ -362,61 +370,139 @@ async function processArchive(zipPath) {
362
370
  for (let order = 0; order < postFolders.length; order++) {
363
371
  const entry = postFolders[order];
364
372
  const entryPath = path.join(postsDir, entry);
365
- try {
366
- // Metadata: info.json overrides folder name and md heading
367
- const infoPath = path.join(entryPath, 'info.json');
368
- const info = fs.existsSync(infoPath)
369
- ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
370
- : {};
373
+ const folderTitle = entry.replace(/^\d+-/, '');
374
+
375
+ const infoPath = path.join(entryPath, 'info.json');
376
+ const info = fs.existsSync(infoPath)
377
+ ? JSON.parse(fs.readFileSync(infoPath, 'utf8'))
378
+ : {};
379
+ const seriesTitle = info.title || folderTitle;
380
+
381
+ // Check if this is a multi-part series (has numbered subdirectories)
382
+ const subDirs = fs.readdirSync(entryPath)
383
+ .filter(f => fs.statSync(path.join(entryPath, f)).isDirectory())
384
+ .sort();
385
+ const mdFiles = fs.readdirSync(entryPath).filter(f => f.endsWith('.md'));
386
+ const isSeries = subDirs.length > 0;
387
+
388
+ if (isSeries) {
389
+ // Register the series itself as a parent product
390
+ try {
391
+ const description = info.description || `A ${subDirs.length}-part series`;
392
+ await sanoraCreateProduct(tenant, seriesTitle, 'post-series', description, 0, 0, `post,series,order:${order}`);
393
+
394
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
395
+ if (covers.length > 0) {
396
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
397
+ await sanoraUploadImage(tenant, seriesTitle, coverBuf, covers[0]);
398
+ }
399
+
400
+ // Optional series-level intro .md
401
+ if (mdFiles.length > 0) {
402
+ const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
403
+ await sanoraUploadArtifact(tenant, seriesTitle, mdBuf, mdFiles[0], 'text');
404
+ }
405
+
406
+ console.log(`[shoppe] 📝 series [${order + 1}]: ${seriesTitle} (${subDirs.length} parts)`);
407
+ } catch (err) {
408
+ console.warn(`[shoppe] ⚠️ series ${entry}: ${err.message}`);
409
+ }
371
410
 
372
- // Find the .md file
373
- const mdFiles = fs.readdirSync(entryPath).filter(f => f.endsWith('.md'));
374
- if (mdFiles.length === 0) {
375
- console.warn(`[shoppe] ⚠️ post ${entry}: no .md file found, skipping`);
376
- continue;
411
+ // Register each part
412
+ for (let partIndex = 0; partIndex < subDirs.length; partIndex++) {
413
+ const partEntry = subDirs[partIndex];
414
+ const partPath = path.join(entryPath, partEntry);
415
+ const partFolderTitle = partEntry.replace(/^\d+-/, '');
416
+
417
+ const partInfoPath = path.join(partPath, 'info.json');
418
+ const partInfo = fs.existsSync(partInfoPath)
419
+ ? JSON.parse(fs.readFileSync(partInfoPath, 'utf8'))
420
+ : {};
421
+ const partTitle = partInfo.title || partFolderTitle;
422
+ // Sanora product title must be unique — namespace under series
423
+ const productTitle = `${seriesTitle}: ${partTitle}`;
424
+
425
+ try {
426
+ const partMdFiles = fs.readdirSync(partPath).filter(f => f.endsWith('.md'));
427
+ if (partMdFiles.length === 0) {
428
+ console.warn(`[shoppe] ⚠️ part ${partEntry}: no .md file, skipping`);
429
+ continue;
430
+ }
431
+
432
+ const mdBuf = fs.readFileSync(path.join(partPath, partMdFiles[0]));
433
+ const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
434
+ const description = partInfo.description || firstHeading || partTitle;
435
+
436
+ await sanoraCreateProduct(tenant, productTitle, 'post', description, 0, 0,
437
+ `post,blog,series:${seriesTitle},part:${partIndex + 1},order:${order}`);
438
+
439
+ await sanoraUploadArtifact(tenant, productTitle, mdBuf, partMdFiles[0], 'text');
440
+
441
+ const partCovers = fs.readdirSync(partPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
442
+ if (partCovers.length > 0) {
443
+ const coverBuf = fs.readFileSync(path.join(partPath, partCovers[0]));
444
+ await sanoraUploadImage(tenant, productTitle, coverBuf, partCovers[0]);
445
+ }
446
+
447
+ const partAssets = fs.readdirSync(partPath).filter(f =>
448
+ !f.endsWith('.md') && f !== 'info.json' && f !== partCovers[0] &&
449
+ IMAGE_EXTS.has(path.extname(f).toLowerCase())
450
+ );
451
+ for (const asset of partAssets) {
452
+ const buf = fs.readFileSync(path.join(partPath, asset));
453
+ await sanoraUploadArtifact(tenant, productTitle, buf, asset, 'image');
454
+ }
455
+
456
+ console.log(`[shoppe] part ${partIndex + 1}: ${partTitle}`);
457
+ } catch (err) {
458
+ console.warn(`[shoppe] ⚠️ part ${partEntry}: ${err.message}`);
459
+ }
377
460
  }
378
- const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
379
- const mdContent = mdBuf.toString('utf8');
380
461
 
381
- // Derive title: info.json > folder name stripped of numeric prefix
382
- const folderTitle = entry.replace(/^\d+-/, '');
383
- const firstHeading = mdContent.split('\n')[0].replace(/^#+\s*/, '');
384
- const title = info.title || folderTitle;
385
- const description = info.description || firstHeading || title;
462
+ results.posts.push({ title: seriesTitle, order, parts: subDirs.length });
386
463
 
387
- await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
464
+ } else {
465
+ // Single post
466
+ try {
467
+ if (mdFiles.length === 0) {
468
+ console.warn(`[shoppe] ⚠️ post ${entry}: no .md file found, skipping`);
469
+ continue;
470
+ }
471
+ const mdBuf = fs.readFileSync(path.join(entryPath, mdFiles[0]));
472
+ const firstHeading = mdBuf.toString('utf8').split('\n')[0].replace(/^#+\s*/, '');
473
+ const title = info.title || folderTitle;
474
+ const description = info.description || firstHeading || title;
388
475
 
389
- // Upload the markdown as the main artifact
390
- await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
476
+ await sanoraCreateProduct(tenant, title, 'post', description, 0, 0, `post,blog,order:${order}`);
477
+ await sanoraUploadArtifact(tenant, title, mdBuf, mdFiles[0], 'text');
391
478
 
392
- // Upload cover image if present
393
- const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
394
- if (covers.length > 0) {
395
- const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
396
- await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
397
- }
479
+ const covers = fs.readdirSync(entryPath).filter(f => IMAGE_EXTS.has(path.extname(f).toLowerCase()));
480
+ if (covers.length > 0) {
481
+ const coverBuf = fs.readFileSync(path.join(entryPath, covers[0]));
482
+ await sanoraUploadImage(tenant, title, coverBuf, covers[0]);
483
+ }
398
484
 
399
- // Upload remaining assets (images not used as cover)
400
- const assets = fs.readdirSync(entryPath).filter(f =>
401
- !f.endsWith('.md') && f !== 'info.json' && f !== covers[0] &&
402
- IMAGE_EXTS.has(path.extname(f).toLowerCase())
403
- );
404
- for (const asset of assets) {
405
- const buf = fs.readFileSync(path.join(entryPath, asset));
406
- await sanoraUploadArtifact(tenant, title, buf, asset, 'image');
407
- }
485
+ const assets = fs.readdirSync(entryPath).filter(f =>
486
+ !f.endsWith('.md') && f !== 'info.json' && f !== covers[0] &&
487
+ IMAGE_EXTS.has(path.extname(f).toLowerCase())
488
+ );
489
+ for (const asset of assets) {
490
+ const buf = fs.readFileSync(path.join(entryPath, asset));
491
+ await sanoraUploadArtifact(tenant, title, buf, asset, 'image');
492
+ }
408
493
 
409
- results.posts.push({ title, order });
410
- console.log(`[shoppe] 📝 post [${order + 1}]: ${title}`);
411
- } catch (err) {
412
- console.warn(`[shoppe] ⚠️ post ${entry}: ${err.message}`);
494
+ results.posts.push({ title, order });
495
+ console.log(`[shoppe] 📝 post [${order + 1}]: ${title}`);
496
+ } catch (err) {
497
+ console.warn(`[shoppe] ⚠️ post ${entry}: ${err.message}`);
498
+ }
413
499
  }
414
500
  }
415
501
  }
416
502
 
417
503
  // ---- albums/ ----
418
504
  // Each subfolder is a photo album
419
- const albumsDir = path.join(tmpDir, 'albums');
505
+ const albumsDir = path.join(root, 'albums');
420
506
  if (fs.existsSync(albumsDir)) {
421
507
  for (const entry of fs.readdirSync(albumsDir)) {
422
508
  const entryPath = path.join(albumsDir, entry);
@@ -442,7 +528,7 @@ async function processArchive(zipPath) {
442
528
 
443
529
  // ---- products/ ----
444
530
  // Each subfolder is a physical product with cover.jpg + info.json
445
- const productsDir = path.join(tmpDir, 'products');
531
+ const productsDir = path.join(root, 'products');
446
532
  if (fs.existsSync(productsDir)) {
447
533
  for (const entry of fs.readdirSync(productsDir)) {
448
534
  const entryPath = path.join(productsDir, entry);