Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/check-cli.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion common/src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
34 changes: 30 additions & 4 deletions common/src/theme/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion gallery/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion gallery/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>', '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
Expand Down
9 changes: 5 additions & 4 deletions gallery/src/modules/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
62 changes: 52 additions & 10 deletions gallery/src/modules/clean/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';

import { findGalleries } from '../../utils';

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<CommandResultSummary> {
async function cleanGallery(galleryDir: string, removeAll: boolean, ui?: ConsolaInstance): Promise<CommandResultSummary> {
let filesRemoved = 0;

// Remove index.html file from the gallery directory
Expand All @@ -27,15 +32,33 @@ async function cleanGallery(galleryDir: string, ui?: ConsolaInstance): Promise<C
}
}

// Remove gallery directory and all its contents
const galleryPath = path.join(galleryDir, 'gallery');
if (fs.existsSync(galleryPath)) {
try {
fs.rmSync(galleryPath, { recursive: true, force: true });
ui?.debug(`Removed directory: ${galleryPath}`);
filesRemoved++;
} catch (error) {
ui?.warn(`Failed to remove gallery directory: ${error}`);
if (removeAll) {
// Remove the gallery directory and all its contents, including gallery.json
try {
fs.rmSync(galleryPath, { recursive: true, force: true });
ui?.debug(`Removed directory: ${galleryPath}`);
filesRemoved++;
} catch (error) {
ui?.warn(`Failed to remove gallery directory: ${error}`);
}
} else {
// Remove only generated files, preserving gallery.json with the user's titles, descriptions and sections
for (const entry of fs.readdirSync(galleryPath)) {
if (PRESERVED_FILES.has(entry)) {
continue;
}

const entryPath = path.join(galleryPath, entry);
try {
fs.rmSync(entryPath, { recursive: true, force: true });
ui?.debug(`Removed: ${entryPath}`);
filesRemoved++;
} catch (error) {
ui?.warn(`Failed to remove ${entryPath}: ${error}`);
}
}
}
}

Expand All @@ -50,7 +73,8 @@ async function cleanGallery(galleryDir: string, ui?: ConsolaInstance): Promise<C

/**
* Clean command implementation
* Removes all gallery-related files and directories
* Removes generated gallery files (index.html, thumbnails, built assets).
* gallery.json is preserved unless the --all option is passed, since it contains user-curated content.
*/
export async function clean(options: CleanOptions, ui: ConsolaInstance): Promise<CommandResultSummary> {
try {
Expand All @@ -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) {
Comment thread
haltakov marked this conversation as resolved.
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'}`);
Expand Down
4 changes: 4 additions & 0 deletions gallery/src/modules/clean/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
12 changes: 9 additions & 3 deletions gallery/src/modules/init/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -316,7 +322,7 @@ async function processDirectory(
result.subGallery = {
title: capitalizeTitle(dirName),
headerImage: mediaFiles[0]?.filename || '',
path: path.join('..', dirName),
path: `../${dirName}`,
};
}

Expand Down
Loading
Loading