diff --git a/.github/workflows/check-cli.yml b/.github/workflows/check-cli.yml index ce67724..15e714e 100644 --- a/.github/workflows/check-cli.yml +++ b/.github/workflows/check-cli.yml @@ -1,11 +1,9 @@ -name: Run Checks and Tests for the CLI +name: Run Checks and Tests on: pull_request: branches: [main] types: [opened, synchronize, reopened] - paths: - - "gallery/**" jobs: test: @@ -40,6 +38,10 @@ jobs: working-directory: ./gallery run: yarn check + - name: Run checks (theme-modern) + working-directory: ./themes/modern + run: yarn lint && yarn format + - name: Run tests working-directory: ./gallery run: yarn test diff --git a/common/package.json b/common/package.json index 98afb9c..70796bd 100644 --- a/common/package.json +++ b/common/package.json @@ -39,7 +39,7 @@ "lint:fix": "eslint . --fix", "format": "prettier --write .", "format:fix": "prettier --write .", - "prepublish": "yarn build" + "prepack": "yarn build" }, "dependencies": { "marked": "^16.0.0", diff --git a/common/src/theme/index.ts b/common/src/theme/index.ts index e247844..d976881 100644 --- a/common/src/theme/index.ts +++ b/common/src/theme/index.ts @@ -20,7 +20,15 @@ export { LANDSCAPE_SIZES, PORTRAIT_SIZES } from './constants'; export { renderMarkdown } from './markdown'; // Path utilities -export { buildHeroSrcset, getPhotoPath, getRelativePath, getSubgalleryThumbnailPath, getThumbnailPath } from './paths'; +export { + buildHeroSrcset, + getPhotoPath, + getRelativePath, + getSubgalleryThumbnailPath, + getThumbnailPath, + joinUrl, + toUrlPath, +} from './paths'; // Gallery loading export type { LoadGalleryDataOptions } from './loader'; diff --git a/common/src/theme/paths.ts b/common/src/theme/paths.ts index b4f90ad..94ce5fe 100644 --- a/common/src/theme/paths.ts +++ b/common/src/theme/paths.ts @@ -2,12 +2,38 @@ import path from 'node:path'; import { getThumbnailExtension, type ThumbnailFormat } from './config'; +/** + * Converts a filesystem path to a URL path by replacing backslashes with forward slashes. + * Paths produced by the path module on Windows use backslashes, which are not valid URL separators. + * + * @param fsPath - The filesystem path to convert + * @returns The path with forward slashes only + */ +export function toUrlPath(fsPath: string): string { + return fsPath.replaceAll('\\', '/'); +} + +/** + * Joins a base URL with additional path segments using forward slashes. + * Trailing slashes on the base URL and backslashes in segments are normalized, and empty segments are skipped. + * + * @param baseUrl - The base URL to join the segments to + * @param segments - Path segments to append to the base URL + * @returns The joined URL + */ +export function joinUrl(baseUrl: string, ...segments: string[]): string { + const trimmedBase = baseUrl.replace(/\/+$/, ''); + const parts = segments.map((segment) => toUrlPath(segment)).filter((segment) => segment.length > 0); + + return [trimmedBase, ...parts].join('/'); +} + /** * 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 + * @returns The normalized path relative to the gallery root directory, using forward slashes */ export function getRelativePath(resourcePath: string, galleryJsonPath: string): string { const galleryConfigPath = path.resolve(galleryJsonPath); @@ -16,7 +42,7 @@ export function getRelativePath(resourcePath: string, galleryJsonPath: string): const absoluteResourcePath = path.resolve(path.join(galleryConfigDir, resourcePath)); const baseDir = path.dirname(galleryConfigDir); - return path.relative(baseDir, absoluteResourcePath); + return toUrlPath(path.relative(baseDir, absoluteResourcePath)); } /** @@ -59,7 +85,7 @@ export function getPhotoPath(filename: string, mediaBaseUrl?: string, url?: stri * @param headerImageFilename - The filename of the subgallery header image * @param resolvedSubgalleryPath - The resolved subgallery path relative to the gallery root * @param format - The thumbnail output format used to derive the file extension (defaults to 'avif') - * @returns The normalized path relative to the gallery root directory + * @returns The normalized URL path relative to the gallery root directory */ export function getSubgalleryThumbnailPath( headerImageFilename: string, @@ -70,7 +96,7 @@ export function getSubgalleryThumbnailPath( const thumbnailFilename = `${basename}.${getThumbnailExtension(format)}`; const subgalleryFolder = resolvedSubgalleryPath || path.basename(path.dirname(headerImageFilename)); - return path.join(subgalleryFolder, 'gallery', 'images', thumbnailFilename); + return path.posix.join(toUrlPath(subgalleryFolder), 'gallery', 'images', thumbnailFilename); } /** diff --git a/gallery/package.json b/gallery/package.json index 1bbf83e..26a42d8 100644 --- a/gallery/package.json +++ b/gallery/package.json @@ -44,7 +44,7 @@ "format:fix": "prettier --write .", "test": "jest", "test:coverage": "jest --coverage", - "prepublish": "yarn build" + "prepack": "yarn build" }, "dependencies": { "@simple-photo-gallery/common": "2.1.7", diff --git a/gallery/src/index.ts b/gallery/src/index.ts index d579e29..d6c920c 100644 --- a/gallery/src/index.ts +++ b/gallery/src/index.ts @@ -201,9 +201,13 @@ program program .command('clean') - .description('Remove all gallery files and folders (index.html, gallery/)') + .description( + 'Remove generated gallery files (index.html, thumbnails, built assets). Keeps gallery.json unless --all is used', + ) .option('-g, --gallery ', 'Path to the directory of the gallery. Default: current working directory', process.cwd()) .option('-r, --recursive', 'Clean subdirectories recursively', false) + .option('--all', 'Also remove gallery.json, including all titles, descriptions and sections', false) + .option('-f, --force', 'Skip the confirmation prompt when using --all', false) .action(withCommandContext((options, ui) => clean(options, ui))); program diff --git a/gallery/src/modules/build/index.ts b/gallery/src/modules/build/index.ts index f9db6f3..088348c 100644 --- a/gallery/src/modules/build/index.ts +++ b/gallery/src/modules/build/index.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { joinUrl, toUrlPath } from '@simple-photo-gallery/common/theme'; import { LogLevels, type ConsolaInstance } from 'consola'; import { @@ -345,10 +346,10 @@ export async function build(options: BuildOptions, ui: ConsolaInstance): Promise // 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; + // Build URLs with forward slashes; path.relative uses backslashes on Windows + const relativeDirUrl = toUrlPath(path.relative(options.gallery, dir)); + const baseUrl = options.baseUrl ? joinUrl(options.baseUrl, relativeDirUrl) : undefined; + const thumbsBaseUrl = options.thumbsBaseUrl ? joinUrl(options.thumbsBaseUrl, relativeDirUrl) : undefined; await buildGallery( path.resolve(dir), diff --git a/gallery/src/modules/clean/index.ts b/gallery/src/modules/clean/index.ts index da93ca6..31cb7be 100644 --- a/gallery/src/modules/clean/index.ts +++ b/gallery/src/modules/clean/index.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import process from 'node:process'; import { findGalleries } from '../../utils'; @@ -7,12 +8,16 @@ import type { CleanOptions } from './types'; import type { CommandResultSummary } from '../telemetry/types'; import type { ConsolaInstance } from 'consola'; +/** Files in the gallery directory that contain user-curated content and are preserved unless --all is passed */ +const PRESERVED_FILES = new Set(['gallery.json', 'gallery.json.old']); + /** * Clean gallery files from a single directory * @param galleryDir - Directory containing a gallery + * @param removeAll - Whether to also remove gallery.json instead of only generated files * @param ui - Consola instance for logging */ -async function cleanGallery(galleryDir: string, ui?: ConsolaInstance): Promise { +async function cleanGallery(galleryDir: string, removeAll: boolean, ui?: ConsolaInstance): Promise { let filesRemoved = 0; // Remove index.html file from the gallery directory @@ -27,15 +32,33 @@ async function cleanGallery(galleryDir: string, ui?: ConsolaInstance): Promise { try { @@ -70,9 +94,27 @@ export async function clean(options: CleanOptions, ui: ConsolaInstance): Promise return { processedGalleryCount: 0 }; } + // Ask for confirmation before deleting gallery.json files, which contain user-curated content + if (options.all && !options.force) { + if (!process.stdout.isTTY) { + ui.error('Refusing to remove gallery.json files without confirmation. Use --force to skip the prompt.'); + return { processedGalleryCount: 0 }; + } + + const confirmed = await ui.prompt( + 'This will also delete gallery.json files, including all titles, descriptions and sections. Continue?', + { type: 'confirm' }, + ); + + if (confirmed !== true) { + ui.info('Clean cancelled'); + return { processedGalleryCount: 0 }; + } + } + // Clean each gallery directory for (const dir of galleryDirs) { - await cleanGallery(dir, ui); + await cleanGallery(dir, options.all, ui); } ui.box(`Successfully cleaned ${galleryDirs.length} ${galleryDirs.length === 1 ? 'gallery' : 'galleries'}`); diff --git a/gallery/src/modules/clean/types/index.ts b/gallery/src/modules/clean/types/index.ts index 104481d..e0be0c3 100644 --- a/gallery/src/modules/clean/types/index.ts +++ b/gallery/src/modules/clean/types/index.ts @@ -4,4 +4,8 @@ export interface CleanOptions { gallery: string; /** Whether to clean galleries in subdirectories recursively */ recursive: boolean; + /** Whether to also remove gallery.json files instead of only generated files */ + all: boolean; + /** Whether to skip the confirmation prompt when removing gallery.json files */ + force: boolean; } diff --git a/gallery/src/modules/init/index.ts b/gallery/src/modules/init/index.ts index 33352d4..769f12a 100644 --- a/gallery/src/modules/init/index.ts +++ b/gallery/src/modules/init/index.ts @@ -1,6 +1,8 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { toUrlPath } from '@simple-photo-gallery/common/theme'; + import { capitalizeTitle, getMediaFileType } from './utils'; import type { GallerySettingsFromUser, ProcessDirectoryResult, ScanDirectoryResult, ScanOptions, SubGallery } from './types'; @@ -51,6 +53,10 @@ export async function scanDirectory(dirPath: string, ui: ConsolaInstance): Promi throw error; } + // Sort the results for a deterministic gallery order; fs.readdir order depends on the file system + mediaFiles.sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true })); + subGalleryDirectories.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + return { mediaFiles, subGalleryDirectories }; } @@ -131,10 +137,10 @@ async function createGalleryJson( const isSameLocation = path.relative(scanPath, path.join(galleryDir, '..')) === ''; const mediaBasePath = isSameLocation ? undefined : scanPath; - // Convert subGallery header image paths to be relative to gallery.json + // Convert subGallery header image paths to be relative to gallery.json, using forward slashes for portability const relativeSubGalleries = subGalleries.map((subGallery) => ({ ...subGallery, - headerImage: subGallery.headerImage ? path.relative(galleryDir, subGallery.headerImage) : '', + headerImage: subGallery.headerImage ? toUrlPath(path.relative(galleryDir, subGallery.headerImage)) : '', })); // Build thumbnails config from CLI options @@ -316,7 +322,7 @@ async function processDirectory( result.subGallery = { title: capitalizeTitle(dirName), headerImage: mediaFiles[0]?.filename || '', - path: path.join('..', dirName), + path: `../${dirName}`, }; } diff --git a/gallery/tests/clean.test.ts b/gallery/tests/clean.test.ts new file mode 100644 index 0000000..2df9c43 --- /dev/null +++ b/gallery/tests/clean.test.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { clean } from '../src/modules/clean'; + +import type { ConsolaInstance } from 'consola'; + +// Mock console UI for testing +const createMockUI = (promptResult?: boolean): ConsolaInstance => + ({ + info: jest.fn(), + start: jest.fn(), + success: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + box: jest.fn(), + prompt: jest.fn().mockResolvedValue(promptResult), + warn: jest.fn(), + log: jest.fn(), + }) as unknown as ConsolaInstance; + +// Helper to create a gallery directory structure with generated files +function createTestGallery(rootDir: string): void { + const galleryPath = path.join(rootDir, 'gallery'); + fs.mkdirSync(path.join(galleryPath, 'images'), { recursive: true }); + fs.writeFileSync(path.join(galleryPath, 'gallery.json'), JSON.stringify({ title: 'Test Gallery' })); + fs.writeFileSync(path.join(galleryPath, 'images', 'photo.avif'), 'thumbnail'); + fs.writeFileSync(path.join(rootDir, 'index.html'), ''); +} + +describe('clean', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spg-clean-test-')); + createTestGallery(tempDir); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should remove generated files but keep gallery.json by default', async () => { + const ui = createMockUI(); + + await clean({ gallery: tempDir, recursive: false, all: false, force: false }, ui); + + expect(fs.existsSync(path.join(tempDir, 'index.html'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, 'gallery', 'images'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, 'gallery', 'gallery.json'))).toBe(true); + }); + + test('should remove the entire gallery directory with --all and --force', async () => { + const ui = createMockUI(); + + await clean({ gallery: tempDir, recursive: false, all: true, force: true }, ui); + + expect(fs.existsSync(path.join(tempDir, 'index.html'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, 'gallery'))).toBe(false); + }); + + test('should refuse to remove gallery.json with --all when confirmation is not possible', async () => { + const ui = createMockUI(); + + // process.stdout.isTTY is falsy when running under Jest, so the confirmation prompt cannot be shown + await clean({ gallery: tempDir, recursive: false, all: true, force: false }, ui); + + expect(fs.existsSync(path.join(tempDir, 'gallery', 'gallery.json'))).toBe(true); + expect(ui.error).toHaveBeenCalledWith(expect.stringContaining('--force')); + }); + + test('should clean galleries in subdirectories with --recursive', async () => { + const subDir = path.join(tempDir, 'japan'); + fs.mkdirSync(subDir); + createTestGallery(subDir); + + const ui = createMockUI(); + + await clean({ gallery: tempDir, recursive: true, all: false, force: false }, ui); + + expect(fs.existsSync(path.join(subDir, 'index.html'))).toBe(false); + expect(fs.existsSync(path.join(subDir, 'gallery', 'images'))).toBe(false); + expect(fs.existsSync(path.join(subDir, 'gallery', 'gallery.json'))).toBe(true); + }); +}); diff --git a/gallery/tests/gallery.test.ts b/gallery/tests/gallery.test.ts index 618a028..22231ee 100644 --- a/gallery/tests/gallery.test.ts +++ b/gallery/tests/gallery.test.ts @@ -1,5 +1,5 @@ import { execSync } from 'node:child_process'; -import { existsSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -757,14 +757,15 @@ describe('Clean command', () => { }); describe('single gallery clean', () => { - test('should remove index.html and gallery directory while preserving photos', () => { + test('should remove generated files while preserving gallery.json and photos', () => { // Copy fixture and initialize gallery copySync(singleFixturePath, cleanTestPath); runCliCommand(`${tsxPath} ${cliPath} init --photos ${cleanTestPath} -d`); - // Create an index.html file to simulate build output + // Create an index.html file and a thumbnails directory to simulate build output const indexPath = path.resolve(cleanTestPath, 'index.html'); const galleryPath = path.resolve(cleanTestPath, 'gallery'); + const imagesPath = path.resolve(galleryPath, 'images'); // Verify initial state - photos exist and gallery exists const photoCount = readdirSync(cleanTestPath).filter((file) => file.endsWith('.jpg')).length; @@ -772,12 +773,39 @@ describe('Clean command', () => { expect(existsSync(galleryPath)).toBe(true); writeFileSync(indexPath, 'Test Gallery'); + mkdirSync(imagesPath, { recursive: true }); + writeFileSync(path.resolve(imagesPath, 'photo.avif'), 'thumbnail'); expect(existsSync(indexPath)).toBe(true); // Run clean command runCliCommand(`${tsxPath} ${cliPath} clean --gallery ${cleanTestPath}`); - // Verify gallery files are removed + // Verify generated files are removed but gallery.json is preserved + expect(existsSync(indexPath)).toBe(false); + expect(existsSync(imagesPath)).toBe(false); + expect(existsSync(path.resolve(galleryPath, 'gallery.json'))).toBe(true); + + // Verify photos are still there + const photoFiles = readdirSync(cleanTestPath).filter((file) => file.endsWith('.jpg')); + expect(photoFiles.length).toBe(3); + }); + + test('should remove the gallery directory including gallery.json with --all --force', () => { + // Clean up from previous test and copy fresh fixture + if (existsSync(cleanTestPath)) { + rmSync(cleanTestPath, { recursive: true, force: true }); + } + copySync(singleFixturePath, cleanTestPath); + runCliCommand(`${tsxPath} ${cliPath} init --photos ${cleanTestPath} -d`); + + const indexPath = path.resolve(cleanTestPath, 'index.html'); + const galleryPath = path.resolve(cleanTestPath, 'gallery'); + writeFileSync(indexPath, 'Test Gallery'); + + // Run clean command with --all --force + runCliCommand(`${tsxPath} ${cliPath} clean --gallery ${cleanTestPath} --all --force`); + + // Verify the whole gallery directory is removed expect(existsSync(indexPath)).toBe(false); expect(existsSync(galleryPath)).toBe(false); @@ -821,13 +849,13 @@ describe('Clean command', () => { // Run recursive clean runCliCommand(`${tsxPath} ${cliPath} clean --gallery ${cleanMultiTestPath} -r`); - // Verify all gallery files and directories are removed + // Verify all generated files are removed while gallery.json files are preserved expect(existsSync(path.resolve(cleanMultiTestPath, 'index.html'))).toBe(false); - expect(existsSync(path.resolve(cleanMultiTestPath, 'gallery'))).toBe(false); + expect(existsSync(path.resolve(cleanMultiTestPath, 'gallery', 'gallery.json'))).toBe(true); expect(existsSync(path.resolve(cleanMultiTestPath, 'first', 'index.html'))).toBe(false); - expect(existsSync(path.resolve(cleanMultiTestPath, 'first', 'gallery'))).toBe(false); + expect(existsSync(path.resolve(cleanMultiTestPath, 'first', 'gallery', 'gallery.json'))).toBe(true); expect(existsSync(path.resolve(cleanMultiTestPath, 'second', 'index.html'))).toBe(false); - expect(existsSync(path.resolve(cleanMultiTestPath, 'second', 'gallery'))).toBe(false); + expect(existsSync(path.resolve(cleanMultiTestPath, 'second', 'gallery', 'gallery.json'))).toBe(true); // Verify photos are still there const rootPhotos = readdirSync(cleanMultiTestPath).filter((file) => file.endsWith('.jpg')); @@ -855,9 +883,9 @@ describe('Clean command', () => { // Run non-recursive clean runCliCommand(`${tsxPath} ${cliPath} clean --gallery ${cleanMultiTestPath}`); - // Verify only root gallery is cleaned + // Verify only root gallery is cleaned, with gallery.json preserved expect(existsSync(path.resolve(cleanMultiTestPath, 'index.html'))).toBe(false); - expect(existsSync(path.resolve(cleanMultiTestPath, 'gallery'))).toBe(false); + expect(existsSync(path.resolve(cleanMultiTestPath, 'gallery', 'gallery.json'))).toBe(true); // Verify subdirectory galleries remain expect(existsSync(path.resolve(cleanMultiTestPath, 'first', 'index.html'))).toBe(true); diff --git a/gallery/tests/scan.test.ts b/gallery/tests/scan.test.ts new file mode 100644 index 0000000..d61de62 --- /dev/null +++ b/gallery/tests/scan.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { scanDirectory } from '../src/modules/init'; + +import type { ConsolaInstance } from 'consola'; + +// Mock console UI for testing +const createMockUI = (): ConsolaInstance => + ({ + info: jest.fn(), + start: jest.fn(), + success: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }) as unknown as ConsolaInstance; + +describe('scanDirectory', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spg-scan-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('should return media files sorted by filename regardless of creation order', async () => { + for (const filename of ['cherry.mp4', 'banana.jpg', 'apple.jpg']) { + fs.writeFileSync(path.join(tempDir, filename), 'media'); + } + + const { mediaFiles } = await scanDirectory(tempDir, createMockUI()); + + expect(mediaFiles.map((file) => file.filename)).toEqual(['apple.jpg', 'banana.jpg', 'cherry.mp4']); + }); + + test('should sort numbered filenames numerically', async () => { + for (const filename of ['IMG_10.jpg', 'IMG_1.jpg', 'IMG_2.jpg']) { + fs.writeFileSync(path.join(tempDir, filename), 'media'); + } + + const { mediaFiles } = await scanDirectory(tempDir, createMockUI()); + + expect(mediaFiles.map((file) => file.filename)).toEqual(['IMG_1.jpg', 'IMG_2.jpg', 'IMG_10.jpg']); + }); + + test('should return subdirectories sorted by name', async () => { + for (const dirName of ['zebra', 'alpha', 'monkey']) { + fs.mkdirSync(path.join(tempDir, dirName)); + } + + const { subGalleryDirectories } = await scanDirectory(tempDir, createMockUI()); + + expect(subGalleryDirectories).toEqual([ + path.join(tempDir, 'alpha'), + path.join(tempDir, 'monkey'), + path.join(tempDir, 'zebra'), + ]); + }); + + test('should ignore files that are not images or videos', async () => { + fs.writeFileSync(path.join(tempDir, 'photo.jpg'), 'media'); + fs.writeFileSync(path.join(tempDir, 'notes.txt'), 'text'); + + const { mediaFiles } = await scanDirectory(tempDir, createMockUI()); + + expect(mediaFiles.map((file) => file.filename)).toEqual(['photo.jpg']); + }); +}); diff --git a/gallery/tests/url-paths.test.ts b/gallery/tests/url-paths.test.ts new file mode 100644 index 0000000..550d59c --- /dev/null +++ b/gallery/tests/url-paths.test.ts @@ -0,0 +1,69 @@ +import path from 'node:path'; + +import { getRelativePath, getSubgalleryThumbnailPath, joinUrl, toUrlPath } from '@simple-photo-gallery/common/theme'; + +describe('toUrlPath', () => { + test('should convert Windows backslashes to forward slashes', () => { + expect(toUrlPath(String.raw`sub\dir\photo.jpg`)).toBe('sub/dir/photo.jpg'); + }); + + test('should leave POSIX paths unchanged', () => { + expect(toUrlPath('sub/dir/photo.jpg')).toBe('sub/dir/photo.jpg'); + }); + + test('should handle empty strings', () => { + expect(toUrlPath('')).toBe(''); + }); +}); + +describe('joinUrl', () => { + test('should join a base URL and a segment with a single slash', () => { + expect(joinUrl('https://example.com/photos', 'japan')).toBe('https://example.com/photos/japan'); + }); + + test('should not produce double slashes when the base URL has a trailing slash', () => { + expect(joinUrl('https://example.com/photos/', 'japan')).toBe('https://example.com/photos/japan'); + }); + + test('should return the trimmed base URL when no segments are provided', () => { + expect(joinUrl('https://example.com/photos/')).toBe('https://example.com/photos'); + }); + + test('should skip empty segments', () => { + expect(joinUrl('https://example.com/photos', '')).toBe('https://example.com/photos'); + }); + + test('should normalize Windows backslashes in segments', () => { + expect(joinUrl('https://example.com/photos', String.raw`trips\japan`)).toBe('https://example.com/photos/trips/japan'); + }); +}); + +describe('getSubgalleryThumbnailPath', () => { + test('should build the thumbnail path from a resolved subgallery path', () => { + expect(getSubgalleryThumbnailPath('photo.jpg', 'japan')).toBe('japan/gallery/images/photo.avif'); + }); + + test('should use forward slashes when the resolved subgallery path contains backslashes', () => { + expect(getSubgalleryThumbnailPath('photo.jpg', String.raw`trips\japan`)).toBe('trips/japan/gallery/images/photo.avif'); + }); + + test('should fall back to the header image directory when no resolved path is provided', () => { + expect(getSubgalleryThumbnailPath('japan/photo.jpg')).toBe('japan/gallery/images/photo.avif'); + }); + + test('should replace the header image extension with .avif', () => { + expect(getSubgalleryThumbnailPath('photo.png', 'japan')).toBe('japan/gallery/images/photo.avif'); + }); +}); + +describe('getRelativePath', () => { + test('should resolve a subgallery path relative to the gallery root', () => { + const galleryJsonPath = path.join(path.sep, 'galleries', 'main', 'gallery', 'gallery.json'); + expect(getRelativePath(path.join('..', 'japan'), galleryJsonPath)).toBe('japan'); + }); + + test('should return forward slashes for nested paths', () => { + const galleryJsonPath = path.join(path.sep, 'galleries', 'main', 'gallery', 'gallery.json'); + expect(getRelativePath(path.join('..', 'trips', 'japan'), galleryJsonPath)).toBe('trips/japan'); + }); +}); diff --git a/themes/modern/src/features/themes/base-theme/layouts/MainLayout.astro b/themes/modern/src/features/themes/base-theme/layouts/MainLayout.astro index 4b044f2..790d289 100644 --- a/themes/modern/src/features/themes/base-theme/layouts/MainLayout.astro +++ b/themes/modern/src/features/themes/base-theme/layouts/MainLayout.astro @@ -21,13 +21,7 @@ const rowHeightMax = thumbnails?.size ?? 160; - +