simple-photo-gallery 2.0.17 → 2.1.0

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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import process3, { stdout } from 'process';
2
+ import process4, { stdout } from 'process';
3
3
  import { Command } from 'commander';
4
4
  import { LogLevels, createConsola } from 'consola';
5
5
  import { execSync, spawn } from 'child_process';
@@ -9,15 +9,16 @@ import { Buffer } from 'buffer';
9
9
  import sharp2 from 'sharp';
10
10
  import { encode } from 'blurhash';
11
11
  import { GalleryDataSchema, GalleryDataDeprecatedSchema } from '@simple-photo-gallery/common';
12
+ import { extractThumbnailConfigFromGallery, loadThemeConfig, mergeThumbnailConfig } from '@simple-photo-gallery/common/theme';
12
13
  import ExifReader from 'exifreader';
13
14
  import ffprobe from 'node-ffprobe';
15
+ import { fileURLToPath } from 'url';
14
16
  import os from 'os';
15
17
  import Conf from 'conf';
16
18
  import axios from 'axios';
17
19
  import { compareSemVer, parseSemVer } from 'semver-parser';
18
20
 
19
21
  // src/config/index.ts
20
- var DEFAULT_THUMBNAIL_SIZE = 300;
21
22
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".tiff", ".tif", ".svg", ".avif"]);
22
23
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".avi", ".mov", ".wmv", ".flv", ".webm", ".mkv", ".m4v", ".3gp"]);
23
24
  var HEADER_IMAGE_LANDSCAPE_WIDTHS = [3840, 2560, 1920, 1280, 960, 640];
@@ -46,7 +47,7 @@ async function cropAndResizeImage(image, outputPath, width, height, format = "av
46
47
  withoutEnlargement: true
47
48
  }).toFormat(format).toFile(outputPath);
48
49
  }
49
- async function createImageThumbnails(image, metadata, outputPath, outputPathRetina, size) {
50
+ async function createImageThumbnails(image, metadata, outputPath, outputPathRetina, size, sizeDimension = "auto") {
50
51
  const originalWidth = metadata.width || 0;
51
52
  const originalHeight = metadata.height || 0;
52
53
  if (originalWidth === 0 || originalHeight === 0) {
@@ -55,12 +56,20 @@ async function createImageThumbnails(image, metadata, outputPath, outputPathReti
55
56
  const aspectRatio = originalWidth / originalHeight;
56
57
  let width;
57
58
  let height;
58
- if (originalWidth > originalHeight) {
59
+ if (sizeDimension === "width") {
59
60
  width = size;
60
61
  height = Math.round(size / aspectRatio);
61
- } else {
62
+ } else if (sizeDimension === "height") {
62
63
  width = Math.round(size * aspectRatio);
63
64
  height = size;
65
+ } else {
66
+ if (originalWidth > originalHeight) {
67
+ width = size;
68
+ height = Math.round(size / aspectRatio);
69
+ } else {
70
+ width = Math.round(size * aspectRatio);
71
+ height = size;
72
+ }
64
73
  }
65
74
  await resizeImage(image, outputPath, width, height);
66
75
  await resizeImage(image, outputPathRetina, width * 2, height * 2);
@@ -76,6 +85,30 @@ async function generateBlurHash(imagePath, componentX = 4, componentY = 3) {
76
85
  }
77
86
 
78
87
  // src/modules/build/utils/index.ts
88
+ function wrapText(text, maxCharsPerLine) {
89
+ const words = text.split(" ");
90
+ const lines = [];
91
+ let currentLine = "";
92
+ for (const word of words) {
93
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
94
+ if (word.length > maxCharsPerLine) {
95
+ if (currentLine) {
96
+ lines.push(currentLine);
97
+ currentLine = "";
98
+ }
99
+ lines.push(word);
100
+ } else if (testLine.length > maxCharsPerLine && currentLine) {
101
+ lines.push(currentLine);
102
+ currentLine = word;
103
+ } else {
104
+ currentLine = testLine;
105
+ }
106
+ }
107
+ if (currentLine) {
108
+ lines.push(currentLine);
109
+ }
110
+ return lines;
111
+ }
79
112
  async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPath, ui) {
80
113
  ui?.start(`Creating social media card image`);
81
114
  const headerBasename = path7.basename(headerPhotoPath, path7.extname(headerPhotoPath));
@@ -87,14 +120,38 @@ async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPa
87
120
  const resizedImageBuffer = await image.resize(1200, 631, { fit: "cover" }).jpeg({ quality: 90 }).toBuffer();
88
121
  const outputPath = ouputPath;
89
122
  await sharp2(resizedImageBuffer).toFile(outputPath);
123
+ const CANVAS_WIDTH = 1200;
124
+ const CANVAS_HEIGHT = 631;
125
+ const FONT_SIZE = 72;
126
+ const MARGIN = 50;
127
+ const CHAR_WIDTH_RATIO = 0.6;
128
+ const usableWidth = CANVAS_WIDTH - 2 * MARGIN;
129
+ const maxCharsPerLine = Math.floor(usableWidth / (FONT_SIZE * CHAR_WIDTH_RATIO));
130
+ const lines = wrapText(title, maxCharsPerLine);
131
+ const lineHeight = FONT_SIZE * 1.2;
132
+ const totalTextHeight = FONT_SIZE + (lines.length - 1) * lineHeight;
133
+ const startY = CANVAS_HEIGHT - MARGIN - totalTextHeight + FONT_SIZE;
134
+ const leftX = MARGIN;
135
+ const tspanElements = lines.map((line, index) => {
136
+ const yPosition = startY + index * lineHeight;
137
+ const escapedLine = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
138
+ return `<tspan x="${leftX}" y="${yPosition}">${escapedLine}</tspan>`;
139
+ }).join("\n ");
90
140
  const svgText = `
91
- <svg width="1200" height="631" xmlns="http://www.w3.org/2000/svg">
141
+ <svg width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
92
142
  <defs>
143
+ <linearGradient id="darkGradient" x1="0%" y1="0%" x2="0%" y2="100%">
144
+ <stop offset="0%" style="stop-color:rgb(0,0,0);stop-opacity:0" />
145
+ <stop offset="100%" style="stop-color:rgb(0,0,0);stop-opacity:0.65" />
146
+ </linearGradient>
93
147
  <style>
94
- .title { font-family: 'Arial, sans-serif'; font-size: 96px; font-weight: bold; fill: white; stroke: black; stroke-width: 5; paint-order: stroke; text-anchor: middle; }
148
+ .title { font-family: 'Arial, sans-serif'; font-size: ${FONT_SIZE}px; font-weight: bold; fill: white; text-anchor: start; }
95
149
  </style>
96
150
  </defs>
97
- <text x="600" y="250" class="title">${title}</text>
151
+ <rect x="0" y="0" width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" fill="url(#darkGradient)" />
152
+ <text x="${leftX}" class="title">
153
+ ${tspanElements}
154
+ </text>
98
155
  </svg>
99
156
  `;
100
157
  const finalImageBuffer = await sharp2(resizedImageBuffer).composite([{ input: Buffer.from(svgText), top: 0, left: 0 }]).jpeg({ quality: 90 }).toBuffer();
@@ -278,11 +335,15 @@ function migrateGalleryJson(deprecatedGalleryData, galleryJsonPath, ui) {
278
335
  filename: path7.basename(image.path)
279
336
  }))
280
337
  }));
