Tooling for a static photo gallery site hosted on Cloudflare Pages + R2.
This repo holds only the tooling β Astro project, build scripts, configs. Photo originals and per-album metadata live in a separate local-only content repo (see Content repo layout), referenced from a gitignored .config.yaml.
ββββββββββββββββββββββββββββββββββββββββ
β content_root (local / NFS / etc.) β
β βββ content/ β
β β <folder>/<album>/ β
β β meta.yaml β
β β *.jpg β
β βββ build/ (auto-created) β
β derivatives/ β
β image-meta.json β
β derivative-cache.json β
β legacy-keys.json β
ββββββββββββββββββββ¬ββββββββββββββββββββ
β yaml_to_content.mjs
β process_images.mjs
βΌ
βββββββββββββββββββββββββββββββ
β This repo (photo-portfolio) β
β src/content/ (generated)β
β dist/ (built) β
ββββββββββββββββ¬βββββββββββββββ
β publish.mjs
βΌ
ββββββββββββββββββββ ββββββββββββββββββββββββββ
β Cloudflare Pages β β Cloudflare R2 β
β gallery.β¦ β β assets.β¦ β
ββββββββββββββββββββ ββββββββββββββββββββββββββ
- Astro generates the site. Content collections are typed; lightbox + password gate are interactive islands.
- sharp encodes responsive AVIF/WebP/JPEG derivatives + 200Γ200 thumbnails at widths 480/960/1600/2400.
- R2 serves all image bytes (derivatives + originals) from
assets.prenticew.comin production. For local dev, derivatives are served by the Astro dev server via apublic/derivatives β <content_root>/build/derivativessymlink.
- Node 20+ (for Astro + sharp)
- Python 3.11+ (only for the SmugMug helper scripts; not needed for local dev)
- For production deploy only: a Cloudflare account with Pages + R2 + API token + R2 access key pair
For local development, none of the Cloudflare bits are required.
git clone <repo-url> photo-portfolio
cd photo-portfolio
npm install
# Point at your portfolio directory
cp .config.example.yaml .config.yaml
$EDITOR .config.yaml # set content_root: <absolute path>
# Create site-wide config under content_root (site_url, name, etc.)
cp .site.example.yaml "$(yq .content_root .config.yaml | tr -d '"')/site.yaml"
$EDITOR "$(yq .content_root .config.yaml | tr -d '"')/site.yaml".config.yaml and .env are gitignored. <content_root>/site.yaml lives outside the repo so the tooling is reusable across portfolios β never commit it to this repo either.
The fastest way to see the site work end-to-end against fake but real photos:
# 1. Generate procedural test JPEGs + site.yaml into ./test-content/
node scripts/seed_test_content.mjs
# 2. Point .config.yaml at the fixture
echo "content_root: $(pwd)/test-content" > .config.yaml
# 3. Build + serve. `npm run dev` and `npm run build:local` auto-create the
# public/derivatives symlink and load <content_root>/site.local.yaml
# (or apply built-in localhost defaults if it's absent).
npm run dev # http://localhost:4321 with HMR
# β¦or for a static preview:
npm run build:local
npm run preview # http://localhost:4321After this, edit any photo, meta.yaml, or component and re-run npm run build (or rely on HMR for component edits). Drop your own JPEGs into test-content/Sample/Sunset-Hike/ and they'll be auto-discovered.
To switch to your real content repo later, just change content_root in .config.yaml β same scripts, same commands.
The site deploys to Cloudflare Pages (HTML/CSS/JS) with images on Cloudflare R2 (assets.prenticew.com). One-time setup, then npm run deploy is incremental β only changed images upload to R2.
Wrangler and the upload script can't create their own credentials. There are exactly two manual dashboard steps; the rest is CLI.
Step 1 β Cloudflare API token (lets wrangler create the R2 bucket + Pages project + DNS records).
Dashboard β My Profile β API Tokens β Create Token β Custom token. Permissions:
Account β Cloudflare Pages β EditAccount β Workers R2 Storage β EditZone β DNS β Edit(scope:prenticew.com)
cp .env.example .env
$EDITOR .env # paste CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID (from dashboard sidebar)Step 2 β Provision R2 + Pages (idempotent; safe to re-run):
npm install # picks up @aws-sdk/client-s3 and wrangler
npm run cf:setup # creates R2 bucket, attaches assets.prenticew.com, creates Pages projectStep 3 β R2 S3 access keys (lets publish.mjs PUT images into the bucket).
Dashboard β R2 β Manage R2 API Tokens β Create API token.
Permission: Object Read & Write, scoped to the photo-portfolio bucket (now exists thanks to step 2).
$EDITOR .env # paste AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEYnpm run deploy # = npm run build && npm run publishbuildruns the local pipeline (ingest β encode derivatives β astro build β verify).publishwalksbuild/derivatives/and the originals referenced by each album, sha256s each file, skips anything already inbuild/upload-cache.json, uploads the rest to R2, then runswrangler pages deploy dist/.
Initial deploy uploads everything (~14k originals + ~210k derivatives) and takes 1β2 hours depending on uplink. Subsequent deploys, after adding a new album, only upload that album's images β typically seconds to minutes.
The first wrangler pages deploy creates a *.pages.dev URL. Wrangler 3.x has no pages subcommand for attaching a custom domain, so use the Cloudflare REST API (which cf_setup.mjs-style tokens already have permission for):
node --env-file=.env -e "
const acct = process.env.CLOUDFLARE_ACCOUNT_ID;
const proj = process.env.CF_PAGES_PROJECT;
const host = process.env.CF_PAGES_SHADOW_HOSTNAME || 'sgallery.prenticew.com';
const r = await fetch(
'https://api.cloudflare.com/client/v4/accounts/' + acct + '/pages/projects/' + proj + '/domains',
{ method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.CLOUDFLARE_API_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: host }) });
console.log(r.status, await r.text());
"CF auto-creates the DNS record on the zone and provisions a cert (1β3 min). Or use the dashboard: Workers & Pages β photo-portfolio β Custom domains β Set up a custom domain.
Cut over to gallery.prenticew.com only after shadow parity is satisfactory (plan Β§9).
content_root is the portfolio parent directory, which has two subdirectories: content/ (canonical source of truth β edit YAML, drop in photos, rebuild) and build/ (auto-created, holds derivatives + caches; safe to delete to force a full re-encode).
<content_root>/
βββ site.yaml # site-wide config (site_url, name, etc.)
βββ content/
β βββ .legacy-keys.json # auto-maintained; preserves legacy-site deep links
β βββ Travel/
β β βββ meta.yaml # folder
β β βββ Iceland-Ring-Road/
β β βββ meta.yaml # album
β β βββ 306B2755.jpg # original (any of .jpg/.jpeg/.png)
β β βββ 306B2756.jpg
β βββ Weddings/
β βββ meta.yaml
β βββ Sample-Wedding/
β βββ meta.yaml
β βββ ...
βββ build/ # auto-created by process_images.mjs
βββ derivatives/<image_id>/{480,960,1600,2400}.{avif,webp,jpg}, thumb.{...}
βββ image-meta.json # dims + projected EXIF per image_id
βββ derivative-cache.json # image_id β source sha256 (skip-unchanged)
βββ legacy-keys.json # mirrored from content/.legacy-keys.json
- Directory path is the URL.
<content_root>/Travel/Iceland-Ring-Road/β/Travel/Iceland-Ring-Roadon the live site. - Image files are auto-discovered. Anything matching
.jpg|.jpeg|.png(case-insensitive) in an album directory is included. - Captions + keywords are read from embedded IPTC/XMP (the metadata written into the file). YAML overrides only when needed.
- Plaintext passwords live in the content repo and never leave it β they're hashed (sha256 + per-album salt) during the build before reaching
dist/or R2.
mkdir -p "$CONTENT_ROOT/content/Travel/Joshua-Tree-Spring"
cp ~/exports/joshua-tree/*.jpg "$CONTENT_ROOT/content/Travel/Joshua-Tree-Spring/"The parent folder (content/Travel/) must already exist with its own meta.yaml.
# <content_root>/content/Travel/Joshua-Tree-Spring/meta.yaml
name: "Joshua Tree Spring"
date: 2026-06-15That's the minimum. Defaults kick in for privacy, security_type, sort, etc.
You don't write:
url_nameβ derived from the directory nameid:β auto-stamped on the first build (and written back into this file)images:β auto-discovered from the directory- Per-image captions / keywords if your photos already carry them in IPTC/XMP
npm run deploy # build + upload changed images to R2 + deploy PagesThis runs the full chain: ingest YAML β encode derivatives β render site β verify invariants β delta-upload to R2 (only changed/new images) β wrangler pages deploy. Use npm run build alone if you want to render the site without publishing.
After the build, open meta.yaml again and you'll see a new line:
id: alb_h3k9m2qx # β auto-stamped, leave it aloneThis is the album's permanent identifier β it survives directory renames and keeps every photo's R2 keys + share URLs stable.
mkdir -p "$CONTENT_ROOT/content/Events"
cat > "$CONTENT_ROOT/content/Events/meta.yaml" <<EOF
name: "Events"
EOFThen add albums inside it.
Drop the new file in over the old one (same filename). Rebuild.
cp ~/exports/joshua-tree/IMG_4501.jpg "$CONTENT_ROOT/content/Travel/Joshua-Tree-Spring/"
npm run build # `npm run deploy` will replace this once publish.mjs landsThe image_id is derived from album.id + filename, so it doesn't change β share URLs survive, only the changed file gets re-encoded.
mv "$CONTENT_ROOT/content/Travel/Joshua-Tree-Spring" "$CONTENT_ROOT/content/Travel/Joshua-Tree-2026"
npm run build # `npm run deploy` will replace this once publish.mjs landsThe URL changes (/Travel/Joshua-Tree-2026) but album.id lives in the YAML, so all image_id values are unchanged β no R2 churn.
If you want the old URL to keep working, add a one-line entry to public/_redirects:
/Travel/Joshua-Tree-Spring/* /Travel/Joshua-Tree-2026/:splat 301
name: "Sample Wedding"
date: 2024-06-22
security_type: password
password: "summer2024" # plaintext β local-only, never reaches dist/ or R2
password_hint: "season + year"Rebuild. The plaintext is hashed (sha256 + per-album salt) at build time. Visitors must enter the password before content loads.
privacy: unlisted # hides from indexes; URL still works for anyone with the linkUse this only when the photo's embedded IPTC/XMP doesn't carry what you want.
images:
IMG_4501.jpg:
caption: "Sunset over the vineyard"
keywords: [sunset, vineyard]To explicitly clear keywords from one photo (overriding embedded metadata):
images:
IMG_4501.jpg:
keywords: []hero: IMG_4502.jpg # which image represents the album in folder/home grids (default: first image)The hero field controls which image is used as the album's thumbnail in folder pages and the homepage "Recently Added" grid. It is not rendered on the album page itself.
sort: date # filename | date | manualmanual sort respects the order in which filenames appear under images: in YAML, falling back to filename for any image not listed.
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Display name |
date |
YYYY-MM-DD | required | Event date |
url_name |
string | derived | Path segment; defaults to directory name |
id |
string | auto-stamped | Don't write by hand |
description |
string | "" |
Long-form, shown on album page |
keywords |
string[] | [] |
Album-level keywords |
privacy |
public | unlisted |
public |
|
security_type |
none | password |
none |
|
password |
string | null |
Plaintext, local-only |
password_hint |
string | null |
Shown above password input |
sort |
filename | date | manual |
filename |
|
hero |
filename | first image | |
images |
map | {} |
Sparse per-image overrides |
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | required | Display name |
url_name |
string | derived | Path segment; defaults to directory name |
description |
string | "" |
Shown on folder page |
privacy |
public | unlisted |
public |
| Command | What it does | Status |
|---|---|---|
npm run build |
Production: yaml_to_content β process_images β astro build β verify. Uses bare site.yaml. |
β works |
npm run build:local |
Same chain but with PROFILE=local (localhost URLs, public/derivatives symlink). |
β works |
npm run dev |
Auto-sets up symlinks + runs Astro dev server with HMR on PROFILE=local. |
β works |
npm run preview |
Serves the last-built dist/. |
β works |
npm run setup:local |
Idempotent: creates the public/derivatives symlink and a site.local.yaml stub if absent. |
β works |
npm run publish |
Sync R2 deltas β wrangler pages deploy dist/ |
β³ not yet implemented |
npm run deploy |
build then publish |
β³ blocked on publish |
Re-runs are idempotent. The derivative cache (build/derivative-cache.json) short-circuits on unchanged source files via sha256.
If a build invariant fails (e.g. missing password for a protected album, duplicate album id:, plaintext password leaked into src/content/), the chain halts with a non-zero exit. Fix the root cause β don't bypass.
scripts/verify.mjs runs 10 checks per TDD Β§14:
- Every album
url_pathhas a matchingdist/<path>/index.html - Every folder
url_pathhas a matching HTML page - Every image has at least one derivative at width 960 (or the largest available width if smaller)
- No
/private/paths indist/index.htmlor anysitemap-*.xml - Every password-protected album has a 64-char hex hash + salt; no plaintext password appears anywhere under
src/content/ordist/ dist/_redirectscontains both legacy-key rewrite rules- Random sample of 20 derivatives: actual width never exceeds source intrinsic
- Every
.legacy-keys.jsonentry resolves to an existing album + filename - Every album has an
id:matching^alb_[A-Za-z0-9]+$, no duplicates - Native
image_idnamespace and legacy SmugMug key namespace don't overlap
The site preserves URL patterns from the legacy site so external inbound links survive:
- Folder page:
/<folder> - Album page:
/<folder>/<album>(up to 4 levels deep) - Image deep link:
/<folder>/<album>/i-<id>β opens the lightbox on the keyed image. Both nativeimage_idvalues (11-char lowercase base32) and legacy site keys are accepted; the lightbox resolves them transparently. - Legacy size suffixes (
/X3,/A, etc.) on image URLs are accepted and ignored.
Native share URLs from the lightbox always use image_id. Legacy keys are resolved via <content_root>/.legacy-keys.json (auto-maintained).
photo-portfolio/
βββ .config.yaml # gitignored; { content_root: <absolute path> }
βββ .config.example.yaml # committed; documents the shape
βββ .env # gitignored; PUBLIC_ASSETS_BASE_URL, CF tokens
βββ astro.config.mjs
βββ tsconfig.json
βββ package.json
βββ public/
β βββ _redirects # Pages routing rules (legacy-key rewrites)
β βββ derivatives β <content_root>/build/derivatives # local-dev symlink (gitignored)
βββ src/
β βββ content/
β β βββ config.ts # Zod schemas (album, folder, albumImage)
β β βββ albums/ # gitignored; generated each build
β β βββ folders/ # gitignored; generated each build
β βββ components/
β β βββ BaseLayout (in layouts/)
β β βββ HomePage.astro
β β βββ FolderPage.astro
β β βββ AlbumPage.astro
β β βββ ResponsivePicture.astro
β β βββ Thumbnail.astro
β β βββ Breadcrumbs.astro
β β βββ Lightbox.tsx # React island
β β βββ PasswordGate.tsx # React island
β βββ layouts/
β β βββ BaseLayout.astro
β βββ lib/
β β βββ asset-urls.ts # R2 / local URL builders
β β βββ image-meta.ts # loads build/image-meta.json
β β βββ legacy-keys.ts # loads build/legacy-keys.json + per-album slice
β β βββ password.ts # Web Crypto sha256+salt verify
β β βββ url-state.ts # pathname β { album, key }
β βββ pages/
β β βββ index.astro # β HomePage
β β βββ [...slug].astro # β FolderPage | AlbumPage
β βββ styles/
β βββ global.css
βββ scripts/
β βββ seed_test_content.mjs # generate procedural test-content/ (local dev only)
β βββ yaml_to_content.mjs # walk content_root β src/content/*.json
β βββ process_images.mjs # sharp β build/derivatives/ + image-meta.json
β βββ build.mjs # orchestrator invoked by `npm run build`
β βββ verify.mjs # 10 build invariants
β βββ publish.mjs # R2 sync + wrangler pages deploy (not yet implemented)
β βββ manifest_to_yaml.mjs # one-time SmugMug β content_root migration (not yet implemented)
β βββ smugmug_*.py # legacy SmugMug crawl/download/auth helpers
β βββ requirements.txt
βββ (no build/ in this repo β build artifacts live under <content_root>/build/)
| Symptom | Fix |
|---|---|
Error: content_root does not exist |
Check the path in .config.yaml. For local dev, point it at ./test-content/ and run node scripts/seed_test_content.mjs. |
Build halted: album <slug> has security_type=password but no password |
Add a password: to that album's meta.yaml. |
verify: duplicate album id |
Two albums share the same id:. Rare. Delete the duplicated id: line from one of them and rebuild β it'll get a fresh stamp. |
verify: plaintext password leaked into β¦ |
A password: value from meta.yaml ended up in generated content. Should never happen β file a bug; in the meantime, inspect the offending file and remove. |
| Photos look sideways | The pipeline respects EXIF orientation. If a single photo is wrong, fix its orientation tag (e.g. exiftool -Orientation=1 -n file.jpg) and rebuild. |
| Album shows but photos are missing | Check build/image-meta.json for the album's image_id entries; rerun npm run build to repopulate the derivative cache. |
Expected β¦/content to exist |
Photos must live under <content_root>/content/, not directly under <content_root>/. If you have an older layout, mkdir <content_root>/content && mv <content_root>/<folders> <content_root>/content/. |
| Photos 404 in local dev | Confirm public/derivatives symlinks to <content_root>/build/derivatives and PUBLIC_ASSETS_BASE_URL=/ is set in .env. |
| R2 upload partial-fails | (Phase 1: publish.mjs not yet implemented.) When it lands: rerun npm run publish β the upload cache resumes from where it left off. |