From b3280eb7bf6c7ad735fca751158a4debae54e06c Mon Sep 17 00:00:00 2001 From: rustoma Date: Tue, 23 Dec 2025 08:23:02 +0100 Subject: [PATCH 01/55] feat: add theme package option for gallery build --- gallery/src/index.ts | 1 + gallery/src/modules/build/index.ts | 13 ++++++++++--- gallery/src/modules/build/types/index.ts | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/gallery/src/index.ts b/gallery/src/index.ts index cc0b174..dc3a87a 100644 --- a/gallery/src/index.ts +++ b/gallery/src/index.ts @@ -179,6 +179,7 @@ program .option('-t, --thumbs-base-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('--theme ', 'Theme package to use (e.g., @simple-photo-gallery/theme-modern or @your-org/your-private-theme)', '@simple-photo-gallery/theme-modern') .action(withCommandContext((options, ui) => build(options, ui))); program diff --git a/gallery/src/modules/build/index.ts b/gallery/src/modules/build/index.ts index d46614a..77e1ac1 100644 --- a/gallery/src/modules/build/index.ts +++ b/gallery/src/modules/build/index.ts @@ -267,10 +267,15 @@ export async function build(options: BuildOptions, ui: ConsolaInstance): Promise return { processedGalleryCount: 0 }; } - // Get the astro theme directory from the default one - const themePath = await import.meta.resolve('@simple-photo-gallery/theme-modern/package.json'); + // Get the theme package name (default to the modern theme) + const themePackage = options.theme || '@simple-photo-gallery/theme-modern'; + + // Get the astro theme directory from the specified theme package + const themePath = await import.meta.resolve(`${themePackage}/package.json`); const themeDir = path.dirname(new URL(themePath).pathname); + ui.debug(`Using theme: ${themePackage} (${themeDir})`); + // Process each gallery directory let totalGalleries = 0; for (const dir of galleryDirs) { @@ -289,7 +294,9 @@ export async function build(options: BuildOptions, ui: ConsolaInstance): Promise return { processedGalleryCount: totalGalleries }; } catch (error) { if (error instanceof Error && error.message.includes('Cannot find package')) { - ui.error('Theme package not found: @simple-photo-gallery/theme-modern/package.json'); + ui.error( + `Theme package not found: ${options.theme || '@simple-photo-gallery/theme-modern'}. Make sure it's installed.`, + ); } else { ui.error('Error building gallery'); } diff --git a/gallery/src/modules/build/types/index.ts b/gallery/src/modules/build/types/index.ts index 97552ae..63b03bd 100644 --- a/gallery/src/modules/build/types/index.ts +++ b/gallery/src/modules/build/types/index.ts @@ -12,4 +12,6 @@ export interface BuildOptions { scan: boolean; /** Create thumbnails */ thumbnails: boolean; + /** Theme package name to use for building (e.g., '@simple-photo-gallery/theme-modern' or '@your-org/your-private-theme') */ + theme?: string; } From cf68b3f3bb6f36264a5f32dd997ca755ecf13e8c Mon Sep 17 00:00:00 2001 From: rustoma Date: Tue, 23 Dec 2025 08:26:43 +0100 Subject: [PATCH 02/55] docs: add custom themes documentation and update build command options --- docs/README.md | 1 + docs/commands/build.md | 30 +++-- docs/themes.md | 278 +++++++++++++++++++++++++++++++++++++++++ gallery/src/index.ts | 6 +- 4 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 docs/themes.md diff --git a/docs/README.md b/docs/README.md index c6c9da3..ce7b731 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ Complete documentation for the Simple Photo Gallery CLI tool. - **[Commands](./commands/README.md)** - All CLI commands and options - **[Gallery Configuration](./configuration.md)** - Manual editing of gallery.json +- **[Custom Themes](./themes.md)** - Create and use custom themes - **[Deployment](./deployment.md)** - Hosting and deployment options - **[Embedding](./embedding.md)** - Customize the gallery when embedding in another site diff --git a/docs/commands/build.md b/docs/commands/build.md index 13cea56..1c9d570 100644 --- a/docs/commands/build.md +++ b/docs/commands/build.md @@ -14,17 +14,18 @@ If you have created the gallery in a different folder from the photos folder, th ## Options -| Option | Description | Default | -| ----------------------------- | ------------------------------------------- | ----------------- | -| `-g, --gallery ` | Path to gallery directory | Current directory | -| `-r, --recursive` | Build all galleries | `false` | -| `-b, --base-url ` | Base URL for external hosting | None | -| `-t, --thumbs-base-url ` | Base URL for external hosting of thumbnails | None | -| `--no-scan` | Do not scan for new photos | `true` | -| `--no-thumbnails` | Skip creating thumbnails | `true` | -| `-v, --verbose` | Show detailed output | | -| `-q, --quiet` | Only show warnings/errors | | -| `-h, --help` | Show command help | | +| Option | Description | Default | +| ----------------------------- | ------------------------------------------- | ------------------------------------ | +| `-g, --gallery ` | Path to gallery directory | Current directory | +| `-r, --recursive` | Build all galleries | `false` | +| `-b, --base-url ` | Base URL for external hosting | None | +| `-t, --thumbs-base-url ` | Base URL for external hosting of thumbnails | None | +| `--theme ` | Theme package to use | `@simple-photo-gallery/theme-modern` | +| `--no-scan` | Do not scan for new photos | `true` | +| `--no-thumbnails` | Skip creating thumbnails | `true` | +| `-v, --verbose` | Show detailed output | | +| `-q, --quiet` | Only show warnings/errors | | +| `-h, --help` | Show command help | | ## Examples @@ -49,4 +50,11 @@ spg build --no-scan # Build without creating thumbnails spg build --no-thumbnails + +# Build with a custom theme package +spg build --theme @your-org/your-private-theme ``` + +## Custom Themes + +You can use custom theme packages by specifying the `--theme` option. The theme package must be installed as a dependency in your project. See the [Custom Themes](../themes.md) guide for requirements and how to create your own theme. diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 0000000..7061da9 --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,278 @@ +# Custom Themes + +Simple Photo Gallery supports custom themes, allowing you to create your own visual design and layout while leveraging the gallery's core functionality. + +## Using Custom Themes + +To use a custom theme, install it as a dependency and specify it when building: + +```bash +# Install your custom theme package +npm install @your-org/your-private-theme + +# Build with the custom theme +spg build --theme @your-org/your-private-theme +``` + +If you don't specify `--theme`, the default `@simple-photo-gallery/theme-modern` theme will be used. + +## Creating a Custom Theme + +A theme is an npm package built with [Astro](https://astro.build/) that follows a specific structure and interface. + +### Package Structure + +Your theme package should have the following structure: + +``` +your-theme-package/ +├── package.json +├── astro.config.ts +├── tsconfig.json +└── src/ + └── pages/ + └── index.astro +``` + +### Required Files + +#### 1. `package.json` + +Your theme package must be a valid npm package with: + +- A unique package name (e.g., `@your-org/your-theme-name`) +- `"type": "module"` for ES modules support +- Required dependencies (see below) +- Files array that includes all necessary files: + +```json +{ + "name": "@your-org/your-theme-name", + "version": "1.0.0", + "type": "module", + "files": ["public", "src", "astro.config.ts", "tsconfig.json"], + "dependencies": { + "astro": "^5.11.0", + "@simple-photo-gallery/common": "^1.0.5" + } +} +``` + +#### 2. `astro.config.ts` + +Your Astro config must: + +- Use `output: 'static'` for static site generation +- Set `outDir` to `${outputDir}/_build` where `outputDir` comes from `process.env.GALLERY_OUTPUT_DIR` +- Define `process.env.GALLERY_JSON_PATH` in Vite's `define` config +- Use the `astro-relative-links` integration (recommended) + +Example: + +```typescript +import { defineConfig } from "astro/config"; +import relativeLinks from "astro-relative-links"; + +const sourceGalleryPath = process.env.GALLERY_JSON_PATH; +if (!sourceGalleryPath) { + throw new Error("GALLERY_JSON_PATH environment variable is not set"); +} + +const outputDir = + process.env.GALLERY_OUTPUT_DIR || + sourceGalleryPath.replace("gallery.json", ""); + +export default defineConfig({ + output: "static", + outDir: outputDir + "/_build", + build: { + assets: "assets", + assetsPrefix: "gallery", + }, + integrations: [relativeLinks()], + vite: { + define: { + "process.env.GALLERY_JSON_PATH": JSON.stringify(sourceGalleryPath), + }, + }, +}); +``` + +#### 3. `src/pages/index.astro` + +This is your main theme entry point. It must: + +- Read `gallery.json` from the path specified in `process.env.GALLERY_JSON_PATH` +- Parse and use the `GalleryData` structure +- Generate valid HTML output + +Example: + +```astro +--- +import fs from 'node:fs'; +import type { GalleryData } from '@simple-photo-gallery/common/src/gallery'; + +// Read gallery.json from the path provided by the build process +const galleryJsonPath = process.env.GALLERY_JSON_PATH || './gallery.json'; +const galleryData = JSON.parse(fs.readFileSync(galleryJsonPath, 'utf8')); +const gallery = galleryData as GalleryData; + +// Extract gallery properties +const { + title, + description, + metadata, + sections, + subGalleries, + mediaBaseUrl, + thumbsBaseUrl, + url, + analyticsScript, + headerImage, + headerImageBlurHash, + ctaBanner, +} = gallery; +--- + + + + {title} + + {analyticsScript && ( + + )} + + + +

{title}

+

{description}

+ + + {sections.map((section) => ( +
+ {section.images.map((image) => ( + {image.caption + ))} +
+ ))} + + +``` + +### Gallery Data Structure + +Your theme receives a `GalleryData` object with the following structure: + +```typescript +interface GalleryData { + title: string; + description: string; + headerImage: string; + headerImageBlurHash?: string; + mediaBasePath?: string; + mediaBaseUrl?: string; + thumbsBaseUrl?: string; + url?: string; + analyticsScript?: string; + ctaBanner?: boolean; + thumbnailSize?: number; + metadata: { + image?: string; + imageWidth?: number; + imageHeight?: number; + ogUrl?: string; + ogType?: string; + ogSiteName?: string; + twitterSite?: string; + twitterCreator?: string; + author?: string; + keywords?: string; + canonicalUrl?: string; + robots?: string; + }; + sections: Array<{ + title?: string; + description?: string; + images: Array<{ + filename: string; + width: number; + height: number; + caption?: string; + description?: string; + blurHash?: string; + thumbnail?: { + filename: string; + width: number; + height: number; + }; + // ... other image properties + }>; + }>; + subGalleries?: { + title: string; + galleries: Array<{ + title: string; + headerImage: string; + path: string; + }>; + }; +} +``` + +### Environment Variables + +The build process sets these environment variables that your theme can access: + +- `GALLERY_JSON_PATH`: Absolute path to the `gallery.json` file +- `GALLERY_OUTPUT_DIR`: Directory where the built gallery should be output + +### Build Process + +When building, the gallery CLI will: + +1. Set `GALLERY_JSON_PATH` and `GALLERY_OUTPUT_DIR` environment variables +2. Run `npx astro build` in your theme package directory +3. Copy the built output from `_build` to the gallery output directory +4. Move `index.html` to the gallery root + +Your theme must output an `index.html` file in the build directory. + +### Best Practices + +1. **Use TypeScript**: Import types from `@simple-photo-gallery/common` for type safety +2. **Handle optional fields**: Many fields in `GalleryData` are optional - always check before using +3. **Respect base URLs**: Use `mediaBaseUrl` and `thumbsBaseUrl` when provided for external hosting +4. **Optimize assets**: Use Astro's asset optimization features +5. **Test locally**: Use `astro dev` to preview your theme during development +6. **Follow Astro conventions**: Use Astro components, layouts, and best practices + +### Example Theme Package + +You can use `@simple-photo-gallery/theme-modern` as a reference implementation. The source code is available in the [themes/modern](../themes/modern) directory of this repository. + +### Publishing Your Theme + +Once your theme is ready: + +1. Publish it to npm (or your private registry) +2. Install it in your project: `npm install @your-org/your-theme-name` +3. Use it when building: `spg build --theme @your-org/your-theme-name` + +### Troubleshooting + +**Theme package not found** + +- Ensure the theme package is installed: `npm install @your-org/your-theme-name` +- Verify the package name matches exactly (including scope) + +**Build errors** + +- Check that `GALLERY_JSON_PATH` is being read correctly +- Verify your `astro.config.ts` matches the required structure +- Ensure all dependencies are installed + +**Missing gallery data** + +- Verify `gallery.json` exists at the expected path +- Check that the `GalleryData` structure matches the expected format diff --git a/gallery/src/index.ts b/gallery/src/index.ts index dc3a87a..609460a 100644 --- a/gallery/src/index.ts +++ b/gallery/src/index.ts @@ -179,7 +179,11 @@ program .option('-t, --thumbs-base-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('--theme ', 'Theme package to use (e.g., @simple-photo-gallery/theme-modern or @your-org/your-private-theme)', '@simple-photo-gallery/theme-modern') + .option( + '--theme ', + 'Theme package to use (e.g., @simple-photo-gallery/theme-modern or @your-org/your-private-theme)', + '@simple-photo-gallery/theme-modern', + ) .action(withCommandContext((options, ui) => build(options, ui))); program From f7a5dbbd636d7811ade9503dc87d670ff3aa90c6 Mon Sep 17 00:00:00 2001 From: rustoma Date: Sun, 28 Dec 2025 11:38:11 +0100 Subject: [PATCH 03/55] docs: enhance custom themes documentation and update build command to support local theme paths --- docs/commands/build.md | 36 +++++++++++------- docs/themes.md | 41 +++++++++++++++++--- gallery/src/index.ts | 4 +- gallery/src/modules/build/index.ts | 61 +++++++++++++++++++++++------- 4 files changed, 107 insertions(+), 35 deletions(-) diff --git a/docs/commands/build.md b/docs/commands/build.md index 1c9d570..72d7416 100644 --- a/docs/commands/build.md +++ b/docs/commands/build.md @@ -14,18 +14,18 @@ If you have created the gallery in a different folder from the photos folder, th ## Options -| Option | Description | Default | -| ----------------------------- | ------------------------------------------- | ------------------------------------ | -| `-g, --gallery ` | Path to gallery directory | Current directory | -| `-r, --recursive` | Build all galleries | `false` | -| `-b, --base-url ` | Base URL for external hosting | None | -| `-t, --thumbs-base-url ` | Base URL for external hosting of thumbnails | None | -| `--theme ` | Theme package to use | `@simple-photo-gallery/theme-modern` | -| `--no-scan` | Do not scan for new photos | `true` | -| `--no-thumbnails` | Skip creating thumbnails | `true` | -| `-v, --verbose` | Show detailed output | | -| `-q, --quiet` | Only show warnings/errors | | -| `-h, --help` | Show command help | | +| Option | Description | Default | +| ----------------------------- | ------------------------------------------- | -------------------------------- | ------------------------------------ | +| `-g, --gallery ` | Path to gallery directory | Current directory | +| `-r, --recursive` | Build all galleries | `false` | +| `-b, --base-url ` | Base URL for external hosting | None | +| `-t, --thumbs-base-url ` | Base URL for external hosting of thumbnails | None | +| `--theme ` | Theme package name or local path | `@simple-photo-gallery/theme-modern` | +| `--no-scan` | Do not scan for new photos | `true` | +| `--no-thumbnails` | Skip creating thumbnails | `true` | +| `-v, --verbose` | Show detailed output | | +| `-q, --quiet` | Only show warnings/errors | | +| `-h, --help` | Show command help | | ## Examples @@ -51,10 +51,18 @@ spg build --no-scan # Build without creating thumbnails spg build --no-thumbnails -# Build with a custom theme package +# Build with a custom theme package (npm) spg build --theme @your-org/your-private-theme + +# Build with a local theme (path) +spg build --theme ./themes/my-local-theme ``` ## Custom Themes -You can use custom theme packages by specifying the `--theme` option. The theme package must be installed as a dependency in your project. See the [Custom Themes](../themes.md) guide for requirements and how to create your own theme. +You can use custom themes by specifying the `--theme` option. Themes can be: + +- **npm packages**: Install as a dependency and use the package name (e.g., `@your-org/your-private-theme`) +- **Local paths**: Use a relative or absolute path to a local theme directory (e.g., `./themes/my-local-theme`) + +See the [Custom Themes](../themes.md) guide for requirements and how to create your own theme. diff --git a/docs/themes.md b/docs/themes.md index 7061da9..3c3330f 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -4,7 +4,11 @@ Simple Photo Gallery supports custom themes, allowing you to create your own vis ## Using Custom Themes -To use a custom theme, install it as a dependency and specify it when building: +You can use custom themes in two ways: + +### Using npm Packages + +Install the theme as a dependency and use the package name: ```bash # Install your custom theme package @@ -14,6 +18,20 @@ npm install @your-org/your-private-theme spg build --theme @your-org/your-private-theme ``` +### Using Local Themes + +You can also use a local theme directory without publishing to npm: + +```bash +# Build with a local theme (relative path) +spg build --theme ./themes/my-local-theme + +# Build with a local theme (absolute path) +spg build --theme /path/to/my-theme +``` + +The local theme directory must contain a `package.json` file and follow the same structure as an npm theme package. + If you don't specify `--theme`, the default `@simple-photo-gallery/theme-modern` theme will be used. ## Creating a Custom Theme @@ -251,20 +269,31 @@ Your theme must output an `index.html` file in the build directory. You can use `@simple-photo-gallery/theme-modern` as a reference implementation. The source code is available in the [themes/modern](../themes/modern) directory of this repository. -### Publishing Your Theme +### Using Your Theme + +Once your theme is ready, you can use it in two ways: -Once your theme is ready: +**Option 1: Local Development (No Publishing Required)** +```bash +# Use the local theme directly +spg build --theme ./themes/my-theme +``` +**Option 2: Publish to npm** 1. Publish it to npm (or your private registry) 2. Install it in your project: `npm install @your-org/your-theme-name` 3. Use it when building: `spg build --theme @your-org/your-theme-name` +Local themes are perfect for development and private projects, while npm packages are ideal for sharing themes with others or using across multiple projects. + ### Troubleshooting -**Theme package not found** +**Theme not found** -- Ensure the theme package is installed: `npm install @your-org/your-theme-name` -- Verify the package name matches exactly (including scope) +- For npm packages: Ensure the theme package is installed: `npm install @your-org/your-theme-name` +- For npm packages: Verify the package name matches exactly (including scope) +- For local paths: Verify the path is correct and the directory contains a `package.json` file +- For local paths: Use an absolute path or a path relative to your current working directory **Build errors** diff --git a/gallery/src/index.ts b/gallery/src/index.ts index 609460a..e9f3aff 100644 --- a/gallery/src/index.ts +++ b/gallery/src/index.ts @@ -180,8 +180,8 @@ program .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( - '--theme ', - 'Theme package to use (e.g., @simple-photo-gallery/theme-modern or @your-org/your-private-theme)', + '--theme ', + 'Theme package name (e.g., @simple-photo-gallery/theme-modern) or local path (e.g., ./themes/my-theme)', '@simple-photo-gallery/theme-modern', ) .action(withCommandContext((options, ui) => build(options, ui))); diff --git a/gallery/src/modules/build/index.ts b/gallery/src/modules/build/index.ts index 77e1ac1..91219d1 100644 --- a/gallery/src/modules/build/index.ts +++ b/gallery/src/modules/build/index.ts @@ -254,10 +254,42 @@ async function buildGallery( } /** - * Main build command implementation - builds HTML galleries from gallery.json files - * @param options - Options specifying gallery path, recursion, and base URL + * Determines if a theme identifier is a local path or an npm package name + * @param theme - Theme identifier (path or package name) + * @returns true if it's a path, false if it's a package name + */ +function isLocalThemePath(theme: string): boolean { + // Check if it starts with ./ or ../ or / or contains path separators + return theme.startsWith('./') || theme.startsWith('../') || theme.startsWith('/') || theme.includes(path.sep); +} + +/** + * Resolves the theme directory from either a local path or npm package name + * @param theme - Theme identifier (path or package name) * @param ui - ConsolaInstance for logging + * @returns Promise resolving to the theme directory path */ +async function resolveThemeDir(theme: string, ui: ConsolaInstance): Promise { + if (isLocalThemePath(theme)) { + // Resolve local path + const themeDir = path.resolve(theme); + const packageJsonPath = path.join(themeDir, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Theme directory not found or invalid: ${themeDir}. package.json not found.`); + } + + ui.debug(`Using local theme: ${themeDir}`); + return themeDir; + } else { + // Resolve npm package + const themePath = await import.meta.resolve(`${theme}/package.json`); + const themeDir = path.dirname(new URL(themePath).pathname); + ui.debug(`Using npm theme package: ${theme} (${themeDir})`); + return themeDir; + } +} + export async function build(options: BuildOptions, ui: ConsolaInstance): Promise { try { // Find all gallery directories @@ -267,14 +299,11 @@ export async function build(options: BuildOptions, ui: ConsolaInstance): Promise return { processedGalleryCount: 0 }; } - // Get the theme package name (default to the modern theme) - const themePackage = options.theme || '@simple-photo-gallery/theme-modern'; + // Get the theme identifier (default to the modern theme) + const themeIdentifier = options.theme || '@simple-photo-gallery/theme-modern'; - // Get the astro theme directory from the specified theme package - const themePath = await import.meta.resolve(`${themePackage}/package.json`); - const themeDir = path.dirname(new URL(themePath).pathname); - - ui.debug(`Using theme: ${themePackage} (${themeDir})`); + // Resolve the theme directory (supports both local paths and npm packages) + const themeDir = await resolveThemeDir(themeIdentifier, ui); // Process each gallery directory let totalGalleries = 0; @@ -293,10 +322,16 @@ export async function build(options: BuildOptions, ui: ConsolaInstance): Promise return { processedGalleryCount: totalGalleries }; } catch (error) { - if (error instanceof Error && error.message.includes('Cannot find package')) { - ui.error( - `Theme package not found: ${options.theme || '@simple-photo-gallery/theme-modern'}. Make sure it's installed.`, - ); + if (error instanceof Error) { + if (error.message.includes('Cannot find package')) { + ui.error( + `Theme package not found: ${options.theme || '@simple-photo-gallery/theme-modern'}. Make sure it's installed.`, + ); + } else if (error.message.includes('Theme directory not found') || error.message.includes('package.json not found')) { + ui.error(error.message); + } else { + ui.error('Error building gallery'); + } } else { ui.error('Error building gallery'); } From 9a1d4a3cf3f30d528d2aaeefb1a7751d62699e07 Mon Sep 17 00:00:00 2001 From: rustoma Date: Sun, 28 Dec 2025 11:54:10 +0100 Subject: [PATCH 04/55] feat: add create-theme command and implementation for generating custom themes --- gallery/src/index.ts | 11 + gallery/src/modules/create-theme/index.ts | 132 +++ gallery/src/modules/create-theme/templates.ts | 946 ++++++++++++++++++ .../src/modules/create-theme/types/index.ts | 7 + 4 files changed, 1096 insertions(+) create mode 100644 gallery/src/modules/create-theme/index.ts create mode 100644 gallery/src/modules/create-theme/templates.ts create mode 100644 gallery/src/modules/create-theme/types/index.ts diff --git a/gallery/src/index.ts b/gallery/src/index.ts index e9f3aff..a8c973d 100644 --- a/gallery/src/index.ts +++ b/gallery/src/index.ts @@ -7,6 +7,7 @@ import { createConsola, LogLevels, type ConsolaInstance } from 'consola'; import { build } from './modules/build'; import { clean } from './modules/clean'; +import { createTheme } from './modules/create-theme'; import { init } from './modules/init'; import { telemetry } from './modules/telemetry'; import { TelemetryService } from './modules/telemetry/service'; @@ -193,6 +194,16 @@ program .option('-r, --recursive', 'Clean subdirectories recursively', false) .action(withCommandContext((options, ui) => clean(options, ui))); +program + .command('create-theme') + .description('Create a new theme template') + .argument('', 'Name of the theme to create') + .option('-p, --path ', 'Path where the theme should be created. Default: ./themes/') + .action(async (name, options, command) => { + const handler = withCommandContext((opts: { path?: string }, ui) => createTheme({ name, path: opts.path }, ui)); + await handler(options, command); + }); + program .command('telemetry') .description('Manage anonymous telemetry preferences. Use 1 to enable, 0 to disable, or no argument to check status') diff --git a/gallery/src/modules/create-theme/index.ts b/gallery/src/modules/create-theme/index.ts new file mode 100644 index 0000000..7851b92 --- /dev/null +++ b/gallery/src/modules/create-theme/index.ts @@ -0,0 +1,132 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import * as templates from './templates'; + +import type { CreateThemeOptions } from './types'; +import type { CommandResultSummary } from '../telemetry/types'; +import type { ConsolaInstance } from 'consola'; + +/** + * Validates the theme name + * @param name - Theme name to validate + * @returns true if valid, throws error if invalid + */ +function validateThemeName(name: string): boolean { + if (!name || name.trim().length === 0) { + throw new Error('Theme name cannot be empty'); + } + + // Check for invalid characters (basic validation) + if (!/^[a-z0-9-]+$/i.test(name)) { + throw new Error('Theme name can only contain letters, numbers, and hyphens'); + } + + return true; +} + +/** + * Creates a directory if it doesn't exist + * @param dirPath - Path to create + * @param ui - ConsolaInstance for logging + */ +async function ensureDirectory(dirPath: string, ui: ConsolaInstance): Promise { + try { + await fs.promises.mkdir(dirPath, { recursive: true }); + ui.debug(`Created directory: ${dirPath}`); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code !== 'EEXIST') { + throw new Error(`Failed to create directory ${dirPath}: ${error.message}`); + } + } +} + +/** + * Writes a file with content + * @param filePath - Path to the file + * @param content - Content to write + * @param ui - ConsolaInstance for logging + */ +async function writeFile(filePath: string, content: string, ui: ConsolaInstance): Promise { + await fs.promises.writeFile(filePath, content, 'utf8'); + ui.debug(`Created file: ${filePath}`); +} + +/** + * Main function to create a new theme + * @param options - Options for creating the theme + * @param ui - ConsolaInstance for logging + * @returns CommandResultSummary + */ +export async function createTheme(options: CreateThemeOptions, ui: ConsolaInstance): Promise { + try { + // Validate theme name + validateThemeName(options.name); + + // Determine theme directory path + const themeDir = options.path || path.resolve(process.cwd(), 'themes', options.name); + + // Check if directory already exists + if (fs.existsSync(themeDir)) { + throw new Error(`Theme directory already exists: ${themeDir}`); + } + + ui.start(`Creating theme: ${options.name}`); + + // Create directory structure + await ensureDirectory(themeDir, ui); + await ensureDirectory(path.join(themeDir, 'src'), ui); + await ensureDirectory(path.join(themeDir, 'src', 'pages'), ui); + await ensureDirectory(path.join(themeDir, 'src', 'layouts'), ui); + await ensureDirectory(path.join(themeDir, 'src', 'lib'), ui); + await ensureDirectory(path.join(themeDir, 'src', 'utils'), ui); + await ensureDirectory(path.join(themeDir, 'public'), ui); + + // Generate all files + ui.debug('Generating theme files...'); + + // Root files + await writeFile(path.join(themeDir, 'package.json'), templates.getPackageJson(options.name), ui); + await writeFile(path.join(themeDir, 'astro.config.ts'), templates.getAstroConfig(), ui); + await writeFile(path.join(themeDir, 'tsconfig.json'), templates.getTsConfig(), ui); + await writeFile(path.join(themeDir, 'eslint.config.mjs'), templates.getEslintConfig(), ui); + await writeFile(path.join(themeDir, '.prettierrc.mjs'), templates.getPrettierConfig(), ui); + await writeFile(path.join(themeDir, '.prettierignore'), templates.getPrettierIgnore(), ui); + await writeFile(path.join(themeDir, '.gitignore'), templates.getGitIgnore(), ui); + await writeFile(path.join(themeDir, 'README.md'), templates.getReadme(options.name), ui); + + // Layout files + await writeFile(path.join(themeDir, 'src', 'layouts', 'MainHead.astro'), templates.getMainHead(), ui); + await writeFile(path.join(themeDir, 'src', 'layouts', 'MainLayout.astro'), templates.getMainLayout(), ui); + + // Page files + await writeFile(path.join(themeDir, 'src', 'pages', 'index.astro'), templates.getIndexPage(), ui); + + // Library files + await writeFile(path.join(themeDir, 'src', 'lib', 'markdown.ts'), templates.getMarkdownLib(), ui); + await writeFile( + path.join(themeDir, 'src', 'lib', 'photoswipe-video-plugin.ts'), + templates.getPhotoswipeVideoPlugin(), + ui, + ); + + // Utility files + await writeFile(path.join(themeDir, 'src', 'utils', 'index.ts'), templates.getUtilsIndex(), ui); + + ui.success(`Theme created successfully at: ${themeDir}`); + ui.info(`\nNext steps:`); + ui.info(`1. cd ${themeDir}`); + ui.info(`2. npm install`); + ui.info(`3. Customize your theme in src/pages/index.astro`); + ui.info(`4. Build a gallery with: spg build --theme ${themeDir}`); + + return { processedGalleryCount: 0 }; + } catch (error) { + if (error instanceof Error) { + ui.error(error.message); + } else { + ui.error('Failed to create theme'); + } + throw error; + } +} diff --git a/gallery/src/modules/create-theme/templates.ts b/gallery/src/modules/create-theme/templates.ts new file mode 100644 index 0000000..2eda20b --- /dev/null +++ b/gallery/src/modules/create-theme/templates.ts @@ -0,0 +1,946 @@ +/** + * Template generators for theme files + */ + +export function getPackageJson(themeName: string): string { + return `{ + "name": "@your-org/theme-${themeName}", + "version": "1.0.0", + "description": "Custom theme for Simple Photo Gallery", + "license": "MIT", + "type": "module", + "files": ["public", "src", "astro.config.ts", "tsconfig.json"], + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "lint": "eslint . --ext .astro,.js,.jsx,.ts,.tsx", + "lint:fix": "eslint . --ext .astro,.js,.jsx,.ts,.tsx --fix", + "format": "prettier --check './**/*.{js,jsx,ts,tsx,css,scss,md,json,astro}'", + "format:fix": "prettier --write './**/*.{js,jsx,ts,tsx,css,scss,md,json,astro}'" + }, + "dependencies": { + "astro": "^5.11.0", + "astro-relative-links": "^0.4.2", + "blurhash": "^2.0.5", + "marked": "^16.4.0", + "photoswipe": "^5.4.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.30.1", + "@simple-photo-gallery/common": "^1.0.5", + "@types/photoswipe": "^4.1.6", + "@typescript-eslint/eslint-plugin": "^8.35.1", + "@typescript-eslint/parser": "^8.35.1", + "eslint": "^9.30.1", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-astro": "^1.3.1", + "eslint-plugin-import": "^2.31.0", + "prettier": "^3.4.2", + "prettier-plugin-astro": "^0.14.1", + "typescript": "^5.8.3" + } +} +`; +} + +export function getAstroConfig(): string { + return `import fs from 'node:fs'; +import path from 'node:path'; + +import { defineConfig } from 'astro/config'; +import relativeLinks from 'astro-relative-links'; + +import type { AstroIntegration } from 'astro'; + +// Dynamically import gallery.json from source path or fallback to local +const sourceGalleryPath = process.env.GALLERY_JSON_PATH; +if (!sourceGalleryPath) throw new Error('GALLERY_JSON_PATH environment variable is not set'); + +const outputDir = process.env.GALLERY_OUTPUT_DIR || sourceGalleryPath.replace('gallery.json', ''); + +/** + * Astro integration to prevent empty content collection files from being generated + */ +function preventEmptyContentFiles(): AstroIntegration { + return { + name: 'prevent-empty-content-files', + hooks: { + 'astro:build:done': ({ dir }) => { + const filesToRemove = ['content-assets.mjs', 'content-modules.mjs']; + for (const fileName of filesToRemove) { + const filePath = path.join(dir.pathname, fileName); + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + if (content.trim() === 'export default new Map();' || content.trim() === '') { + fs.unlinkSync(filePath); + } + } catch { + // Silently ignore errors + } + } + } + }, + }, + }; +} + +export default defineConfig({ + output: 'static', + outDir: outputDir + '/_build', + build: { + assets: 'assets', + assetsPrefix: 'gallery', + }, + integrations: [relativeLinks(), preventEmptyContentFiles()], + publicDir: 'public', + vite: { + define: { + 'process.env.GALLERY_JSON_PATH': JSON.stringify(sourceGalleryPath), + }, + build: { + cssCodeSplit: false, + rollupOptions: { + output: { + manualChunks: undefined, + }, + }, + }, + }, +}); +`; +} + +export function getTsConfig(): string { + return `{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + } +} +`; +} + +export function getEslintConfig(): string { + return `import eslint from '@eslint/js'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import eslintPluginAstro from 'eslint-plugin-astro'; +import globals from 'globals'; +import path from 'node:path'; +import tseslint from 'typescript-eslint'; + +const tseslintConfig = tseslint.config(eslint.configs.recommended, tseslint.configs.recommended); + +export default [ + { + ignores: ['node_modules', '.astro', '**/dist/*', '**/public/*', '**/_build/**'], + }, + ...tseslintConfig, + eslintConfigPrettier, + ...eslintPluginAstro.configs['flat/recommended'], + { + files: ['**/*.{js,mjs,cjs,ts,jsx,tsx,astro}'], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + settings: { + 'import/resolver': { + alias: { + map: [['@', path.resolve(import.meta.dirname, './src')]], + extensions: ['.js', '.jsx', '.ts', '.d.ts', '.tsx', '.astro'], + }, + }, + }, + rules: { + 'no-console': ['warn', { allow: ['warn', 'error'] }], + '@typescript-eslint/no-explicit-any': ['warn'], + '@typescript-eslint/consistent-type-imports': 'warn', + }, + }, +]; +`; +} + +export function getPrettierConfig(): string { + return `/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + semi: true, + printWidth: 125, + tabWidth: 2, + useTabs: false, + singleQuote: true, + endOfLine: 'lf', + bracketSpacing: true, + trailingComma: 'all', + quoteProps: 'as-needed', + bracketSameLine: true, + plugins: ['prettier-plugin-astro'], + overrides: [ + { + files: '*.astro', + options: { + parser: 'astro', + }, + }, + ], +}; + +export default config; +`; +} + +export function getPrettierIgnore(): string { + return `**/node_modules/* +**/dist/* +**/.astro/* +**/_build/** +`; +} + +export function getGitIgnore(): string { + return `node_modules/ +dist/ +.astro/ +_build/ +*.log +.DS_Store +`; +} + +export function getReadme(themeName: string): string { + return `# ${themeName} Theme + +A custom theme for Simple Photo Gallery built with Astro. + +## Development + +\`\`\`bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview +\`\`\` + +## Customization + +Edit \`src/pages/index.astro\` to customize your theme. This is the main entry point that receives gallery data and renders your gallery. + +## Building Galleries + +To use this theme when building a gallery: + +\`\`\`bash +# Using local path +spg build --theme ./themes/${themeName} + +# Or if published to npm +spg build --theme @your-org/theme-${themeName} +\`\`\` + +## Structure + +- \`src/pages/index.astro\` - Main gallery page +- \`src/layouts/\` - Layout components (MainHead, MainLayout) +- \`src/lib/\` - Utility libraries (markdown, photoswipe-video-plugin) +- \`src/utils/\` - Helper functions for paths and resources +- \`public/\` - Static assets +`; +} + +export function getMainHead(): string { + return `--- +import type { GalleryMetadata } from '@simple-photo-gallery/common/src/gallery'; + +interface Props { + title: string; + description?: string; + url?: string; + thumbsBaseUrl?: string; + metadata?: GalleryMetadata; +} + +const { title, description, url, thumbsBaseUrl, metadata } = Astro.props; +--- + + + + + {title} + + + + {/* Basic SEO */} + + {metadata?.keywords && } + {metadata?.author && } + {metadata?.canonicalUrl || (url && )} + + {/* Open Graph */} + + + {metadata?.image && } + {metadata?.ogUrl || (url && )} + + {/* Twitter */} + + + + {metadata?.image && } + +`; +} + +export function getMainLayout(): string { + return `--- +import MainHead from '@/layouts/MainHead'; + +import type { GalleryMetadata } from '@simple-photo-gallery/common/src/gallery'; + +interface Props { + title: string; + description?: string; + url?: string; + thumbsBaseUrl?: string; + metadata?: GalleryMetadata; + analyticsScript?: string; +} + +const { title, description, metadata, url, thumbsBaseUrl, analyticsScript } = Astro.props; +--- + + + + + + + + {analyticsScript && } + + + + +`; +} + +export function getIndexPage(): string { + return `--- +import fs from 'node:fs'; + +import MainLayout from '@/layouts/MainLayout'; +import { getPhotoPath, getThumbnailPath } from '@/utils'; +import { renderMarkdown } from '@/lib/markdown'; + +import type { GalleryData } from '@simple-photo-gallery/common/src/gallery'; + +// Read gallery.json from the path provided by the build process +const galleryJsonPath = process.env.GALLERY_JSON_PATH || './gallery.json'; +const galleryData = JSON.parse(fs.readFileSync(galleryJsonPath, 'utf8')); +const gallery = galleryData as GalleryData; + +const { title, description, sections, mediaBaseUrl, thumbsBaseUrl, headerImage, headerImageBlurHash } = gallery; + +// Render description markdown if present +const descriptionHtml = description ? await renderMarkdown(description) : ''; +--- + + +
+
+

{title}

+ {descriptionHtml &&
} +
+ + {/* Render gallery sections */} + {sections.map((section) => ( + + ))} +
+
+ + + + +`; +} + +export function getMarkdownLib(): string { + return `import { marked } from 'marked'; + +// Configure marked to only allow specific formatting options +const renderer = new marked.Renderer(); + +// Disable headings by rendering them as paragraphs +renderer.heading = ({ text }: { text: string }) => { + return '

' + text + '

\\n'; +}; + +// Disable images +renderer.image = () => ''; + +// Disable HTML +renderer.html = () => ''; + +// Disable tables +renderer.table = () => ''; +renderer.tablerow = () => ''; +renderer.tablecell = () => ''; + +// Configure marked options +marked.use({ + renderer: renderer, + breaks: true, + gfm: true, +}); + +/** + * Renders markdown with limited formatting options. + * Supported: paragraphs, bold, italic, lists, code blocks, blockquotes, links + * Disabled: headings (rendered as paragraphs), images, HTML, tables + */ +export async function renderMarkdown(markdown: string): Promise { + if (!markdown) return ''; + return await marked.parse(markdown); +} +`; +} + +export function getPhotoswipeVideoPlugin(): string { + return `import type PhotoSwipe from 'photoswipe'; +import type PhotoSwipeLightbox from 'photoswipe/lightbox'; + +interface Slide { + content: Content; + height: number; + currZoomLevel: number; + bounds: { center: { y: number } }; + placeholder?: { element: HTMLElement }; + isActive: boolean; +} + +interface Content { + data: SlideData; + element?: HTMLVideoElement | HTMLImageElement | HTMLDivElement; + state?: string; + type?: string; + isAttached?: boolean; + onLoaded?: () => void; + appendImage?: () => void; + slide?: Slide; + _videoPosterImg?: HTMLImageElement; +} + +interface SlideData { + type?: string; + msrc?: string; + videoSrc?: string; + videoSources?: Array<{ src: string; type: string }>; +} + +interface VideoPluginOptions { + videoAttributes?: Record; + autoplay?: boolean; + preventDragOffset?: number; +} + +interface EventData { + content?: Content; + slide?: Slide; + width?: number; + height?: number; + originalEvent?: PointerEvent; + preventDefault?: () => void; +} + +const defaultOptions: VideoPluginOptions = { + videoAttributes: { controls: '', playsinline: '', preload: 'auto' }, + autoplay: true, + preventDragOffset: 40, +}; + +/** + * Check if slide has video content + */ +function isVideoContent(content: Content | Slide): boolean { + return content && 'data' in content && content.data && content.data.type === 'video'; +} + +class VideoContentSetup { + private options: VideoPluginOptions; + + constructor(lightbox: PhotoSwipeLightbox, options: VideoPluginOptions) { + this.options = options; + + this.initLightboxEvents(lightbox); + lightbox.on('init', () => { + if (lightbox.pswp) { + this.initPswpEvents(lightbox.pswp); + } + }); + } + + private initLightboxEvents(lightbox: PhotoSwipeLightbox): void { + lightbox.on('contentLoad', (data: unknown) => this.onContentLoad(data as EventData)); + lightbox.on('contentDestroy', (data: unknown) => this.onContentDestroy(data as { content: Content })); + lightbox.on('contentActivate', (data: unknown) => this.onContentActivate(data as { content: Content })); + lightbox.on('contentDeactivate', (data: unknown) => this.onContentDeactivate(data as { content: Content })); + lightbox.on('contentAppend', (data: unknown) => this.onContentAppend(data as EventData)); + lightbox.on('contentResize', (data: unknown) => this.onContentResize(data as EventData)); + + lightbox.addFilter('isKeepingPlaceholder', (value: unknown, ...args: unknown[]) => + this.isKeepingPlaceholder(value as boolean, args[0] as Content), + ); + lightbox.addFilter('isContentZoomable', (value: unknown, ...args: unknown[]) => + this.isContentZoomable(value as boolean, args[0] as Content), + ); + lightbox.addFilter('useContentPlaceholder', (value: unknown, ...args: unknown[]) => + this.useContentPlaceholder(value as boolean, args[0] as Content), + ); + + lightbox.addFilter('domItemData', (value: unknown, ...args: unknown[]) => { + const itemData = value as Record; + const linkEl = args[1] as HTMLAnchorElement; + + if (itemData.type === 'video' && linkEl) { + if (linkEl.dataset.pswpVideoSources) { + itemData.videoSources = JSON.parse(linkEl.dataset.pswpVideoSources); + } else if (linkEl.dataset.pswpVideoSrc) { + itemData.videoSrc = linkEl.dataset.pswpVideoSrc; + } else { + itemData.videoSrc = linkEl.href; + } + } + return itemData; + }); + } + + private initPswpEvents(pswp: PhotoSwipe): void { + pswp.on('pointerDown', (data: unknown) => { + const e = data as EventData; + const slide = pswp.currSlide as Slide | undefined; + if (slide && isVideoContent(slide) && this.options.preventDragOffset) { + const origEvent = e.originalEvent; + if (origEvent && origEvent.type === 'pointerdown') { + const videoHeight = Math.ceil(slide.height * slide.currZoomLevel); + const verticalEnding = videoHeight + slide.bounds.center.y; + const pointerYPos = origEvent.pageY - pswp.offset.y; + if (pointerYPos > verticalEnding - this.options.preventDragOffset! && pointerYPos < verticalEnding) { + e.preventDefault?.(); + } + } + } + }); + + pswp.on('appendHeavy', (data: unknown) => { + const e = data as EventData; + if (e.slide && isVideoContent(e.slide) && !e.slide.isActive) { + e.preventDefault?.(); + } + }); + + pswp.on('close', () => { + const slide = pswp.currSlide as Slide | undefined; + if (slide && isVideoContent(slide.content)) { + if (!pswp.options.showHideAnimationType || pswp.options.showHideAnimationType === 'zoom') { + pswp.options.showHideAnimationType = 'fade'; + } + this.pauseVideo(slide.content); + } + }); + } + + private onContentDestroy({ content }: { content: Content }): void { + if (isVideoContent(content) && content._videoPosterImg) { + const handleLoad = () => { + if (content._videoPosterImg) { + content._videoPosterImg.removeEventListener('error', handleError); + } + }; + const handleError = () => { + // Error handler + }; + + content._videoPosterImg.addEventListener('load', handleLoad); + content._videoPosterImg.addEventListener('error', handleError); + content._videoPosterImg = undefined; + } + } + + private onContentResize(e: EventData): void { + if (e.content && isVideoContent(e.content)) { + e.preventDefault?.(); + + const width = e.width!; + const height = e.height!; + const content = e.content; + + if (content.element) { + content.element.style.width = width + 'px'; + content.element.style.height = height + 'px'; + } + + if (content.slide && content.slide.placeholder) { + const placeholderElStyle = content.slide.placeholder.element.style; + placeholderElStyle.transform = 'none'; + placeholderElStyle.width = width + 'px'; + placeholderElStyle.height = height + 'px'; + } + } + } + + private isKeepingPlaceholder(isZoomable: boolean, content: Content): boolean { + if (isVideoContent(content)) { + return false; + } + return isZoomable; + } + + private isContentZoomable(isZoomable: boolean, content: Content): boolean { + if (isVideoContent(content)) { + return false; + } + return isZoomable; + } + + private onContentActivate({ content }: { content: Content }): void { + if (isVideoContent(content) && this.options.autoplay) { + this.playVideo(content); + } + } + + private onContentDeactivate({ content }: { content: Content }): void { + if (isVideoContent(content)) { + this.pauseVideo(content); + } + } + + private onContentAppend(e: EventData): void { + if (e.content && isVideoContent(e.content)) { + e.preventDefault?.(); + e.content.isAttached = true; + e.content.appendImage?.(); + } + } + + private onContentLoad(e: EventData): void { + const content = e.content!; + + if (!isVideoContent(content)) { + return; + } + + e.preventDefault?.(); + + if (content.element) { + return; + } + + content.state = 'loading'; + content.type = 'video'; + + content.element = document.createElement('video'); + + if (this.options.videoAttributes) { + for (const key in this.options.videoAttributes) { + content.element.setAttribute(key, this.options.videoAttributes[key] || ''); + } + } + + content.element.setAttribute('poster', content.data.msrc || ''); + + this.preloadVideoPoster(content, content.data.msrc); + + content.element.style.position = 'absolute'; + content.element.style.left = '0'; + content.element.style.top = '0'; + + if (content.data.videoSources) { + for (const source of content.data.videoSources) { + const sourceEl = document.createElement('source'); + sourceEl.src = source.src; + sourceEl.type = source.type; + content.element.append(sourceEl); + } + } else if (content.data.videoSrc) { + content.element.src = content.data.videoSrc; + } + } + + private preloadVideoPoster(content: Content, src?: string): void { + if (!content._videoPosterImg && src) { + content._videoPosterImg = new Image(); + content._videoPosterImg.src = src; + if (content._videoPosterImg.complete) { + content.onLoaded?.(); + } else { + content._videoPosterImg.addEventListener('load', () => { + content.onLoaded?.(); + }); + content._videoPosterImg.addEventListener('error', () => { + content.onLoaded?.(); + }); + } + } + } + + private playVideo(content: Content): void { + if (content.element) { + (content.element as HTMLVideoElement).play(); + } + } + + private pauseVideo(content: Content): void { + if (content.element) { + (content.element as HTMLVideoElement).pause(); + } + } + + private useContentPlaceholder(usePlaceholder: boolean, content: Content): boolean { + if (isVideoContent(content)) { + return true; + } + return usePlaceholder; + } +} + +class PhotoSwipeVideoPlugin { + constructor(lightbox: PhotoSwipeLightbox, options: VideoPluginOptions = {}) { + new VideoContentSetup(lightbox, { + ...defaultOptions, + ...options, + }); + } +} + +export default PhotoSwipeVideoPlugin; +export type { VideoPluginOptions }; +`; +} + +export function getUtilsIndex(): string { + return `import path from 'node:path'; + +/** + * Normalizes resource paths to be relative to the gallery root directory. + * + * @param resourcePath - The resource path (file or directory), typically relative to the gallery.json file + * @returns The normalized path relative to the gallery root directory + */ +export const getRelativePath = (resourcePath: string) => { + const galleryConfigPath = path.resolve(process.env.GALLERY_JSON_PATH || ''); + const galleryConfigDir = path.dirname(galleryConfigPath); + + const absoluteResourcePath = path.resolve(path.join(galleryConfigDir, resourcePath)); + const baseDir = path.dirname(galleryConfigDir); + + return path.relative(baseDir, absoluteResourcePath); +}; + +/** + * Get the path to a thumbnail that is relative to the gallery root directory or the thumbnails base URL. + * + * @param resourcePath - The resource path (file or directory), typically relative to the gallery.json file + * @param thumbsBaseUrl - The base URL for the thumbnails (gallery-level) + * @param thumbnailBaseUrl - Optional thumbnail-specific base URL that overrides thumbsBaseUrl if provided + * @returns The normalized path relative to the gallery root directory or the thumbnails base URL + */ +export const getThumbnailPath = (resourcePath: string, thumbsBaseUrl?: string, thumbnailBaseUrl?: string) => { + // If thumbnail-specific baseUrl is provided, use it and combine with the path + if (thumbnailBaseUrl) { + return \`\${thumbnailBaseUrl}/\${resourcePath}\`; + } + // Otherwise, use the gallery-level thumbsBaseUrl if provided + return thumbsBaseUrl ? \`\${thumbsBaseUrl}/\${resourcePath}\` : \`gallery/images/\${path.basename(resourcePath)}\`; +}; + +/** + * Get the path to a photo that is always in the gallery root directory. + * + * @param filename - The filename to get the path for + * @param mediaBaseUrl - The base URL for the media + * @param url - Optional URL that, if provided, will be used directly regardless of base URL or path + * @returns The normalized path relative to the gallery root directory, or the provided URL + */ +export const getPhotoPath = (filename: string, mediaBaseUrl?: string, url?: string) => { + // If url is provided, always use it regardless of base URL or path + if (url) { + return url; + } + + return mediaBaseUrl ? \`\${mediaBaseUrl}/\${filename}\` : filename; +}; + +/** + * Get the path to a subgallery thumbnail that is always in the subgallery directory. + * + * @param subgalleryHeaderImagePath - The path to the subgallery header image on the hard disk + * @returns The normalized path relative to the subgallery directory + */ +export const getSubgalleryThumbnailPath = (subgalleryHeaderImagePath: string) => { + const photoBasename = path.basename(subgalleryHeaderImagePath); + const subgalleryFolderName = path.basename(path.dirname(subgalleryHeaderImagePath)); + + return path.join(subgalleryFolderName, 'gallery', 'thumbnails', photoBasename); +}; +`; +} diff --git a/gallery/src/modules/create-theme/types/index.ts b/gallery/src/modules/create-theme/types/index.ts new file mode 100644 index 0000000..4e06d65 --- /dev/null +++ b/gallery/src/modules/create-theme/types/index.ts @@ -0,0 +1,7 @@ +/** Options for creating a new theme */ +export interface CreateThemeOptions { + /** Name of the theme to create */ + name: string; + /** Path where the theme should be created. Default: ./themes/ */ + path?: string; +} From cd9f65aac6a4f8272635423f108aeabee1597a58 Mon Sep 17 00:00:00 2001 From: rustoma Date: Sun, 28 Dec 2025 12:03:15 +0100 Subject: [PATCH 05/55] feat: enhance createTheme function to support custom theme paths and prevent overwriting existing themes --- gallery/src/modules/create-theme/index.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/gallery/src/modules/create-theme/index.ts b/gallery/src/modules/create-theme/index.ts index 7851b92..49dfe4e 100644 --- a/gallery/src/modules/create-theme/index.ts +++ b/gallery/src/modules/create-theme/index.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import process from 'node:process'; import * as templates from './templates'; @@ -64,11 +65,24 @@ export async function createTheme(options: CreateThemeOptions, ui: ConsolaInstan validateThemeName(options.name); // Determine theme directory path - const themeDir = options.path || path.resolve(process.cwd(), 'themes', options.name); + let themeDir: string; + if (options.path) { + // If a custom path is provided, use it as-is + themeDir = path.resolve(options.path); + } else { + // Default: create in ./themes/ directory + const themesBaseDir = path.resolve(process.cwd(), 'themes'); + themeDir = path.join(themesBaseDir, options.name); + + // Ensure the themes base directory exists (but don't overwrite anything) + if (!fs.existsSync(themesBaseDir)) { + await ensureDirectory(themesBaseDir, ui); + } + } - // Check if directory already exists + // Check if directory already exists - prevent overwriting existing themes if (fs.existsSync(themeDir)) { - throw new Error(`Theme directory already exists: ${themeDir}`); + throw new Error(`Theme directory already exists: ${themeDir}. Cannot overwrite existing theme.`); } ui.start(`Creating theme: ${options.name}`); From cb2573cbc23b80e4c75ac122101735fbccefd706 Mon Sep 17 00:00:00 2001 From: rustoma Date: Sun, 28 Dec 2025 12:21:22 +0100 Subject: [PATCH 06/55] feat: add gallery script to package.json and implement monorepo root detection in createTheme function --- gallery/src/modules/create-theme/index.ts | 35 ++++++++++++++++++++++- package.json | 3 ++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/gallery/src/modules/create-theme/index.ts b/gallery/src/modules/create-theme/index.ts index 49dfe4e..68d214e 100644 --- a/gallery/src/modules/create-theme/index.ts +++ b/gallery/src/modules/create-theme/index.ts @@ -8,6 +8,37 @@ import type { CreateThemeOptions } from './types'; import type { CommandResultSummary } from '../telemetry/types'; import type { ConsolaInstance } from 'consola'; +/** + * Find the nearest ancestor directory (including the starting directory) that looks like a monorepo root + * by checking for a package.json with a "workspaces" field. + * + * This avoids surprising behavior when the CLI is executed from within a workspace package (e.g. ./gallery), + * but the user expects themes to be created under the monorepo root (e.g. ./themes). + */ +function findMonorepoRoot(startDir: string): string | undefined { + let dir = path.resolve(startDir); + + while (true) { + const pkgPath = path.join(dir, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { workspaces?: unknown }; + if (pkg && typeof pkg === 'object' && 'workspaces' in pkg) { + return dir; + } + } catch { + // Ignore JSON parse errors and continue searching upwards + } + } + + const parent = path.dirname(dir); + if (parent === dir) { + return undefined; + } + dir = parent; + } +} + /** * Validates the theme name * @param name - Theme name to validate @@ -71,7 +102,9 @@ export async function createTheme(options: CreateThemeOptions, ui: ConsolaInstan themeDir = path.resolve(options.path); } else { // Default: create in ./themes/ directory - const themesBaseDir = path.resolve(process.cwd(), 'themes'); + const monorepoRoot = findMonorepoRoot(process.cwd()); + const baseDir = monorepoRoot ?? process.cwd(); + const themesBaseDir = path.resolve(baseDir, 'themes'); themeDir = path.join(themesBaseDir, options.name); // Ensure the themes base directory exists (but don't overwrite anything) diff --git a/package.json b/package.json index 32d5be3..1fe9166 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "@simple-photo-gallery/workspace", "version": "1.0.0", "private": true, + "scripts": { + "gallery": "yarn workspace simple-photo-gallery gallery" + }, "workspaces": { "packages": [ "common", From 297da7b31aef5e1134c05a306fe2042e25d601a4 Mon Sep 17 00:00:00 2001 From: rustoma Date: Sun, 28 Dec 2025 12:32:25 +0100 Subject: [PATCH 07/55] feat: update package.json generation to use theme name directly and add peerDependencies for common package --- gallery/src/modules/create-theme/templates.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gallery/src/modules/create-theme/templates.ts b/gallery/src/modules/create-theme/templates.ts index 2eda20b..d3a50d0 100644 --- a/gallery/src/modules/create-theme/templates.ts +++ b/gallery/src/modules/create-theme/templates.ts @@ -4,7 +4,7 @@ export function getPackageJson(themeName: string): string { return `{ - "name": "@your-org/theme-${themeName}", + "name": "${themeName}", "version": "1.0.0", "description": "Custom theme for Simple Photo Gallery", "license": "MIT", @@ -26,6 +26,9 @@ export function getPackageJson(themeName: string): string { "marked": "^16.4.0", "photoswipe": "^5.4.4" }, + "peerDependencies": { + "@simple-photo-gallery/common": "^1.0.5" + }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.30.1", From 35a393ead2401034f028d1f87c67fbcba02e389a Mon Sep 17 00:00:00 2001 From: rustoma Date: Sun, 28 Dec 2025 13:03:02 +0100 Subject: [PATCH 08/55] feat: update yarn.lock with new theme dependencies and modify createTheme instructions for clarity --- gallery/src/modules/create-theme/index.ts | 5 +- gallery/src/modules/create-theme/templates.ts | 57 ++++++++++++------- yarn.lock | 29 +++++++++- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/gallery/src/modules/create-theme/index.ts b/gallery/src/modules/create-theme/index.ts index 68d214e..71aa34c 100644 --- a/gallery/src/modules/create-theme/index.ts +++ b/gallery/src/modules/create-theme/index.ts @@ -163,9 +163,10 @@ export async function createTheme(options: CreateThemeOptions, ui: ConsolaInstan ui.success(`Theme created successfully at: ${themeDir}`); ui.info(`\nNext steps:`); ui.info(`1. cd ${themeDir}`); - ui.info(`2. npm install`); + ui.info(`2. yarn install`); ui.info(`3. Customize your theme in src/pages/index.astro`); - ui.info(`4. Build a gallery with: spg build --theme ${themeDir}`); + ui.info(`4. Initialize a gallery (run from directory with your images): spg init -p `); + ui.info(`5. Build a gallery with your theme: spg build --theme ${themeDir} -g `); return { processedGalleryCount: 0 }; } catch (error) { diff --git a/gallery/src/modules/create-theme/templates.ts b/gallery/src/modules/create-theme/templates.ts index d3a50d0..4e9e046 100644 --- a/gallery/src/modules/create-theme/templates.ts +++ b/gallery/src/modules/create-theme/templates.ts @@ -124,7 +124,8 @@ export function getTsConfig(): string { "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@simple-photo-gallery/common/src/*": ["../../common/src/*"] } } } @@ -236,16 +237,16 @@ A custom theme for Simple Photo Gallery built with Astro. \`\`\`bash # Install dependencies -npm install +yarn install # Start development server -npm run dev +yarn dev # Build for production -npm run build +yarn build # Preview production build -npm run preview +yarn preview \`\`\` ## Customization @@ -257,11 +258,17 @@ Edit \`src/pages/index.astro\` to customize your theme. This is the main entry p To use this theme when building a gallery: \`\`\`bash -# Using local path -spg build --theme ./themes/${themeName} +# 1. Initialize a gallery from your images folder +spg init -p -g + +# 2. Generate thumbnails (optional but recommended) +spg thumbnails -g + +# 3. Build the gallery with your theme +spg build --theme ./themes/${themeName} -g # Or if published to npm -spg build --theme @your-org/theme-${themeName} +spg build --theme @your-org/theme-${themeName} -g \`\`\` ## Structure @@ -319,7 +326,7 @@ const { title, description, url, thumbsBaseUrl, metadata } = Astro.props; export function getMainLayout(): string { return `--- -import MainHead from '@/layouts/MainHead'; +import MainHead from '@/layouts/MainHead.astro'; import type { GalleryMetadata } from '@simple-photo-gallery/common/src/gallery'; @@ -367,7 +374,7 @@ export function getIndexPage(): string { return `--- import fs from 'node:fs'; -import MainLayout from '@/layouts/MainLayout'; +import MainLayout from '@/layouts/MainLayout.astro'; import { getPhotoPath, getThumbnailPath } from '@/utils'; import { renderMarkdown } from '@/lib/markdown'; @@ -378,7 +385,7 @@ const galleryJsonPath = process.env.GALLERY_JSON_PATH || './gallery.json'; const galleryData = JSON.parse(fs.readFileSync(galleryJsonPath, 'utf8')); const gallery = galleryData as GalleryData; -const { title, description, sections, mediaBaseUrl, thumbsBaseUrl, headerImage, headerImageBlurHash } = gallery; +const { title, description, sections, mediaBaseUrl, thumbsBaseUrl } = gallery; // Render description markdown if present const descriptionHtml = description ? await renderMarkdown(description) : ''; @@ -400,11 +407,16 @@ const descriptionHtml = description ? await renderMarkdown(description) : '';