338
+ const thumbnails2 = deprecatedGalleryData.thumbnailSize === void 0 ? void 0 : { size: deprecatedGalleryData.thumbnailSize };
281
339
  const galleryData = {
282
340
  ...deprecatedGalleryData,
341
+ thumbnailSize: void 0,
342
+ // Remove old field
283
343
  headerImage: path7.basename(deprecatedGalleryData.headerImage),
284
344
  sections,
285
- mediaBasePath
345
+ mediaBasePath,
346
+ thumbnails: thumbnails2
286
347
  };
287
348
  ui.debug("Backing up old gallery.json file");
288
349
  fs8.copyFileSync(galleryJsonPath, `${galleryJsonPath}.old`);
@@ -353,9 +414,18 @@ async function getGallerySettingsFromUser(galleryName, defaultImage, ui) {
353
414
  default: defaultImage,
354
415
  placeholder: defaultImage
355
416
  });
356
- return { title, description, url, headerImage };
417
+ return {
418
+ title,
419
+ description,
420
+ url,
421
+ headerImage,
422
+ thumbnails: {
423
+ size: "",
424
+ edge: void 0
425
+ }
426
+ };
357
427
  }
358
- async function createGalleryJson(mediaFiles, galleryJsonPath, scanPath, subGalleries = [], useDefaultSettings, ctaBanner, ui) {
428
+ async function createGalleryJson(mediaFiles, galleryJsonPath, scanPath, subGalleries = [], useDefaultSettings, ctaBanner, configOptions, ui) {
359
429
  const galleryDir = path7.dirname(galleryJsonPath);
360
430
  const isSameLocation = path7.relative(scanPath, path7.join(galleryDir, "..")) === "";
361
431
  const mediaBasePath = isSameLocation ? void 0 : scanPath;
@@ -363,12 +433,23 @@ async function createGalleryJson(mediaFiles, galleryJsonPath, scanPath, subGalle
363
433
  ...subGallery,
364
434
  headerImage: subGallery.headerImage ? path7.relative(galleryDir, subGallery.headerImage) : ""
365
435
  }));
