diff --git a/.github/workflows/check-cli.yml b/.github/workflows/check-cli.yml index ab0fda4..ce67724 100644 --- a/.github/workflows/check-cli.yml +++ b/.github/workflows/check-cli.yml @@ -34,7 +34,7 @@ jobs: - name: Run checks (common) working-directory: ./common - run: yarn check + run: yarn check && yarn build - name: Run checks (gallery) working-directory: ./gallery diff --git a/README.md b/README.md index 4f82ee0..2fcb12f 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,78 @@ This will: - Ubuntu/Debian: `sudo apt install ffmpeg` - Windows: [Download from ffmpeg.org](https://ffmpeg.org/download.html) +## Development + +This is a monorepo using Yarn workspaces. To set up the development environment: + +1. **Clone the repository** + + ```bash + git clone https://github.com/SimplePhotoGallery/core.git + cd spg-core + ``` + +2. **Install dependencies** + + ```bash + yarn install + ``` + +3. **Build the `common` package** (required for TypeScript/ESLint to resolve `@simple-photo-gallery/common`) + + ```bash + yarn workspace @simple-photo-gallery/common build + ``` + +4. **Build the gallery package** (optional, for testing CLI changes) + + ```bash + yarn workspace simple-photo-gallery build + ``` + +5. **Run the CLI in development mode** + ```bash + yarn workspace simple-photo-gallery gallery + ``` + +### Workspace Packages + +- **`common/`** - Shared types, schemas, and utilities used by both the CLI and themes + - Gallery types and Zod validation schemas + - Theme utilities (data loading, path resolution, markdown parsing) + - Client-side utilities (PhotoSwipe, blurhash, CSS helpers) + - See [common/README.md](common/README.md) for full API documentation +- **`gallery/`** - CLI tool (`simple-photo-gallery`) + - Includes base theme template bundled at `gallery/src/modules/create-theme/templates/base/` +- **`themes/modern/`** - Default theme package (reference implementation) + +### Building Packages + +Each workspace package can be built individually: + +- `yarn workspace @simple-photo-gallery/common build` +- `yarn workspace simple-photo-gallery build` +- `yarn workspace @simple-photo-gallery/theme-modern build` + ## Supported Formats **Images:** JPEG, PNG, WebP, GIF, TIFF **Videos:** MP4, MOV, AVI, WebM, MKV +## Architecture + +This project uses a multi-theme architecture: +- **Common package** provides shared utilities for all themes +- **Themes** focus only on layout and presentation +- **CLI** handles gallery generation and theme orchestration + +See the [Architecture Documentation](./docs/architecture.md) for details on how the system works, including: +- Package structure and dependencies +- Data flow from photos to static HTML +- Theme system design and resolution +- Multi-theme support implementation +- Guidelines for adding new features + ## Detailed Documentation For advanced usage, customization, and deployment options, see the comprehensive [documentation](./docs/README.md): @@ -76,7 +143,11 @@ For advanced usage, customization, and deployment options, see the comprehensive - [`build`](./docs/commands/build.md) - Generate static HTML galleries - [`thumbnails`](./docs/commands/thumbnails.md) - Generate optimized thumbnails - [`clean`](./docs/commands/clean.md) - Remove gallery files + - [`create-theme`](./docs/commands/create-theme.md) - Scaffold a new theme package + - [`telemetry`](./docs/commands/telemetry.md) - Manage anonymous telemetry preferences - **[Gallery Configuration](./docs/configuration.md)** - Manual editing of `gallery.json` and advanced features like sections +- **[Custom Themes](./docs/themes.md)** - Create and use custom themes +- **[Common Package API](./common/README.md)** - Utilities and types for theme development - **[Deployment Guide](./docs/deployment.md)** - Guidelines for hosting your gallery ## Python Version diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000..cb4f62f --- /dev/null +++ b/common/README.md @@ -0,0 +1,16 @@ +# @simple-photo-gallery/common + +Shared utilities and types for Simple Photo Gallery themes and CLI. + +## Installation + +```bash +npm install @simple-photo-gallery/common +``` + +## Package Exports + +- `.` - Gallery types and Zod validation schemas +- `./theme` - Theme utilities for data loading and resolution +- `./client` - Browser-side utilities (PhotoSwipe, blurhash, CSS) +- `./styles/photoswipe` - PhotoSwipe CSS bundle diff --git a/common/package.json b/common/package.json index 0ddd323..0d2e82b 100644 --- a/common/package.json +++ b/common/package.json @@ -1,6 +1,6 @@ { "name": "@simple-photo-gallery/common", - "version": "1.0.6", + "version": "2.1.0", "description": "Shared utilities and types for Simple Photo Gallery", "license": "MIT", "author": "Vladimir Haltakov, Tomasz Rusin", @@ -16,7 +16,16 @@ "types": "./dist/gallery.d.ts", "import": "./dist/gallery.js", "require": "./dist/gallery.cjs" - } + }, + "./theme": { + "types": "./dist/theme.d.ts", + "import": "./dist/theme.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" + }, + "./styles/photoswipe": "./dist/styles/photoswipe/photoswipe.css" }, "files": [ "dist" @@ -33,12 +42,26 @@ "prepublish": "yarn build" }, "dependencies": { + "marked": "^16.0.0", "zod": "^4.0.14" }, + "peerDependencies": { + "blurhash": "^2.0.5", + "photoswipe": "^5.4.4" + }, + "peerDependenciesMeta": { + "blurhash": { + "optional": true + }, + "photoswipe": { + "optional": true + } + }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.30.1", "@types/node": "^24.0.10", + "@types/photoswipe": "^4.1.6", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", "eslint": "^9.30.1", diff --git a/common/src/client.ts b/common/src/client.ts new file mode 100644 index 0000000..40a7340 --- /dev/null +++ b/common/src/client.ts @@ -0,0 +1 @@ +export * from './client/index'; diff --git a/common/src/client/blurhash.ts b/common/src/client/blurhash.ts new file mode 100644 index 0000000..9b27352 --- /dev/null +++ b/common/src/client/blurhash.ts @@ -0,0 +1,39 @@ +import { decode } from 'blurhash'; + +/** + * Decode a single blurhash and draw it to a canvas element. + * + * @param canvas - The canvas element with a data-blur-hash attribute + * @param width - The width to decode at (default: 32) + * @param height - The height to decode at (default: 32) + */ +export function decodeBlurhashToCanvas(canvas: HTMLCanvasElement, width: number = 32, height: number = 32): void { + const blurHashValue = canvas.dataset.blurHash; + if (!blurHashValue) return; + + const pixels = decode(blurHashValue, width, height); + const ctx = canvas.getContext('2d'); + if (pixels && ctx) { + const imageData = new ImageData(new Uint8ClampedArray(pixels), width, height); + ctx.putImageData(imageData, 0, 0); + } +} + +/** + * Decode and render all blurhash canvases on the page. + * Finds all canvas elements with data-blur-hash attribute and draws the decoded image. + * + * @param selector - CSS selector for canvas elements (default: 'canvas[data-blur-hash]') + * @param width - The width to decode at (default: 32) + * @param height - The height to decode at (default: 32) + */ +export function decodeAllBlurhashes( + selector: string = 'canvas[data-blur-hash]', + width: number = 32, + height: number = 32, +): void { + const canvases = document.querySelectorAll(selector); + for (const canvas of canvases) { + decodeBlurhashToCanvas(canvas, width, height); + } +} diff --git a/common/src/client/css-utils.ts b/common/src/client/css-utils.ts new file mode 100644 index 0000000..d249379 --- /dev/null +++ b/common/src/client/css-utils.ts @@ -0,0 +1,71 @@ +/** + * CSS utility functions for client-side theming and color manipulation. + * These utilities are browser-only and require DOM access. + */ + +/** + * Normalizes hex color values to 6-digit format (e.g., #abc -> #aabbcc). + * Returns null if the hex value is invalid. + * + * @param hex - The hex color value to normalize (with or without #) + * @returns The normalized 6-digit hex color with # prefix, or null if invalid + */ +export function normalizeHex(hex: string): string | null { + hex = hex.replace('#', ''); + if (hex.length === 3) hex = [...hex].map((c) => c + c).join(''); + return hex.length === 6 && /^[0-9A-Fa-f]{6}$/.test(hex) ? `#${hex}` : null; +} + +/** + * Parses and validates a color value. + * Supports CSS color names, hex values, rgb/rgba, and 'transparent'. + * Returns null if the color is invalid. + * + * @param colorParam - The color string to parse + * @returns The validated color string, or null if invalid + */ +export function parseColor(colorParam: string | null): string | null { + if (!colorParam) return null; + const normalized = colorParam.toLowerCase().trim(); + if (normalized === 'transparent') return 'transparent'; + + const testEl = document.createElement('div'); + testEl.style.color = normalized; + if (testEl.style.color) return normalized; + + return normalizeHex(colorParam); +} + +/** + * Sets or removes a CSS custom property (variable) on an element. + * Removes the property if value is null. + * + * @param element - The HTML element to modify + * @param name - The CSS variable name (e.g., '--my-color') + * @param value - The value to set, or null to remove + */ +export function setCSSVar(element: HTMLElement, name: string, value: string | null): void { + if (value) { + element.style.setProperty(name, value); + } else { + element.style.removeProperty(name); + } +} + +/** + * Derives a color with adjusted opacity from an existing color. + * Converts rgb to rgba if needed, or adjusts existing rgba opacity. + * + * @param color - The source color (rgb, rgba, or other CSS color) + * @param opacity - The target opacity (0-1) + * @returns The color with adjusted opacity, or original if not rgb/rgba + */ +export function deriveOpacityColor(color: string, opacity: number): string { + if (color.startsWith('rgba')) { + return color.replace(/,\s*[\d.]+\)$/, `, ${opacity})`); + } + if (color.startsWith('rgb')) { + return color.replace('rgb', 'rgba').replace(')', `, ${opacity})`); + } + return color; +} diff --git a/common/src/client/hero-fallback.ts b/common/src/client/hero-fallback.ts new file mode 100644 index 0000000..d1a8184 --- /dev/null +++ b/common/src/client/hero-fallback.ts @@ -0,0 +1,98 @@ +/** Options for the hero image fallback behavior */ +export interface HeroImageFallbackOptions { + /** CSS selector for the picture element (default: '#hero-bg-picture') */ + pictureSelector?: string; + /** CSS selector for the img element within picture (default: 'img.hero__bg-img') */ + imgSelector?: string; + /** CSS selector for the blurhash canvas element (default: 'canvas[data-blur-hash]') */ + canvasSelector?: string; +} + +/** + * Initialize hero image fallback behavior. + * Handles: + * - Hiding blurhash canvas when image loads successfully + * - Removing source elements and retrying with fallback src on error + * - Keeping blurhash visible if final fallback also fails + * + * @param options - Configuration options for selectors + * + * @example + * ```typescript + * import { initHeroImageFallback } from '@simple-photo-gallery/common/client'; + * + * // Use default selectors + * initHeroImageFallback(); + * + * // Or with custom selectors + * initHeroImageFallback({ + * pictureSelector: '#my-hero-picture', + * imgSelector: 'img.my-hero-img', + * canvasSelector: 'canvas.my-blurhash', + * }); + * ``` + */ +export function initHeroImageFallback(options: HeroImageFallbackOptions = {}): void { + const { + pictureSelector = '#hero-bg-picture', + imgSelector = 'img.hero__bg-img', + canvasSelector = 'canvas[data-blur-hash]', + } = options; + + const picture = document.querySelector(pictureSelector); + const img = picture?.querySelector(imgSelector); + const canvas = document.querySelector(canvasSelector); + + if (!img) return; + + const fallbackSrc = img.getAttribute('src') || ''; + let didFallback = false; + + const hideBlurhash = () => { + if (canvas) { + canvas.style.display = 'none'; + } + }; + + const doFallback = () => { + if (didFallback) return; + didFallback = true; + + if (picture) { + // Remove all elements so the browser does not retry them + for (const sourceEl of picture.querySelectorAll('source')) { + sourceEl.remove(); + } + } + + // Force reload using the src as the final fallback + const current = img.getAttribute('src') || ''; + img.setAttribute('src', ''); + img.setAttribute('src', fallbackSrc || current); + + // If fallback also fails, keep blurhash visible + img.addEventListener( + 'error', + () => { + // Final fallback failed, blurhash stays visible + }, + { once: true }, + ); + + // If fallback succeeds, hide blurhash + img.addEventListener('load', hideBlurhash, { once: true }); + }; + + // Check if image already loaded or failed before script runs + if (img.complete) { + if (img.naturalWidth === 0) { + doFallback(); + } else { + hideBlurhash(); + } + } else { + img.addEventListener('load', hideBlurhash, { once: true }); + } + + img.addEventListener('error', doFallback, { once: true }); +} diff --git a/common/src/client/index.ts b/common/src/client/index.ts new file mode 100644 index 0000000..dfac1e2 --- /dev/null +++ b/common/src/client/index.ts @@ -0,0 +1,21 @@ +// Blurhash utilities +export { decodeAllBlurhashes, decodeBlurhashToCanvas } from './blurhash'; + +// Hero image fallback +export type { HeroImageFallbackOptions } from './hero-fallback'; +export { initHeroImageFallback } from './hero-fallback'; + +// PhotoSwipe integration +export type { GalleryLightboxOptions, VideoPluginOptions, DeepLinkingOptions } from './photoswipe'; +export { + createGalleryLightbox, + PhotoSwipeVideoPlugin, + setupDeepLinking, + restoreFromURL, + updateGalleryURL, + getImageIdFromURL, + openImageById, +} from './photoswipe'; + +// CSS utilities +export { deriveOpacityColor, normalizeHex, parseColor, setCSSVar } from './css-utils'; diff --git a/common/src/client/photoswipe/deep-linking.ts b/common/src/client/photoswipe/deep-linking.ts new file mode 100644 index 0000000..1123e3f --- /dev/null +++ b/common/src/client/photoswipe/deep-linking.ts @@ -0,0 +1,124 @@ +import type PhotoSwipeLightbox from 'photoswipe/lightbox'; + +/** Options for URL deep-linking functionality */ +export interface DeepLinkingOptions { + /** CSS selector for galleries (default: '.gallery-grid') */ + gallerySelector?: string; + /** CSS selector for gallery items (default: 'a') */ + itemSelector?: string; + /** URL parameter name for image ID (default: 'image') */ + parameterName?: string; + /** Delay in ms before opening image on page load (default: 100) */ + openDelay?: number; +} + +/** + * Updates browser URL with current image ID. + * Uses History API to replace state without page reload. + */ +export function updateGalleryURL(imageId: string | null, paramName: string = 'image'): void { + const url = new URL(globalThis.location.href); + if (imageId) { + url.searchParams.set(paramName, imageId); + } else { + url.searchParams.delete(paramName); + } + globalThis.history.replaceState({}, '', url.toString()); +} + +/** + * Extracts image ID from URL parameters. + */ +export function getImageIdFromURL(paramName: string = 'image'): string | null { + const params = new URLSearchParams(globalThis.location.search); + return params.get(paramName); +} + +/** + * Opens a specific image by ID within a gallery. + * Searches through all gallery items and opens matching image in lightbox. + * + * @returns true if image was found and opened, false otherwise + */ +export function openImageById(lightbox: PhotoSwipeLightbox, imageId: string, options: DeepLinkingOptions = {}): boolean { + const gallerySelector = options.gallerySelector ?? '.gallery-grid'; + const itemSelector = options.itemSelector ?? 'a'; + + const galleries = document.querySelectorAll(gallerySelector); + const allItems: HTMLElement[] = []; + + for (const gallery of galleries) { + const items = gallery.querySelectorAll(itemSelector); + allItems.push(...items); + } + + for (const [i, item] of allItems.entries()) { + const itemId = item.dataset.imageId || ''; + if (itemId === imageId) { + lightbox.loadAndOpen(i); + return true; + } + } + return false; +} + +/** + * Sets up automatic URL updates when lightbox changes. + * Call after creating lightbox but before init(). + * + * @example + * ```typescript + * const lightbox = await createGalleryLightbox(); + * setupDeepLinking(lightbox); + * lightbox.init(); + * restoreFromURL(lightbox); + * ``` + */ +export function setupDeepLinking(lightbox: PhotoSwipeLightbox, options: DeepLinkingOptions = {}): void { + const paramName = options.parameterName ?? 'image'; + + // Update URL when slide changes + lightbox.on('change', () => { + if (lightbox.pswp) { + const currSlideElement = lightbox.pswp.currSlide?.data.element as HTMLElement | undefined; + const imageId = currSlideElement?.dataset?.imageId ?? null; + updateGalleryURL(imageId, paramName); + } + }); + + // Clear URL parameter when lightbox closes + lightbox.on('close', () => { + updateGalleryURL(null, paramName); + }); +} + +/** + * Restores gallery state from URL on page load. + * Automatically opens image if specified in URL parameters. + * Call after lightbox.init(). + * + * @example + * ```typescript + * const lightbox = await createGalleryLightbox(); + * setupDeepLinking(lightbox); + * lightbox.init(); + * restoreFromURL(lightbox); + * ``` + */ +export function restoreFromURL(lightbox: PhotoSwipeLightbox, options: DeepLinkingOptions = {}): void { + const paramName = options.parameterName ?? 'image'; + const openDelay = options.openDelay ?? 100; + const imageIdFromURL = getImageIdFromURL(paramName); + + if (imageIdFromURL) { + const open = () => { + setTimeout(() => openImageById(lightbox, imageIdFromURL, options), openDelay); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', open); + } else { + open(); + } + } +} diff --git a/common/src/client/photoswipe/index.ts b/common/src/client/photoswipe/index.ts new file mode 100644 index 0000000..43dae77 --- /dev/null +++ b/common/src/client/photoswipe/index.ts @@ -0,0 +1,8 @@ +export type { GalleryLightboxOptions } from './lightbox'; +export { createGalleryLightbox } from './lightbox'; +export type { VideoPluginOptions } from './types'; +export { PhotoSwipeVideoPlugin } from './video-plugin'; + +// Deep linking utilities +export type { DeepLinkingOptions } from './deep-linking'; +export { updateGalleryURL, getImageIdFromURL, openImageById, setupDeepLinking, restoreFromURL } from './deep-linking'; diff --git a/common/src/client/photoswipe/lightbox.ts b/common/src/client/photoswipe/lightbox.ts new file mode 100644 index 0000000..ee8d3f7 --- /dev/null +++ b/common/src/client/photoswipe/lightbox.ts @@ -0,0 +1,113 @@ +import { PhotoSwipeVideoPlugin } from './video-plugin'; + +import type { VideoPluginOptions } from './types'; +import type PhotoSwipe from 'photoswipe'; +import type PhotoSwipeLightbox from 'photoswipe/lightbox'; + +/** Options for creating a gallery lightbox */ +export interface GalleryLightboxOptions { + /** CSS selector for gallery container (default: '.gallery-grid') */ + gallerySelector?: string; + /** CSS selector for gallery items within container (default: 'a') */ + childrenSelector?: string; + /** Animation duration when opening in ms (default: 300) */ + showAnimationDuration?: number; + /** Animation duration when closing in ms (default: 300) */ + hideAnimationDuration?: number; + /** Enable mouse wheel zoom (default: true) */ + wheelToZoom?: boolean; + /** Loop back to first image after last (default: false) */ + loop?: boolean; + /** Background opacity 0-1 (default: 1) */ + bgOpacity?: number; + /** Options for the video plugin */ + videoPluginOptions?: VideoPluginOptions; + /** Enable slide-in animations when changing slides (default: true) */ + slideAnimations?: boolean; + /** Enable custom caption UI from data-pswp-caption attribute (default: true) */ + enableCaptions?: boolean; +} + +/** + * Create a PhotoSwipe lightbox with sensible defaults and video support. + * Returns the lightbox instance for further customization before calling init(). + * + * IMPORTANT: You must import the PhotoSwipe CSS files for the lightbox to work properly: + * - `import 'photoswipe/style.css'` - Base PhotoSwipe styles + * - `import '@simple-photo-gallery/common/styles/photoswipe'` - SPG enhancement styles + * + * @example + * ```typescript + * import { createGalleryLightbox } from '@simple-photo-gallery/common/client'; + * import 'photoswipe/style.css'; + * import '@simple-photo-gallery/common/styles/photoswipe'; + * + * // Basic usage + * const lightbox = await createGalleryLightbox(); + * lightbox.init(); + * + * // With custom options + * const lightbox = await createGalleryLightbox({ + * gallerySelector: '.my-gallery', + * loop: true, + * }); + * + * // Add custom event handlers before init + * lightbox.on('change', () => console.log('slide changed')); + * lightbox.init(); + * ``` + */ +export async function createGalleryLightbox(options: GalleryLightboxOptions = {}): Promise { + const photoswipeModule = await import('photoswipe'); + const PhotoSwipe = photoswipeModule.default; + const lightboxModule = await import('photoswipe/lightbox'); + const PhotoSwipeLightboxModule = lightboxModule.default; + + const lightbox = new PhotoSwipeLightboxModule({ + gallery: options.gallerySelector ?? '.gallery-grid', + children: options.childrenSelector ?? 'a', + pswpModule: PhotoSwipe, + showAnimationDuration: options.showAnimationDuration ?? 300, + hideAnimationDuration: options.hideAnimationDuration ?? 300, + wheelToZoom: options.wheelToZoom ?? true, + loop: options.loop ?? false, + bgOpacity: options.bgOpacity ?? 1, + }); + + new PhotoSwipeVideoPlugin(lightbox, options.videoPluginOptions); + + // Slide animations (enabled by default) + if (options.slideAnimations !== false) { + lightbox.on('contentDeactivate', ({ content }: { content: { element?: HTMLElement } }) => { + content.element?.classList.remove('pswp__img--in-viewport'); + }); + lightbox.on('contentActivate', ({ content }: { content: { element?: HTMLElement } }) => { + content.element?.classList.add('pswp__img--in-viewport'); + }); + } + + // Custom caption UI (enabled by default) + if (options.enableCaptions !== false) { + lightbox.on('uiRegister', () => { + (lightbox.pswp as PhotoSwipe | undefined)?.ui?.registerElement({ + name: 'custom-caption', + isButton: false, + className: 'pswp__caption', + appendTo: 'wrapper', + onInit: (el: HTMLElement) => { + (lightbox.pswp as PhotoSwipe | undefined)?.on('change', () => { + const currSlideElement = (lightbox.pswp as PhotoSwipe | undefined)?.currSlide?.data.element as + | HTMLElement + | undefined; + if (currSlideElement) { + const caption = (currSlideElement as HTMLElement).dataset.pswpCaption; + el.innerHTML = caption || currSlideElement.querySelector('img')?.alt || ''; + } + }); + }, + }); + }); + } + + return lightbox; +} diff --git a/common/src/client/photoswipe/types.ts b/common/src/client/photoswipe/types.ts new file mode 100644 index 0000000..edfb02b --- /dev/null +++ b/common/src/client/photoswipe/types.ts @@ -0,0 +1,46 @@ +export interface Slide { + content: Content; + height: number; + currZoomLevel: number; + bounds: { center: { y: number } }; + placeholder?: { element: HTMLElement }; + isActive: boolean; +} + +export interface Content { + data: SlideData; + element?: HTMLVideoElement | HTMLImageElement | HTMLDivElement; + state?: string; + type?: string; + isAttached?: boolean; + onLoaded?: () => void; + appendImage?: () => void; + slide?: Slide; + _videoPosterImg?: HTMLImageElement; +} + +export interface SlideData { + type?: string; + msrc?: string; + videoSrc?: string; + videoSources?: Array<{ src: string; type: string }>; +} + +/** Options for the PhotoSwipe video plugin */ +export interface VideoPluginOptions { + /** HTML attributes to apply to video elements */ + videoAttributes?: Record; + /** Whether to autoplay videos when they become active */ + autoplay?: boolean; + /** Pixels from bottom of video where drag is prevented (for video controls) */ + preventDragOffset?: number; +} + +export interface EventData { + content?: Content; + slide?: Slide; + width?: number; + height?: number; + originalEvent?: PointerEvent; + preventDefault?: () => void; +} diff --git a/themes/modern/src/lib/photoswipe-video-plugin.ts b/common/src/client/photoswipe/video-plugin.ts similarity index 88% rename from themes/modern/src/lib/photoswipe-video-plugin.ts rename to common/src/client/photoswipe/video-plugin.ts index b9721b3..d1cff31 100644 --- a/themes/modern/src/lib/photoswipe-video-plugin.ts +++ b/common/src/client/photoswipe/video-plugin.ts @@ -1,49 +1,7 @@ +import type { Content, EventData, Slide, VideoPluginOptions } from './types'; 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, @@ -262,9 +220,6 @@ class VideoContentSetup { content.element.append(sourceEl); } } else if (content.data.videoSrc) { - // Force video preload - // https://muffinman.io/blog/hack-for-ios-safari-to-display-html-video-thumbnail/ - // this.element.src = this.data.videoSrc + '#t=0.001'; content.element.src = content.data.videoSrc; } } @@ -306,7 +261,28 @@ class VideoContentSetup { } } -class PhotoSwipeVideoPlugin { +/** + * PhotoSwipe plugin that adds video support to the lightbox. + * Videos are automatically detected by the `data-pswp-type="video"` attribute + * on gallery links. + * + * @example + * ```typescript + * import PhotoSwipe from 'photoswipe'; + * import PhotoSwipeLightbox from 'photoswipe/lightbox'; + * import { PhotoSwipeVideoPlugin } from '@simple-photo-gallery/common/client'; + * + * const lightbox = new PhotoSwipeLightbox({ + * gallery: '.gallery', + * children: 'a', + * pswpModule: PhotoSwipe, + * }); + * + * new PhotoSwipeVideoPlugin(lightbox); + * lightbox.init(); + * ``` + */ +export class PhotoSwipeVideoPlugin { constructor(lightbox: PhotoSwipeLightbox, options: VideoPluginOptions = {}) { new VideoContentSetup(lightbox, { ...defaultOptions, @@ -314,6 +290,3 @@ class PhotoSwipeVideoPlugin { }); } } - -export default PhotoSwipeVideoPlugin; -export type { VideoPluginOptions }; diff --git a/common/src/gallery.ts b/common/src/gallery.ts index 94902bb..ba85e46 100644 --- a/common/src/gallery.ts +++ b/common/src/gallery.ts @@ -1,165 +1 @@ -import { z } from 'zod'; - -/** Zod schema for thumbnail metadata including path and dimensions */ -export const ThumbnailSchema = z.object({ - baseUrl: z.string().optional(), - path: z.string(), - pathRetina: z.string(), - width: z.number(), - height: z.number(), - blurHash: z.string().optional(), -}); - -/** Zod schema for media file metadata including type, dimensions, and thumbnail info */ -export const MediaFileSchema = z.object({ - type: z.enum(['image', 'video']), - filename: z.string(), - url: z.string().optional(), - alt: z.string().optional(), - width: z.number(), - height: z.number(), - thumbnail: ThumbnailSchema.optional(), - lastMediaTimestamp: z.string().optional(), -}); - -/** Zod schema for media file with path (deprecated) */ -export const MediaFileDeprecatedSchema = z.object({ - type: z.enum(['image', 'video']), - path: z.string(), - alt: z.string().optional(), - width: z.number(), - height: z.number(), - thumbnail: ThumbnailSchema.optional(), - lastMediaTimestamp: z.string().optional(), -}); - -/** Zod schema for a gallery section containing title, description, and media files */ -export const GallerySectionSchema = z.object({ - title: z.string().optional(), - description: z.string().optional(), - images: z.array(MediaFileSchema), -}); - -/** Zod schema for a gallery section containing title, description, and media files (deprecated) */ -export const GallerySectionDeprecatedSchema = z.object({ - title: z.string().optional(), - description: z.string().optional(), - images: z.array(MediaFileDeprecatedSchema), -}); - -/** Zod schema for sub-gallery metadata including title, header image, and path */ -export const SubGallerySchema = z.object({ - title: z.string(), - headerImage: z.string(), - path: z.string(), -}); - -/** Zod schema for portrait image size variants */ -const PortraitSizesSchema = z.object({ - 360: z.string().optional(), - 480: z.string().optional(), - 720: z.string().optional(), - 1080: z.string().optional(), -}); - -/** Zod schema for landscape image size variants */ -const LandscapeSizesSchema = z.object({ - 640: z.string().optional(), - 960: z.string().optional(), - 1280: z.string().optional(), - 1920: z.string().optional(), - 2560: z.string().optional(), - 3840: z.string().optional(), -}); - -/** Zod schema for header image variants allowing explicit specification of responsive hero images */ -export const HeaderImageVariantsSchema = z.object({ - portrait: z - .object({ - avif: PortraitSizesSchema.optional(), - jpg: PortraitSizesSchema.optional(), - }) - .optional(), - landscape: z - .object({ - avif: LandscapeSizesSchema.optional(), - jpg: LandscapeSizesSchema.optional(), - }) - .optional(), -}); - -/** Zod schema for complete gallery data including metadata, sections, and sub-galleries */ -export const GalleryMetadataSchema = z.object({ - image: z.string().optional(), - imageWidth: z.number().optional(), - imageHeight: z.number().optional(), - ogUrl: z.string().optional(), - ogType: z.string().optional(), - ogSiteName: z.string().optional(), - twitterSite: z.string().optional(), - twitterCreator: z.string().optional(), - author: z.string().optional(), - keywords: z.string().optional(), - canonicalUrl: z.string().optional(), - language: z.string().optional(), - robots: z.string().optional(), -}); - -/** Zod schema for complete gallery data including metadata, sections, and sub-galleries */ -export const GalleryDataSchema = z.object({ - title: z.string(), - description: z.string(), - mediaBasePath: z.string().optional(), - url: z.string().optional(), - headerImage: z.string(), - headerImageBlurHash: z.string().optional(), - headerImageVariants: HeaderImageVariantsSchema.optional(), - thumbnailSize: z.number().optional(), - metadata: GalleryMetadataSchema, - mediaBaseUrl: z.string().optional(), - thumbsBaseUrl: z.string().optional(), - analyticsScript: z.string().optional(), - ctaBanner: z.boolean().optional(), - sections: z.array(GallerySectionSchema), - subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) }), -}); - -/** Zod schema for complete gallery data without mediaBasePath (deprecated) */ -export const GalleryDataDeprecatedSchema = z.object({ - title: z.string(), - description: z.string(), - url: z.string().optional(), - headerImage: z.string(), - thumbnailSize: z.number().optional(), - metadata: GalleryMetadataSchema, - mediaBaseUrl: z.string().optional(), - analyticsScript: z.string().optional(), - sections: z.array(GallerySectionDeprecatedSchema), - subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) }), -}); - -/** TypeScript type for thumbnail metadata */ -export type Thumbnail = z.infer; - -/** TypeScript type for media file metadata */ -export type MediaFile = z.infer; - -/** TypeScript type for gallery section data */ -export type GallerySection = z.infer; - -/** TypeScript type for sub-gallery metadata */ -export type SubGallery = z.infer; - -/** TypeScript type for header image variants */ -export type HeaderImageVariants = z.infer; - -/** TypeScript type for gallery metadata */ -export type GalleryMetadata = z.infer; - -/** TypeScript type for complete gallery data structure */ -export type GalleryData = z.infer; - -/** Deprecated types */ -export type MediaFileWithPath = z.infer; -export type GallerySectionDeprecated = z.infer; -export type GalleryDataDeprecated = z.infer; +export * from './gallery/index'; diff --git a/common/src/gallery/index.ts b/common/src/gallery/index.ts new file mode 100644 index 0000000..7b431f3 --- /dev/null +++ b/common/src/gallery/index.ts @@ -0,0 +1,25 @@ +export { + GalleryDataDeprecatedSchema, + GalleryDataSchema, + GalleryMetadataSchema, + GallerySectionDeprecatedSchema, + GallerySectionSchema, + HeaderImageVariantsSchema, + MediaFileDeprecatedSchema, + MediaFileSchema, + SubGallerySchema, + ThumbnailSchema, +} from './schemas'; + +export type { + GalleryData, + GalleryDataDeprecated, + GalleryMetadata, + GallerySection, + GallerySectionDeprecated, + HeaderImageVariants, + MediaFile, + MediaFileWithPath, + SubGallery, + Thumbnail, +} from './types'; diff --git a/common/src/gallery/schemas.ts b/common/src/gallery/schemas.ts new file mode 100644 index 0000000..95c2ad3 --- /dev/null +++ b/common/src/gallery/schemas.ts @@ -0,0 +1,154 @@ +import { z } from 'zod'; + +/** Zod schema for thumbnail metadata including path and dimensions */ +export const ThumbnailSchema = z.object({ + baseUrl: z.string().optional(), + path: z.string(), + pathRetina: z.string(), + width: z.number(), + height: z.number(), + blurHash: z.string().optional(), +}); + +/** Zod schema for media file metadata including type, dimensions, and thumbnail info */ +export const MediaFileSchema = z.object({ + type: z.enum(['image', 'video']), + filename: z.string(), + url: z.string().optional(), + alt: z.string().optional(), + width: z.number(), + height: z.number(), + thumbnail: ThumbnailSchema.optional(), + lastMediaTimestamp: z.string().optional(), +}); + +/** + * Zod schema for media file with path. + * @deprecated Use MediaFileSchema instead which uses 'filename' instead of 'path'. + */ +export const MediaFileDeprecatedSchema = z.object({ + type: z.enum(['image', 'video']), + path: z.string(), + alt: z.string().optional(), + width: z.number(), + height: z.number(), + thumbnail: ThumbnailSchema.optional(), + lastMediaTimestamp: z.string().optional(), +}); + +/** Zod schema for a gallery section containing title, description, and media files */ +export const GallerySectionSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + images: z.array(MediaFileSchema), +}); + +/** + * Zod schema for a gallery section containing title, description, and media files. + * @deprecated Use GallerySectionSchema instead which uses MediaFileSchema. + */ +export const GallerySectionDeprecatedSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + images: z.array(MediaFileDeprecatedSchema), +}); + +/** Zod schema for sub-gallery metadata including title, header image, and path */ +export const SubGallerySchema = z.object({ + title: z.string(), + headerImage: z.string(), + path: z.string(), +}); + +/** Zod schema for portrait image size variants */ +const PortraitSizesSchema = z.object({ + 360: z.string().optional(), + 480: z.string().optional(), + 720: z.string().optional(), + 1080: z.string().optional(), +}); + +/** Zod schema for landscape image size variants */ +const LandscapeSizesSchema = z.object({ + 640: z.string().optional(), + 960: z.string().optional(), + 1280: z.string().optional(), + 1920: z.string().optional(), + 2560: z.string().optional(), + 3840: z.string().optional(), +}); + +/** Zod schema for header image variants allowing explicit specification of responsive hero images */ +export const HeaderImageVariantsSchema = z.object({ + portrait: z + .object({ + avif: PortraitSizesSchema.optional(), + jpg: PortraitSizesSchema.optional(), + }) + .optional(), + landscape: z + .object({ + avif: LandscapeSizesSchema.optional(), + jpg: LandscapeSizesSchema.optional(), + }) + .optional(), +}); + +/** Zod schema for complete gallery data including metadata, sections, and sub-galleries */ +export const GalleryMetadataSchema = z.object({ + image: z.string().optional(), + imageWidth: z.number().optional(), + imageHeight: z.number().optional(), + ogUrl: z.string().optional(), + ogType: z.string().optional(), + ogSiteName: z.string().optional(), + twitterSite: z.string().optional(), + twitterCreator: z.string().optional(), + author: z.string().optional(), + keywords: z.string().optional(), + canonicalUrl: z.string().optional(), + language: z.string().optional(), + robots: z.string().optional(), +}); + +/** Zod schema for complete gallery data including metadata, sections, and sub-galleries */ +export const GalleryDataSchema = z.object({ + title: z.string(), + description: z.string(), + mediaBasePath: z.string().optional(), + url: z.string().optional(), + headerImage: z.string(), + headerImageBlurHash: z.string().optional(), + headerImageVariants: HeaderImageVariantsSchema.optional(), + theme: z.string().optional(), + thumbnails: z + .object({ + size: z.number().optional(), + edge: z.enum(['auto', 'width', 'height']).optional(), + }) + .optional(), + metadata: GalleryMetadataSchema, + mediaBaseUrl: z.string().optional(), + thumbsBaseUrl: z.string().optional(), + analyticsScript: z.string().optional(), + ctaBanner: z.boolean().optional(), + sections: z.array(GallerySectionSchema), + subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) }), +}); + +/** + * Zod schema for complete gallery data without mediaBasePath. + * @deprecated Use GalleryDataSchema instead which includes mediaBasePath and headerImageVariants. + */ +export const GalleryDataDeprecatedSchema = z.object({ + title: z.string(), + description: z.string(), + url: z.string().optional(), + headerImage: z.string(), + thumbnailSize: z.number().optional(), + metadata: GalleryMetadataSchema, + mediaBaseUrl: z.string().optional(), + analyticsScript: z.string().optional(), + sections: z.array(GallerySectionDeprecatedSchema), + subGalleries: z.object({ title: z.string(), galleries: z.array(SubGallerySchema) }), +}); diff --git a/common/src/gallery/types.ts b/common/src/gallery/types.ts new file mode 100644 index 0000000..010c81e --- /dev/null +++ b/common/src/gallery/types.ts @@ -0,0 +1,52 @@ +import type { + GalleryDataDeprecatedSchema, + GalleryDataSchema, + GalleryMetadataSchema, + GallerySectionDeprecatedSchema, + GallerySectionSchema, + HeaderImageVariantsSchema, + MediaFileDeprecatedSchema, + MediaFileSchema, + SubGallerySchema, + ThumbnailSchema, +} from './schemas'; +import type { z } from 'zod'; + +/** TypeScript type for thumbnail metadata */ +export type Thumbnail = z.infer; + +/** TypeScript type for media file metadata */ +export type MediaFile = z.infer; + +/** TypeScript type for gallery section data */ +export type GallerySection = z.infer; + +/** TypeScript type for sub-gallery metadata */ +export type SubGallery = z.infer; + +/** TypeScript type for header image variants */ +export type HeaderImageVariants = z.infer; + +/** TypeScript type for gallery metadata */ +export type GalleryMetadata = z.infer; + +/** TypeScript type for complete gallery data structure */ +export type GalleryData = z.infer; + +/** + * TypeScript type for media file with path. + * @deprecated Use MediaFile instead which uses 'filename' instead of 'path'. + */ +export type MediaFileWithPath = z.infer; + +/** + * TypeScript type for gallery section with deprecated media file format. + * @deprecated Use GallerySection instead which uses MediaFile. + */ +export type GallerySectionDeprecated = z.infer; + +/** + * TypeScript type for complete gallery data without mediaBasePath. + * @deprecated Use GalleryData instead which includes mediaBasePath and headerImageVariants. + */ +export type GalleryDataDeprecated = z.infer; diff --git a/common/src/styles/photoswipe/photoswipe.css b/common/src/styles/photoswipe/photoswipe.css new file mode 100644 index 0000000..09862f8 --- /dev/null +++ b/common/src/styles/photoswipe/photoswipe.css @@ -0,0 +1,138 @@ +/* PhotoSwipe CSS Custom Properties - Override these in your theme */ +:root { + /* Background */ + --pswp-bg-color: rgba(0, 0, 0, 1); + + /* Buttons */ + --pswp-button-color: white; + --pswp-button-opacity: 0.8; + --pswp-button-opacity-hover: 1; + + /* Counter */ + --pswp-counter-color: white; + --pswp-counter-font-size: 1rem; + + /* Caption container */ + --pswp-caption-bg: rgba(128, 128, 128, 0.3); + --pswp-caption-blur: 8px; + --pswp-caption-color: white; + --pswp-caption-padding: 16px; + --pswp-caption-padding-mobile: 8px 16px; + --pswp-caption-radius: 16px; + --pswp-caption-margin: 1rem; + --pswp-caption-margin-mobile: 8px 0; + --pswp-caption-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + --pswp-caption-max-width: 42rem; + + /* Caption typography */ + --pswp-caption-title-size: 1.5rem; + --pswp-caption-title-weight: 600; + --pswp-caption-text-size: 1rem; + --pswp-caption-text-size-mobile: 0.8rem; + --pswp-caption-description-size: 0.95rem; + + /* Animation */ + --pswp-slide-animation-duration: 0.6s; + --pswp-slide-animation-easing: ease-out; +} + +/* PhotoSwipe enhanced styles */ +.pswp .pswp__bg { + --pswp-bg: var(--pswp-bg-color); +} + +.pswp__counter { + color: var(--pswp-counter-color); + font-size: var(--pswp-counter-font-size); +} + +.pswp__button { + color: var(--pswp-button-color); + opacity: var(--pswp-button-opacity); + transition: opacity 0.3s ease; +} + +.pswp__button:hover { + opacity: var(--pswp-button-opacity-hover); +} + +.pswp__caption { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); +} + +@media (max-width: 768px) { + .pswp__caption { + width: calc(100% - 16px); + } +} + +.pswp__caption .image-caption { + text-align: left; + background: var(--pswp-caption-bg); + backdrop-filter: blur(var(--pswp-caption-blur)); + -webkit-backdrop-filter: blur(var(--pswp-caption-blur)); + color: var(--pswp-caption-color); + padding: var(--pswp-caption-padding); + border-radius: var(--pswp-caption-radius); + margin: var(--pswp-caption-margin); + box-shadow: var(--pswp-caption-shadow); +} + +@media (max-width: 768px) { + .pswp__caption .image-caption { + padding: var(--pswp-caption-padding-mobile); + font-size: var(--pswp-caption-text-size-mobile); + margin: var(--pswp-caption-margin-mobile); + } +} + +.pswp__caption__center { + text-align: center; + max-width: var(--pswp-caption-max-width); + margin: 0 auto; +} + +.pswp__caption h3 { + font-size: var(--pswp-caption-title-size); + font-weight: var(--pswp-caption-title-weight); + margin-bottom: 0.5rem; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); +} + +.pswp__caption p { + font-size: var(--pswp-caption-text-size); + opacity: 0.95; + line-height: 1.6; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + font-weight: 400; +} + +.pswp__caption .description { + display: block; + margin-top: 0.5rem; + font-size: var(--pswp-caption-description-size); + opacity: 0.9; + font-weight: 300; + font-style: italic; +} + +/* Slide-in animation */ +.pswp__img { + opacity: 0; +} + +.pswp__img--in-viewport { + animation: pswp-slideIn var(--pswp-slide-animation-duration) var(--pswp-slide-animation-easing) forwards; +} + +@keyframes pswp-slideIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/common/src/theme.ts b/common/src/theme.ts new file mode 100644 index 0000000..7143e6d --- /dev/null +++ b/common/src/theme.ts @@ -0,0 +1 @@ +export * from './theme/index'; diff --git a/common/src/theme/astro.ts b/common/src/theme/astro.ts new file mode 100644 index 0000000..81f6a6c --- /dev/null +++ b/common/src/theme/astro.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Astro integration type (simplified to avoid astro dependency in common) */ +interface AstroIntegration { + name: string; + hooks: { + 'astro:build:done': (options: { dir: URL }) => void; + }; +} + +/** + * Astro integration to prevent empty content collection files from being generated. + * Removes empty content-assets.mjs and content-modules.mjs files after build. + */ +export 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 + } + } + } + }, + }, + }; +} diff --git a/common/src/theme/config.ts b/common/src/theme/config.ts new file mode 100644 index 0000000..34e7cf7 --- /dev/null +++ b/common/src/theme/config.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +/** Zod schema for thumbnail configuration */ +export const ThumbnailConfigSchema = z.object({ + size: z.number().min(50).max(4000).optional(), + edge: z.enum(['auto', 'width', 'height']).optional(), +}); + +/** Zod schema for theme configuration file (themeConfig.json) */ +export const ThemeConfigSchema = z.object({ + thumbnails: ThumbnailConfigSchema.optional(), +}); + +/** TypeScript type for thumbnail configuration */ +export type ThumbnailConfig = z.infer; + +/** TypeScript type for theme configuration */ +export type ThemeConfig = z.infer; + +/** Default thumbnail configuration values */ +export const DEFAULT_THUMBNAIL_CONFIG: Required = { + size: 300, + edge: 'auto', +}; + +/** + * Extracts thumbnail config from gallery data. + * @param gallery - The gallery data object + * @returns ThumbnailConfig with values from gallery data + */ +export function extractThumbnailConfigFromGallery(gallery: { thumbnails?: ThumbnailConfig }): ThumbnailConfig { + return { + size: gallery.thumbnails?.size, + edge: gallery.thumbnails?.edge, + }; +} + +/** + * Merges thumbnail configurations with hierarchy: + * 1. CLI flags (highest precedence) + * 2. gallery.json settings + * 3. themeConfig.json (theme defaults) + * 4. Built-in defaults (lowest) + * + * @param cliConfig - Config from CLI flags (optional) + * @param galleryConfig - Config from gallery.json (optional) + * @param themeConfig - Config from themeConfig.json (optional) + * @returns Merged thumbnail configuration with all values resolved + */ +export function mergeThumbnailConfig( + cliConfig?: ThumbnailConfig, + galleryConfig?: ThumbnailConfig, + themeConfig?: ThumbnailConfig, +): Required { + return { + size: cliConfig?.size ?? galleryConfig?.size ?? themeConfig?.size ?? DEFAULT_THUMBNAIL_CONFIG.size, + edge: cliConfig?.edge ?? galleryConfig?.edge ?? themeConfig?.edge ?? DEFAULT_THUMBNAIL_CONFIG.edge, + }; +} diff --git a/common/src/theme/constants.ts b/common/src/theme/constants.ts new file mode 100644 index 0000000..b09409f --- /dev/null +++ b/common/src/theme/constants.ts @@ -0,0 +1,5 @@ +/** Portrait image sizes for responsive hero images */ +export const PORTRAIT_SIZES = [360, 480, 720, 1080] as const; + +/** Landscape image sizes for responsive hero images */ +export const LANDSCAPE_SIZES = [640, 960, 1280, 1920, 2560, 3840] as const; diff --git a/common/src/theme/index.ts b/common/src/theme/index.ts new file mode 100644 index 0000000..5473588 --- /dev/null +++ b/common/src/theme/index.ts @@ -0,0 +1,32 @@ +// Types +export type { ResolvedGalleryData, ResolvedHero, ResolvedImage, ResolvedSection, ResolvedSubGallery } from './types'; + +// Theme config +export type { ThemeConfig, ThumbnailConfig } from './config'; +export { + DEFAULT_THUMBNAIL_CONFIG, + extractThumbnailConfigFromGallery, + mergeThumbnailConfig, + ThemeConfigSchema, + ThumbnailConfigSchema, +} from './config'; + +// Constants +export { LANDSCAPE_SIZES, PORTRAIT_SIZES } from './constants'; + +// Markdown +export { renderMarkdown } from './markdown'; + +// Path utilities +export { buildHeroSrcset, getPhotoPath, getRelativePath, getSubgalleryThumbnailPath, getThumbnailPath } from './paths'; + +// Gallery loading +export type { LoadGalleryDataOptions } from './loader'; +export { loadGalleryData, loadThemeConfig } from './loader'; + +// Data resolution +export type { ResolveGalleryDataOptions } from './resolver'; +export { resolveGalleryData } from './resolver'; + +// Astro integration +export { preventEmptyContentFiles } from './astro'; diff --git a/common/src/theme/loader.ts b/common/src/theme/loader.ts new file mode 100644 index 0000000..9ce9e48 --- /dev/null +++ b/common/src/theme/loader.ts @@ -0,0 +1,104 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +import { ThemeConfigSchema, type ThumbnailConfig } from './config'; + +import { GalleryDataSchema, type GalleryData } from '../gallery'; + +export interface LoadGalleryDataOptions { + /** + * When true, validates the loaded JSON against the GalleryData schema. + * Throws a descriptive error if validation fails. + * @default false + */ + validate?: boolean; +} + +/** + * Load theme configuration from themeConfig.json file. + * + * Searches for themeConfig.json in the following locations (in priority order): + * 1. Current working directory (process.cwd()) - checked first + * 2. Provided themePath parameter - checked second + * + * The first valid configuration found is returned. This means a themeConfig.json + * in the project root will take precedence over the theme's built-in configuration, + * allowing users to override theme defaults without modifying the theme itself. + * + * **Note for theme authors:** Your theme's themeConfig.json provides sensible defaults, + * but users can override these by placing their own themeConfig.json in their project root. + * + * @param themePath - Optional path to the theme directory + * @returns The thumbnail configuration from the theme, or undefined if not found + * + * @example + * ```typescript + * // Load from theme directory + * const themeConfig = loadThemeConfig('/path/to/theme'); + * + * // Load from current directory + * const themeConfig = loadThemeConfig(); + * ``` + */ +export function loadThemeConfig(themePath?: string): ThumbnailConfig | undefined { + const themeConfigPaths: string[] = [path.resolve(process.cwd(), 'themeConfig.json')]; + + if (themePath) { + themeConfigPaths.push(path.resolve(themePath, 'themeConfig.json')); + } + + for (const configPath of themeConfigPaths) { + if (fs.existsSync(configPath)) { + try { + const configData = JSON.parse(fs.readFileSync(configPath, 'utf8')); + const parsed = ThemeConfigSchema.safeParse(configData); + if (parsed.success) { + return parsed.data.thumbnails; + } + // Log validation errors for debugging + if (process.env.DEBUG || process.env.VERBOSE) { + console.warn(`Failed to validate themeConfig at ${configPath}:`, parsed.error.message); + } + } catch (error) { + // Log parse errors for debugging + if (process.env.DEBUG || process.env.VERBOSE) { + console.warn(`Failed to parse themeConfig at ${configPath}:`, error); + } + } + } + } + + return undefined; +} + +/** + * Load gallery data from a JSON file. + * + * @param galleryJsonPath - Path to the gallery.json file. Defaults to './gallery.json'. + * @param options - Optional settings for loading behavior. + * @returns The parsed gallery data + * @throws Error if file cannot be read, parsed, or fails validation + * + * @example + * ```typescript + * // Basic usage (no validation) + * const gallery = loadGalleryData('./gallery.json'); + * + * // With schema validation + * const gallery = loadGalleryData('./gallery.json', { validate: true }); + * ``` + */ +export function loadGalleryData(galleryJsonPath = './gallery.json', options?: LoadGalleryDataOptions): GalleryData { + const galleryData = JSON.parse(fs.readFileSync(galleryJsonPath, 'utf8')); + + if (options?.validate) { + const result = GalleryDataSchema.safeParse(galleryData); + if (!result.success) { + throw new Error(`Invalid gallery.json at ${galleryJsonPath}: ${result.error.message}`); + } + return result.data; + } + + return galleryData as GalleryData; +} diff --git a/themes/modern/src/lib/markdown.ts b/common/src/theme/markdown.ts similarity index 100% rename from themes/modern/src/lib/markdown.ts rename to common/src/theme/markdown.ts diff --git a/themes/modern/src/features/themes/base-theme/utils/index.ts b/common/src/theme/paths.ts similarity index 83% rename from themes/modern/src/features/themes/base-theme/utils/index.ts rename to common/src/theme/paths.ts index 5a07667..338399d 100644 --- a/themes/modern/src/features/themes/base-theme/utils/index.ts +++ b/common/src/theme/paths.ts @@ -4,17 +4,18 @@ 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 + * @param galleryJsonPath - Path to the gallery.json file used to resolve relative paths * @returns The normalized path relative to the gallery root directory */ -export const getRelativePath = (resourcePath: string) => { - const galleryConfigPath = path.resolve(process.env.GALLERY_JSON_PATH || ''); +export function getRelativePath(resourcePath: string, galleryJsonPath: string): string { + const galleryConfigPath = path.resolve(galleryJsonPath); 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. @@ -24,14 +25,14 @@ export const getRelativePath = (resourcePath: string) => { * @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) => { +export function getThumbnailPath(resourcePath: string, thumbsBaseUrl?: string, thumbnailBaseUrl?: string): 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. @@ -41,14 +42,14 @@ export const getThumbnailPath = (resourcePath: string, thumbsBaseUrl?: string, t * @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) => { +export function getPhotoPath(filename: string, mediaBaseUrl?: string, url?: string): 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. @@ -56,18 +57,12 @@ export const getPhotoPath = (filename: string, mediaBaseUrl?: string, url?: stri * @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) => { +export function getSubgalleryThumbnailPath(subgalleryHeaderImagePath: string): string { const photoBasename = path.basename(subgalleryHeaderImagePath); const subgalleryFolderName = path.basename(path.dirname(subgalleryHeaderImagePath)); return path.join(subgalleryFolderName, 'gallery', 'thumbnails', photoBasename); -}; - -/** Portrait image sizes for responsive hero images */ -export const PORTRAIT_SIZES = [360, 480, 720, 1080] as const; - -/** Landscape image sizes for responsive hero images */ -export const LANDSCAPE_SIZES = [640, 960, 1280, 1920, 2560, 3840] as const; +} /** * Build a srcset string for responsive images. @@ -82,7 +77,7 @@ export const LANDSCAPE_SIZES = [640, 960, 1280, 1920, 2560, 3840] as const; * @param useDefaultPaths - Whether to use generated paths when no custom variant exists * @returns Comma-separated srcset string */ -export const buildHeroSrcset = ( +export function buildHeroSrcset( variants: Record | undefined, sizes: readonly number[], thumbnailBasePath: string, @@ -90,7 +85,7 @@ export const buildHeroSrcset = ( orientation: 'portrait' | 'landscape', format: 'avif' | 'jpg', useDefaultPaths: boolean, -): string => { +): string { return sizes .map((size) => { const customPath = variants?.[size]; @@ -104,4 +99,4 @@ export const buildHeroSrcset = ( }) .filter(Boolean) .join(', '); -}; +} diff --git a/common/src/theme/resolver.ts b/common/src/theme/resolver.ts new file mode 100644 index 0000000..d88d040 --- /dev/null +++ b/common/src/theme/resolver.ts @@ -0,0 +1,206 @@ +import path from 'node:path'; + +import { extractThumbnailConfigFromGallery, mergeThumbnailConfig, type ThumbnailConfig } from './config'; +import { LANDSCAPE_SIZES, PORTRAIT_SIZES } from './constants'; +import { renderMarkdown } from './markdown'; +import { buildHeroSrcset, getPhotoPath, getRelativePath, getSubgalleryThumbnailPath, getThumbnailPath } from './paths'; + +import type { GalleryData, GallerySection, MediaFile, SubGallery } from '../gallery'; +import type { ResolvedGalleryData, ResolvedHero, ResolvedImage, ResolvedSection, ResolvedSubGallery } from './types'; + +/** + * Resolve a single image with all paths computed. + */ +function resolveImage(image: MediaFile, mediaBaseUrl?: string, thumbsBaseUrl?: string): ResolvedImage { + const imagePath = getPhotoPath(image.filename, mediaBaseUrl, image.url); + const thumbnailPath = image.thumbnail + ? getThumbnailPath(image.thumbnail.path, thumbsBaseUrl, image.thumbnail.baseUrl) + : imagePath; + + const thumbnailSrcSet = image.thumbnail + ? getThumbnailPath(image.thumbnail.path, thumbsBaseUrl, image.thumbnail.baseUrl) + + ' 1x, ' + + getThumbnailPath(image.thumbnail.pathRetina, thumbsBaseUrl, image.thumbnail.baseUrl) + + ' 2x' + : undefined; + + return { + type: image.type, + filename: image.filename, + alt: image.alt, + width: image.width, + height: image.height, + imagePath, + thumbnailPath, + thumbnailSrcSet, + thumbnailWidth: image.thumbnail?.width, + thumbnailHeight: image.thumbnail?.height, + blurHash: image.thumbnail?.blurHash, + }; +} + +/** + * Resolve a section with parsed markdown and resolved image paths. + */ +async function resolveSection( + section: GallerySection, + mediaBaseUrl?: string, + thumbsBaseUrl?: string, +): Promise { + const parsedDescription = section.description ? await renderMarkdown(section.description) : ''; + + return { + title: section.title, + description: section.description, + parsedDescription, + images: section.images.map((img) => resolveImage(img, mediaBaseUrl, thumbsBaseUrl)), + }; +} + +/** + * Resolve a sub-gallery with computed thumbnail path and optional resolved path. + */ +function resolveSubGallery(subGallery: SubGallery, galleryJsonPath?: string): ResolvedSubGallery { + return { + title: subGallery.title, + headerImage: subGallery.headerImage, + path: subGallery.path, + thumbnailPath: getSubgalleryThumbnailPath(subGallery.headerImage), + resolvedPath: galleryJsonPath ? getRelativePath(subGallery.path, galleryJsonPath) : undefined, + }; +} + +/** + * Resolve hero data with all paths computed and srcsets built. + */ +async function resolveHero(gallery: GalleryData): Promise { + const { title, description, headerImage, headerImageBlurHash, headerImageVariants, mediaBaseUrl, thumbsBaseUrl } = gallery; + + const parsedDescription = description ? await renderMarkdown(description) : ''; + const imgBasename = headerImage ? path.basename(headerImage, path.extname(headerImage)) : 'header'; + const thumbnailBasePath = thumbsBaseUrl || 'gallery/images'; + const headerPhotoPath = getPhotoPath(headerImage || '', mediaBaseUrl); + + // Determine which sources to show based on headerImageVariants + // If headerImageVariants is not set, use all generated paths (default behavior) + const useDefaultPaths = !headerImageVariants; + + const srcsets = { + portraitAvif: buildHeroSrcset( + headerImageVariants?.portrait?.avif, + PORTRAIT_SIZES, + thumbnailBasePath, + imgBasename, + 'portrait', + 'avif', + useDefaultPaths, + ), + portraitJpg: buildHeroSrcset( + headerImageVariants?.portrait?.jpg, + PORTRAIT_SIZES, + thumbnailBasePath, + imgBasename, + 'portrait', + 'jpg', + useDefaultPaths, + ), + landscapeAvif: buildHeroSrcset( + headerImageVariants?.landscape?.avif, + LANDSCAPE_SIZES, + thumbnailBasePath, + imgBasename, + 'landscape', + 'avif', + useDefaultPaths, + ), + landscapeJpg: buildHeroSrcset( + headerImageVariants?.landscape?.jpg, + LANDSCAPE_SIZES, + thumbnailBasePath, + imgBasename, + 'landscape', + 'jpg', + useDefaultPaths, + ), + }; + + return { + title, + description, + parsedDescription, + headerImage, + headerPhotoPath, + headerImageBlurHash, + headerImageVariants, + thumbnailBasePath, + imgBasename, + srcsets, + }; +} + +/** + * Options for resolving gallery data. + */ +export interface ResolveGalleryDataOptions { + /** + * Path to the gallery.json file. When provided, enables resolution of + * relative paths for sub-galleries (resolvedPath field). + */ + galleryJsonPath?: string; + /** + * Theme-specific thumbnail configuration from themeConfig.json. + * Used as fallback when gallery.json doesn't specify thumbnail settings. + */ + themeConfig?: ThumbnailConfig; + /** + * CLI-specified thumbnail configuration (highest priority). + * Overrides both gallery.json and theme config settings. + */ + cliConfig?: ThumbnailConfig; +} + +/** + * Transform raw gallery data into a fully resolved structure with all paths + * computed and markdown parsed. This is the main API for themes. + * + * @param gallery - Raw gallery data from loadGalleryData() + * @param options - Optional configuration for path resolution + * @returns Fully resolved gallery data ready for rendering + */ +export async function resolveGalleryData( + gallery: GalleryData, + options?: ResolveGalleryDataOptions, +): Promise { + const { mediaBaseUrl, thumbsBaseUrl, subGalleries } = gallery; + const { galleryJsonPath, themeConfig, cliConfig } = options ?? {}; + + const hero = await resolveHero(gallery); + const sections = await Promise.all( + gallery.sections.map((section) => resolveSection(section, mediaBaseUrl, thumbsBaseUrl)), + ); + + const resolvedSubGalleries = subGalleries?.galleries?.length + ? { + title: subGalleries.title, + galleries: subGalleries.galleries.map((sg) => resolveSubGallery(sg, galleryJsonPath)), + } + : undefined; + + // Merge thumbnail config: CLI > gallery.json > themeConfig > defaults + const galleryThumbnailConfig = extractThumbnailConfigFromGallery(gallery); + const thumbnails = mergeThumbnailConfig(cliConfig, galleryThumbnailConfig, themeConfig); + + return { + title: gallery.title, + url: gallery.url, + metadata: gallery.metadata, + analyticsScript: gallery.analyticsScript, + ctaBanner: gallery.ctaBanner, + hero, + sections, + subGalleries: resolvedSubGalleries, + mediaBaseUrl, + thumbsBaseUrl, + thumbnails, + }; +} diff --git a/common/src/theme/types.ts b/common/src/theme/types.ts new file mode 100644 index 0000000..04b0951 --- /dev/null +++ b/common/src/theme/types.ts @@ -0,0 +1,77 @@ +import type { GalleryMetadata, HeaderImageVariants } from '../gallery'; +import type { ThumbnailConfig } from './config'; + +/** Resolved hero data with all paths computed and markdown parsed */ +export interface ResolvedHero { + title: string; + description?: string; + parsedDescription: string; + headerImage?: string; + headerPhotoPath: string; + headerImageBlurHash?: string; + headerImageVariants?: HeaderImageVariants; + thumbnailBasePath: string; + imgBasename: string; + srcsets: { + portraitAvif: string; + portraitJpg: string; + landscapeAvif: string; + landscapeJpg: string; + }; +} + +/** Resolved image with all paths computed */ +export interface ResolvedImage { + type: 'image' | 'video'; + filename: string; + alt?: string; + width: number; + height: number; + imagePath: string; + thumbnailPath: string; + thumbnailSrcSet?: string; + thumbnailWidth?: number; + thumbnailHeight?: number; + blurHash?: string; +} + +/** Resolved section with parsed markdown and resolved image paths */ +export interface ResolvedSection { + title?: string; + description?: string; + parsedDescription: string; + images: ResolvedImage[]; +} + +/** Resolved sub-gallery with computed thumbnail path */ +export interface ResolvedSubGallery { + title: string; + headerImage: string; + path: string; + thumbnailPath: string; + /** Pre-computed relative path for linking (only present when galleryJsonPath is provided to resolveGalleryData) */ + resolvedPath?: string; +} + +/** Fully resolved gallery data ready for rendering */ +export interface ResolvedGalleryData { + title: string; + url?: string; + metadata: GalleryMetadata; + analyticsScript?: string; + ctaBanner?: boolean; + hero: ResolvedHero; + sections: ResolvedSection[]; + subGalleries?: { + title: string; + galleries: ResolvedSubGallery[]; + }; + // Pass through base URLs for components that need them + mediaBaseUrl?: string; + thumbsBaseUrl?: string; + /** + * Thumbnail configuration with dimension and edge settings. + * Themes should use this for display sizing (e.g., row-height for modern theme). + */ + thumbnails?: Required; +} diff --git a/common/tsconfig.json b/common/tsconfig.json index 44b1402..d370185 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"] }, "include": ["src/**/*.ts", "tsup.config.ts"] } diff --git a/common/tsup.config.ts b/common/tsup.config.ts index 8f2c0c3..50336d2 100644 --- a/common/tsup.config.ts +++ b/common/tsup.config.ts @@ -1,15 +1,55 @@ import { defineConfig } from 'tsup'; -export default defineConfig({ - entry: ['src/gallery.ts'], - format: ['esm', 'cjs'], - dts: true, - splitting: false, - sourcemap: true, - clean: true, - minify: false, - target: 'node20', - outDir: 'dist', - treeshake: true, - external: ['zod'], -}); +export default defineConfig([ + // Main entry point: types + schemas (no heavy deps) + { + entry: ['src/gallery.ts'], + format: ['esm', 'cjs'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + minify: false, + target: 'node20', + outDir: 'dist', + treeshake: true, + external: ['zod'], + }, + // Theme entry point: server-side utilities + { + entry: ['src/theme.ts'], + format: ['esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: false, + minify: false, + target: 'node20', + outDir: 'dist', + treeshake: true, + external: ['zod', 'marked'], + }, + // Client entry point: browser-side utilities (PhotoSwipe plugin, blurhash) + { + entry: ['src/client.ts'], + format: ['esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: false, + minify: false, + target: 'es2020', + outDir: 'dist', + treeshake: true, + external: ['photoswipe', 'photoswipe/lightbox', 'blurhash'], + async onSuccess() { + const fs = await import('node:fs/promises'); + + const srcStyles = 'src/styles'; + const distStyles = 'dist/styles'; + + await fs.mkdir(distStyles, { recursive: true }); + await fs.cp(srcStyles, distStyles, { recursive: true }); + }, + }, +]); diff --git a/docs/README.md b/docs/README.md index c6c9da3..9ee1381 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,9 @@ 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 +- **[Architecture](./architecture.md)** - Multi-theme system design and package structure +- **[Common Package API](../common/README.md)** - Utilities and types for theme development - **[Deployment](./deployment.md)** - Hosting and deployment options - **[Embedding](./embedding.md)** - Customize the gallery when embedding in another site diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a552cae --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,780 @@ +# Architecture + +Detailed overview of Simple Photo Gallery's multi-theme architecture. + +## Package Structure + +### Monorepo Layout + +``` +spg-core/ +├── common/ - Shared library (@simple-photo-gallery/common) +├── gallery/ - CLI tool (simple-photo-gallery) +│ └── src/modules/create-theme/templates/base/ - Base theme template +└── themes/modern/ - Default theme (@simple-photo-gallery/theme-modern) +``` + +### Dependencies + +- **gallery** depends on **common** (uses types and validation schemas) +- **themes** depend on **common** (uses theme and client utilities) +- **common** has no internal dependencies (standalone library) + +**Build order:** `common` → `gallery` | `themes/modern` (parallel) + +### Why Common Must Be Built First + +TypeScript and ESLint need the compiled type definitions from `common/dist/` to resolve imports in gallery and theme packages. Running `yarn workspace @simple-photo-gallery/common build` generates the necessary `.d.ts` files and JavaScript output. + +The common package uses `tsup` to build: +- TypeScript source files → JavaScript (ESM and CJS) +- Type definitions (`.d.ts`) +- Multiple entry points (main, theme, client) +- CSS bundling for PhotoSwipe styles + +--- + +## Data Flow + +### 1. Gallery Initialization (`spg init`) + +``` +User photos → scanDirectory() → generateGalleryData() → gallery.json + ↓ + extractThumbnails() → images/thumbnails/ +``` + +**Implementation:** [gallery/src/modules/init/](../gallery/src/modules/init/) + +The initialization process: + +1. **Directory Scanning** + - Recursively scans photo directories + - Identifies images and videos by extension + - Maintains directory structure for sections + +2. **Metadata Extraction** + - Reads EXIF data for image dimensions + - Extracts video dimensions via FFmpeg + - Generates unique blurhash for each image + - Creates thumbnail metadata structure + +3. **Gallery Data Generation** + - Organizes photos into sections (by directory or custom) + - Creates `GalleryData` structure + - Writes `gallery.json` to output directory + +4. **Thumbnail Generation** + - Creates optimized thumbnail images + - Generates standard and retina (@2x) versions + - Stores in `images/thumbnails/` directory + +### 2. Theme Build Process (`spg build --theme `) + +``` +gallery.json (raw data) + ↓ +Theme package resolution + ↓ +Environment variables set: GALLERY_JSON_PATH, GALLERY_OUTPUT_DIR + ↓ +Theme's Astro build process runs + ↓ (in theme code) +loadGalleryData() → Raw GalleryData + ↓ +resolveGalleryData() → ResolvedGalleryData + ↓ +Components render using resolved data + ↓ +Static HTML output → _build/ → copied to gallery directory +``` + +**Implementation:** [gallery/src/modules/build/](../gallery/src/modules/build/) + +#### Theme Resolution + +The CLI resolves theme packages in the following order: + +1. **Local Path Detection** + - If theme name starts with `.` or `/`: treat as filesystem path + - Resolve relative to current working directory + - Verify `package.json` exists at path + +2. **npm Package Resolution** + - Use Node's module resolution algorithm + - Searches `node_modules/` in current and parent directories + - Works with scoped packages (`@org/theme-name`) + - Supports private npm registries + +3. **Package Validation** + - Verify theme package has `package.json` + - Check for required Astro configuration + - Validate directory structure + +**Default:** If no theme specified, uses `@simple-photo-gallery/theme-modern` + +#### Build Execution + +The build process: + +1. **Environment Setup** + ```javascript + process.env.GALLERY_JSON_PATH = '/absolute/path/to/gallery.json' + process.env.GALLERY_OUTPUT_DIR = '/absolute/path/to/output' + ``` + +2. **Astro Build Invocation** + - Runs `npx astro build` in theme package directory + - Theme's `astro.config.ts` reads environment variables + - Astro performs static site generation + +3. **Output Processing** + - Copy `_build/*` directory contents to gallery output + - Move `_build/index.html` to gallery root + - Preserve existing `gallery.json` and `images/` directories + +#### Data Loading in Themes + +Themes use the common package utilities: + +**[common/src/theme/loader.ts](../common/src/theme/loader.ts):** +```typescript +const raw = loadGalleryData(galleryJsonPath, { validate: true }); +``` + +This function: +- Reads `gallery.json` from filesystem using Node's `fs` module +- Optionally validates with Zod schema +- Returns raw `GalleryData` structure +- Throws descriptive errors if validation fails + +**[common/src/theme/resolver.ts](../common/src/theme/resolver.ts):** +```typescript +const resolved = await resolveGalleryData(raw, { galleryJsonPath }); +``` + +This function: +- **Computes image paths:** Uses `mediaBaseUrl` or relative paths +- **Computes thumbnail paths:** Uses `thumbsBaseUrl` or default location +- **Builds responsive srcsets:** Creates srcset strings for hero images with multiple variants (AVIF, JPG, landscape, portrait) +- **Parses markdown:** Converts markdown descriptions to HTML using `marked` library +- **Resolves sub-galleries:** Computes relative paths between gallery.json files +- Returns `ResolvedGalleryData` ready for rendering + +**Key Design:** All path computation and data transformation happens at **build time**, not runtime. This ensures fast static output with no client-side processing needed. + +### 3. Client-Side Initialization + +After the static HTML is generated, client-side JavaScript enhances the page: + +```html + + + + +``` + +**Implementation:** [common/src/client/](../common/src/client/) + +Client-side features: + +1. **Blurhash Decoding** + - Finds canvas elements with `data-blurhash` attribute + - Decodes blurhash strings to pixel data + - Renders low-quality image placeholders + +2. **PhotoSwipe Lightbox** + - Configures PhotoSwipe with sensible defaults + - Adds video support via custom plugin + - Enables full-screen image viewing + +3. **Hero Image Fallback** + - Transitions from blurhash to actual image + - Smooth fade effect when image loads + - Improves perceived performance + +4. **CSS Utilities** + - Dynamic CSS custom property manipulation + - Color parsing and transformation + - Theme customization support + +--- + +## Theme System Design + +### Theme Package Discovery + +**Local Paths:** +- Theme string starts with `.` or `/`: filesystem path +- Path resolved relative to current working directory +- Must contain valid `package.json` file +- Useful for development and private themes + +**npm Packages:** +- Standard Node.js module resolution +- Searches `node_modules/` directories +- Works with scoped packages (`@organization/theme-name`) +- Supports private npm registries +- Ideal for sharing themes publicly or across projects + +**Example:** +```bash +# Local theme +spg build --theme ./themes/my-theme + +# npm package (installed) +npm install @myorg/custom-theme +spg build --theme @myorg/custom-theme + +# Default theme (no --theme flag) +spg build # Uses @simple-photo-gallery/theme-modern +``` + +### Build Process Integration + +Themes integrate via Astro's static site generation: + +1. **Theme Configuration** ([themes/modern/astro.config.ts](../themes/modern/astro.config.ts)) + ```typescript + const galleryJsonPath = process.env.GALLERY_JSON_PATH; + const outputDir = process.env.GALLERY_OUTPUT_DIR; + + export default defineConfig({ + output: 'static', // Must be static + outDir: `${outputDir}/_build`, // CLI expects _build subdirectory + // ... other config + }); + ``` + +2. **Data Loading** (in theme pages) + ```typescript + import { loadGalleryData, resolveGalleryData } from '@simple-photo-gallery/common/theme'; + + const galleryJsonPath = import.meta.env.GALLERY_JSON_PATH || './gallery.json'; + const raw = loadGalleryData(galleryJsonPath, { validate: true }); + const gallery = await resolveGalleryData(raw, { galleryJsonPath }); + ``` + +3. **Component Rendering** + - Components receive resolved data types + - Use pre-computed paths directly + - No runtime path calculation needed + +4. **Static Output** + - Astro generates `index.html` and assets + - CLI moves output to gallery directory + - Result is fully static, hostable anywhere + +**Key constraint:** Themes MUST use `output: 'static'` and generate `index.html`. The CLI expects this structure and will fail if the build doesn't produce static output. + +### Environment Variable Passing + +#### `GALLERY_JSON_PATH` + +Absolute path to the source `gallery.json` file. + +**Set by:** CLI before invoking Astro build +**Used by:** Theme to load gallery data +**Available as:** `process.env.GALLERY_JSON_PATH` (Node) and `import.meta.env.GALLERY_JSON_PATH` (Vite) + +**Note:** Must be passed to Vite via `define` config for `import.meta.env` access: +```typescript +export default defineConfig({ + vite: { + define: { + 'process.env.GALLERY_JSON_PATH': JSON.stringify(sourceGalleryPath), + }, + }, +}); +``` + +#### `GALLERY_OUTPUT_DIR` + +Directory where the final gallery should be output. + +**Set by:** CLI before invoking Astro build +**Used by:** Theme to set Astro's `outDir` +**Default:** Same directory as `gallery.json` + +**Usage:** +```typescript +const outputDir = process.env.GALLERY_OUTPUT_DIR || galleryJsonPath.replace('gallery.json', ''); + +export default defineConfig({ + outDir: `${outputDir}/_build`, // CLI expects _build subdirectory +}); +``` + +### Why Path Resolution Happens in Common + +**Design Decision:** Themes receive fully-resolved data rather than computing paths themselves. + +**Benefits:** + +1. **Consistency** + - All themes compute paths identically + - No risk of path bugs in individual themes + - Easier to reason about path structure + +2. **Simplicity** + - Themes focus on layout and styling + - No need to understand path computation logic + - Reduces cognitive load for theme developers + +3. **Performance** + - Paths computed once at build time + - No per-component computation + - No runtime overhead + +4. **Flexibility** + - Path logic can evolve in common package + - Themes automatically benefit from improvements + - Bug fixes apply to all themes immediately + +5. **Testability** + - Resolver logic is unit-testable in isolation + - Easier to verify correctness + - Can test edge cases comprehensively + +**Trade-off:** Themes lose some flexibility in custom path logic, but gain reliability and development speed. This is an intentional choice prioritizing consistency over flexibility. + +--- + +## Multi-Theme Support + +### Shared Utilities + +All themes import from `@simple-photo-gallery/common`: + +**Gallery Module** (`.`): +- Raw `GalleryData` types +- Zod validation schemas +- Type definitions for all data structures + +**Theme Module** (`./theme`): +- `loadGalleryData()` - File loading and validation +- `resolveGalleryData()` - Data transformation +- Path utility functions +- Markdown rendering +- Astro integrations + +**Client Module** (`./client`): +- PhotoSwipe lightbox integration +- Blurhash decoding utilities +- Hero image fallback behavior +- CSS manipulation helpers + +**Styles** (`./styles/photoswipe`): +- PhotoSwipe CSS bundle +- Customizable via CSS custom properties + +**This ensures:** +- Consistent behavior across all themes +- Shared bug fixes benefit everyone +- Common package can add features without breaking themes +- Theme developers can focus on presentation + +### Theme Independence + +Themes are independent npm packages with: + +**Own Dependencies:** +- Each theme chooses its Astro version +- Can use different UI libraries +- Can include additional integrations + +**Complete Style Control:** +- Themes own all CSS +- No inherited styles from common +- Full creative freedom + +**Custom Components:** +- Themes can structure components however they want +- No required component hierarchy +- Only constraint: must read `gallery.json` and generate `index.html` + +**Independent Development:** +- Themes can be developed separately +- Can have different maintainers +- Can follow different versioning strategies + +**Example:** A theme could use React components via Astro's framework integrations, while another uses plain Astro components. Both work with the same gallery data. + +### Scaffolding System + +#### Overview + +`spg create-theme ` creates new themes from a base template. + +**Command:** `spg create-theme my-theme [--path ./custom/path]` + +**Implementation:** [gallery/src/modules/create-theme/](../gallery/src/modules/create-theme/) + +#### Template Source + +**Primary Location:** [gallery/src/modules/create-theme/templates/base/](../gallery/src/modules/create-theme/templates/base/) +- Bundled with the CLI package +- Works out-of-the-box after `npm install -g simple-photo-gallery` +- Used in production + +**Fallback Location:** `themes/base/` in workspace root +- Only available during local development +- Allows testing template changes without rebuilding CLI +- Not included in published package + +#### Scaffolding Process + +1. **Validation** + - Theme name must be alphanumeric with optional hyphens + - Validates theme name format: `/^[a-z0-9-]+$/` + +2. **Monorepo Detection** + - Searches for workspace root (looks for `package.json` with `workspaces` field) + - If found: prefer `/themes/` as output location + - Otherwise: use current directory or custom `--path` + +3. **Directory Creation** + - Determine output path + - Verify target directory doesn't exist + - Create parent directories if needed + +4. **Template Copy** + - Copy all files from base template + - **Exclude:** + - `node_modules/` + - `.astro/`, `dist/`, `_build/` + - `.git/`, `.DS_Store` + - `README.md`, `README_BASE.md` (handled separately) + - Log files + +5. **Customization** + - **Update `package.json`:** + - Replace package name with `@simple-photo-gallery/theme-` + - Keep version and dependencies unchanged + + - **Generate `README.md`:** + - Read `README_BASE.md` template + - Replace `{{THEME_NAME}}` placeholders + - Write to `README.md` in new theme + +6. **Completion** + - Print success message with next steps + - Suggest running `yarn install` in theme directory + +#### Base Template Structure + +``` +base/ +├── src/ +│ ├── pages/ +│ │ └── index.astro - Main gallery page +│ ├── features/ +│ │ └── themes/ +│ │ └── base-theme/ - Reusable base theme components +│ │ ├── pages/ - Actual page implementations +│ │ ├── components/ - Gallery components +│ │ ├── layouts/ - HTML structure +│ │ └── scripts/ - Client-side code +│ └── styles/ - Global styles +├── public/ - Static assets +├── astro.config.ts - Astro configuration +├── package.json - Dependencies and scripts +├── tsconfig.json - TypeScript configuration +└── README_BASE.md - Template for generated README +``` + +**Key Pattern:** The template uses a "wrapper + base-theme" structure: +- `src/pages/index.astro` - Simple wrapper that imports BaseTheme +- `src/features/themes/base-theme/` - Actual implementation +- This pattern allows easy customization by modifying the wrapper or extending the base theme + +--- + +## Adding New Features + +### When to Add to Common Package + +Add features to [common/](../common/) when: + +**Cross-Theme Functionality:** +- Feature is needed by multiple themes +- Feature involves shared business logic +- Feature should behave consistently everywhere + +**Examples:** +- New gallery data field → Add to `common/src/gallery/types.ts` and schemas +- Video thumbnail support → Add to `common/src/theme/paths.ts` +- New image transformation → Add to `common/src/theme/resolver.ts` +- Lazy loading utility → Add to `common/src/client/` + +**Data Transformation:** +- Any computation that should happen once at build time +- Path resolution logic +- URL construction +- Responsive image srcset generation + +**Validation:** +- New fields in `gallery.json` structure +- Schema validation logic +- Type definitions + +**Client Utilities:** +- Browser-side helpers that multiple themes might use +- PhotoSwipe extensions +- Animation utilities +- Performance optimizations + +### When to Add to Individual Themes + +Add features to specific theme packages when: + +**Visual/Stylistic:** +- Layout changes +- CSS styling +- Typography choices +- Color schemes + +**Theme-Specific Behavior:** +- Custom navigation patterns +- Unique interaction patterns +- Specialized component variants + +**Examples:** +- Grid layout variations → Theme CSS +- Custom lightbox animations → Theme JavaScript +- Unique hero section design → Theme components +- Brand-specific styling → Theme CSS variables + +### Maintaining Backward Compatibility + +#### Common Package + +**Rules:** +- Never remove exported functions (deprecate instead) +- Add new fields as optional in TypeScript types +- Keep Zod schemas backward-compatible +- Document breaking changes in CHANGELOG +- Consider migration path before major version + +**Example - Adding Optional Field:** +```typescript +// ✅ Good - Optional field +interface GalleryData { + // ... existing fields + newFeature?: string; // Optional +} + +// ❌ Bad - Required field (breaking) +interface GalleryData { + // ... existing fields + newFeature: string; // Required - breaks existing galleries +} +``` + +**Example - Deprecating Function:** +```typescript +// ✅ Good - Deprecate but keep +/** + * @deprecated Use getPhotoPath() instead + */ +export function getImagePath(filename: string): string { + return getPhotoPath(filename); +} +``` + +#### Themes + +**Best Practices:** +- Test against multiple `common` package versions +- Use optional chaining for new fields: `gallery.newFeature?.value` +- Provide sensible fallbacks for missing data +- Document minimum required `common` version in `package.json` + +**Example:** +```typescript +// ✅ Good - Handles missing field gracefully +const feature = gallery.newFeature ?? 'default-value'; + +// ❌ Bad - Assumes field exists +const feature = gallery.newFeature.value; // Runtime error if undefined +``` + +### Testing Across Themes + +When changing the common package: + +1. **Build Common Package** + ```bash + yarn workspace @simple-photo-gallery/common build + ``` + +2. **Test Modern Theme** + ```bash + yarn workspace @simple-photo-gallery/theme-modern build + ``` + Verify no TypeScript errors or build failures + +3. **Create Test Theme** + ```bash + spg create-theme test-theme + cd themes/test-theme + yarn install + ``` + +4. **Test with Real Gallery** + ```bash + # Create test gallery + spg init -p /path/to/photos -g /tmp/test-gallery + + # Build with test theme + spg build --theme ./themes/test-theme -g /tmp/test-gallery + ``` + +5. **Verify Output** + - Open `/tmp/test-gallery/index.html` in browser + - Check console for JavaScript errors + - Verify all features work as expected + - Test responsive behavior + - Verify lightbox functionality + +6. **Cross-Browser Testing** + - Test in Chrome, Firefox, Safari + - Test on mobile devices + - Verify PWA functionality (if applicable) + +--- + +## Key Implementation Files + +| Component | Location | +|-----------|----------| +| Build orchestration | [gallery/src/modules/build/](../gallery/src/modules/build/) | +| Gallery initialization | [gallery/src/modules/init/](../gallery/src/modules/init/) | +| Data loader | [common/src/theme/loader.ts](../common/src/theme/loader.ts) | +| Data resolver | [common/src/theme/resolver.ts](../common/src/theme/resolver.ts) | +| Path utilities | [common/src/theme/paths.ts](../common/src/theme/paths.ts) | +| Markdown rendering | [common/src/theme/markdown.ts](../common/src/theme/markdown.ts) | +| Client utilities | [common/src/client/](../common/src/client/) | +| Blurhash utilities | [common/src/client/blurhash.ts](../common/src/client/blurhash.ts) | +| PhotoSwipe integration | [common/src/client/photoswipe/](../common/src/client/photoswipe/) | +| Gallery types | [common/src/gallery/types.ts](../common/src/gallery/types.ts) | +| Gallery schemas | [common/src/gallery/schemas.ts](../common/src/gallery/schemas.ts) | +| Base template | [gallery/src/modules/create-theme/templates/base/](../gallery/src/modules/create-theme/templates/base/) | +| Modern theme | [themes/modern/](../themes/modern/) | +| Theme base components | [themes/modern/src/features/themes/base-theme/](../themes/modern/src/features/themes/base-theme/) | +| Theme scaffolder | [gallery/src/modules/create-theme/](../gallery/src/modules/create-theme/) | + +--- + +## Design Principles + +### 1. Separation of Concerns + +**CLI** handles: +- Gallery data generation from photos +- Thumbnail creation +- Theme resolution and build orchestration +- File system operations + +**Common** handles: +- Data validation and schemas +- Data transformation and resolution +- Path computation +- Client-side utilities + +**Themes** handle: +- Layout and presentation +- Component structure +- Styling and visual design +- User experience + +This separation allows each package to evolve independently while maintaining clear interfaces between them. + +### 2. Static-First + +Everything is computed at **build time**, not runtime: +- All paths resolved during build +- Markdown parsed to HTML during build +- Responsive srcsets generated during build +- No runtime data transformation + +**Benefits:** +- Fast page loads (no client-side processing) +- Works without JavaScript +- Hostable anywhere (just static files) +- Excellent SEO (all content in HTML) + +### 3. Type Safety + +TypeScript throughout the entire stack: +- Common package exports comprehensive types +- Themes get full IntelliSense support +- Catch errors at development time +- Self-documenting code + +**Example:** +```typescript +import type { ResolvedGalleryData } from '@simple-photo-gallery/common/theme'; + +// TypeScript knows the exact structure +const gallery: ResolvedGalleryData = await resolveGalleryData(raw); +gallery.sections[0].images // ✅ TypeScript validates this +``` + +### 4. Developer Experience + +**Simple scaffolding:** +```bash +spg create-theme my-theme # One command to start +``` + +**Clear utilities:** +```typescript +// Intuitive API +const gallery = await resolveGalleryData(raw); +const lightbox = createGalleryLightbox(); +``` + +**Good defaults:** +- PhotoSwipe configured out of the box +- Sensible path resolution +- Validation enabled by default + +**Comprehensive documentation:** +- API reference in common README +- Architecture guide (this document) +- Theme development guide +- Command documentation + +### 5. Flexibility + +**Multiple theme sources:** +- npm packages (public or private) +- Local filesystem paths +- Default theme built-in + +**Extensibility:** +- Themes can add custom features +- Common package is extensible +- Plugin system via Astro integrations + +**No lock-in:** +- Themes are just npm packages +- Can fork and customize +- Can create completely custom themes + +--- + +## See Also + +- [Common Package API](../common/README.md) - Complete API reference +- [Custom Themes Guide](./themes.md) - Theme development guide +- [Commands Reference](./commands/README.md) - CLI documentation +- [Modern Theme Source](../themes/modern/) - Reference implementation +- [Gallery Configuration](./configuration.md) - gallery.json manual editing diff --git a/docs/commands/README.md b/docs/commands/README.md index 1df2bf5..8a520fe 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -10,6 +10,7 @@ This is the command reference for Simple Photo Gallery CLI. All commands can be - **[build](./build.md)** - Generate the HTML gallery from your photos and gallery.json - **[thumbnails](./thumbnails.md)** - Generate optimized thumbnail images for all media files - **[clean](./clean.md)** - Remove gallery files while preserving original photos +- **[create-theme](./create-theme.md)** - Scaffold a new Astro theme package ## Quick Reference diff --git a/docs/commands/build.md b/docs/commands/build.md index 13cea56..7c0bf82 100644 --- a/docs/commands/build.md +++ b/docs/commands/build.md @@ -14,17 +14,20 @@ 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 name or local path | gallery.json theme or `theme-modern` | +| `--thumbnail-size ` | Override thumbnail size in pixels | From config hierarchy | +| `--thumbnail-edge ` | Override size mode: auto, width, or height | From config hierarchy | +| `--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 +52,22 @@ spg build --no-scan # Build without creating thumbnails spg build --no-thumbnails + +# 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 + +# Build with custom thumbnail settings (overrides gallery.json and theme) +spg build --thumbnail-size 400 --thumbnail-edge height ``` + +## Custom Themes + +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/commands/create-theme.md b/docs/commands/create-theme.md new file mode 100644 index 0000000..6a83827 --- /dev/null +++ b/docs/commands/create-theme.md @@ -0,0 +1,58 @@ +# create-theme + +Scaffolds a new custom theme package (Astro-based) that you can use with `spg build --theme`. + +```bash +spg create-theme [options] +``` + +## How it works + +The command creates a new theme by copying the base theme template (bundled with the package) and customizing it with your theme name. The generated theme is a **1:1 copy of the modern theme** that users are familiar with, including: + +- `package.json`, `astro.config.ts`, `tsconfig.json` +- `src/pages/index.astro` (main entry point) +- Full-featured components (Hero, GallerySection, PhotoSwipe lightbox, SubGalleries, CTA banner, Footer) +- Responsive layouts with blurhash placeholders and AVIF/JPG support +- Query parameter customization utilities +- All configuration files (ESLint, Prettier, etc.) + +The command: + +1. Copies all files from the bundled base theme template (excluding build artifacts like `node_modules`, `.astro`, `dist`, etc.) +2. Updates `package.json` with your theme name +3. Generates `README.md` for the new theme (from the template's `README_BASE.md`) + +By default, the theme is created in `./themes/`. If you run the CLI from inside a monorepo workspace, it will prefer creating themes under the **monorepo root** `./themes/`. + +> **Note:** The base theme template is bundled with the `simple-photo-gallery` package, so `spg create-theme` works out of the box after installation. The template source is located at `gallery/src/modules/create-theme/templates/base` in the repository. For local development, if `themes/base` exists in your workspace root, it will be used as a fallback (allowing you to test template changes without modifying the bundled template). + +## Options + +| Option | Description | Default | +| ------------------- | ----------------------------------------------------------------- | ----------------- | +| `-p, --path ` | Path where the theme should be created (directory must not exist) | `./themes/` | +| `-v, --verbose` | Show detailed output | | +| `-q, --quiet` | Only show warnings/errors | | +| `-h, --help` | Show command help | | + +## Examples + +```bash +# Create ./themes/my-theme +spg create-theme my-theme + +# Create in a custom directory +spg create-theme my-theme --path ./my-theme + +# Build a gallery using the generated theme (local path) +spg build --theme ./themes/my-theme -g /path/to/gallery +``` + +## Next steps + +See the [Custom Themes](../themes.md) guide for: + +- Required theme structure +- How the build process passes `GALLERY_JSON_PATH` / `GALLERY_OUTPUT_DIR` +- How to run `astro dev` while developing a theme diff --git a/docs/commands/init.md b/docs/commands/init.md index eb703e4..51c25b5 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -8,25 +8,37 @@ spg init [options] ## How it works -This command initializes a new gallery by scanning a folder (and optionally its subfolders) for images and videos. The command will prompt you to enter the gallery title, description, header image, and URL where it will be hosted. All of these can later be edited in the `gallery.json` file. +This command initializes a new gallery by scanning a folder (and optionally its subfolders) for images and videos. The command will prompt you to enter: + +- Gallery title +- Gallery description +- Header image (selected from your photos) +- URL where the gallery will be hosted + +All of these settings can later be edited in the `gallery.json` file. > **Note:** The URL is important if you want the automatically generated social media images to work correctly, as it needs to be an absolute URL. -After that, the command will create a `gallery` folder and a `gallery.json` file in it. The `gallery.json` file contains all the information about the gallery, including the title, description, header image, URL and all images and videos in the gallery. +> **Tip:** Theme and thumbnail settings can be specified via CLI flags (`--theme`, `--thumbnail-size`, `--thumbnail-edge`) for users who know what they want upfront. These are stored in `gallery.json` and follow the [configuration hierarchy](../configuration.md#thumbnail-configuration): CLI → gallery.json → themeConfig.json → built-in defaults. + +After that, the command will create a `gallery` folder and a `gallery.json` file in it. The `gallery.json` file contains all the information about the gallery, including the title, description, header image, URL, thumbnail settings (if specified), and all images and videos in the gallery. ## Options -| Option | Description | Default | -| ---------------------- | ------------------------------------------------ | --------------------- | -| `-p, --photos ` | Path to folder containing photos | Current directory | -| `-g, --gallery ` | Where to create the gallery | Same as photos folder | -| `-r, --recursive` | Create galleries from subdirectories | `false` | -| `-d, --default` | Use default settings (skip prompts) | `false` | -| `-f, --force` | Force override existing galleries without asking | `false` | -| `--cta-banner` | Add a Simple Photo Gallery CTA banner to the gallery | `false` | -| `-v, --verbose` | Show detailed output | | -| `-q, --quiet` | Only show warnings/errors | | -| `-h, --help` | Show command help | | +| Option | Description | Default | +| ----------------------------- | ---------------------------------------------------------- | --------------------- | +| `-p, --photos ` | Path to folder containing photos | Current directory | +| `-g, --gallery ` | Where to create the gallery | Same as photos folder | +| `-r, --recursive` | Create galleries from subdirectories | `false` | +| `-d, --default` | Use default settings (skip prompts) | `false` | +| `-f, --force` | Force override existing galleries without asking | `false` | +| `--cta-banner` | Add a Simple Photo Gallery CTA banner to the gallery | `false` | +| `--theme ` | Theme to store in gallery.json | None | +| `--thumbnail-size ` | Thumbnail size in pixels to store in gallery.json | Theme/default (300) | +| `--thumbnail-edge ` | Size application mode: auto, width, or height | Theme/default (auto) | +| `-v, --verbose` | Show detailed output | | +| `-q, --quiet` | Only show warnings/errors | | +| `-h, --help` | Show command help | | ## Examples @@ -51,6 +63,12 @@ spg init -f # Combine options spg init -r -d -f + +# Initialize with a specific theme and thumbnail settings +spg init --theme @simple-photo-gallery/theme-modern --thumbnail-size 400 + +# Initialize with custom thumbnail edge mode +spg init --thumbnail-size 300 --thumbnail-edge height ``` ## Creating the gallery in a folder other than the photos folder diff --git a/docs/commands/thumbnails.md b/docs/commands/thumbnails.md index 331ae30..78414da 100644 --- a/docs/commands/thumbnails.md +++ b/docs/commands/thumbnails.md @@ -10,6 +10,8 @@ spg thumbnails [options] The command will go through all media files and generate thumbnails with suitable resolutions for the gallery. It will also update the `gallery.json` file with the new thumbnail paths. +Thumbnail configuration follows the [configuration hierarchy](../configuration.md#thumbnail-configuration): CLI options → gallery.json → themeConfig.json → built-in defaults (300px, auto edge). If your gallery.json specifies a theme, the theme's thumbnail settings will be loaded as a fallback. + Thumbnails for videos require FFmpeg to be installed so it can extract the first frame of the video. You can install it via: - **macOS:** `brew install ffmpeg` @@ -20,13 +22,15 @@ Thumbnails for videos require FFmpeg to be installed so it can extract the first ## Options -| Option | Description | Default | -| ---------------------- | ------------------------- | ----------------- | -| `-g, --gallery ` | Path to gallery directory | Current directory | -| `-r, --recursive` | Process subdirectories | `false` | -| `-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` | Process subdirectories | `false` | +| `--thumbnail-size ` | Override thumbnail size in pixels | From config hierarchy | +| `--thumbnail-edge ` | Override size mode: auto, width, or height | From config hierarchy | +| `-v, --verbose` | Show detailed output | | +| `-q, --quiet` | Only show warnings/errors | | +| `-h, --help` | Show command help | | ## Examples @@ -39,4 +43,10 @@ spg thumbnails -g /path/to/gallery # Process all galleries recursively npx simple-photo-gallery@latest thumbnails -r + +# Override thumbnail size via CLI +spg thumbnails --thumbnail-size 400 + +# Override both size and edge mode +spg thumbnails --thumbnail-size 250 --thumbnail-edge height ``` diff --git a/docs/configuration.md b/docs/configuration.md index ba90183..7381836 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,6 +29,31 @@ Additionally, the following parameters are automatically generated, but you can - `ogImageWidth` - The width of the image - `ogImageHeight` - The height of the image +## Theme + +You can specify the theme to use for your gallery by setting the `theme` field in `gallery.json`. This allows each gallery to use a different theme without needing to specify it via CLI each time. + +```json +{ + "title": "My Gallery", + "theme": "@simple-photo-gallery/theme-modern" +} +``` + +The theme can be specified as: +- An npm package name (e.g., `@simple-photo-gallery/theme-modern`) +- A local path (e.g., `./themes/my-custom-theme`) + +### Theme Resolution Priority + +When building a gallery, the theme is resolved in this order: + +1. **CLI option** (highest priority) - `--theme` flag +2. **gallery.json** - The `theme` field in your gallery configuration +3. **Default theme** (lowest priority) - `@simple-photo-gallery/theme-modern` + +When you specify a theme via the CLI `--theme` flag, it is saved to `gallery.json` for future builds. + ## Sections Sections can be used to group your photos with their own titles and descriptions. This is useful if you have a large gallery and want to split it into smaller sections. @@ -237,9 +262,91 @@ Markdown formatting is available in: - **Images** and **tables** are not supported for security and layout consistency - **HTML tags** are stripped for security -## Thumbnail size +## Thumbnail Configuration + +Thumbnail generation and display can be configured through a hierarchical configuration system: + +> **Migration Note:** If you're upgrading from an older version that used `thumbnailSize` (a number field), this has been replaced with a `thumbnails` object containing `size` and `edge` properties. Your existing `thumbnailSize` value will be automatically migrated to `thumbnails.size` when you run any gallery command. + +1. **CLI options** (highest priority) - Passed via `--thumbnail-size` and `--thumbnail-edge` flags +2. **Gallery-level configuration** - Set in `gallery.json` +3. **Theme-level configuration** - Set in theme's `themeConfig.json` file +4. **Built-in defaults** (lowest priority) - 300px on auto (longer edge) + +### Configuration Hierarchy + +The thumbnail settings follow this priority order: + +``` +CLI options > gallery.json > themeConfig.json > built-in defaults +``` + +This means CLI options always take highest priority, followed by `gallery.json` settings. If not specified in either, the theme's `themeConfig.json` will be checked. If neither is set, the built-in defaults (300px, auto edge) will be used. + +When you provide thumbnail settings via CLI flags to the `init`, `thumbnails`, or `build` commands, those settings are saved to `gallery.json` for future builds. + +### Gallery-level Configuration + +You can override thumbnail settings per gallery by adding a `thumbnails` object to your `gallery.json`: + +```json +{ + "title": "My Gallery", + "thumbnails": { + "size": 400, + "edge": "height" + } +} +``` + +**Options:** + +- `size` (number): The thumbnail size in pixels. Default: `300` +- `edge` (string): How the size is applied. Default: `"auto"` + - `"auto"` - Applied to the longer edge (default behavior) + - `"width"` - Applied to width (good for masonry layouts) + - `"height"` - Applied to height (good for row-based layouts like modern theme) + +### Theme-level Configuration + +Themes can provide their own default thumbnail settings by including a `themeConfig.json` file in the theme root directory: + +```json +{ + "thumbnails": { + "size": 300, + "edge": "height" + } +} +``` + +This allows theme authors to set optimal defaults for their specific layout while still allowing users to override these settings per gallery. + +**For theme developers:** Place `themeConfig.json` in your theme's root directory (same level as `package.json`). The configuration will be automatically loaded when the theme is used. + +### Display Behavior + +When thumbnails are configured, they also control the display size in themes: + +- **Modern theme**: `thumbnails.size` becomes the row height (e.g., `size: 200` sets `--row-height: 200px`) +- **Default behavior** (no thumbnails config): Uses responsive breakpoints (96px on mobile, scaling up to 160px on desktop) + +### CLI Configuration + +You can configure thumbnail settings when running `gallery init`, `gallery thumbnails`, or `gallery build` by using the CLI flags: + +```bash +# Set thumbnail size and edge during init +gallery init --thumbnail-size 400 --thumbnail-edge height + +# Override settings when generating thumbnails +gallery thumbnails --thumbnail-size 300 --thumbnail-edge auto + +# Override settings when building +gallery build --thumbnail-size 400 --thumbnail-edge height +``` -Thumbnails will automatically be generated using sizes that fit the theme (300px height and 600px height for retina displays). If you want, you can change the size using the `thumbnailSize` attribute in the `gallery.json` file. +When provided via CLI, these settings are saved to `gallery.json` for future builds, keeping the configuration hierarchy consistent. ## Header image variants diff --git a/docs/themes.md b/docs/themes.md new file mode 100644 index 0000000..6028b8e --- /dev/null +++ b/docs/themes.md @@ -0,0 +1,572 @@ +# 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 + +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 +npm install @your-org/your-private-theme + +# Build with the custom 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 + +The fastest way to create a theme is to use the built-in scaffolder: + +```bash +# Creates ./themes/my-theme (or in your monorepo root if you run this inside a workspace package) +spg create-theme my-theme +``` + +If you prefer a custom output directory: + +```bash +spg create-theme my-theme --path ./my-theme +``` + +The `create-theme` command works by copying the base theme template (bundled with the package) and customizing it with your theme name. This means: + +- All files from the bundled template are copied (excluding build artifacts) +- The theme name is automatically updated in `package.json` and `README.md` +- You get a complete, working theme ready to customize + +After creating your theme: + +```bash +cd ./themes/my-theme +yarn install +``` + +> **Note:** The generated theme requires `GALLERY_JSON_PATH` to be set (it's how the theme reads your `gallery.json`). When you run `spg build`, the CLI sets it automatically. When you run `astro dev` directly, you need to set it yourself (see "Theme Development" below). + +> **Tip:** The base theme template is bundled with the `simple-photo-gallery` package. The source is located at `gallery/src/modules/create-theme/templates/base` in the repository. For local development, if you're working on the CLI itself and want to test template changes, you can modify the template files there. Alternatively, you can create `themes/base` in the workspace root as a fallback for testing - it will be used if present. + +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 +├── themeConfig.json (optional, but recommended) +├── 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": "^2.1.0" + } +} +``` + +#### 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. `themeConfig.json` (Optional, but recommended) + +Theme authors can provide default configuration for their theme by including a `themeConfig.json` file in the theme root directory (same level as `package.json`). + +This allows you to set optimal defaults for your theme's layout while still allowing users to override these settings per gallery in their `gallery.json` file. + +**Example themeConfig.json:** + +```json +{ + "thumbnails": { + "size": 300, + "edge": "height" + } +} +``` + +**Configuration Options:** + +- `thumbnails.size` (number): Default thumbnail size in pixels (default: 300) +- `thumbnails.edge` (string): How the size is applied + - `"auto"`: Applied to longer edge (default) + - `"width"`: Applied to width (good for masonry layouts) + - `"height"`: Applied to height (good for row-based layouts) + +**Configuration Hierarchy:** + +The thumbnail settings follow this priority order: + +1. **Gallery-level** (highest priority): Settings in user's `gallery.json` +2. **Theme-level**: Settings in theme's `themeConfig.json` +3. **Built-in defaults** (lowest priority): 300px on auto (longer edge) + +This means users can override your theme defaults per gallery, while your theme provides sensible defaults that work well with its layout. + +**Loading Theme Config:** + +Use the `loadThemeConfig()` utility from `@simple-photo-gallery/common/theme`: + +```typescript +import path from "node:path"; +import { loadThemeConfig } from "@simple-photo-gallery/common/theme"; + +const themePath = path.resolve(import.meta.dirname, "../.."); +const themeConfig = loadThemeConfig(themePath); +``` + +See the example in section 3 below for complete integration. + +#### 4. `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 + +**Recommended approach** - Use the resolver utilities from `@simple-photo-gallery/common/theme`: + +```astro +--- +import path from 'node:path'; +import { loadGalleryData, loadThemeConfig, resolveGalleryData } from '@simple-photo-gallery/common/theme'; +import type { ResolvedGalleryData } from '@simple-photo-gallery/common/theme'; + +// Read gallery.json from the path provided by the build process +const galleryJsonPath = import.meta.env.GALLERY_JSON_PATH || './gallery.json'; + +// Load gallery data +const raw = loadGalleryData(galleryJsonPath, { validate: true }); + +// Load theme config (optional, for theme-level defaults) +const themePath = path.resolve(import.meta.dirname, '../..'); +const themeConfig = loadThemeConfig(themePath); + +// Resolve gallery data with theme config +const gallery: ResolvedGalleryData = await resolveGalleryData(raw, { + galleryJsonPath, + themeConfig +}); + +// Extract resolved gallery properties +const { hero, sections, subGalleries, metadata, thumbnails } = gallery; +--- + + + + {hero.title} + + {metadata.analyticsScript && ( + + )} + + + +

{hero.title}

+
+ + + + + + + + {hero.title} + + + + {sections.map((section) => ( +
+

{section.title}

+
+ {section.images.map((image) => ( + + {image.alt + + ))} +
+ ))} + + +``` + +**Alternative - Manual approach** (not recommended for new themes): + +```astro +--- +import fs from 'node:fs'; +import type { GalleryData } from '@simple-photo-gallery/common'; + +// 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 - note: paths and markdown NOT pre-computed +const { title, description, sections } = gallery; +--- + + + + {title} + + + +

{title}

+

{description}

+ + + {sections.map((section) => ( +
+ {section.images.map((image) => ( + {image.alt + ))} +
+ ))} + + +``` + +> **Note:** The resolver approach is recommended because it provides pre-computed paths, responsive srcsets, and parsed markdown. The modern theme uses this pattern. See the [Common Package API](../common/README.md) for complete documentation. + +### Gallery Data Structure + +> **Important:** There are two data structures to understand: +> +> - **`GalleryData`** - Raw structure from `gallery.json` (manual approach) +> - **`ResolvedGalleryData`** - Transformed structure with pre-computed paths (recommended approach) +> +> **Recommendation:** Use `resolveGalleryData()` from `@simple-photo-gallery/common/theme` to get resolved data. This is what the modern theme uses. + +#### Raw GalleryData (Manual Approach) + +Your theme receives a `GalleryData` object from `gallery.json` 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; + }>; + }; +} +``` + +### Using Common Package Utilities + +The `@simple-photo-gallery/common` package provides utilities that make theme development easier and more consistent. + +#### Data Loading and Resolution + +**`loadGalleryData()`** - Load gallery.json with optional validation: + +```typescript +import { loadGalleryData } from "@simple-photo-gallery/common/theme"; + +const gallery = loadGalleryData("./gallery.json", { validate: true }); +``` + +**`resolveGalleryData()`** - Transform raw data into resolved structure: + +```typescript +import { resolveGalleryData } from "@simple-photo-gallery/common/theme"; + +const resolved = await resolveGalleryData(gallery, { galleryJsonPath: "./gallery.json" }); + +// Access pre-computed data +resolved.hero.src; // Computed hero image path +resolved.hero.srcsets; // Responsive image srcsets +resolved.sections[0].parsedDescription; // HTML from markdown +resolved.sections[0].images[0].imagePath; // Computed image path +``` + +**Benefits of using the resolver:** + +- All image paths pre-computed (no manual path logic) +- Responsive srcsets built automatically +- Markdown descriptions parsed to HTML +- Type-safe with `ResolvedGalleryData` type + +#### Client-Side Utilities + +The `@simple-photo-gallery/common/client` module provides browser-side utilities: + +**PhotoSwipe Lightbox:** + +```typescript +import { createGalleryLightbox } from "@simple-photo-gallery/common/client"; + +const lightbox = createGalleryLightbox({ + gallery: "#gallery", + children: "a", +}); +lightbox.init(); +``` + +**Blurhash Decoding:** + +```typescript +import { decodeAllBlurhashes } from "@simple-photo-gallery/common/client"; + +// Decodes all canvas elements with data-blurhash attribute +decodeAllBlurhashes(); +``` + +**Hero Image Fallback:** + +```typescript +import { initHeroImageFallback } from "@simple-photo-gallery/common/client"; + +// Smooth transition from blurhash to actual image +initHeroImageFallback(); +``` + +**CSS Utilities:** + +```typescript +import { setCSSVar, deriveOpacityColor } from "@simple-photo-gallery/common/client"; + +// Set CSS custom properties dynamically +setCSSVar("--primary-color", "#007bff"); + +// Create semi-transparent colors +const bgColor = deriveOpacityColor("#007bff", 0.1); +setCSSVar("--bg-color", bgColor); +``` + +#### Complete API Reference + +For a comprehensive list of all utilities and types, see the [Common Package API documentation](../common/README.md). + +--- + +### 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. + +### Theme Development (running `astro dev`) + +When you develop a theme, you’ll usually want to point it at a real `gallery.json` generated by the CLI. + +1. Create a gallery (once): + +```bash +spg init -p /path/to/photos -g /path/to/gallery +``` + +2. Run the theme dev server with the required environment variables: + +```bash +# macOS / Linux +export GALLERY_JSON_PATH="/path/to/gallery/gallery.json" +export GALLERY_OUTPUT_DIR="/path/to/gallery" +yarn dev +``` + +```bash +# Windows (PowerShell) +$env:GALLERY_JSON_PATH="C:\path\to\gallery\gallery.json" +$env:GALLERY_OUTPUT_DIR="C:\path\to\gallery" +yarn dev +``` + +### Best Practices + +1. **Use the resolver**: Use `resolveGalleryData()` from `@simple-photo-gallery/common/theme` for path computation and data transformation (recommended) +2. **Use TypeScript**: Import types from `@simple-photo-gallery/common` for type safety +3. **Leverage client utilities**: Import from `@simple-photo-gallery/common/client` for browser-side functionality like PhotoSwipe and blurhash +4. **Handle optional fields**: Many fields in `GalleryData` are optional - always check before using +5. **Use resolved types**: Work with `ResolvedGalleryData`, `ResolvedHero`, `ResolvedSection`, etc. for pre-computed data +6. **Optimize assets**: Use Astro's asset optimization features +7. **Test locally**: Use `astro dev` to preview your theme during development +8. **Follow Astro conventions**: Use Astro components, layouts, and best practices + +### Example Theme Packages + +- **Base theme template**: Bundled with the `simple-photo-gallery` package and used by `spg create-theme`. This is a minimal, functional theme that serves as the starting point for all new themes. The source is in `gallery/src/modules/create-theme/templates/base` (or `themes/base` in the repository for development). +- **`@simple-photo-gallery/theme-modern`**: A more advanced theme example. The source code is available in the `themes/modern` directory of this repository. + +Both themes demonstrate the required structure and can be used as reference implementations. + +### Using Your Theme + +Once your theme is ready, you can use it in two ways: + +**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 not found** + +- 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** + +- 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/eslint.config.mjs b/gallery/eslint.config.mjs index aa637be..1197bda 100644 --- a/gallery/eslint.config.mjs +++ b/gallery/eslint.config.mjs @@ -28,7 +28,7 @@ const eslintConfig = [ importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript, { - files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], + files: ['**/*.ts', '**/*.tsx'], languageOptions: { ecmaVersion: 2020, parserOptions: { @@ -109,6 +109,73 @@ const eslintConfig = [ 'unicorn/consistent-function-scoping': 'off', }, }, + { + files: ['**/*.{js,mjs,cjs}'], + languageOptions: { + ecmaVersion: 2020, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + globals: { + ...globals.jest, + ...globals.browser, + ...globals.node, + AddEventListenerOptions: 'readonly', + EventListener: 'readonly', + }, + }, + settings: { + 'import/resolver': { + node: { + paths: ['src'], + extensions: ['.js', '.jsx', '.ts', '.d.ts', '.tsx'], + }, + alias: { + map: [['@', path.resolve(import.meta.dirname, './src')]], + extensions: ['.js', '.jsx', '.ts', '.d.ts', '.tsx'], + }, + }, + }, + rules: { + 'import/no-extraneous-dependencies': 'off', + 'import/prefer-default-export': 'off', + 'import/extensions': 'off', + 'no-undef': 'error', + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-null': 'off', + 'import/order': [ + 'warn', + { + alphabetize: { + caseInsensitive: true, + order: 'asc', + }, + groups: ['builtin', 'external', 'index', 'sibling', 'parent', 'internal', 'type'], + pathGroups: [ + { + pattern: 'react', + group: 'external', + position: 'before', + }, + ], + pathGroupsExcludedImportTypes: ['types'], + 'newlines-between': 'always', + }, + ], + 'import/no-named-as-default-member': 'off', + 'unicorn/filename-case': [ + 'error', + { + cases: { + camelCase: true, + pascalCase: true, + kebabCase: true, + }, + }, + ], + 'unicorn/consistent-function-scoping': 'off', + }, + }, ]; export default eslintConfig; diff --git a/gallery/jest.config.cjs b/gallery/jest.config.cjs index 7433259..62fb1e5 100644 --- a/gallery/jest.config.cjs +++ b/gallery/jest.config.cjs @@ -5,10 +5,12 @@ module.exports = { extensionsToTreatAsEsm: ['.ts'], testMatch: ['**/tests/**/*.test.ts'], testPathIgnorePatterns: ['/node_modules/', '/dist/'], - transformIgnorePatterns: ['node_modules/(?!(@simple-photo-gallery/common)/)'], + transformIgnorePatterns: ['node_modules/(?!(@simple-photo-gallery/common|marked)/)'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', '^@simple-photo-gallery/common$': '/../common/src/gallery.ts', + '^@simple-photo-gallery/common/theme$': '/../common/src/theme/index.ts', + '^@simple-photo-gallery/common/theme/config$': '/../common/src/theme/config.ts', }, transform: { '^.+\\.tsx?$': [ @@ -17,6 +19,7 @@ module.exports = { useESM: true, tsconfig: { module: 'ES2022', + moduleResolution: 'NodeNext', }, }, ], diff --git a/gallery/package.json b/gallery/package.json index b7ae339..3a5fbc4 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -1,6 +1,6 @@ { "name": "simple-photo-gallery", - "version": "2.0.18", + "version": "2.1.0", "description": "Simple Photo Gallery CLI", "license": "MIT", "author": "Vladimir Haltakov, Tomasz Rusin", @@ -10,7 +10,8 @@ }, "homepage": "https://simple.photo", "files": [ - "dist" + "dist", + "src/modules/create-theme/templates" ], "bin": { "simple-photo-gallery": "./dist/index.js", @@ -47,8 +48,8 @@ "prepublish": "yarn build" }, "dependencies": { - "@simple-photo-gallery/common": "1.0.6", - "@simple-photo-gallery/theme-modern": "2.0.18", + "@simple-photo-gallery/common": "2.1.0", + "@simple-photo-gallery/theme-modern": "2.1.0", "axios": "^1.12.2", "blurhash": "^2.0.5", "commander": "^12.0.0", diff --git a/gallery/src/index.ts b/gallery/src/index.ts index cc0b174..38b9195 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'; @@ -161,6 +162,9 @@ program .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 ', 'Theme package name or local path to store in gallery.json') + .option('--thumbnail-size ', 'Thumbnail size in pixels to store in gallery.json', Number.parseInt) + .option('--thumbnail-edge ', 'How thumbnail size is applied: auto, width, or height') .action(withCommandContext((options, ui) => init(options, ui))); program @@ -168,6 +172,8 @@ program .description('Create thumbnails for all media files in the gallery') .option('-g, --gallery ', 'Path to the directory of the gallery. Default: current working directory', process.cwd()) .option('-r, --recursive', 'Scan subdirectories recursively', false) + .option('--thumbnail-size ', 'Override thumbnail size in pixels', Number.parseInt) + .option('--thumbnail-edge ', 'Override how thumbnail size is applied: auto, width, or height') .action(withCommandContext((options, ui) => thumbnails(options, ui))); program @@ -179,6 +185,12 @@ 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 name (e.g., @simple-photo-gallery/theme-modern) or local path (e.g., ./themes/my-theme)', + ) + .option('--thumbnail-size ', 'Override thumbnail size in pixels', Number.parseInt) + .option('--thumbnail-edge ', 'Override how thumbnail size is applied: auto, width, or height') .action(withCommandContext((options, ui) => build(options, ui))); program @@ -188,6 +200,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/build/index.ts b/gallery/src/modules/build/index.ts index d46614a..45725a1 100644 --- a/gallery/src/modules/build/index.ts +++ b/gallery/src/modules/build/index.ts @@ -20,6 +20,7 @@ import { processGalleryThumbnails } from '../thumbnails'; import type { BuildOptions } from './types'; import type { CommandResultSummary } from '../telemetry/types'; import type { GalleryData } from '@simple-photo-gallery/common'; +import type { ThumbnailConfig } from '@simple-photo-gallery/common/theme'; /** * Copies photos from gallery subdirectory to main directory when needed @@ -113,6 +114,8 @@ async function scanAndAppendNewFiles( * @param ui - ConsolaInstance for logging * @param baseUrl - Optional base URL for hosting photos * @param thumbsBaseUrl - Optional base URL for hosting thumbnails + * @param cliThumbnailConfig - Optional CLI overrides for thumbnail configuration + * @param cliTheme - Optional CLI theme identifier to save to gallery.json */ async function buildGallery( galleryDir: string, @@ -122,6 +125,8 @@ async function buildGallery( ui: ConsolaInstance, baseUrl?: string, thumbsBaseUrl?: string, + cliThumbnailConfig?: ThumbnailConfig, + cliTheme?: string, ): Promise { ui.start(`Building gallery ${galleryDir}`); @@ -206,6 +211,30 @@ async function buildGallery( fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); } + // If the theme is provided via CLI, update the gallery.json file if needed + if (cliTheme && galleryData.theme !== cliTheme) { + ui.debug('Updating gallery.json with theme'); + galleryData.theme = cliTheme; + fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + } + + // If thumbnail settings are provided via CLI, update the gallery.json file if needed + if (cliThumbnailConfig) { + const needsUpdate = + (cliThumbnailConfig.size !== undefined && galleryData.thumbnails?.size !== cliThumbnailConfig.size) || + (cliThumbnailConfig.edge !== undefined && galleryData.thumbnails?.edge !== cliThumbnailConfig.edge); + + if (needsUpdate) { + ui.debug('Updating gallery.json with thumbnail settings'); + galleryData.thumbnails = { + ...galleryData.thumbnails, + ...(cliThumbnailConfig.size !== undefined && { size: cliThumbnailConfig.size }), + ...(cliThumbnailConfig.edge !== undefined && { edge: cliThumbnailConfig.edge }), + }; + fs.writeFileSync(galleryJsonPath, JSON.stringify(galleryData, null, 2)); + } + } + // Set the social media card URL if changed if (!galleryData.metadata.image) { @@ -219,7 +248,7 @@ async function buildGallery( // Generate the thumbnails if needed if (shouldCreateThumbnails) { - await processGalleryThumbnails(galleryDir, ui); + await processGalleryThumbnails(galleryDir, ui, cliThumbnailConfig); } // Build the template @@ -254,10 +283,44 @@ 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 / (absolute path) + // Note: We don't check for path.sep in the middle because scoped npm packages + // like @scope/package contain / but should be treated as npm packages + return theme.startsWith('./') || theme.startsWith('../') || theme.startsWith('/'); +} + +/** + * 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 */ +export 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,19 +330,40 @@ 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'); - const themeDir = path.dirname(new URL(themePath).pathname); + // Create CLI thumbnail config from options (only include values that were provided) + const cliThumbnailConfig: ThumbnailConfig | undefined = + options.thumbnailSize !== undefined || options.thumbnailEdge !== undefined + ? { size: options.thumbnailSize, edge: options.thumbnailEdge } + : undefined; // Process each gallery directory let totalGalleries = 0; for (const dir of galleryDirs) { + const galleryJsonPath = path.join(dir, 'gallery', 'gallery.json'); + const galleryData = parseGalleryJson(galleryJsonPath, ui); + + // Theme resolution: CLI option > gallery.json > default + const themeIdentifier = options.theme || galleryData.theme || '@simple-photo-gallery/theme-modern'; + + // Resolve the theme directory (supports both local paths and npm packages) + const themeDir = await resolveThemeDir(themeIdentifier, ui); + const baseUrl = options.baseUrl ? `${options.baseUrl}${path.relative(options.gallery, dir)}` : undefined; const thumbsBaseUrl = options.thumbsBaseUrl ? `${options.thumbsBaseUrl}${path.relative(options.gallery, dir)}` : undefined; - await buildGallery(path.resolve(dir), themeDir, options.scan, options.thumbnails, ui, baseUrl, thumbsBaseUrl); + await buildGallery( + path.resolve(dir), + themeDir, + options.scan, + options.thumbnails, + ui, + baseUrl, + thumbsBaseUrl, + cliThumbnailConfig, + options.theme, + ); ++totalGalleries; } @@ -288,8 +372,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: @simple-photo-gallery/theme-modern/package.json'); + 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'); } diff --git a/gallery/src/modules/build/types/index.ts b/gallery/src/modules/build/types/index.ts index 97552ae..cb3f291 100644 --- a/gallery/src/modules/build/types/index.ts +++ b/gallery/src/modules/build/types/index.ts @@ -12,4 +12,10 @@ 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; + /** Override thumbnail size in pixels */ + thumbnailSize?: number; + /** Override how thumbnail size should be applied: 'auto', 'width', or 'height' */ + thumbnailEdge?: 'auto' | 'width' | 'height'; } diff --git a/gallery/src/modules/create-theme/index.ts b/gallery/src/modules/create-theme/index.ts new file mode 100644 index 0000000..5f11e6f --- /dev/null +++ b/gallery/src/modules/create-theme/index.ts @@ -0,0 +1,275 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +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 + * @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}`); + } + } +} + +/** + * Files and directories to exclude when copying the base theme + */ +const EXCLUDE_PATTERNS = ['node_modules', '.astro', 'dist', '_build', '.git', '*.log', '.DS_Store']; + +/** + * Check if a file or directory should be excluded + */ +function shouldExclude(name: string): boolean { + // Template README files are not meant to be copied into a newly created theme. + // - README.md: template maintainer docs (source template warning, etc.) + // - README_BASE.md: scaffold used to generate the new theme's README.md + if (name === 'README.md' || name === 'README_BASE.md') { + return true; + } + + return EXCLUDE_PATTERNS.some((pattern) => { + if (pattern.includes('*')) { + const regexPattern = pattern.split('*').join('.*'); + const regex = new RegExp(regexPattern); + return regex.test(name); + } + return name === pattern; + }); +} + +/** + * Copy a directory recursively, excluding certain files and directories + * @param src - Source directory path + * @param dest - Destination directory path + * @param ui - ConsolaInstance for logging + */ +async function copyDirectory(src: string, dest: string, ui: ConsolaInstance): Promise { + await fs.promises.mkdir(dest, { recursive: true }); + const entries = await fs.promises.readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + if (shouldExclude(entry.name)) { + ui.debug(`Skipping excluded file/directory: ${entry.name}`); + continue; + } + + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + await copyDirectory(srcPath, destPath, ui); + } else { + await fs.promises.copyFile(srcPath, destPath); + ui.debug(`Copied file: ${destPath}`); + } + } +} + +/** + * Find the base theme directory path + * Looks for templates/base relative to this module, with fallback to workspace themes/base for development + */ +function findBaseThemePath(): string { + // Primary: Look for templates bundled with the package (for npm users) + // When installed from npm: dist/modules/create-theme/index.js -> src/modules/create-theme/templates/base + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const bundledTemplatePath = path.resolve(moduleDir, '../../../src/modules/create-theme/templates/base'); + + if (fs.existsSync(bundledTemplatePath)) { + return bundledTemplatePath; + } + + // Fallback: Try to find from workspace root (for local development) + // This allows developers to modify themes/base and test changes + const monorepoRoot = findMonorepoRoot(process.cwd()); + const workspaceRoot = monorepoRoot ?? process.cwd(); + const workspaceBaseThemePath = path.join(workspaceRoot, 'themes', 'base'); + + if (fs.existsSync(workspaceBaseThemePath)) { + return workspaceBaseThemePath; + } + + throw new Error( + `Base theme template not found. Tried:\n - ${bundledTemplatePath}\n - ${workspaceBaseThemePath}\n\nPlease ensure the templates are included in the package or themes/base exists in the workspace.`, + ); +} + +/** + * Update package.json with the new theme name + * @param themeDir - Theme directory path + * @param themeName - New theme name + * @param ui - ConsolaInstance for logging + */ +async function updatePackageJson(themeDir: string, themeName: string, ui: ConsolaInstance): Promise { + const packageJsonPath = path.join(themeDir, 'package.json'); + const packageJsonContent = await fs.promises.readFile(packageJsonPath, 'utf8'); + const packageJson = JSON.parse(packageJsonContent) as { name?: string }; + packageJson.name = themeName; + await fs.promises.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf8'); + ui.debug(`Updated package.json with theme name: ${themeName}`); +} + +/** + * Create README.md from README_BASE.md and fill placeholders with the new theme name + * @param baseThemePath - Base theme template directory path + * @param themeDir - Theme directory path + * @param themeName - New theme name + * @param ui - ConsolaInstance for logging + */ +async function createReadmeFromBase( + baseThemePath: string, + themeDir: string, + themeName: string, + ui: ConsolaInstance, +): Promise { + const readmeBasePath = path.join(baseThemePath, 'README_BASE.md'); + const readmePath = path.join(themeDir, 'README.md'); + + if (!fs.existsSync(readmeBasePath)) { + throw new Error(`README_BASE.md not found in template: ${readmeBasePath}`); + } + + let readme = await fs.promises.readFile(readmeBasePath, 'utf8'); + + // Display name: "my-theme" -> "My Theme" + const displayName = themeName + .split('-') + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + readme = readme.replaceAll('{THEME_NAME}', displayName); + readme = readme.replaceAll('{THEME_NAME_LOWER}', displayName.toLowerCase()); + + await fs.promises.writeFile(readmePath, readme, 'utf8'); + ui.debug(`Created README.md from README_BASE.md for theme: ${themeName}`); +} + +/** + * 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 + 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 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) + if (!fs.existsSync(themesBaseDir)) { + await ensureDirectory(themesBaseDir, ui); + } + } + + // Check if directory already exists - prevent overwriting existing themes + if (fs.existsSync(themeDir)) { + throw new Error(`Theme directory already exists: ${themeDir}. Cannot overwrite existing theme.`); + } + + ui.start(`Creating theme: ${options.name}`); + + // Find the base theme directory + const baseThemePath = findBaseThemePath(); + ui.debug(`Using base theme from: ${baseThemePath}`); + + // Copy entire base theme directory + ui.debug('Copying base theme files...'); + await copyDirectory(baseThemePath, themeDir, ui); + + // Update theme-specific files + ui.debug('Updating theme-specific files...'); + await updatePackageJson(themeDir, options.name, ui); + await createReadmeFromBase(baseThemePath, themeDir, options.name, ui); + + ui.success(`Theme created successfully at: ${themeDir}`); + ui.info(`\nNext steps:`); + ui.info(`1. cd ${themeDir}`); + ui.info(`2. yarn install`); + ui.info(`3. Customize your theme in src/pages/index.astro`); + 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) { + 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/base/.gitignore b/gallery/src/modules/create-theme/templates/base/.gitignore new file mode 100644 index 0000000..489afbc --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.astro/ +_build/ +*.log +.DS_Store diff --git a/gallery/src/modules/create-theme/templates/base/.prettierignore b/gallery/src/modules/create-theme/templates/base/.prettierignore new file mode 100644 index 0000000..6a2350b --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/.prettierignore @@ -0,0 +1,4 @@ +**/node_modules/* +**/dist/* +**/.astro/* +**/_build/** diff --git a/gallery/src/modules/create-theme/templates/base/.prettierrc.mjs b/gallery/src/modules/create-theme/templates/base/.prettierrc.mjs new file mode 100644 index 0000000..dfaab3f --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/.prettierrc.mjs @@ -0,0 +1,27 @@ +/** + * @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; diff --git a/gallery/src/modules/create-theme/templates/base/README.md b/gallery/src/modules/create-theme/templates/base/README.md new file mode 100644 index 0000000..3d157c2 --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/README.md @@ -0,0 +1,42 @@ +# base Theme + +The base theme template for Simple Photo Gallery built with Astro. + +> **⚠️ Important:** This theme is used as the **source template** for `spg create-theme`. This template is bundled with the `simple-photo-gallery` package, so any changes made here will be reflected in all **new themes** created with `spg create-theme` after the package is updated and published. Existing themes created before your changes will not be affected. + +## How it works + +When you run `spg create-theme `, this template (bundled with the package) is copied to create a new theme. The command: + +1. Copies all files from this directory (excluding build artifacts like `node_modules`, `.astro`, `dist`, etc.) +2. Updates `package.json` with the new theme name +3. Updates `README.md` with the new theme name + +This means: + +- **Modifying files here** → Changes will appear in themes created **after** your modifications +- **Adding new files** → New files will be included in future themes +- **Removing files** → Files won't be included in future themes +- **Existing themes** → Already created themes are not affected by changes here + +You can customize this theme to change the default structure, dependencies, or configuration for all new themes created with `spg create-theme`. + +## Structure + +This theme includes the following structure that will be copied to new themes: + +- `package.json` - Package configuration (name will be updated for new themes) +- `astro.config.ts` - Astro build configuration +- `tsconfig.json` - TypeScript configuration +- `eslint.config.mjs` - ESLint configuration +- `.prettierrc.mjs` - Prettier configuration +- `.prettierignore` - Prettier ignore patterns +- `.gitignore` - Git ignore patterns +- `src/pages/index.astro` - Main gallery page entry point +- `src/layouts/` - Layout components (MainHead, MainLayout) +- `src/components/` - Reusable components (Hero) +- `src/lib/` - Utility libraries (markdown, photoswipe-video-plugin) +- `src/utils/` - Helper functions for paths and resources +- `public/` - Static assets directory + +All of these files and directories will be included when creating new themes with `spg create-theme`. diff --git a/gallery/src/modules/create-theme/templates/base/README_BASE.md b/gallery/src/modules/create-theme/templates/base/README_BASE.md new file mode 100644 index 0000000..92400a9 --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/README_BASE.md @@ -0,0 +1,62 @@ +# {THEME_NAME} Theme + +This is the {THEME_NAME_LOWER} theme for Simple Photo Gallery built with Astro. + +This theme is a copy of the **modern theme** and includes all the production-ready components: + +- **Hero** - Full-screen header with responsive images (AVIF/JPG) and blurhash placeholders +- **Gallery Sections** - Flex-grow masonry layout with hover effects +- **PhotoSwipe Lightbox** - Full-featured image viewer with deep linking support +- **Sub-Galleries** - Navigation grid for nested galleries +- **CTA Banner** - Call-to-action component (optional via `ctaBanner: true` in gallery.json) +- **Footer** - Simple footer component + +## Getting Started + +1. Install dependencies: + + ```bash + yarn install + ``` + +2. Customize your theme in `src/pages/index.astro` and the components in `src/components/` + +3. Initialize a gallery (run from directory with your images): + + ```bash + spg init -p + ``` + +4. Build a gallery with your theme: + + ```bash + spg build --theme -g + ``` + +## Directory Structure + +``` +src/ +├── pages/ +│ └── index.astro # Main entry point +├── layouts/ +│ ├── MainLayout.astro # Root HTML layout with global styles +│ └── MainHead.astro # Meta tags and image preloading +├── components/ +│ ├── container/ # Max-width wrapper +│ ├── cta/ # Call-to-action banner +│ ├── footer/ # Footer component +│ ├── gallery-section/ # Photo grid components +│ ├── hero/ # Hero header component +│ ├── lightbox/ # PhotoSwipe integration +│ └── sub-galleries/ # Sub-gallery navigation +└── utils/ + └── queryParams.ts # URL query parameter customizations +``` + +## Customization + +- **Styles**: Edit the ` diff --git a/gallery/src/modules/create-theme/templates/base/src/components/cta/CtaBanner.astro b/gallery/src/modules/create-theme/templates/base/src/components/cta/CtaBanner.astro new file mode 100644 index 0000000..07f4eb5 --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/src/components/cta/CtaBanner.astro @@ -0,0 +1,101 @@ +--- +import Container from '@/components/container/Container.astro'; +--- + +
+ +
+
+

Share your story

+

Build your own gallery with Simple Photo Gallery

+

+ Turn your photos into a fast, beautiful web gallery in minutes and share it with your friends. +

+
+ Create your gallery +
+
+
+ + diff --git a/gallery/src/modules/create-theme/templates/base/src/components/footer/Footer.astro b/gallery/src/modules/create-theme/templates/base/src/components/footer/Footer.astro new file mode 100644 index 0000000..5e00105 --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/src/components/footer/Footer.astro @@ -0,0 +1,43 @@ +--- +// Footer component for Simple Photo Gallery +--- + + + + diff --git a/gallery/src/modules/create-theme/templates/base/src/components/gallery-section/GallerySection.astro b/gallery/src/modules/create-theme/templates/base/src/components/gallery-section/GallerySection.astro new file mode 100644 index 0000000..0159551 --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/src/components/gallery-section/GallerySection.astro @@ -0,0 +1,71 @@ +--- +import Container from '@/components/container/Container.astro'; +import GallerySectionHeader from '@/components/gallery-section/GallerySectionHeader.astro'; +import GallerySectionItem from '@/components/gallery-section/GallerySectionItem.astro'; + +import type { ResolvedSection } from '@simple-photo-gallery/common/theme'; + +interface Props { + section: ResolvedSection; + sectionIndex: number; +} + +const { section, sectionIndex } = Astro.props; + +// Filter to only valid images with proper dimensions +const validImages = section.images.filter( + (image) => image.width > 0 && image.height > 0 && (image.thumbnailWidth || 0) > 0 && (image.thumbnailHeight || 0) > 0, +); +--- + +
+ + + + +
+ + diff --git a/gallery/src/modules/create-theme/templates/base/src/components/gallery-section/GallerySectionHeader.astro b/gallery/src/modules/create-theme/templates/base/src/components/gallery-section/GallerySectionHeader.astro new file mode 100644 index 0000000..e5619af --- /dev/null +++ b/gallery/src/modules/create-theme/templates/base/src/components/gallery-section/GallerySectionHeader.astro @@ -0,0 +1,41 @@ +--- +import type { ResolvedSection } from '@simple-photo-gallery/common/theme'; + +interface Props { + section: ResolvedSection; +} + +const { section } = Astro.props; +--- + +