Introduction
Simple Gal is a static site generator built for photographers who want a portfolio that lasts.
You point it at a directory of images, run one command, and get a complete gallery site -- HTML, CSS, responsive images, and nothing else. No database. No CMS. No JavaScript framework. The output can be dropped onto any file server and it will work, today and twenty years from now.
Who it's for
Simple Gal is for photographers -- professional or otherwise -- who have accumulated galleries over time and want a clean, permanent home for them online. If you care about how your images are presented (compression, sharpening, aspect ratios, ordering) and you don't want to fight a platform to get it right, this is for you.
It is not for photographers who need comments, search, e-commerce, or client proofing. It generates a portfolio, not a platform.
The problem
Most gallery solutions share the same failure modes:
- Features you didn't ask for. Login screens, likes, comments, social sharing buttons -- bolted on for the platform's benefit, not yours.
- Breaking changes. Updates that rearrange your layout, change your URLs, or require migration work.
- Subscription fees. Monthly charges for hosting what amounts to static files.
- Server infrastructure. Maintenance, updates, security patches, database backups -- all for showing pictures.
- Bespoke data models. Your images and metadata locked into a format that forces you to start over when you switch tools.
- Poor photographic control. Aggressive compression, generic center-crops, no control over sharpening or aspect ratios.
These aren't edge cases. They're the norm. If you've been making photographs for a decade, you've probably been through several of these cycles already.
What Simple Gal gives you
Your filesystem is the data source. Directories become albums. Images become photos. Filenames and IPTC tags provide titles and descriptions. There is no tool-specific data format to migrate from -- your files are the truth.
Photographic control. You set the image quality, sharpening, responsive breakpoints, and thumbnail aspect ratios. Per gallery, if you want. No more fighting a platform's one-size-fits-all image pipeline.
Fast, clean output. Each page weighs about 9 KB before images. Navigation is page-based with smooth transitions. Mobile-first layout with swipe support. Light and dark modes. No spinners, no layout shift, no pop-ups.
Nothing to maintain. The generated site is plain HTML and CSS with about 30 lines of vanilla JavaScript (for keyboard shortcuts and swipe gestures -- click navigation is pure HTML/CSS). No runtime dependencies. No build tools. No framework updates to chase.
Installable. Every generated site is a Progressive Web App out of the box. Visitors can add it to their home screen for offline, app-like viewing.
How it works
Simple Gal runs a three-stage pipeline:
- Scan -- reads your content directory and builds a manifest of albums, images, pages, and configuration.
- Process -- generates responsive image sizes and thumbnails in AVIF format.
- Generate -- produces the final HTML site with inline CSS.
You run it with a single command:
simple-gal build
The output lands in dist/ and is ready to deploy. See the Quick Start to get a working gallery in five minutes, or read about The Forever Stack to understand why it's built this way.
Quick Start
This guide takes you from zero to a working photography gallery in about five minutes.
Install
Grab a pre-built binary from the releases page for your platform, or install via Cargo:
cargo install simple-gal
Verify the installation:
simple-gal --version
Create your content
Simple Gal reads from a content/ directory. Create one with an album inside it:
mkdir -p content/010-My-Album
Copy some JPEG or PNG files into that album directory:
cp ~/Photos/favorites/*.jpg content/010-My-Album/
That's it. The directory name becomes the album title (My-Album), and the numeric prefix (010) controls where it appears in navigation.
Build
Run the build command from the directory that contains content/:
simple-gal build
You'll see output like this:
Scanning content...
Found 1 album, 12 images
Processing images...
Generating site...
Done. Output: dist/
The dist/ directory now contains a complete, self-contained gallery site.
View
Open the generated site in your browser:
open dist/index.html
You should see a home page with a thumbnail grid for your album. Click into the album to browse photos with keyboard arrows, swipe gestures, or edge clicks.
Add a site title
By default the site has no title. Create a configuration file to set one:
echo 'site_title = "My Portfolio"' > content/config.toml
Rebuild:
simple-gal build
The title now appears in the header and in the browser tab.
Add more albums
Create additional directories with numeric prefixes to control their order:
mkdir content/020-Portraits
mkdir content/030-Travel
Drop images into each one and rebuild. The home page will show one thumbnail per album, ordered by prefix.
Add an album description
Create a description.md (or description.txt) file inside any album directory:
echo "Landscapes from the Pacific Northwest, 2019--2024." > content/010-My-Album/description.txt
The text appears above the thumbnail grid on that album's page.
Add a page
Markdown files in the content root with a numeric prefix become pages in the site navigation:
cat > content/040-About.md << 'EOF'
# About
Photographer based in Portland, Oregon.
Contact: photos@example.com
EOF
Rebuild, and "About" appears in the navigation bar.
Explore the full config
Run gen-config to see every available option with its default value:
simple-gal gen-config
Redirect it to a file to use as a starting point:
simple-gal gen-config > content/config.toml
From here, you can customize colors and theme, fonts, thumbnail aspect ratios, image quality, and more. Each album can override any setting with its own config.toml -- only the keys you change need to be listed.
Next steps
- Directory Structure -- how the filesystem maps to your site
- Configuration Overview -- how config cascading works
- Deployment -- putting your gallery online
- The Forever Stack -- why it's built this way
Directory Structure
Ordering and Naming
Metadata
Pages and Links
Albums and Groups
Configuration Overview
Simple Gal uses TOML configuration files that cascade through the directory tree. Each level inherits settings from its parent and can override specific keys without repeating the rest.
The config chain
Configuration is resolved in four layers, from least to most specific:
- Stock defaults -- built into the binary, always present
- Root
config.toml-- in your content directory root - Group
config.toml-- in a group (nested album) directory - Gallery
config.toml-- in an individual album directory
Each layer merges on top of the previous one. Only the keys you specify are overridden; everything else passes through unchanged.
Stock defaults
└─ content/config.toml (root overrides)
└─ content/020-Travel/config.toml (group overrides)
└─ content/020-Travel/010-Japan/config.toml (gallery overrides)
Partial configs
Config files are sparse. You never need to specify every key -- just the ones you want to change. For example, a gallery that only needs different AVIF quality:
# content/020-Travel/010-Japan/config.toml
[images]
quality = 75
This gallery inherits all other settings (colors, fonts, thumbnail ratios, theme spacing) from its parent group, which in turn inherits from the root, which inherits from stock defaults.
Merge example
Consider this directory tree:
content/
├── config.toml
├── 010-Portraits/
│ ├── photo1.jpg
│ └── photo2.jpg
└── 020-Travel/
├── config.toml
└── 010-Japan/
├── config.toml
└── photo1.jpg
Root config (content/config.toml):
site_title = "My Portfolio"
[font]
font = "Playfair Display"
font_type = "serif"
weight = "400"
[thumbnails]
aspect_ratio = [3, 4]
Group config (content/020-Travel/config.toml):
[thumbnails]
aspect_ratio = [1, 1]
Gallery config (content/020-Travel/010-Japan/config.toml):
[images]
quality = 75
Here is what each level sees:
| Setting | 010-Portraits | 020-Travel | 020-Travel/010-Japan |
|---|---|---|---|
site_title | "My Portfolio" | "My Portfolio" | "My Portfolio" |
font.font | "Playfair Display" | "Playfair Display" | "Playfair Display" |
thumbnails.aspect_ratio | [3, 4] | [1, 1] | [1, 1] |
images.quality | 90 (stock default) | 90 (stock default) | 75 |
The 010-Portraits album has no config.toml, so it uses the root config as-is. The 020-Travel group overrides only the thumbnail aspect ratio; everything else flows through from root. The 010-Japan gallery inherits the square thumbnails from its parent group and overrides only the image quality.
Merge rules
- Scalar values (strings, numbers): the child value replaces the parent value.
- Arrays (like
images.sizes): the child array replaces the parent array entirely. There is no element-level merging. - Subsections (like
[colors.light]): merged key by key. Settingbackgroundin a child does not resettextor other sibling keys. - Absent keys: inherited from the parent level without change.
Unknown key rejection
Simple Gal rejects any key it does not recognize. This catches typos before they silently produce wrong output:
[images]
qualty = 90 # Error: unknown field "qualty"
The error message names the unknown field and lists the valid alternatives, so the fix is usually obvious.
Generating a starter config
Run simple-gal gen-config to print a fully-commented config.toml with every key and its stock default value:
simple-gal gen-config > content/config.toml
Edit the generated file to keep only the keys you want to customize, or leave them all in place as documentation. Either way works -- stock-default values are harmless to repeat.
Configuration Reference
Every configuration key, its type, default value, and purpose. All keys are optional. Run simple-gal gen-config to generate a complete annotated config file.
Top-level keys
| Key | Type | Default | Description |
|---|---|---|---|
content_root | string | "content" | Path to the content directory. Only meaningful in the root config. |
site_title | string | "Gallery" | Site title used in breadcrumbs and the browser tab for the index page. |
assets_dir | string | "assets" | Directory for static assets (favicon, fonts, etc.), relative to content root. Contents are copied verbatim to the output root. Silently skipped if it does not exist. |
site_description_file | string | "site" | Stem of the site description file in the content root. If site.md or site.txt exists, its content is rendered on the index page. |
content_root = "content"
site_title = "My Portfolio"
assets_dir = "assets"
site_description_file = "site"
[thumbnails]
Controls how thumbnails are cropped and sized.
| Key | Type | Default | Description |
|---|---|---|---|
aspect_ratio | [u32, u32] | [4, 5] | Width-to-height ratio for thumbnail crops. [1, 1] for square, [3, 2] for landscape. |
size | u32 | 400 | Short-edge size in pixels for generated thumbnails. |
[thumbnails]
aspect_ratio = [4, 5]
size = 400
Common aspect ratio choices:
| Ratio | Effect |
|---|---|
[1, 1] | Square thumbnails |
[4, 5] | Slightly tall portrait (default) |
[3, 2] | Classic landscape |
[2, 3] | Tall portrait |
[images]
Controls responsive image generation.
| Key | Type | Default | Description |
|---|---|---|---|
sizes | [u32, ...] | [800, 1400, 2080] | Pixel widths (longer edge) to generate for responsive <picture> elements. |
quality | u32 | 90 | AVIF encoding quality. 0 = smallest file / worst quality, 100 = largest file / best quality. |
[images]
sizes = [800, 1400, 2080]
quality = 90
Validation rules:
qualitymust be 0--100.sizesmust contain at least one value.
[theme]
Layout spacing values. All values are CSS length strings.
| Key | Type | Default | Description |
|---|---|---|---|
thumbnail_gap | string | "1rem" | Gap between thumbnails in album and image grids. |
grid_padding | string | "2rem" | Padding around the thumbnail grid container. |
[theme]
thumbnail_gap = "1rem"
grid_padding = "2rem"
[theme.mat_x]
Horizontal mat (spacing) around images on photo pages. Rendered as CSS clamp(min, size, max).
| Key | Type | Default | Description |
|---|---|---|---|
size | string | "3vw" | Preferred/fluid value, typically viewport-relative. |
min | string | "1rem" | Minimum bound. |
max | string | "2.5rem" | Maximum bound. |
[theme.mat_x]
size = "3vw"
min = "1rem"
max = "2.5rem"
[theme.mat_y]
Vertical mat (spacing) around images on photo pages. Same structure as mat_x.
| Key | Type | Default | Description |
|---|---|---|---|
size | string | "6vw" | Preferred/fluid value. |
min | string | "2rem" | Minimum bound. |
max | string | "5rem" | Maximum bound. |
[theme.mat_y]
size = "6vw"
min = "2rem"
max = "5rem"
[colors.light]
Light mode color scheme. Applied by default and when the user's system is set to light mode.
| Key | Type | Default | Description |
|---|---|---|---|
background | string | "#ffffff" | Page background color. |
text | string | "#111111" | Primary text color. |
text_muted | string | "#666666" | Secondary text: nav menu, breadcrumbs, captions. |
border | string | "#e0e0e0" | Border color. |
separator | string | "#e0e0e0" | Separator color: header underline, nav menu divider. |
link | string | "#333333" | Link color. |
link_hover | string | "#000000" | Link hover color. |
[colors.light]
background = "#ffffff"
text = "#111111"
text_muted = "#666666"
border = "#e0e0e0"
separator = "#e0e0e0"
link = "#333333"
link_hover = "#000000"
[colors.dark]
Dark mode color scheme. Applied when the user's system prefers dark mode (prefers-color-scheme: dark).
| Key | Type | Default | Description |
|---|---|---|---|
background | string | "#000000" | Page background color. |
text | string | "#fafafa" | Primary text color. |
text_muted | string | "#999999" | Secondary text. |
border | string | "#333333" | Border color. |
separator | string | "#333333" | Separator color. |
link | string | "#cccccc" | Link color. |
link_hover | string | "#ffffff" | Link hover color. |
[colors.dark]
background = "#000000"
text = "#fafafa"
text_muted = "#999999"
border = "#333333"
separator = "#333333"
link = "#cccccc"
link_hover = "#ffffff"
[font]
Typography settings. By default, fonts are loaded from Google Fonts. Set source to use a local font file instead.
| Key | Type | Default | Description |
|---|---|---|---|
font | string | "Space Grotesk" | Font family name. Used as the Google Fonts family name, or as the font-family name for local fonts. |
weight | string | "600" | Font weight to load. |
font_type | string | "sans" | "sans" or "serif". Determines the CSS fallback font stack. |
source | string | (none) | Path to a local font file, relative to the site root. When set, generates @font-face CSS instead of loading from Google Fonts. Supported formats: .woff2, .woff, .ttf, .otf. |
# Google Fonts (default behavior)
[font]
font = "Space Grotesk"
weight = "600"
font_type = "sans"
# Local font file
[font]
font = "My Custom Font"
weight = "400"
font_type = "sans"
source = "fonts/MyFont.woff2"
[processing]
Parallel image processing settings.
| Key | Type | Default | Description |
|---|---|---|---|
max_processes | u32 | (auto: CPU core count) | Maximum number of parallel image processing workers. When omitted, uses all available CPU cores. Values larger than the core count are clamped down. |
[processing]
max_processes = 4
CSS custom properties
Config values are compiled into CSS custom properties, injected as inline <style> blocks in every page. The stylesheet references these variables rather than hardcoded values.
Color variables
| CSS variable | Config key |
|---|---|
--color-bg | colors.{light,dark}.background |
--color-text | colors.{light,dark}.text |
--color-text-muted | colors.{light,dark}.text_muted |
--color-border | colors.{light,dark}.border |
--color-separator | colors.{light,dark}.separator |
--color-link | colors.{light,dark}.link |
--color-link-hover | colors.{light,dark}.link_hover |
Light mode values are set on :root. Dark mode values are set inside @media (prefers-color-scheme: dark).
Theme variables
| CSS variable | Config key | Generated as |
|---|---|---|
--mat-x | theme.mat_x.* | clamp(min, size, max) |
--mat-y | theme.mat_y.* | clamp(min, size, max) |
--thumbnail-gap | theme.thumbnail_gap | Direct value |
--grid-padding | theme.grid_padding | Direct value |
Font variables
| CSS variable | Config key |
|---|---|
--font-family | font.font + font.font_type (includes fallback stack) |
--font-weight | font.weight |
Colors and Theme
Simple Gal generates CSS custom properties from your color and theme configuration. Colors adapt automatically to the visitor's system preference (light or dark mode). Theme settings control the spatial layout around images and thumbnails.
Color schemes
Two independent color schemes are defined under [colors.light] and [colors.dark]. The light scheme is the default; the dark scheme activates via the prefers-color-scheme: dark media query.
Each scheme has seven color slots:
| Slot | Used for |
|---|---|
background | Page background |
text | Primary body text |
text_muted | Secondary text: navigation, breadcrumbs, captions |
border | Element borders |
separator | Header underline, nav menu dividers |
link | Link text |
link_hover | Link text on hover |
Overriding individual colors
You do not need to redefine all seven colors. Override only the ones you want to change:
[colors.light]
background = "#f5f0eb"
text = "#2a2a2a"
The remaining five light-mode colors keep their stock defaults. Dark mode is entirely unaffected.
Generated CSS
The color config produces two CSS blocks:
:root {
--color-bg: #ffffff;
--color-text: #111111;
--color-text-muted: #666666;
--color-border: #e0e0e0;
--color-link: #333333;
--color-link-hover: #000000;
--color-separator: #e0e0e0;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #000000;
--color-text: #fafafa;
--color-text-muted: #999999;
--color-border: #333333;
--color-link: #cccccc;
--color-link-hover: #ffffff;
--color-separator: #333333;
}
}
These variables are referenced throughout the stylesheet. You can also use them in a custom.css file to style additional elements consistently.
Mat spacing
In traditional photography, a mat (or mount) is the border between a print and its frame. Simple Gal uses this concept for the breathing room around full-size images on photo pages.
Two mat dimensions are configurable:
mat_x-- horizontal spacing (left and right of the image)mat_y-- vertical spacing (above and below the image)
Each is defined as three values that map to CSS clamp():
[theme.mat_x]
size = "3vw" # preferred size, scales with viewport
min = "1rem" # never smaller than this
max = "2.5rem" # never larger than this
How CSS clamp() works
clamp(min, preferred, max) picks the preferred value but constrains it within bounds:
- On a narrow phone screen,
3vwmight compute to10px, which is less than1rem(~16px). The mat stays at1rem. - On a standard laptop,
3vwmight be28px, comfortably between1remand2.5rem. The mat uses28px. - On a 4K monitor,
3vwcould be60px, exceeding2.5rem(~40px). The mat caps at2.5rem.
This produces spacing that feels proportional on every screen without becoming too tight on phones or too wide on large displays.
The generated CSS:
:root {
--mat-x: clamp(1rem, 3vw, 2.5rem);
--mat-y: clamp(2rem, 6vw, 5rem);
--thumbnail-gap: 1rem;
--grid-padding: 2rem;
}
Adjusting mat size
To make the image presentation tighter (less surrounding space):
[theme.mat_x]
size = "1.5vw"
min = "0.5rem"
max = "1.5rem"
[theme.mat_y]
size = "3vw"
min = "1rem"
max = "2.5rem"
To make it more expansive (gallery-wall feel):
[theme.mat_x]
size = "5vw"
min = "2rem"
max = "4rem"
[theme.mat_y]
size = "8vw"
min = "3rem"
max = "6rem"
You can override individual fields within a mat section. For example, adjusting only the minimum without touching the preferred or maximum values:
[theme.mat_x]
min = "0.5rem"
Grid spacing
Two values control the thumbnail grid layout:
| Key | Default | Effect |
|---|---|---|
thumbnail_gap | "1rem" | Space between individual thumbnails in the grid |
grid_padding | "2rem" | Padding around the entire grid container |
[theme]
thumbnail_gap = "0.5rem"
grid_padding = "1rem"
These accept any valid CSS length value: rem, em, px, vw, etc. Use rem for values that scale with the user's font size preference, or px for fixed spacing.
Tight grid example
For a dense, mosaic-style layout with minimal spacing:
[theme]
thumbnail_gap = "2px"
grid_padding = "0"
Spacious grid example
For a gallery feel with generous breathing room:
[theme]
thumbnail_gap = "1.5rem"
grid_padding = "3rem"
Per-album theming
Because config files cascade through the directory tree, you can give different albums different visual treatments. A travel photography group might use tighter spacing, while a studio portrait gallery uses wider mats:
# content/020-Travel/config.toml
[theme]
thumbnail_gap = "2px"
grid_padding = "0.5rem"
[theme.mat_x]
size = "1vw"
min = "0.25rem"
max = "1rem"
# content/010-Portraits/config.toml
[theme.mat_x]
size = "5vw"
min = "2rem"
max = "4rem"
Colors, fonts, and other settings not specified in these files are inherited from the root config or stock defaults.
Fonts
Simple Gal supports two ways to load fonts: from Google Fonts (the default) or from a local font file. Both methods produce the same CSS custom properties and fallback stack.
Google Fonts (default)
By default, Simple Gal loads the font from Google Fonts via a <link> stylesheet tag. Configure the family name and weight:
[font]
font = "Space Grotesk"
weight = "600"
font_type = "sans"
This generates a <link> tag pointing to:
https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@600&display=swap
The display=swap parameter ensures text remains visible while the font loads.
Choosing a Google Font
Any family available on fonts.google.com works. Set font to the exact family name as it appears on Google Fonts. Examples:
# Clean geometric sans-serif
[font]
font = "Space Grotesk"
weight = "600"
font_type = "sans"
# Elegant serif
[font]
font = "Playfair Display"
weight = "400"
font_type = "serif"
# Minimal sans-serif
[font]
font = "Inter"
weight = "500"
font_type = "sans"
# Monospace
[font]
font = "JetBrains Mono"
weight = "400"
font_type = "sans"
Local fonts
To use a self-hosted font file instead of Google Fonts, add the source key pointing to the font file path relative to the site root:
[font]
font = "My Custom Font"
weight = "400"
font_type = "sans"
source = "fonts/MyFont.woff2"
When source is set:
- No Google Fonts
<link>tag is generated. - A
@font-faceCSS declaration is generated inline. - The font file must be placed in your assets directory so it gets copied to the output.
Setting up a local font
-
Create a
fonts/directory inside your assets directory:content/ └── assets/ └── fonts/ └── MyFont.woff2 -
Configure the font in your
config.toml:[font] font = "My Custom Font" weight = "400" font_type = "serif" source = "fonts/MyFont.woff2" -
The generated CSS will include:
@font-face { font-family: "My Custom Font"; src: url("/fonts/MyFont.woff2") format("woff2"); font-weight: 400; font-display: swap; } :root { --font-family: "My Custom Font", Georgia, "Times New Roman", serif; --font-weight: 400; }
Supported font formats
| Extension | CSS format | Notes |
|---|---|---|
.woff2 | woff2 | Recommended. Best compression, wide browser support. |
.woff | woff | Good compression, universal support. |
.ttf | truetype | Larger files, universal support. |
.otf | opentype | Similar to TTF, supports advanced typographic features. |
Use .woff2 when possible for the smallest file size.
Font type and fallback stacks
The font_type setting determines which system fonts are used as fallbacks while the custom font loads or if it fails to load:
font_type | Fallback stack |
|---|---|
"sans" | Helvetica, Verdana, sans-serif |
"serif" | Georgia, "Times New Roman", serif |
The full --font-family CSS variable includes both the configured font name and the fallback stack:
/* font_type = "sans" */
--font-family: "Space Grotesk", Helvetica, Verdana, sans-serif;
/* font_type = "serif" */
--font-family: "Playfair Display", Georgia, "Times New Roman", serif;
Choose the font_type that best matches the character of your chosen font. This ensures the fallback text has a similar feel if the primary font is unavailable.
Font weight
The weight key is a string (not a number) that specifies which weight to load. Common values:
| Weight | Name |
|---|---|
"100" | Thin |
"200" | Extra Light |
"300" | Light |
"400" | Regular |
"500" | Medium |
"600" | Semi Bold |
"700" | Bold |
"800" | Extra Bold |
"900" | Black |
The weight applies site-wide. Simple Gal loads a single weight to keep page loads fast.
Per-album font overrides
Because configuration cascades, you can set a different font for specific albums or groups:
# content/010-Portraits/config.toml
[font]
font = "Cormorant Garamond"
weight = "300"
font_type = "serif"
This album uses a different font while inheriting all other settings from the parent config. Albums without a [font] section inherit the font from their parent.
Custom CSS and HTML Snippets
Simple Gal supports three convention files that let you inject custom CSS, analytics, tracking scripts, and other HTML into every page. Drop them into your assets/ directory and they are picked up automatically -- no configuration required.
Convention Files
| File | Injection point | Typical use |
|---|---|---|
custom.css | <link> after the main stylesheet | CSS overrides, layout tweaks, custom fonts |
head.html | End of <head>, after all other tags | Analytics snippets, Open Graph meta tags, additional <link> or <meta> elements |
body-end.html | Immediately before </body> | Tracking scripts, chat widgets, cookie banners |
All three are optional. Use any combination you need.
How It Works
During the build, Simple Gal copies everything in assets/ to the output root. It then checks whether custom.css, head.html, or body-end.html exist in the output directory and injects references or content into every generated HTML page.
custom.cssis loaded via a<link rel="stylesheet">tag that appears after the main inline<style>block. This means your rules override the defaults at equal specificity -- no!importantneeded.head.htmlis inserted as raw HTML at the very end of<head>, after the service worker script. Use it for anything that belongs in the document head.body-end.htmlis inserted as raw HTML right before the closing</body>tag, after all page content.
Examples
Plausible Analytics
Create assets/head.html:
<script defer data-domain="yourdomain.com"
src="https://plausible.io/js/script.js"></script>
Google Analytics
Create assets/head.html:
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
</script>
Open Graph Meta Tags
Add these to assets/head.html (you can combine multiple snippets in one file):
<meta property="og:title" content="Jane Doe Photography">
<meta property="og:description" content="Fine art landscape portfolio">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:url" content="https://example.com">
CSS Overrides
Create assets/custom.css:
/* Increase thumbnail size on the index page */
.album-grid {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
/* Wider mat spacing on photo pages */
:root {
--mat-x: 4rem;
--mat-y: 3rem;
}
Because custom.css loads after the main styles, these rules take effect without needing !important.
Cookie Consent Banner
Create assets/body-end.html:
<script defer src="https://cdn.example.com/cookie-consent.js"></script>
<div id="cookie-banner" style="display:none;">
<!-- your cookie banner markup -->
</div>
Chat Widget
Create assets/body-end.html:
<script>
(function() {
var s = document.createElement('script');
s.src = 'https://chat.example.com/widget.js';
s.async = true;
document.body.appendChild(s);
})();
</script>
Directory Layout
A typical setup with all three convention files:
my-portfolio/
config.toml
assets/
custom.css # CSS overrides
head.html # Analytics, meta tags
body-end.html # Tracking scripts
fonts/ # Local font files (see Assets chapter)
favicon.svg # Custom favicon (see Assets chapter)
01-landscapes/
photo-01.jpg
...
Tips
- The files are injected as-is with no processing or escaping. Make sure your HTML is valid.
custom.cssis served as a separate file (not inlined), so the browser can cache it independently.- You can reference other files in your
assets/directory from these snippets using absolute paths (e.g.,/fonts/my-font.woff2), since the entireassets/directory is copied to the output root. - If you only need to change colors, fonts, or spacing, use
config.tomlinstead. Convention files are for customizations that go beyond what the configuration system provides.
Assets Directory
The assets/ directory holds static files -- favicons, fonts, images, and anything else you want served alongside your portfolio. Its entire contents are copied verbatim to the output root during the build.
Basic Usage
Place an assets/ directory next to your config.toml:
my-portfolio/
config.toml
assets/
favicon.svg
fonts/
my-font.woff2
custom.css
01-landscapes/
...
After building, these files appear at the output root:
dist/
favicon.svg # → accessible at /favicon.svg
fonts/
my-font.woff2 # → accessible at /fonts/my-font.woff2
custom.css # → accessible at /custom.css
...
Directory structure within assets/ is preserved.
Changing the Assets Directory
By default, Simple Gal looks for a directory called assets. You can change this in config.toml:
assets_dir = "site-assets"
The path is relative to the content root.
Favicons
Simple Gal ships with a default favicon.png. To use your own, place a favicon file in assets/. The build detects favicon files in this priority order:
| Priority | File | MIME type |
|---|---|---|
| 1 | favicon.svg | image/svg+xml |
| 2 | favicon.ico | image/x-icon |
| 3 | favicon.png | image/png |
The first match is injected as a <link rel="icon"> tag in every page. Since assets are copied to the output root after the default favicon.png is written, placing your own favicon.png in assets/ replaces the default.
For best results, use an SVG favicon. It scales to any size and supports dark mode via CSS prefers-color-scheme media queries inside the SVG.
PWA Icons
Simple Gal generates a Progressive Web App manifest with default icons. To use your own, place these files in assets/:
| File | Size | Purpose |
|---|---|---|
icon-192.png | 192x192 px | Android home screen icon |
icon-512.png | 512x512 px | Android splash screen |
apple-touch-icon.png | 180x180 px | iOS home screen icon |
As with favicons, your files in assets/ overwrite the defaults because assets are copied after the built-in icons are written.
Custom Fonts
To use a locally hosted font instead of a Google Font:
- Place font files in
assets/fonts/:
assets/
fonts/
garamond.woff2
garamond-italic.woff2
- Declare the font face in
assets/custom.css:
@font-face {
font-family: 'EB Garamond';
src: url('/fonts/garamond.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'EB Garamond';
src: url('/fonts/garamond-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
- Set the font family in
config.toml:
[font]
family = "EB Garamond"
source = "local"
Using source = "local" tells Simple Gal not to generate a Google Fonts <link> tag. The @font-face rules in custom.css handle loading instead.
Other Static Files
Any file you place in assets/ is served at the output root. Common uses:
robots.txt-- search engine directives_headersor_redirects-- Netlify/Cloudflare Pages configurationCNAME-- GitHub Pages custom domainog-image.jpg-- a shared Open Graph image referenced fromhead.html
How Copying Works
The build pipeline writes default files (favicon, PWA icons, service worker) to the output directory first, then copies the contents of assets/ on top. This means:
- Any file in
assets/with the same name as a default file replaces it. - Files in subdirectories of
assets/are placed in matching subdirectories in the output. - The only exception:
manifest.jsonfiles are skipped during the copy to avoid conflicts with the generatedsite.webmanifest.
Advanced Customization
This chapter covers the CSS custom properties and class names available for styling overrides. All overrides go in assets/custom.css (see Custom CSS and HTML Snippets).
CSS Custom Properties
Simple Gal generates CSS custom properties from your config.toml values and injects them into a :root block before the main stylesheet. You can override any of them in custom.css.
Color Properties
| Property | Default | Controls |
|---|---|---|
--color-bg | #ffffff | Page background |
--color-text | #1a1a1a | Primary text |
--color-text-muted | #6b6b6b | Secondary text (captions, descriptions, nav groups) |
--color-border | #e0e0e0 | Borders, image placeholder background |
--color-link | #1a1a1a | Link text |
--color-link-hover | #4a4a4a | Link hover state |
--color-separator | #e8e8e8 | Header border, nav dividers |
These are best set via config.toml under [colors], but you can override them in custom.css when you need conditional logic (e.g., dark mode via prefers-color-scheme).
Theme Properties
| Property | Default | Controls |
|---|---|---|
--mat-x | 2rem | Horizontal mat (padding) around photos on image pages |
--mat-y | 2rem | Vertical mat (padding) around photos on image pages |
--thumbnail-gap | 0.5rem | Gap between thumbnails in album and index grids |
--grid-padding | 0 | Outer padding of thumbnail grids |
Font Properties
| Property | Default | Controls |
|---|---|---|
--font-family | system-ui, sans-serif | Font stack for all text |
--font-weight | 400 | Base font weight |
Internal Properties
These are defined in the stylesheet and not generated from config, but you can still override them:
| Property | Default | Controls |
|---|---|---|
--header-height | 3rem | Height of the fixed header bar |
--font-size-base | 18px | Base font size |
--font-size-small | 14px | Small text (captions, metadata) |
--font-size-heading | 1.5rem | Album and page headings |
--transition-speed | 0.2s | Duration of hover transitions |
Key CSS Classes
These are the main classes in the generated HTML. Target them in custom.css for structural overrides.
Layout
| Class | Element | Description |
|---|---|---|
.site-header | <header> | Fixed top bar with breadcrumb and navigation |
.breadcrumb | <nav> | Breadcrumb trail inside the header |
.site-nav | <nav> | Navigation container (hamburger menu) |
.nav-panel | <div> | Slide-in navigation panel |
Index Page
| Class | Element | Description |
|---|---|---|
.index-page | <main> | Index page main container |
.index-header | <div> | Site title and description block |
.album-grid | <div> | Grid of album cards |
.album-card | <a> | Individual album link with thumbnail and title |
.album-title | <span> | Album title text below thumbnail |
Album Page
| Class | Element | Description |
|---|---|---|
.album-page | <main> | Album page main container |
.album-header | <div> | Album title and description block |
.album-description | <div> | Album description text |
.thumbnail-grid | <div> | Grid of image thumbnails |
.thumb-link | <a> | Individual thumbnail link |
Image Page
| Class | Element | Description |
|---|---|---|
body.image-view | <body> | Body class on image pages (sets overflow: hidden) |
.image-page | <div> | Image page main container |
.image-frame | <div> | Container for the photo itself |
.image-caption | <div> | Short caption below the image |
.image-description | <div> | Long description (scrollable) |
.image-nav | <div> | Navigation dots between images |
.nav-prev, .nav-next | <a> | Invisible click zones for prev/next navigation |
Content Pages
| Class | Element | Description |
|---|---|---|
.page | <main> | Content page main container |
.page-content | <div> | Rendered markdown content |
Examples
Dark Mode Override
Override colors when the user prefers a dark color scheme:
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1a1a1a;
--color-text: #e0e0e0;
--color-text-muted: #999999;
--color-border: #333333;
--color-link: #e0e0e0;
--color-link-hover: #ffffff;
--color-separator: #333333;
}
}
Larger Thumbnails
Make album cards bigger on the index page:
.album-grid {
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
}
Tighter Thumbnail Grid
Remove gaps for a mosaic look:
:root {
--thumbnail-gap: 0;
--grid-padding: 0;
}
More Mat Space
Give photos more breathing room on image pages:
:root {
--mat-x: 6rem;
--mat-y: 4rem;
}
Custom Hover Effect
Replace the default subtle zoom with an opacity fade:
.album-card:hover img {
transform: none;
opacity: 0.8;
}
.thumb-link:hover img {
transform: none;
opacity: 0.85;
}
Hide the Header
Remove the fixed header entirely:
.site-header {
display: none;
}
main {
margin-top: 0;
}
Adjust Header Height
Make the header taller and change its font size:
:root {
--header-height: 4rem;
}
.site-header {
font-size: 16px;
}
Style Captions
Customize caption appearance on image pages:
.image-caption {
font-style: italic;
text-align: center;
font-size: 13px;
}
Fixed-Column Grid
Use a fixed number of columns instead of auto-fill:
.thumbnail-grid {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 768px) {
.thumbnail-grid {
grid-template-columns: repeat(2, 1fr);
}
}
Square Thumbnails
Change the default 4:5 aspect ratio to 1:1:
.album-card img,
.thumb-link img {
aspect-ratio: 1 / 1;
}
Tips
- Override custom properties on
:rootto change values globally. Override them on specific selectors for scoped changes. custom.cssloads after the main stylesheet, so your rules win at equal specificity. You should rarely need!important.- Use your browser's developer tools to inspect the generated HTML and find the exact class names and structure for the element you want to customize.
- View transitions use a 0.2-second fade by default. Override the
::view-transition-old(root)and::view-transition-new(root)pseudo-elements to change the transition animation.
Responsive Sizes
Visitors view your portfolio on screens ranging from phones to 5K displays. Serving a single image size wastes bandwidth on small screens and looks soft on large ones. Simple Gal solves this by generating multiple sizes of each image and letting the browser pick the best one.
How it works
For each source image, Simple Gal produces an AVIF file at every configured breakpoint width. The generated HTML uses a standard <img> tag with a srcset attribute:
<img src="001-dawn-1400.avif"
srcset="001-dawn-800.avif 800w,
001-dawn-1400.avif 1400w,
001-dawn-2080.avif 2080w"
sizes="(max-width: 800px) 100vw, 80vw"
alt="Dawn">
The browser reads the srcset list, considers the viewport width and device pixel ratio, and downloads only the size it needs. A phone on a cellular connection gets the 800px version; a retina desktop gets the 2080px version. You do nothing at runtime -- the browser handles selection automatically.
Configuring sizes
Set the breakpoints in your config.toml:
[images]
sizes = [800, 1400, 2080]
Each value is a pixel width for the longer edge of the image. Simple Gal preserves the original aspect ratio and calculates the shorter edge proportionally.
For example, a 4000x3000 landscape source at size 800 produces an 800x600 AVIF. A 3000x4000 portrait source at size 800 produces an 600x800 AVIF.
Choosing breakpoints
The defaults cover most use cases:
| Size | Target |
|---|---|
| 800 | Phones, small tablets |
| 1400 | Laptops, standard desktops |
| 2080 | Large/retina displays |
If your audience skews toward high-end monitors, add a larger size:
[images]
sizes = [800, 1400, 2080, 3200]
If you want faster builds and smaller output at the cost of sharpness on large screens, trim to two sizes:
[images]
sizes = [800, 1600]
More sizes mean more files and longer processing time, but each additional size only affects images large enough to benefit from it.
Small source images
When a source image is smaller than a configured size, that size is skipped. Simple Gal never upscales.
Consider sizes = [800, 1400, 2080] with a 1200x900 source:
- 800: generated (source is large enough)
- 1400: skipped (source is only 1200px wide)
- 2080: skipped
If the source is smaller than every configured size, Simple Gal generates a single AVIF at the original dimensions. The image is still converted to AVIF for the file size benefit, but no scaling occurs.
Output format
All responsive images are encoded as AVIF. There is no option to output JPEG or WebP -- AVIF provides better compression at equivalent visual quality, and browser support is broad enough for a photography portfolio.
The encoding quality is controlled separately. See Quality for details.
Per-gallery overrides
Responsive sizes are set at any level of the config chain. A travel album with low-resolution phone photos might use smaller breakpoints:
# content/020-Travel/010-Phone-Snaps/config.toml
[images]
sizes = [400, 800]
This album generates only two sizes while other albums use the root config's three sizes. See Configuration Overview for how the config chain works.
Thumbnails
Thumbnails are the small preview images shown in album grids. Every image gets a thumbnail that is cropped to a consistent aspect ratio so the grid looks clean regardless of the mix of portrait and landscape originals.
How thumbnails are created
The process has two steps:
- Resize to fill -- the source image is scaled down using Lanczos3 resampling so that it completely covers the target dimensions, with no empty space.
- Center crop -- any overflow is trimmed equally from both sides, keeping the center of the image.
This is the same "cover" behavior you see in CSS object-fit: cover. A landscape photo cropped to a portrait thumbnail loses the left and right edges; a portrait photo cropped to a landscape thumbnail loses the top and bottom.
After cropping, a light unsharp mask (sigma 0.5, threshold 0) is applied to keep thumbnails crisp at small sizes.
Configuration
Two settings control thumbnail geometry:
[thumbnails]
aspect_ratio = [4, 5]
size = 400
Aspect ratio
aspect_ratio is expressed as [width, height]. The first value is the horizontal proportion; the second is the vertical.
# Portrait (taller than wide)
aspect_ratio = [4, 5]
# Square
aspect_ratio = [1, 1]
# Landscape (wider than tall)
aspect_ratio = [3, 2]
Common choices:
| Ratio | Shape | Notes |
|---|---|---|
[4, 5] | Portrait | Default. Works well for figure photography and vertical compositions. |
[1, 1] | Square | Clean, symmetric grids. Good all-rounder. |
[3, 2] | Landscape | Matches the 35mm frame. Good for horizontal work. |
[16, 9] | Wide landscape | Cinematic feel, but crops aggressively on portrait originals. |
[4, 3] | Mild landscape | Less aggressive crop than 16:9. |
Size
size is the short edge of the thumbnail in pixels. The long edge is calculated from the aspect ratio.
With aspect_ratio = [4, 5] and size = 400:
- Short edge (width) = 400px
- Long edge (height) = 400 * 5/4 = 500px
- Final thumbnail: 400x500 pixels
With aspect_ratio = [3, 2] and size = 300:
- Short edge (height) = 300px
- Long edge (width) = 300 * 3/2 = 450px
- Final thumbnail: 450x300 pixels
The default of 400px produces sharp thumbnails on standard and retina screens without excessive file sizes.
Per-gallery overrides
Each album can override thumbnail settings through its own config.toml. This is useful when a gallery has a different visual character.
# content/010-Landscapes/config.toml
[thumbnails]
aspect_ratio = [3, 2]
This album gets landscape thumbnails while every other album uses the root config's portrait ratio.
You can also override at the group level. All albums under a group inherit the group's settings:
# content/020-Travel/config.toml
[thumbnails]
aspect_ratio = [1, 1]
size = 350
Now every album under 020-Travel uses square 350px thumbnails, unless an individual album overrides again.
Only the keys you specify are overridden. Setting aspect_ratio in a gallery config does not reset size to the default -- it keeps whatever value was inherited from the parent.
Output format
Thumbnails are encoded as AVIF using the same quality setting as responsive images. Each thumbnail is saved as {stem}-thumb.avif alongside the responsive sizes:
processed/010-Landscapes/
├── 001-dawn-800.avif
├── 001-dawn-1400.avif
├── 001-dawn-2080.avif
└── 001-dawn-thumb.avif
Image Quality
Simple Gal encodes all output images -- responsive sizes and thumbnails -- in AVIF format. The quality setting controls the tradeoff between file size and visual fidelity.
Configuration
[images]
quality = 90
The value is an integer from 0 to 100. Higher values produce larger files with fewer compression artifacts. Lower values produce smaller files with more visible degradation.
Choosing a quality value
The default of 90 is a good starting point for fine art photography. At this level, compression artifacts are invisible at normal viewing distances, and the files are roughly half the size of equivalent JPEGs.
| Quality | Use case | Notes |
|---|---|---|
| 95-100 | Archival, print-resolution work | Minimal compression. Large files. Diminishing returns above 95. |
| 85-90 | Portfolio display (default range) | Visually lossless for web viewing. Good balance. |
| 70-80 | Documentation, travel snapshots | Noticeable softening on close inspection. Significantly smaller files. |
| Below 70 | Not recommended | Visible artifacts, especially in gradients and fine detail. |
For most photography portfolios, values between 85 and 90 are the sweet spot. Going above 90 increases file size substantially with no perceptible improvement on screen.
AVIF vs JPEG
AVIF achieves better compression than JPEG at the same visual quality. As a rough guide:
- AVIF quality 90 is comparable to JPEG quality 95 in perceived sharpness
- AVIF files at quality 90 are typically 40-60% smaller than the equivalent JPEG
Simple Gal uses the rav1e encoder, a pure Rust AV1 implementation. This means no system dependencies -- the encoder is built into the binary. The tradeoff is that encoding is slower than hardware-accelerated alternatives, but this is offset by parallel processing across CPU cores.
Per-gallery overrides
Quality can be overridden at any level of the config chain. A gallery of phone snapshots might use a lower quality to reduce output size:
# content/030-Snapshots/config.toml
[images]
quality = 75
This gallery uses quality 75 while other galleries inherit the root config's quality 90. The quality setting applies to both responsive images and thumbnails for that gallery.
File size impact
To give a sense of scale, here are approximate file sizes for a single 2080px-wide landscape image at different quality levels:
| Quality | Approximate size |
|---|---|
| 100 | 800-1200 KB |
| 90 | 200-400 KB |
| 80 | 100-200 KB |
| 70 | 60-120 KB |
Actual sizes vary with image content. Images with smooth gradients (skies, studio backdrops) compress better than images with fine detail (foliage, textured surfaces).
Processing Pipeline
Image processing is stage 2 of the Simple Gal build pipeline. It reads the manifest produced by the scan stage, processes every source image into responsive sizes and thumbnails, and writes the results to a temp directory for the generate stage to consume.
What happens during processing
For each image in every album, the processing stage:
- Reads the source file from your content directory
- Extracts dimensions and any embedded IPTC metadata (title, description)
- Generates AVIF files at each configured responsive size (skipping sizes larger than the source)
- Generates a single AVIF thumbnail at the configured aspect ratio and size
- Records all generated paths and dimensions in an output manifest
The output goes to .simple-gal-temp/processed/, organized by album:
.simple-gal-temp/processed/
├── manifest.json
├── 010-Landscapes/
│ ├── 001-dawn-800.avif
│ ├── 001-dawn-1400.avif
│ ├── 001-dawn-2080.avif
│ ├── 001-dawn-thumb.avif
│ ├── 002-dusk-800.avif
│ ├── 002-dusk-1400.avif
│ ├── 002-dusk-2080.avif
│ └── 002-dusk-thumb.avif
└── 020-Portraits/
├── 001-studio-800.avif
├── 001-studio-1400.avif
├── 001-studio-2080.avif
└── 001-studio-thumb.avif
The manifest.json contains the full metadata for every album and image, including generated file paths, dimensions, titles, descriptions, and resolved configuration. The generate stage reads this file to produce the final HTML site.
Parallel processing
Simple Gal uses rayon to process images in parallel across CPU cores. By default, it uses all available cores. This makes a significant difference -- AVIF encoding is CPU-intensive, and a portfolio with 200 images can take minutes on a single core but seconds on a modern multi-core machine.
Limiting threads
If you need to constrain CPU usage (for example, on a shared server or while doing other work), set max_processes:
[processing]
max_processes = 4
This caps the number of parallel workers. If you set a value higher than the number of available cores, it is clamped down to the core count. Omit the key entirely to use all cores.
Setting max_processes = 1 disables parallelism and processes images sequentially.
Input formats
Simple Gal accepts the following source image formats:
| Format | Extensions |
|---|---|
| JPEG | .jpg, .jpeg |
| PNG | .png |
| TIFF | .tiff, .tif |
| WebP | .webp |
All input formats are converted to AVIF on output. The source files are never modified.
Output format
Every generated file is AVIF, encoded with the rav1e encoder. This is a pure Rust AV1 implementation compiled into the Simple Gal binary. There are no system dependencies -- no ImageMagick, no FFmpeg, no shared libraries to install.
The resampling algorithm for all resizing is Lanczos3, which produces sharp results with minimal ringing artifacts.
The temp directory
The .simple-gal-temp/ directory is a build artifact. It holds the processed images and manifest between the process and generate stages. You can safely delete it at any time -- it will be recreated on the next build.
Add it to your .gitignore:
.simple-gal-temp/
Per-album configuration
Each album uses its own resolved configuration from the config chain. This means different albums can have different responsive sizes, quality settings, and thumbnail aspect ratios. The processing stage reads the per-album config from the scan manifest and applies it independently.
See Responsive Sizes, Thumbnails, and Quality for the settings that control image output.
PWA Overview
Every site Simple Gal generates is a Progressive Web App. There is nothing to enable -- it works out of the box.
What this means for your visitors
When someone visits your portfolio on their phone, their browser will offer to install it as an app. Once installed, your portfolio:
- Lives on their home screen alongside their other apps, with your own icon and name.
- Opens full-screen without browser chrome -- no address bar, no tabs, just your photos.
- Loads instantly on repeat visits. A service worker caches your pages and images, so returning visitors see content immediately even on slow connections.
- Works offline. Pages and images that have been viewed before are available without a network connection. A visitor who browsed your Japan album on hotel wifi can revisit those photos on the plane.
None of this requires your visitors to visit an app store or create an account. The browser handles everything.
How it works (briefly)
Simple Gal generates three things that make this possible:
- A service worker (
sw.js) -- a small script that runs in the background and manages a local cache of your site's pages and images. - A web manifest (
site.webmanifest) -- a file that tells the browser your site's name, icons, and display preferences. - Registration code on every page that connects the two.
The caching strategy is stale-while-revalidate: the service worker serves cached content immediately, then fetches a fresh copy in the background. This means returning visitors always get a fast response, and the cache quietly stays up to date.
Automatic cache management
Each build stamps the cache with a version identifier (simple-gal-v<version>). When you deploy an updated site, the new service worker activates and cleans up old caches automatically. Your visitors get the new content on their next visit without any stale-cache issues.
Zero configuration
The PWA works with stock defaults and no configuration. If you want to customize the app name, icons, or theme color, see Customizing. But it is entirely optional -- the default setup is a fully functional PWA.
Installing as an App
Your visitors can install your portfolio as an app on their device. The process is slightly different on each platform, but in every case it takes a few seconds and requires no app store.
iOS (Safari)
Safari does not show an automatic install prompt. Visitors use the Share menu:
- Open your portfolio in Safari.
- Tap the Share button (the square with an upward arrow).
- Scroll down and tap Add to Home Screen.
- Optionally edit the name, then tap Add.
Your portfolio now appears on the home screen with its icon. Tapping it opens the site in standalone mode -- full screen, no Safari UI.
Note: On iOS, the PWA must be installed from Safari. Other browsers (Chrome, Firefox) on iOS do not support Add to Home Screen.
Android (Chrome)
Chrome shows an install banner automatically when it detects a valid PWA. Visitors can also install manually:
- Open your portfolio in Chrome.
- Tap the three-dot menu in the top-right corner.
- Tap Add to Home Screen (or Install app on newer versions).
- Confirm by tapping Add.
The portfolio appears in the app drawer and on the home screen, and opens without browser chrome.
Desktop (Chrome, Edge)
Desktop browsers also support PWA installation:
Chrome:
- Visit your portfolio.
- Click the install icon in the address bar (a monitor with a down arrow), or open the three-dot menu and select Install.
Edge:
- Visit your portfolio.
- Click the App available icon in the address bar, or open the three-dot menu and select Apps > Install this site as an app.
Once installed, the portfolio opens in its own window without browser tabs or address bar.
What visitors see after installation
Regardless of platform, the installed app:
- Uses the name from your
site_titleconfiguration. - Shows your custom icon if you placed one in
assets/(see Customizing), or a default icon otherwise. - Opens in standalone display mode -- the full screen is your portfolio, with no browser UI.
- Loads cached content instantly on launch.
Customizing the PWA
The PWA works without any configuration, but you can control the app name, icons, and theme color.
App name
The PWA uses site_title from your config.toml as the app name. This is the name visitors see when they install the app and on their home screen.
# content/config.toml
site_title = "Sarah Chen Photography"
If no site_title is set, the PWA falls back to the stock default.
Icons
Place icon files in your assets directory to replace the defaults:
content/
└── assets/
├── icon-192.png # 192x192 -- used on Android home screens
├── icon-512.png # 512x512 -- used for splash screens
└── apple-touch-icon.png # 180x180 -- used on iOS home screens
All three files are optional. Any icon you do not provide will use the built-in default.
Recommendations:
- Use square PNG images with no transparency for best results across platforms.
- The
icon-192.pngis the most visible -- it is the app icon on most Android devices. - The
apple-touch-icon.pngis what iOS uses on the home screen. If you provide only one custom icon, make it this one andicon-192.png.
Theme color
The theme color controls the browser toolbar tint and splash screen background on Android. It defaults to white (#ffffff).
To change it, place a custom site.webmanifest file in your assets directory:
content/
└── assets/
└── site.webmanifest
The file should contain valid JSON. Here is a minimal example that changes only the theme color:
{
"name": "Sarah Chen Photography",
"short_name": "Sarah Chen",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#1a1a1a",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
When you provide a custom site.webmanifest, it replaces the generated one entirely. Make sure to include all required fields.
Root-only limitation
The PWA service worker requires that your site is served from the root of its domain. This means:
| URL | Works |
|---|---|
https://photos.example.com/ | Yes |
https://example.com/ | Yes |
https://example.com/photos/ | No |
If your portfolio lives under a subdirectory, the service worker will not register correctly and PWA features (offline access, home screen installation) will not work. The site will still function normally as a website -- only the PWA features are affected.
The simplest solution is to use a subdomain (photos.example.com) instead of a subdirectory (example.com/photos/).
Local Usage
Installation
From crates.io:
cargo install simple-gal
From GitHub releases:
Download a pre-built binary for your platform from the releases page. Place it somewhere on your PATH.
Building your site
The default command reads from content/ and writes to dist/:
simple-gal build
Override the input and output directories with flags:
simple-gal build --source photos --output public
This processes all images and generates the complete static site in the output directory.
CLI commands
| Command | What it does |
|---|---|
simple-gal build | Run the full pipeline: scan, process images, generate HTML |
simple-gal scan | Scan the content directory and print the manifest (no image processing or HTML output) |
simple-gal process | Scan and process images (generate responsive sizes and thumbnails) without generating HTML |
simple-gal generate | Scan, process, and generate HTML (same as build) |
simple-gal gen-config | Print a fully-commented config.toml with all stock defaults |
The individual stage commands (scan, process) are useful for debugging. In normal use, build is all you need.
Generating a starter config
To see every available configuration option with its default value:
simple-gal gen-config > content/config.toml
Edit the generated file to keep only the settings you want to customize. See Configuration Overview for details on how config merging works.
Previewing locally
The output in dist/ is a static site. Any local HTTP server will work for previewing. A few options:
# Python (built into macOS and most Linux)
python3 -m http.server --directory dist 8000
# Node.js
npx serve dist
# PHP
php -S localhost:8000 -t dist
Then open http://localhost:8000 in your browser.
Note: PWA features (service worker, offline mode) require HTTPS in production, but work over
localhostduring development.
GitHub Actions
The simple-gal-action GitHub Action builds your site in CI without installing anything locally. It downloads the Simple Gal binary, runs the build, and produces a dist/ directory ready for deployment.
Full workflow: build and deploy to GitHub Pages
Create .github/workflows/deploy.yml in your repository:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
# Allow manual trigger from the Actions tab
workflow_dispatch:
# Required permissions for GitHub Pages deployment
permissions:
contents: read
pages: write
id-token: write
# Prevent concurrent deployments
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build site
uses: arthur-debert/simple-gal-action@v1
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
This workflow:
- Triggers on every push to
main(and can be triggered manually). - Checks out your repository.
- Runs
simple-gal buildvia the action, producingdist/. - Uploads
dist/as a GitHub Pages artifact. - Deploys to GitHub Pages.
GitHub Pages setup
Before the workflow can deploy, enable GitHub Pages in your repository settings:
- Go to Settings > Pages.
- Under Source, select GitHub Actions.
That is all. The workflow handles the rest.
Custom domain
To use a custom domain (e.g., photos.example.com):
- In your repository settings under Pages > Custom domain, enter your domain.
- Add a
CNAMEfile to the root of your content directory containing just the domain name:
photos.example.com
- Configure DNS with your domain registrar -- a CNAME record pointing to
<username>.github.io.
GitHub will provision an SSL certificate automatically.
Custom source directory
If your content is not in the default content/ directory, pass options to the action:
- name: Build site
uses: arthur-debert/simple-gal-action@v1
with:
source: photos
output: dist
Caching
Image processing is the slowest step in a build. The action output (dist/) includes processed images, so subsequent builds that use a cache of the output directory can skip reprocessing unchanged images. GitHub's actions/cache can help here if build times become a concern.
Hosting
Simple Gal produces a self-contained static site in dist/. There is no server-side processing, no database, no runtime. Any service that can serve files over HTTP will work.
GitHub Pages
The easiest option if your source is already on GitHub. See GitHub Actions for a complete workflow that builds and deploys automatically.
For manual deployment, push the contents of dist/ to a gh-pages branch:
# Build locally
simple-gal build
# Deploy to gh-pages branch
npx gh-pages -d dist
Then set the Pages source to the gh-pages branch in your repository settings.
Netlify
Connect your GitHub repository and configure the build:
| Setting | Value |
|---|---|
| Build command | (leave blank -- use a GitHub Action to build, or install simple-gal in a build plugin) |
| Publish directory | dist |
The simplest approach is to build with GitHub Actions and deploy the dist/ directory. Alternatively, commit the built dist/ directory and point Netlify at it directly.
For drag-and-drop deployment without any repository, use the Netlify CLI:
simple-gal build
npx netlify-cli deploy --dir dist --prod
Vercel
Similar to Netlify. Connect your repository and set the output directory to dist.
For manual deployment:
simple-gal build
npx vercel --prod dist
Vercel will serve the static files without any framework configuration.
Amazon S3 + CloudFront
For AWS hosting:
# Build the site
simple-gal build
# Sync to S3
aws s3 sync dist/ s3://your-bucket-name --delete
# Invalidate CloudFront cache (if using a distribution)
aws cloudfront create-invalidation \
--distribution-id YOUR_DIST_ID \
--paths "/*"
Configure the S3 bucket for static website hosting and point CloudFront at it for HTTPS and caching.
Nginx
Serve dist/ directly. A minimal configuration:
server {
listen 80;
server_name photos.example.com;
root /var/www/photos;
location / {
try_files $uri $uri/index.html =404;
}
}
Copy the built site to the server:
simple-gal build
rsync -avz dist/ user@server:/var/www/photos/
Apache
Enable mod_dir (usually on by default) and point the document root at the output directory:
<VirtualHost *:80>
ServerName photos.example.com
DocumentRoot /var/www/photos
<Directory /var/www/photos>
Options -Indexes
AllowOverride None
Require all granted
</Directory>
</VirtualHost>
General advice
- HTTPS is required for PWA features. The service worker will not register over plain HTTP (except on
localhost). Most hosting services provide free SSL certificates. - No special server rules needed. Simple Gal generates clean
index.htmlfiles in each directory, so standard static file serving works without URL rewriting. - Cache headers are optional. The service worker handles caching on the client side. If you want to set server-side cache headers, long cache times on image files (
/images/**) are safe since filenames include content hashes.
The Forever Stack
Simple Gal is designed to produce gallery sites that work 20 years from now. This page explains what that claim rests on, what could break it, and what we give up to make it credible.
The claim
If you run simple-gal build today and put the output on a file server, someone should be able to open that site in a browser in 2045 and see your photographs exactly as you intended. No software updates required. No server migration. No database restoration. Just files served over HTTP.
Why we believe it
The output of Simple Gal consists of:
- HTML -- the foundational document format of the web, backward-compatible since the 1990s. Simple Gal uses basic, well-established elements. No custom elements, no Web Components, no framework-specific markup.
- CSS -- inline styles and a single stylesheet using properties that have been stable for over a decade. No CSS-in-JS, no preprocessor, no build step.
- AVIF images -- the output image format. AVIF is based on the AV1 video codec and backed by the Alliance for Open Media (Google, Apple, Mozilla, Microsoft, Netflix, Amazon). It is an ISO standard (ISO/IEC 23000-22). Browser support is universal in modern browsers.
- ~30 lines of vanilla JavaScript -- for keyboard navigation and swipe gestures only. Click-based navigation is pure HTML anchor tags and CSS. If JavaScript stopped running entirely, you could still browse every photo by clicking.
There are no dependencies in the output. No node_modules, no CDN links, no third-party scripts, no API calls. The site is self-contained: a directory of files that reference only each other.
The tool itself
The generator is a single Rust binary with no runtime dependencies. It reads files from disk and writes files to disk. To use it, you need exactly one thing: the ability to run a compiled program.
Pre-built binaries are provided for major platforms. Even if the Rust toolchain disappeared tomorrow, the existing binaries would continue to work on any compatible OS. And since the input is just a directory of images and text files, you could always rebuild the output with a different tool if needed -- there is no lock-in to a proprietary data format.
What could break it
We should be honest about the assumptions:
Browsers drop AVIF support. This is the most format-specific risk. However, AVIF is an ISO standard backed by every major browser vendor, and the web has never dropped support for an image format once it reached universal adoption (GIF, JPEG, PNG, and WebP are all still supported decades after introduction). We consider this extremely unlikely.
HTML fundamentally changes. The <img>, <a>, and <div> elements that Simple Gal relies on have been stable since the mid-1990s. Browsers maintain backward compatibility with content from that era. A change that broke basic HTML would break a significant fraction of the existing web. This is not a realistic concern.
The file server goes away. Simple Gal generates static files, but someone still needs to serve them. If your hosting provider shuts down, you need to move the files. The good news is that static file hosting is the simplest, cheapest, most widely available form of web hosting. Moving a directory of files to a new server is a solved problem.
You lose the source files. Simple Gal does not replace your backup strategy. The generated site contains processed (resized, compressed) images, not your originals. If you lose the content directory, you lose the ability to rebuild at different settings. Back up your source files.
What we give up
The Forever Stack is a deliberate tradeoff: longevity and simplicity over features. Here is what Simple Gal does not do, and will not do:
- No comments. Commenting systems require a server, a database, and moderation. They are a maintenance liability with a finite lifespan.
- No search. Client-side search requires a JavaScript index that grows with the site. Server-side search requires infrastructure. Neither fits the model.
- No infinite scroll. Infinite scroll depends on JavaScript for loading and layout. Page-based navigation is pure HTML and works without scripting.
- No analytics (built in). Tracking visitors requires either JavaScript or server logs. Simple Gal does not inject tracking code. You can add your own via the
assets/head.htmlinjection point if you want it. - No image lazy-loading via JavaScript. The site uses native browser
loading="lazy"attributes, which are HTML-only and require no scripting.
Each of these omissions removes a dependency, a maintenance burden, or a failure mode. The site does less, but what it does, it will keep doing.
The tradeoff in practice
If you need comments, search, e-commerce, or client proofing, Simple Gal is not the right tool. Use something designed for those features.
If you want a portfolio that shows your photographs in a clean, fast, controllable way -- and you want it to still work without intervention in 2035, 2040, 2045 -- then the constraints are the point. Every feature we didn't add is a thing that can't break.