436
+ const thumbnailsConfig = {};
437
+ if (configOptions.thumbnailSize !== void 0) {
438
+ thumbnailsConfig.size = configOptions.thumbnailSize;
439
+ }
440
+ if (configOptions.thumbnailEdge !== void 0) {
441
+ thumbnailsConfig.edge = configOptions.thumbnailEdge;
442
+ }
366
443
  let galleryData = {
367
444
  title: "My Gallery",
368
445
  description: "My gallery with fantastic photos.",
369
446
  headerImage: mediaFiles[0]?.filename || "",
370
447
  mediaBasePath,
371
448
  metadata: {},
449
+ // Include theme if provided via CLI
450
+ ...configOptions.theme && { theme: configOptions.theme },
451
+ // Include thumbnails if any values were set via CLI
452
+ ...Object.keys(thumbnailsConfig).length > 0 && { thumbnails: thumbnailsConfig },
372
453
  sections: [
373
454
  {
374
455
  images: mediaFiles
@@ -381,13 +462,15 @@ async function createGalleryJson(mediaFiles, galleryJsonPath, scanPath, subGalle
381
462
  ...ctaBanner !== void 0 && { ctaBanner }
382
463
  };
383
464
  if (!useDefaultSettings) {
465
+ const userSettings = await getGallerySettingsFromUser(
466
+ path7.basename(path7.join(galleryDir, "..")),
467
+ path7.basename(mediaFiles[0]?.filename || ""),
468
+ ui
469
+ );
470
+ const { thumbnails: thumbnails2, ...otherSettings } = userSettings;
384
471
  galleryData = {
385
472
  ...galleryData,
386
- ...await getGallerySettingsFromUser(
387
- path7.basename(path7.join(galleryDir, "..")),
388
- path7.basename(mediaFiles[0]?.filename || ""),
389
- ui
390
- )
473
+ ...otherSettings
391
474
  };
392
475
  }
393
476
  await promises.writeFile(galleryJsonPath, JSON.stringify(galleryData, null, 2));
@@ -402,7 +485,7 @@ async function galleryExists(outputPath) {
402
485
  return false;
403
486
  }
404
487
  }
405
- async function processDirectory(scanPath, outputPath, recursive, useDefaultSettings, force, ctaBanner, ui) {
488
+ async function processDirectory(scanPath, outputPath, recursive, useDefaultSettings, force, ctaBanner, configOptions, ui) {
406
489
  ui.start(`Scanning ${scanPath}`);
407
490
  let totalFiles = 0;
408
491
  let totalGalleries = 1;
@@ -418,6 +501,7 @@ async function processDirectory(scanPath, outputPath, recursive, useDefaultSetti
418
501
  useDefaultSettings,
419
502
  force,
420
503
  ctaBanner,
504
+ configOptions,
421
505
  ui
422
506
  );
423
507
  totalFiles += result2.totalFiles;
@@ -443,7 +527,16 @@ async function processDirectory(scanPath, outputPath, recursive, useDefaultSetti
443
527
  }
444
528
  try {
445
529
  await promises.mkdir(galleryPath, { recursive: true });
446
- await createGalleryJson(mediaFiles, galleryJsonPath, scanPath, subGalleries, useDefaultSettings, ctaBanner, ui);
530
+ await createGalleryJson(
531
+ mediaFiles,
532
+ galleryJsonPath,
533
+ scanPath,
534
+ subGalleries,
535
+ useDefaultSettings,
536
+ ctaBanner,
537
+ configOptions,
538
+ ui
539
+ );
447
540
  ui.success(
448
541
  `Create gallery with ${mediaFiles.length} files and ${subGalleries.length} subgalleries at: ${galleryJsonPath}`
449
542
  );
@@ -467,6 +560,11 @@ async function init(options, ui) {
467
560
  try {
468
561
  const scanPath = path7.resolve(options.photos);
469
562
  const outputPath = options.gallery ? path7.resolve(options.gallery) : scanPath;
563
+ const configOptions = {
564
+ theme: options.theme,
565
+ thumbnailSize: options.thumbnailSize,
566
+ thumbnailEdge: options.thumbnailEdge
567
+ };
470
568
  const result = await processDirectory(
471
569
  scanPath,
472
570
  outputPath,
@@ -474,6 +572,7 @@ async function init(options, ui) {
474
572
  options.default,
475
573
  options.force,
476
574
  options.ctaBanner,
575
+ configOptions,
477
576
  ui
478
577
  );
479
578
  ui.box(
@@ -523,9 +622,25 @@ async function getVideoDimensions(filePath) {
523
622
  }
524
623
  return dimensions;
525
624
  }
526
- async function createVideoThumbnails(inputPath, videoDimensions, outputPath, outputPathRetina, height, verbose = false) {
625
+ async function createVideoThumbnails(inputPath, videoDimensions, outputPath, outputPathRetina, size, sizeDimension = "auto", verbose = false) {
527
626
  const aspectRatio = videoDimensions.width / videoDimensions.height;
528
- const width = Math.round(height * aspectRatio);
627
+ let width;
628
+ let height;
629
+ if (sizeDimension === "width") {
630
+ width = size;
631
+ height = Math.round(size / aspectRatio);
632
+ } else if (sizeDimension === "height") {
633
+ width = Math.round(size * aspectRatio);
634
+ height = size;
635
+ } else {
636
+ if (videoDimensions.width > videoDimensions.height) {
637
+ width = size;
638
+ height = Math.round(size / aspectRatio);
639
+ } else {
640
+ width = Math.round(size * aspectRatio);
641
+ height = size;
642
+ }
643
+ }
529
644
  const tempFramePath = `${outputPath}.temp.png`;
530
645
  return new Promise((resolve, reject) => {
531
646
  const ffmpeg = spawn("ffmpeg", [
@@ -566,7 +681,7 @@ async function createVideoThumbnails(inputPath, videoDimensions, outputPath, out
566
681
  }
567
682
 
568
683
  // src/modules/thumbnails/index.ts
569
- async function processImage(imagePath, thumbnailPath, thumbnailPathRetina, thumbnailSize, lastMediaTimestamp) {
684
+ async function processImage(imagePath, thumbnailPath, thumbnailPathRetina, thumbnailSize, thumbnailSizeDimension = "auto", lastMediaTimestamp) {
570
685
  const fileMtime = await getFileMtime(imagePath);
571
686
  if (lastMediaTimestamp && fileMtime <= lastMediaTimestamp && fs8.existsSync(thumbnailPath)) {
572
687
  return void 0;
@@ -585,7 +700,8 @@ async function processImage(imagePath, thumbnailPath, thumbnailPathRetina, thumb
585
700
  metadata,
586
701
  thumbnailPath,
587
702
  thumbnailPathRetina,
588
- thumbnailSize
703
+ thumbnailSize,
704
+ thumbnailSizeDimension
589
705
  );
590
706
  const blurHash = await generateBlurHash(thumbnailPath);
591
707
  return {
@@ -604,7 +720,7 @@ async function processImage(imagePath, thumbnailPath, thumbnailPathRetina, thumb
604
720
  lastMediaTimestamp: fileMtime.toISOString()
605
721
  };
606
722
  }
607
- async function processVideo(videoPath, thumbnailPath, thumbnailPathRetina, thumbnailSize, verbose, lastMediaTimestamp) {
723
+ async function processVideo(videoPath, thumbnailPath, thumbnailPathRetina, thumbnailSize, thumbnailSizeDimension = "auto", verbose, lastMediaTimestamp) {
608
724
  const fileMtime = await getFileMtime(videoPath);
609
725
  if (lastMediaTimestamp && fileMtime <= lastMediaTimestamp && fs8.existsSync(thumbnailPath)) {
610
726
  return void 0;
@@ -616,6 +732,7 @@ async function processVideo(videoPath, thumbnailPath, thumbnailPathRetina, thumb
616
732
  thumbnailPath,
617
733
  thumbnailPathRetina,
618
734
  thumbnailSize,
735
+ thumbnailSizeDimension,
619
736
  verbose
620
737
  );
621
738
  const blurHash = await generateBlurHash(thumbnailPath);
@@ -635,7 +752,7 @@ async function processVideo(videoPath, thumbnailPath, thumbnailPathRetina, thumb
635
752
  lastMediaTimestamp: fileMtime.toISOString()
636
753
  };
637
754
  }
638
- async function processMediaFile(mediaFile, mediaBasePath, thumbnailsPath, thumbnailSize, ui) {
755
+ async function processMediaFile(mediaFile, mediaBasePath, thumbnailsPath, thumbnailConfig, ui) {
639
756
  try {
640
757
  const filePath = path7.resolve(path7.join(mediaBasePath, mediaFile.filename));
641
758
  const fileName = mediaFile.filename;
@@ -646,7 +763,22 @@ async function processMediaFile(mediaFile, mediaBasePath, thumbnailsPath, thumbn
646
763
  const lastMediaTimestamp = mediaFile.lastMediaTimestamp ? new Date(mediaFile.lastMediaTimestamp) : void 0;
647
764
  const verbose = ui.level === LogLevels.debug;
648
765
  ui.debug(` Processing ${mediaFile.type}: ${fileName}`);
649
- const updatedMediaFile = await (mediaFile.type === "image" ? processImage(filePath, thumbnailPath, thumbnailPathRetina, thumbnailSize, lastMediaTimestamp) : processVideo(filePath, thumbnailPath, thumbnailPathRetina, thumbnailSize, verbose, lastMediaTimestamp));
766
+ const updatedMediaFile = await (mediaFile.type === "image" ? processImage(
767
+ filePath,
768
+ thumbnailPath,
769
+ thumbnailPathRetina,
770
+ thumbnailConfig.size,
771
+ thumbnailConfig.edge,
772
+ lastMediaTimestamp
773
+ ) : processVideo(
774
+ filePath,
775
+ thumbnailPath,
776
+ thumbnailPathRetina,
777
+ thumbnailConfig.size,
778
+ thumbnailConfig.edge,
779
+ verbose,
780
+ lastMediaTimestamp
781
+ ));
650
782
  if (!updatedMediaFile) {
651
783
  ui.debug(` Skipping ${fileName} because it has already been processed`);
652
784
  if (mediaFile.thumbnail && !mediaFile.thumbnail.blurHash && fs8.existsSync(thumbnailPath)) {
@@ -682,19 +814,30 @@ async function processMediaFile(mediaFile, mediaBasePath, thumbnailsPath, thumbn
682
814
  return { ...mediaFile, thumbnail: void 0 };
683
815
  }
684
816
  }
685
- async function processGalleryThumbnails(galleryDir, ui) {
817
+ async function processGalleryThumbnails(galleryDir, ui, cliThumbnailConfig) {
686
818
  const galleryJsonPath = path7.join(galleryDir, "gallery", "gallery.json");
687
819
  const thumbnailsPath = path7.join(galleryDir, "gallery", "images");
688
820
  ui.start(`Creating thumbnails: ${galleryDir}`);
689
821
  try {
690
822
  fs8.mkdirSync(thumbnailsPath, { recursive: true });
691
823
  const galleryData = parseGalleryJson(galleryJsonPath, ui);
692
- const thumbnailSize = galleryData.thumbnailSize || DEFAULT_THUMBNAIL_SIZE;
824
+ const galleryThumbnailConfig = extractThumbnailConfigFromGallery(galleryData);
825
+ let themeConfig;
826
+ if (galleryData.theme) {
827
+ try {
828
+ const themeDir = await resolveThemeDir(galleryData.theme, ui);
829
+ themeConfig = loadThemeConfig(themeDir);
830
+ } catch {
831
+ ui.debug(`Could not load theme config from ${galleryData.theme}, using defaults`);
832
+ }
833
+ }
834
+ const thumbnailConfig = mergeThumbnailConfig(cliThumbnailConfig, galleryThumbnailConfig, themeConfig);
835
+ ui.debug(`Thumbnail config: size=${thumbnailConfig.size}, edge=${thumbnailConfig.edge}`);
693
836
  const mediaBasePath = galleryData.mediaBasePath ?? path7.join(galleryDir);
694
837
  let processedCount = 0;
695
838
  for (const section of galleryData.sections) {
696
839
  for (const [index, mediaFile] of section.images.entries()) {
697
- section.images[index] = await processMediaFile(mediaFile, mediaBasePath, thumbnailsPath, thumbnailSize, ui);
840
+ section.images[index] = await processMediaFile(mediaFile, mediaBasePath, thumbnailsPath, thumbnailConfig, ui);
698
841
  }
699
842
  processedCount += section.images.length;
700
843
  }
@@ -713,10 +856,11 @@ async function thumbnails(options, ui) {
713
856
  ui.error("No galleries found.");
714
857
  return { processedGalleryCount: 0, processedMediaCount: 0 };
715
858
  }
859
+ const cliThumbnailConfig = options.thumbnailSize !== void 0 || options.thumbnailEdge !== void 0 ? { size: options.thumbnailSize, edge: options.thumbnailEdge } : void 0;
716
860
  let totalGalleries = 0;
717
861
  let totalProcessed = 0;
718
862
  for (const galleryDir of galleryDirs) {
719
- const processed = await processGalleryThumbnails(galleryDir, ui);
863
+ const processed = await processGalleryThumbnails(galleryDir, ui, cliThumbnailConfig);
720
864
  if (processed > 0) {
721
865
  ++totalGalleries;
722
866
  totalProcessed += processed;
@@ -775,7 +919,7 @@ async function scanAndAppendNewFiles(galleryDir, galleryJsonPath, galleryData, u
775
919
  }
776
920
  return galleryData;
777
921
  }
778
- async function buildGallery(galleryDir, templateDir, scan, shouldCreateThumbnails, ui, baseUrl, thumbsBaseUrl) {
922
+ async function buildGallery(galleryDir, templateDir, scan, shouldCreateThumbnails, ui, baseUrl, thumbsBaseUrl, cliThumbnailConfig, cliTheme) {
779
923
  ui.start(`Building gallery ${galleryDir}`);
780
924
  const galleryJsonPath = path7.join(galleryDir, "gallery", "gallery.json");
781
925
  let galleryData = parseGalleryJson(galleryJsonPath, ui);
@@ -828,18 +972,35 @@ async function buildGallery(galleryDir, templateDir, scan, shouldCreateThumbnail
828
972
  galleryData.thumbsBaseUrl = thumbsBaseUrl;
829
973
  fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
830
974
  }
975
+ if (cliTheme && galleryData.theme !== cliTheme) {
976
+ ui.debug("Updating gallery.json with theme");
977
+ galleryData.theme = cliTheme;
978
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
979
+ }
980
+ if (cliThumbnailConfig) {
981
+ const needsUpdate = cliThumbnailConfig.size !== void 0 && galleryData.thumbnails?.size !== cliThumbnailConfig.size || cliThumbnailConfig.edge !== void 0 && galleryData.thumbnails?.edge !== cliThumbnailConfig.edge;
982
+ if (needsUpdate) {
983
+ ui.debug("Updating gallery.json with thumbnail settings");
984
+ galleryData.thumbnails = {
985
+ ...galleryData.thumbnails,
986
+ ...cliThumbnailConfig.size !== void 0 && { size: cliThumbnailConfig.size },
987
+ ...cliThumbnailConfig.edge !== void 0 && { edge: cliThumbnailConfig.edge }
988
+ };
989
+ fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
990
+ }
991
+ }
831
992
  if (!galleryData.metadata.image) {
832
993
  ui.debug("Updating gallery.json with social media card URL");
833
994
  galleryData.metadata.image = thumbsBaseUrl ? `${thumbsBaseUrl}/${path7.basename(socialMediaCardImagePath)}` : `${galleryData.url || ""}/${path7.relative(galleryDir, socialMediaCardImagePath)}`;
834
995
  fs8.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2));
835
996
  }
836
997
  if (shouldCreateThumbnails) {
837
- await processGalleryThumbnails(galleryDir, ui);
998
+ await processGalleryThumbnails(galleryDir, ui, cliThumbnailConfig);
838
999
  }
839
1000
  ui.debug("Building gallery from template");
840
1001
  try {
841
- process3.env.GALLERY_JSON_PATH = galleryJsonPath;
842
- process3.env.GALLERY_OUTPUT_DIR = path7.join(galleryDir, "gallery");
1002
+ process4.env.GALLERY_JSON_PATH = galleryJsonPath;
1003
+ process4.env.GALLERY_OUTPUT_DIR = path7.join(galleryDir, "gallery");
843
1004
  execSync("npx astro build", { cwd: templateDir, stdio: ui.level === LogLevels.debug ? "inherit" : "ignore" });
844
1005
  } catch (error) {
845
1006
  ui.error(`Build failed for ${galleryDir}`);
@@ -856,6 +1017,25 @@ async function buildGallery(galleryDir, templateDir, scan, shouldCreateThumbnail
856
1017
  fs8.rmSync(buildDir, { recursive: true, force: true });
857
1018
  ui.success(`Gallery built successfully`);
858
1019
  }
1020
+ function isLocalThemePath(theme) {
1021
+ return theme.startsWith("./") || theme.startsWith("../") || theme.startsWith("/");
1022
+ }
1023
+ async function resolveThemeDir(theme, ui) {
1024
+ if (isLocalThemePath(theme)) {
1025
+ const themeDir = path7.resolve(theme);
1026
+ const packageJsonPath = path7.join(themeDir, "package.json");
1027
+ if (!fs8.existsSync(packageJsonPath)) {
1028
+ throw new Error(`Theme directory not found or invalid: ${themeDir}. package.json not found.`);
1029
+ }
1030
+ ui.debug(`Using local theme: ${themeDir}`);
1031
+ return themeDir;
1032
+ } else {
1033
+ const themePath = await import.meta.resolve(`${theme}/package.json`);
1034
+ const themeDir = path7.dirname(new URL(themePath).pathname);
1035
+ ui.debug(`Using npm theme package: ${theme} (${themeDir})`);
1036
+ return themeDir;
1037
+ }
1038
+ }
859
1039
  async function build(options, ui) {
860
1040
  try {
861
1041
  const galleryDirs = findGalleries(options.gallery, options.recursive);
@@ -863,20 +1043,41 @@ async function build(options, ui) {
863
1043
  ui.error("No galleries found.");
864
1044
  return { processedGalleryCount: 0 };
865
1045
  }
866
- const themePath = await import.meta.resolve("@simple-photo-gallery/theme-modern/package.json");
867
- const themeDir = path7.dirname(new URL(themePath).pathname);
1046
+ const cliThumbnailConfig = options.thumbnailSize !== void 0 || options.thumbnailEdge !== void 0 ? { size: options.thumbnailSize, edge: options.thumbnailEdge } : void 0;
868
1047
  let totalGalleries = 0;
869
1048
  for (const dir of galleryDirs) {
1049
+ const galleryJsonPath = path7.join(dir, "gallery", "gallery.json");
1050
+ const galleryData = parseGalleryJson(galleryJsonPath, ui);
1051
+ const themeIdentifier = options.theme || galleryData.theme || "@simple-photo-gallery/theme-modern";
1052
+ const themeDir = await resolveThemeDir(themeIdentifier, ui);
870
1053
  const baseUrl = options.baseUrl ? `${options.baseUrl}${path7.relative(options.gallery, dir)}` : void 0;
871
1054
  const thumbsBaseUrl = options.thumbsBaseUrl ? `${options.thumbsBaseUrl}${path7.relative(options.gallery, dir)}` : void 0;
872
- await buildGallery(path7.resolve(dir), themeDir, options.scan, options.thumbnails, ui, baseUrl, thumbsBaseUrl);
1055
+ await buildGallery(
1056
+ path7.resolve(dir),
1057
+ themeDir,
1058
+ options.scan,
1059
+ options.thumbnails,
1060
+ ui,
1061
+ baseUrl,
1062
+ thumbsBaseUrl,
1063
+ cliThumbnailConfig,
1064
+ options.theme
1065
+ );
873
1066
  ++totalGalleries;
874
1067
  }
875
1068
  ui.box(`Built ${totalGalleries} ${totalGalleries === 1 ? "gallery" : "galleries"} successfully`);
876
1069
  return { processedGalleryCount: totalGalleries };
877
1070
  } catch (error) {
878
- if (error instanceof Error && error.message.includes("Cannot find package")) {
879
- ui.error("Theme package not found: @simple-photo-gallery/theme-modern/package.json");
1071
+ if (error instanceof Error) {
1072
+ if (error.message.includes("Cannot find package")) {
1073
+ ui.error(
1074
+ `Theme package not found: ${options.theme || "@simple-photo-gallery/theme-modern"}. Make sure it's installed.`
1075
+ );
1076
+ } else if (error.message.includes("Theme directory not found") || error.message.includes("package.json not found")) {
1077
+ ui.error(error.message);
1078
+ } else {
1079
+ ui.error("Error building gallery");
1080
+ }
880
1081
  } else {
881
1082
  ui.error("Error building gallery");
882
1083
  }
@@ -934,6 +1135,162 @@ async function clean(options, ui) {
934
1135
  throw error;
935
1136
  }
936
1137
  }
1138
+ function findMonorepoRoot(startDir) {
1139
+ let dir = path7.resolve(startDir);
1140
+ while (true) {
1141
+ const pkgPath = path7.join(dir, "package.json");
1142
+ if (fs8.existsSync(pkgPath)) {
1143
+ try {
1144
+ const pkg = JSON.parse(fs8.readFileSync(pkgPath, "utf8"));
1145
+ if (pkg && typeof pkg === "object" && "workspaces" in pkg) {
1146
+ return dir;
1147
+ }
1148
+ } catch {
1149
+ }
1150
+ }
1151
+ const parent = path7.dirname(dir);
1152
+ if (parent === dir) {
1153
+ return void 0;
1154
+ }
1155
+ dir = parent;
1156
+ }
1157
+ }
1158
+ function validateThemeName(name) {
1159
+ if (!name || name.trim().length === 0) {
1160
+ throw new Error("Theme name cannot be empty");
1161
+ }
1162
+ if (!/^[a-z0-9-]+$/i.test(name)) {
1163
+ throw new Error("Theme name can only contain letters, numbers, and hyphens");
1164
+ }
1165
+ return true;
1166
+ }
1167
+ async function ensureDirectory(dirPath, ui) {
1168
+ try {
1169
+ await fs8.promises.mkdir(dirPath, { recursive: true });
1170
+ ui.debug(`Created directory: ${dirPath}`);
1171
+ } catch (error) {
1172
+ if (error instanceof Error && "code" in error && error.code !== "EEXIST") {
1173
+ throw new Error(`Failed to create directory ${dirPath}: ${error.message}`);
1174
+ }
1175
+ }
1176
+ }
1177
+ var EXCLUDE_PATTERNS = ["node_modules", ".astro", "dist", "_build", ".git", "*.log", ".DS_Store"];
1178
+ function shouldExclude(name) {
1179
+ if (name === "README.md" || name === "README_BASE.md") {
1180
+ return true;
1181
+ }
1182
+ return EXCLUDE_PATTERNS.some((pattern) => {
1183
+ if (pattern.includes("*")) {
1184
+ const regexPattern = pattern.split("*").join(".*");
1185
+ const regex = new RegExp(regexPattern);
1186
+ return regex.test(name);
1187
+ }
1188
+ return name === pattern;
1189
+ });
1190
+ }
1191
+ async function copyDirectory(src, dest, ui) {
1192
+ await fs8.promises.mkdir(dest, { recursive: true });
1193
+ const entries = await fs8.promises.readdir(src, { withFileTypes: true });
1194
+ for (const entry of entries) {
1195
+ if (shouldExclude(entry.name)) {
1196
+ ui.debug(`Skipping excluded file/directory: ${entry.name}`);
1197
+ continue;
1198
+ }
1199
+ const srcPath = path7.join(src, entry.name);
1200
+ const destPath = path7.join(dest, entry.name);
1201
+ if (entry.isDirectory()) {
1202
+ await copyDirectory(srcPath, destPath, ui);
1203
+ } else {
1204
+ await fs8.promises.copyFile(srcPath, destPath);
1205
+ ui.debug(`Copied file: ${destPath}`);
1206
+ }
1207
+ }
1208
+ }
1209
+ function findBaseThemePath() {
1210
+ const moduleDir = path7.dirname(fileURLToPath(import.meta.url));
1211
+ const bundledTemplatePath = path7.resolve(moduleDir, "../../../src/modules/create-theme/templates/base");
1212
+ if (fs8.existsSync(bundledTemplatePath)) {
1213
+ return bundledTemplatePath;
1214
+ }
1215
+ const monorepoRoot = findMonorepoRoot(process4.cwd());
1216
+ const workspaceRoot = monorepoRoot ?? process4.cwd();
1217
+ const workspaceBaseThemePath = path7.join(workspaceRoot, "themes", "base");
1218
+ if (fs8.existsSync(workspaceBaseThemePath)) {
1219
+ return workspaceBaseThemePath;
1220
+ }
1221
+ throw new Error(
1222
+ `Base theme template not found. Tried:
1223
+ - ${bundledTemplatePath}
1224
+ - ${workspaceBaseThemePath}
1225
+
1226
+ Please ensure the templates are included in the package or themes/base exists in the workspace.`
1227
+ );
1228
+ }
1229
+ async function updatePackageJson(themeDir, themeName, ui) {
1230
+ const packageJsonPath = path7.join(themeDir, "package.json");
1231
+ const packageJsonContent = await fs8.promises.readFile(packageJsonPath, "utf8");
1232
+ const packageJson = JSON.parse(packageJsonContent);
1233
+ packageJson.name = themeName;
1234
+ await fs8.promises.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n", "utf8");
1235
+ ui.debug(`Updated package.json with theme name: ${themeName}`);
1236
+ }
1237
+ async function createReadmeFromBase(baseThemePath, themeDir, themeName, ui) {
1238
+ const readmeBasePath = path7.join(baseThemePath, "README_BASE.md");
1239
+ const readmePath = path7.join(themeDir, "README.md");
1240
+ if (!fs8.existsSync(readmeBasePath)) {
1241
+ throw new Error(`README_BASE.md not found in template: ${readmeBasePath}`);
1242
+ }
1243
+ let readme = await fs8.promises.readFile(readmeBasePath, "utf8");
1244
+ const displayName = themeName.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
1245
+ readme = readme.replaceAll("{THEME_NAME}", displayName);
1246
+ readme = readme.replaceAll("{THEME_NAME_LOWER}", displayName.toLowerCase());
1247
+ await fs8.promises.writeFile(readmePath, readme, "utf8");
1248
+ ui.debug(`Created README.md from README_BASE.md for theme: ${themeName}`);
1249
+ }
1250
+ async function createTheme(options, ui) {
1251
+ try {
1252
+ validateThemeName(options.name);
1253
+ let themeDir;
1254
+ if (options.path) {
1255
+ themeDir = path7.resolve(options.path);
1256
+ } else {
1257
+ const monorepoRoot = findMonorepoRoot(process4.cwd());
1258
+ const baseDir = monorepoRoot ?? process4.cwd();
1259
+ const themesBaseDir = path7.resolve(baseDir, "themes");
1260
+ themeDir = path7.join(themesBaseDir, options.name);
1261
+ if (!fs8.existsSync(themesBaseDir)) {
1262
+ await ensureDirectory(themesBaseDir, ui);
1263
+ }
1264
+ }
1265
+ if (fs8.existsSync(themeDir)) {
1266
+ throw new Error(`Theme directory already exists: ${themeDir}. Cannot overwrite existing theme.`);
1267
+ }
1268
+ ui.start(`Creating theme: ${options.name}`);
1269
+ const baseThemePath = findBaseThemePath();
1270
+ ui.debug(`Using base theme from: ${baseThemePath}`);
1271
+ ui.debug("Copying base theme files...");
1272
+ await copyDirectory(baseThemePath, themeDir, ui);
1273
+ ui.debug("Updating theme-specific files...");
1274
+ await updatePackageJson(themeDir, options.name, ui);
1275
+ await createReadmeFromBase(baseThemePath, themeDir, options.name, ui);
1276
+ ui.success(`Theme created successfully at: ${themeDir}`);
1277
+ ui.info(`
1278
+ Next steps:`);
1279
+ ui.info(`1. cd ${themeDir}`);
1280
+ ui.info(`2. yarn install`);
1281
+ ui.info(`3. Customize your theme in src/pages/index.astro`);
1282
+ ui.info(`4. Initialize a gallery (run from directory with your images): spg init -p <images-folder>`);
1283
+ ui.info(`5. Build a gallery with your theme: spg build --theme ${themeDir} -g <gallery-folder>`);
1284
+ return { processedGalleryCount: 0 };
1285
+ } catch (error) {
1286
+ if (error instanceof Error) {
1287
+ ui.error(error.message);
1288
+ } else {
1289
+ ui.error("Failed to create theme");
1290
+ }
1291
+ throw error;
1292
+ }
1293
+ }
937
1294
 
938
1295
  // src/modules/telemetry/index.ts
939
1296
  async function telemetry(options, ui, telemetryService2) {
@@ -963,7 +1320,7 @@ var ApiTelemetryClient = class {
963
1320
  axios.post(this.endpoint, event, {
964
1321
  headers: {
965
1322
  "content-type": "application/json",
966
- "user-agent": `simple-photo-gallery/${event.packageVersion} (${process3.platform}; ${process3.arch})`
1323
+ "user-agent": `simple-photo-gallery/${event.packageVersion} (${process4.platform}; ${process4.arch})`
967
1324
  }
968
1325
  });
969
1326
  } catch {
@@ -1000,11 +1357,11 @@ var TelemetryService = class {
1000
1357
  if (override) {
1001
1358
  return override === "1";
1002
1359
  }
1003
- if (process3.env.CI || process3.env.DO_NOT_TRACK) {
1360
+ if (process4.env.CI || process4.env.DO_NOT_TRACK) {
1004
1361
  return false;
1005
1362
  }
1006
- if (process3.env.SPG_TELEMETRY) {
1007
- return process3.env.SPG_TELEMETRY === "1";
1363
+ if (process4.env.SPG_TELEMETRY) {
1364
+ return process4.env.SPG_TELEMETRY === "1";
1008
1365
  }
1009
1366
  const stored = this.getStoredPreference();
1010
1367
  if (stored === void 0) {
@@ -1046,7 +1403,7 @@ var TelemetryService = class {
1046
1403
  durationMs: now - startedAt,
1047
1404
  packageName: this.packageName,
1048
1405
  packageVersion: this.packageVersion,
1049
- nodeVersion: process3.version,
1406
+ nodeVersion: process4.version,
1050
1407
  osPlatform: os.platform(),
1051
1408
  osRelease: os.release(),
1052
1409
  osArch: os.arch(),
@@ -1080,7 +1437,7 @@ var TelemetryService = class {
1080
1437
  /** Returns the telemetry client. */
1081
1438
  getClient() {
1082
1439
  if (!this.client) {
1083
- switch (process3.env.SPG_TELEMETRY_PROVIDER) {
1440
+ switch (process4.env.SPG_TELEMETRY_PROVIDER) {
1084
1441
  case "none": {
1085
1442
  this.client = void 0;
1086
1443
  break;
@@ -1172,7 +1529,7 @@ async function waitForUpdateCheck(checkPromise) {
1172
1529
  // package.json
1173
1530
  var package_default = {
1174
1531
  name: "simple-photo-gallery",
1175
- version: "2.0.17"};
1532
+ version: "2.1.0"};
1176
1533
 
1177
1534
  // src/index.ts
1178
1535
  var program = new Command();
@@ -1214,7 +1571,7 @@ function withCommandContext(handler) {
1214
1571
  } catch (error) {
1215
1572
  ui.debug(error);
1216
1573
  errorInfo = error instanceof Error ? { name: error.name, message: error.message } : { name: "UnknownError", message: String(error) };
1217
- process3.exitCode = 1;
1574
+ process4.exitCode = 1;
1218
1575
  }
1219
1576
  const updateInfo = await waitForUpdateCheck(updateCheckPromise);
1220
1577
  if (updateInfo) {
@@ -1236,14 +1593,21 @@ function withCommandContext(handler) {
1236
1593
  program.command("init").description("Initialize a gallery by scaning a folder for images and videos").option(
1237
1594
  "-p, --photos <path>",
1238
1595
  "Path to the folder where the photos are stored. Default: current working directory",
1239
- process3.cwd()
1596
+ process4.cwd()
1240
1597
  ).option(
1241
1598
  "-g, --gallery <path>",
1242
1599
  "Path to the directory where the gallery will be initialized. Default: same directory as the photos folder"
1243
- ).option("-r, --recursive", "Recursively create galleries from all photos subdirectories", false).option("-d, --default", "Use default gallery settings instead of asking the user", false).option("-f, --force", "Force override existing galleries without asking", false).option("--cta-banner", "Add a Simple Photo Gallery call-to-action banner to the end of the gallery", false).action(withCommandContext((options, ui) => init(options, ui)));
1244
- program.command("thumbnails").description("Create thumbnails for all media files in the gallery").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process3.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).action(withCommandContext((options, ui) => thumbnails(options, ui)));
1245
- program.command("build").description("Build the HTML gallery in the specified directory").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process3.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).option("-b, --base-url <url>", "Base URL where the photos are hosted").option("-t, --thumbs-base-url <url>", "Base URL where the thumbnails are hosted").option("--no-thumbnails", "Skip creating thumbnails when building the gallery", true).option("--no-scan", "Do not scan for new photos when building the gallery", true).action(withCommandContext((options, ui) => build(options, ui)));
1246
- program.command("clean").description("Remove all gallery files and folders (index.html, gallery/)").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process3.cwd()).option("-r, --recursive", "Clean subdirectories recursively", false).action(withCommandContext((options, ui) => clean(options, ui)));
1600
+ ).option("-r, --recursive", "Recursively create galleries from all photos subdirectories", false).option("-d, --default", "Use default gallery settings instead of asking the user", false).option("-f, --force", "Force override existing galleries without asking", false).option("--cta-banner", "Add a Simple Photo Gallery call-to-action banner to the end of the gallery", false).option("--theme <package|path>", "Theme package name or local path to store in gallery.json").option("--thumbnail-size <pixels>", "Thumbnail size in pixels to store in gallery.json", Number.parseInt).option("--thumbnail-edge <mode>", "How thumbnail size is applied: auto, width, or height").action(withCommandContext((options, ui) => init(options, ui)));
1601
+ program.command("thumbnails").description("Create thumbnails for all media files in the gallery").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process4.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).option("--thumbnail-size <pixels>", "Override thumbnail size in pixels", Number.parseInt).option("--thumbnail-edge <mode>", "Override how thumbnail size is applied: auto, width, or height").action(withCommandContext((options, ui) => thumbnails(options, ui)));
1602
+ program.command("build").description("Build the HTML gallery in the specified directory").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process4.cwd()).option("-r, --recursive", "Scan subdirectories recursively", false).option("-b, --base-url <url>", "Base URL where the photos are hosted").option("-t, --thumbs-base-url <url>", "Base URL where the thumbnails are hosted").option("--no-thumbnails", "Skip creating thumbnails when building the gallery", true).option("--no-scan", "Do not scan for new photos when building the gallery", true).option(
1603
+ "--theme <package|path>",
1604
+ "Theme package name (e.g., @simple-photo-gallery/theme-modern) or local path (e.g., ./themes/my-theme)"
1605
+ ).option("--thumbnail-size <pixels>", "Override thumbnail size in pixels", Number.parseInt).option("--thumbnail-edge <mode>", "Override how thumbnail size is applied: auto, width, or height").action(withCommandContext((options, ui) => build(options, ui)));
1606
+ program.command("clean").description("Remove all gallery files and folders (index.html, gallery/)").option("-g, --gallery <path>", "Path to the directory of the gallery. Default: current working directory", process4.cwd()).option("-r, --recursive", "Clean subdirectories recursively", false).action(withCommandContext((options, ui) => clean(options, ui)));
1607
+ program.command("create-theme").description("Create a new theme template").argument("<name>", "Name of the theme to create").option("-p, --path <path>", "Path where the theme should be created. Default: ./themes/<name>").action(async (name, options, command) => {
1608
+ const handler = withCommandContext((opts, ui) => createTheme({ name, path: opts.path }, ui));
1609
+ await handler(options, command);
1610
+ });
1247
1611
  program.command("telemetry").description("Manage anonymous telemetry preferences. Use 1 to enable, 0 to disable, or no argument to check status").option("-s, --state <state>", "Enable (1) or disable (0) telemetry", parseTelemetryOption).action(withCommandContext((options, ui) => telemetry(options, ui, telemetryService)));
1248
1612
  program.parse();
1249
1613
  //# sourceMappingURL=index.js.map