Port upcoming levineuwirth.org photography systems to Ozymandias

This commit is contained in:
Levi Neuwirth 2026-04-28 20:52:31 -04:00
parent 34708f4568
commit cf0f9f37c3
34 changed files with 3799 additions and 56 deletions

36
.gitignore vendored
View File

@ -58,7 +58,41 @@ IGNORE.txt
# Download with: make download-model
static/models/
# Vendored Leaflet + leaflet.markercluster (~150 KB total, pinned in
# tools/download-leaflet.sh). Used by the /photography/map/ page only.
# Downloaded by `make build` when content/photography/ exists.
static/leaflet/
# Generated WebP companions (produced by tools/convert-images.sh at build time).
# To intentionally commit a WebP, use: git add -f path/to/file.webp
static/**/*.webp
content/**/*.webp
content/**/*.webp
# Photography sidecars (produced by tools/extract-exif.py and
# tools/extract-palette.py at build time; consumed by Hakyll). Recreated
# from the photo file on every `make build`, so they don't belong in
# version control — committing them would just produce churn.
content/photography/**/*.exif.yaml
content/photography/**/*.palette.yaml
# Image-dimension sidecars (produced by tools/extract-dimensions.py at
# build time; consumed by build/Filters/Images.hs to emit width / height
# attrs on every <img> for CLS prevention). Same churn-avoidance reasons
# as the photography sidecars above; recreated on every `make build`.
**/*.dims.yaml
# Photography: defense-in-depth — refuse to commit RAW or oversize
# originals. Per the photography section's design, only ≤2400px
# web-optimized JPEGs are committed; originals stay outside the repo.
# To intentionally commit one of these formats (rare), use
# `git add -f path/to/file`.
content/photography/**/*.cr2
content/photography/**/*.cr3
content/photography/**/*.nef
content/photography/**/*.arw
content/photography/**/*.dng
content/photography/**/*.raf
content/photography/**/*.orf
content/photography/**/*.tif
content/photography/**/*.tiff
content/photography/**/*.psd

View File

@ -1,4 +1,4 @@
.PHONY: build deploy sign download-model convert-images pdf-thumbs watch clean dev
.PHONY: build deploy sign download-model download-leaflet convert-images pdf-thumbs watch clean dev
# Source .env for GITHUB_TOKEN and GITHUB_REPO if it exists.
# .env format: KEY=value (one per line, no `export` prefix, no quotes needed).
@ -9,6 +9,18 @@ build:
@date +%s > data/build-start.txt
@./tools/convert-images.sh
@$(MAKE) -s pdf-thumbs
@if [ -d content/photography ]; then ./tools/download-leaflet.sh; fi
# Photography pipeline: when content/photography/ exists, generate
# per-photo EXIF + palette sidecars and per-image dimension sidecars
# (the latter site-wide for CLS prevention). Gated on .venv presence,
# matching the embed.py pattern — failures are non-fatal.
@if [ -d .venv ]; then \
uv run python tools/extract-exif.py || echo "Warning: EXIF extraction failed (build continues with frontmatter only)"; \
uv run python tools/extract-palette.py || echo "Warning: palette extraction failed (build continues with frontmatter only)"; \
uv run python tools/extract-dimensions.py || echo "Warning: dimension extraction failed (build continues without width/height attrs)"; \
else \
echo "Photography sidecars skipped: run 'uv sync' to enable EXIF + palette + dimension extraction (build continues with frontmatter only)"; \
fi
cabal run site -- build
pagefind --site _site
@if [ -d .venv ]; then \
@ -29,6 +41,13 @@ sign:
download-model:
@./tools/download-model.sh
# Vendor Leaflet + leaflet.markercluster into static/leaflet/.
# Used only by /photography/map/. Runs automatically as part of `build`
# when content/photography/ exists (skips when files already present).
# Files are gitignored; sha256-verified against tools/leaflet-checksums.sha256.
download-leaflet:
@./tools/download-leaflet.sh
# Convert JPEG/PNG images to WebP companions (also runs automatically in build).
# Requires cwebp: pacman -S libwebp / apt install webp
convert-images:

View File

@ -8,6 +8,7 @@ A full-featured static site framework built with [Hakyll](https://jaspervdj.be/h
- **Epistemic profiles** — tag essays with confidence, evidence quality, importance, and stability; readers see a compact credibility signal before committing to read.
- **Backlinks** — two-pass wikilink resolution with automatic backlink sections.
- **Score reader** — swipeable SVG score viewer for music compositions.
- **Photography** — opt-in section with masonry / grid / chronological / map / contact-sheet views, EXIF + palette auto-extraction, geo-precision-aware Leaflet map, and a darkroom-mode lightbox. Activates automatically when `content/photography/` exists.
- **Typography** — dropcaps, smallcaps auto-detection, abbreviation tooltips, old-style figures.
- **Math** — KaTeX rendering for inline and display equations.
- **Citations** — Pandoc citeproc with Chicago Notes; bibliography and further-reading sections.
@ -15,7 +16,7 @@ A full-featured static site framework built with [Hakyll](https://jaspervdj.be/h
- **Semantic search** — optional embedding pipeline (sentence-transformers + FAISS) for "similar links."
- **Settings** — dark mode, text size, focus mode, reduce motion.
- **Wikilinks**`[[Page Name]]` and `[[Page Name|display text]]` syntax.
- **Atom feeds** — site-wide and per-section (e.g., music-only).
- **Atom feeds** — site-wide and per-section (e.g., music-only, photography-only).
- **Library** — configurable portal taxonomy that groups content by tag hierarchy.
- **Version history** — git-derived stability heuristic with manual history annotations.
- **Reading mode** — dedicated layout for poetry and fiction.
@ -79,14 +80,17 @@ Makefile Build, deploy, dev targets
## Content types
| Type | Path | Template |
|:------------|:---------------------------------|:----------------|
| Essay | `content/essays/*.md` | essay.html |
| Blog post | `content/blog/*.md` | blog-post.html |
| Poetry | `content/poetry/*.md` | reading.html |
| Fiction | `content/fiction/*.md` | reading.html |
| Composition | `content/music/<slug>/index.md` | composition.html|
| Page | `content/*.md` | page.html |
| Type | Path | Template |
|:------------|:-------------------------------------------|:------------------|
| Essay | `content/essays/*.md` | essay.html |
| Blog post | `content/blog/*.md` | blog-post.html |
| Poetry | `content/poetry/*.md` | reading.html |
| Fiction | `content/fiction/*.md` | reading.html |
| Composition | `content/music/<slug>/index.md` | composition.html |
| Photo | `content/photography/<slug>/index.md` | photography.html |
| Page | `content/*.md` | page.html |
The photography section is opt-in: leave `content/photography/` absent and zero photo rules run, no leaflet is downloaded, no sidecars are generated. To turn it on, create the directory with an `index.md` (the section landing) and a first photo entry. Add `{ slug: "photography", name: "Photography" }` to `site.yaml`'s `portals` to surface a library shelf. Each photo entry can specify a JPEG file, EXIF and palette sidecars are auto-generated at build time (Pillow + colorthief), and a geo-precision-rounded `map.json` feeds the Leaflet view at `/photography/map/`.
## Deployment

View File

@ -7,6 +7,7 @@ module Compilers
, poetryCompiler
, fictionCompiler
, compositionCompiler
, photographyCompiler
, readerOpts
, writerOpts
) where
@ -200,6 +201,25 @@ fictionCompiler = essayCompiler
compositionCompiler :: Compiler (Item String)
compositionCompiler = essayCompiler
-- | Compiler for photography pages: body prose runs through the same
-- source preprocessors and AST filters as other content (so wikilinks,
-- smallcaps, sidenotes, image @<picture>@ wrapping, etc. all work in
-- caption / process-note prose), but skips TOC, word-count,
-- reading-time, citations, and further-reading. Visual content has no
-- meaningful word count, and the epistemic / bibliography surfaces in
-- 'essayCtx' don't apply here.
photographyCompiler :: Compiler (Item String)
photographyCompiler = do
body <- getResourceBody
let src = itemBody body
body' = itemSetBody (preprocessSource src) body
filePath <- getResourceFilePath
let srcDir = takeDirectory filePath
pandocItem <- readPandocWith readerOpts body'
pandocFiltered <- unsafeCompiler $ applyAll srcDir (itemBody pandocItem)
let pandocItem' = itemSetBody pandocFiltered pandocItem
return (writePandocWith writerOpts pandocItem')
-- | Compiler for simple pages: filters applied, no TOC snapshot.
pageCompiler :: Compiler (Item String)
pageCompiler = do

View File

@ -8,23 +8,30 @@ module Contexts
, poetryCtx
, fictionCtx
, compositionCtx
, photographyCtx
, contentKindField
, abstractField
, tagLinksField
, authorLinksField
, affiliationField
) where
import Data.Aeson (Value (..))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as AK
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import Data.List (intercalate, isPrefixOf)
import Data.Maybe (fromMaybe)
import qualified Data.Scientific as Sci
import Data.Time.Calendar (toGregorian)
import Data.Time.Clock (getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale)
import System.FilePath (takeDirectory, takeFileName)
import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM)
import System.Directory (doesFileExist)
import System.FilePath (takeDirectory, takeFileName, (</>))
import Text.Read (readMaybe)
import qualified Data.Text as T
import qualified Data.Yaml as Y
import qualified Config
import Text.Pandoc (runPure, readMarkdown, writeHtml5String, Pandoc(..), Block(..), Inline(..))
import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..))
@ -99,12 +106,13 @@ contentKindField = field "item-kind" $ \item -> do
return $ case r of
Nothing -> "Page"
Just r'
| "essays/" `isPrefixOf` r' -> "Essay"
| "blog/" `isPrefixOf` r' -> "Post"
| "poetry/" `isPrefixOf` r' -> "Poem"
| "fiction/" `isPrefixOf` r' -> "Fiction"
| "music/" `isPrefixOf` r' -> "Composition"
| otherwise -> "Page"
| "essays/" `isPrefixOf` r' -> "Essay"
| "blog/" `isPrefixOf` r' -> "Post"
| "poetry/" `isPrefixOf` r' -> "Poem"
| "fiction/" `isPrefixOf` r' -> "Fiction"
| "music/" `isPrefixOf` r' -> "Composition"
| "photography/" `isPrefixOf` r' -> "Photo"
| otherwise -> "Page"
-- ---------------------------------------------------------------------------
-- Site-wide context
@ -570,3 +578,394 @@ compositionCtx =
<> field "has-audio"
(\i -> maybe (fail "no audio") (const (return "true"))
(movAudio (itemBody i)))
-- ---------------------------------------------------------------------------
-- Photography context
-- ---------------------------------------------------------------------------
-- | Extract the photo entry's slug from its identifier.
--
-- * Flat single @content/photography/<slug>.md@ → @<slug>@
-- * Directory @content/photography/<slug>/index.md@ → @<slug>@
--
-- The slug is the URL segment under @/photography/@ and the directory
-- name into which co-located assets (the photo, EXIF + palette
-- sidecars) are copied by the asset rule.
photoSlug :: Item a -> String
photoSlug item =
let fp = toFilePath (itemIdentifier item)
fname = takeFileName fp
in if fname == "index.md"
then takeFileName (takeDirectory fp)
else takeWhile (/= '.') fname
-- ---------------------------------------------------------------------------
-- Sidecar reader
-- ---------------------------------------------------------------------------
--
-- @{photo}.exif.yaml@ and @{photo}.palette.yaml@ are produced by the
-- Python tools at @make build@ time (see @tools/extract-exif.py@ and
-- @tools/extract-palette.py@). They live alongside the photo file
-- under @content/photography/<slug>/@ and back-fill metadata that the
-- author chose not to write in frontmatter.
--
-- Read strategy: 'unsafeCompiler' + 'doesFileExist'. Sidecars are NOT
-- registered as Hakyll items, so this read bypasses the dependency
-- tracker. That is acceptable because:
--
-- * The Python tools always run before @cabal run site -- build@
-- (the Makefile orders them that way).
-- * Re-running the EXIF / palette extractor invalidates only those
-- fields' rendered output; rebuilding @make build@ from scratch
-- covers the dependency-edge case for free.
--
-- Resolution rule for every sidecar-backed field: frontmatter wins;
-- if frontmatter is absent OR empty, fall back to sidecar; if neither
-- supplies a value, return 'noResult' so the consuming template's
-- @$if(...)$@ guard suppresses the row.
-- | Compute the sidecar path for a photo entry.
--
-- @suffix@ is @".exif.yaml"@ or @".palette.yaml"@.
-- Returns @Nothing@ when the entry has no @photo:@ frontmatter or
-- when the entry is flat-form (no co-located asset directory).
photoSidecarPath :: String -> Item a -> Compiler (Maybe FilePath)
photoSidecarPath suffix item = do
meta <- getMetadata (itemIdentifier item)
let fp = toFilePath (itemIdentifier item)
isDir = takeFileName fp == "index.md"
case (isDir, lookupString "photo" meta) of
(True, Just photo) | not (null photo) ->
return $ Just $ takeDirectory fp </> photo ++ suffix
_ -> return Nothing
-- | Load a sidecar YAML file as an Aeson Object (same shape Hakyll
-- uses for frontmatter). Returns 'KM.empty' when the file is
-- missing or fails to parse — sidecars are advisory, never fatal.
loadSidecar :: FilePath -> IO Aeson.Object
loadSidecar path = do
exists <- doesFileExist path
if not exists
then return KM.empty
else do
decoded <- Y.decodeFileEither path
case decoded of
Right (Object obj) -> return obj
_ -> return KM.empty
-- | Read a sidecar object for a given suffix. Returns the empty object
-- when the entry has no resolvable sidecar path or when the file is
-- absent / malformed.
readPhotoSidecar :: String -> Item a -> Compiler Aeson.Object
readPhotoSidecar suffix item = do
mPath <- photoSidecarPath suffix item
case mPath of
Nothing -> return KM.empty
Just path -> unsafeCompiler (loadSidecar path)
-- | Coerce a YAML scalar value to a plain String for template
-- interpolation. Integers render without a trailing @.0@; structures
-- and arrays return 'Nothing' (callers needing those should branch
-- on 'Value' directly).
yamlAsString :: Value -> Maybe String
yamlAsString (String t) =
let s = T.unpack t
in if null (trim s) then Nothing else Just (trim s)
yamlAsString (Number n) =
case Sci.floatingOrInteger n :: Either Double Integer of
Right i -> Just (show i)
Left d -> Just (show d)
yamlAsString _ = Nothing
-- | Look up a key in a sidecar object, coercing scalar values to
-- String. Returns 'Nothing' for missing keys, empty strings, and
-- structural values (arrays / nested objects).
sidecarLookupString :: String -> Aeson.Object -> Maybe String
sidecarLookupString key obj = yamlAsString =<< KM.lookup (AK.fromString key) obj
-- | Generic frontmatter > EXIF-sidecar fallback field.
--
-- @key@ is the YAML key — same name on both sides. Frontmatter
-- wins when present and non-empty; otherwise the matching key in
-- @{photo}.exif.yaml@. 'noResult' fires when neither supplies a
-- value, so the consuming template's @$if(key)$@ guard suppresses
-- the row.
exifBackedField :: String -> Context String
exifBackedField key = field key $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString key meta of
Just v | not (null (trim v)) -> return (trim v)
_ -> do
obj <- readPhotoSidecar ".exif.yaml" item
case sidecarLookupString key obj of
Just v -> return v
Nothing -> noResult ("no " ++ key ++ " in frontmatter or EXIF sidecar")
-- | Canonical URL for a known license name.
--
-- The frontmatter @license:@ string is normalized — lowercased, with
-- internal whitespace collapsed — before lookup, so any of these all
-- resolve identically:
--
-- * @"CC BY-SA 4.0"@
-- * @"cc by-sa 4.0"@
-- * @" CC BY-SA 4.0 "@
--
-- For licenses not in this table (e.g. a custom license, or "All
-- Rights Reserved" which has no URL), the author can supply their
-- own @license-url:@ frontmatter field; the field-level resolver
-- (@licenseUrlField@) prefers explicit @license-url@ and falls back
-- to this lookup only when the author hasn't provided one.
canonicalLicenseUrl :: String -> Maybe String
canonicalLicenseUrl raw =
case unwords (words (map (\c -> if c == '_' then ' ' else toLowerC c) raw)) of
"cc by 4.0" -> Just "https://creativecommons.org/licenses/by/4.0/"
"cc by-sa 4.0" -> Just "https://creativecommons.org/licenses/by-sa/4.0/"
"cc by-nc 4.0" -> Just "https://creativecommons.org/licenses/by-nc/4.0/"
"cc by-nc-sa 4.0" -> Just "https://creativecommons.org/licenses/by-nc-sa/4.0/"
"cc by-nd 4.0" -> Just "https://creativecommons.org/licenses/by-nd/4.0/"
"cc by-nc-nd 4.0" -> Just "https://creativecommons.org/licenses/by-nc-nd/4.0/"
"cc0" -> Just "https://creativecommons.org/publicdomain/zero/1.0/"
"cc0 1.0" -> Just "https://creativecommons.org/publicdomain/zero/1.0/"
"public domain" -> Just "https://creativecommons.org/publicdomain/mark/1.0/"
_ -> Nothing
where
toLowerC c
| c >= 'A' && c <= 'Z' = toEnum (fromEnum c + 32)
| otherwise = c
-- | Context for photography pages and photo cards.
--
-- Frontmatter fields win when present; auto-extracted EXIF + palette
-- sidecars produced by @tools/extract-exif.py@ /
-- @tools/extract-palette.py@ fill in the gaps.
--
-- Photography pages do not include the essay context's epistemic,
-- bibliography, backlinks, similar-links, TOC, word-count, or
-- reading-time fields — none of those apply to visual content.
--
-- Exposed template variables:
-- @$photography$@ — flag, gates @photography.css@ in head.html
-- and the @data-page-type@ body attribute used
-- by the darkroom-mode lightbox
-- @$slug$@ — URL slug under @/photography/@
-- @$photo-url$@ — absolute URL of the photo file. Built as
-- @/photography/<slug>/<photo>@ when the entry
-- is directory-form; @noResult@ for flat
-- singles (templates use the @photo@
-- frontmatter directly there).
-- @$captured-display$@, @$captured-iso$@ — capture date in
-- human-readable and ISO forms; @noResult@
-- when @captured:@ is absent. Distinct from
-- the publication @date:@ shown in card lists.
-- @$photography-tags$@ — listField of @{tag-name, tag-url}@.
-- @$palette-swatches$@ — listField of @{swatch}@ (hex string).
-- @noResult@ when the @palette:@ frontmatter
-- is absent or empty so the template's
-- @$if(palette-swatches)$@ gate suppresses an
-- empty strip.
photographyCtx :: Context String
photographyCtx =
constField "photography" "true"
<> slugField
<> photoUrlField
<> photoWebpUrlField
-- EXIF-backed fields. Each prefers frontmatter and falls back to
-- @{photo}.exif.yaml@ produced by @tools/extract-exif.py@. Sidecars
-- absent on film scans (no EXIF on a film negative) is fine —
-- noResult propagates and the template's @$if(...)$@ gate hides
-- the row.
<> exifBackedField "camera"
<> exifBackedField "lens"
<> exifBackedField "exposure"
<> exifBackedField "shutter"
<> exifBackedField "aperture"
<> exifBackedField "iso"
<> exifBackedField "focal-length"
-- Pixel dimensions for CLS-prevention width/height attrs on every
-- <img>. Read from the EXIF sidecar produced by extract-exif.py;
-- frontmatter wins if the author wants to override (e.g., to
-- declare a different rendered size).
<> exifBackedField "width"
<> exifBackedField "height"
<> capturedDisplayField
<> capturedIsoField
<> paletteSwatchesField
<> licenseUrlField
<> photoLinksField
<> tagLinksField "photography-tags"
<> authorLinksField
<> affiliationField
<> dateField "date" "%-d %B %Y"
<> dateField "date-iso" "%Y-%m-%d"
<> siteCtx
where
slugField :: Context String
slugField = field "slug" (return . photoSlug)
-- Build @/photography/<slug>/<photo>@ when both the directory-form
-- entry and a @photo:@ frontmatter key are present. Flat singles
-- have no co-located asset directory, so @noResult@ there — the
-- template falls back to interpreting the @photo:@ frontmatter
-- as a literal URL.
photoUrlField :: Context String
photoUrlField = field "photo-url" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fp = toFilePath (itemIdentifier item)
isDir = takeFileName fp == "index.md"
case (isDir, lookupString "photo" meta) of
(True, Just photo) ->
return $ "/photography/" ++ photoSlug item ++ "/" ++ photo
_ -> noResult "no co-located photo (flat single, or photo: key absent)"
-- WebP companion URL, mirroring 'photoUrlField'. Returns 'noResult'
-- when the @.webp@ companion doesn't exist on disk at compile time
-- (cwebp not installed, conversion not yet run, or this image
-- failed to convert) so the template's @$if(photo-webp-url)$@
-- guard suppresses the @<source>@ — the @<picture>@ then degrades
-- to a plain @<img>@ on the original-format src. Browsers do NOT
-- fall back from a 404'd @<source>@ to the nested @<img>@; the
-- file-existence check at build time is load-bearing.
photoWebpUrlField :: Context String
photoWebpUrlField = field "photo-webp-url" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fp = toFilePath (itemIdentifier item)
isDir = takeFileName fp == "index.md"
case (isDir, lookupString "photo" meta) of
(True, Just photo) | not (null photo) -> do
let entryDir = takeDirectory fp
webpDisk = entryDir </> photoToWebp photo
exists <- unsafeCompiler (doesFileExist webpDisk)
if exists
then return $ "/photography/" ++ photoSlug item
++ "/" ++ photoToWebp photo
else noResult "no webp companion on disk"
_ -> noResult "no co-located photo (flat single, or photo: key absent)"
where
photoToWebp :: String -> String
photoToWebp p =
let dotIdx = lastDotIndex p
in case dotIdx of
Just i -> take i p ++ ".webp"
Nothing -> p ++ ".webp"
lastDotIndex :: String -> Maybe Int
lastDotIndex s = go (length s - 1)
where
go i
| i < 0 = Nothing
| s !! i == '/' = Nothing -- crossed a path boundary
| s !! i == '.' = Just i
| otherwise = go (i - 1)
-- Resolve the @captured:@ ISO date with frontmatter > sidecar
-- precedence. Centralised so the display and ISO fields stay in
-- agreement on which source they read from.
resolveCapturedIso :: Item a -> Compiler (Maybe String)
resolveCapturedIso item = do
meta <- getMetadata (itemIdentifier item)
case lookupString "captured" meta of
Just v | not (null (trim v)) -> return (Just (trim v))
_ -> do
obj <- readPhotoSidecar ".exif.yaml" item
return (sidecarLookupString "captured" obj)
-- @captured:@ as "15 March 2026". Reads frontmatter, falls back to
-- the EXIF sidecar's @captured:@ key. Returns @noResult@ when
-- absent so @$if(captured-display)$@ gates the metadata row.
capturedDisplayField :: Context String
capturedDisplayField = field "captured-display" $ \item -> do
mIso <- resolveCapturedIso item
case mIso of
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
Just iso ->
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
:: Maybe UTCTime of
Just t -> return (formatTime defaultTimeLocale "%-d %B %Y" t)
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
-- ISO form passed through unchanged (after a parse-validate round-trip
-- so a malformed value in either source doesn't reach the template).
capturedIsoField :: Context String
capturedIsoField = field "captured-iso" $ \item -> do
mIso <- resolveCapturedIso item
case mIso of
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
Just iso ->
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
:: Maybe UTCTime of
Just t -> return (formatTime defaultTimeLocale "%Y-%m-%d" t)
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
-- @palette:@ list field. Frontmatter wins; otherwise pull the
-- list from @{photo}.palette.yaml@ (the @palette:@ key, an array
-- of hex strings produced by @tools/extract-palette.py@). Each
-- swatch exposes @$swatch$@.
paletteSwatchesField :: Context String
paletteSwatchesField = listFieldWith "palette-swatches" swCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let fmEntries = fromMaybe [] (lookupStringList "palette" meta)
fmVisible = filter (not . null . trim) fmEntries
swatches <- if null fmVisible
then do
obj <- readPhotoSidecar ".palette.yaml" item
case KM.lookup "palette" obj of
Just (Array vec) ->
return [ trim s
| val <- V.toList vec
, Just s <- [yamlAsString val]
, not (null (trim s)) ]
_ -> return []
else return fmVisible
if null swatches
then noResult "no palette swatches in frontmatter or palette sidecar"
else return $ zipWith
(\i s -> Item (fromFilePath ("palette-" ++ show i)) s)
([0 ..] :: [Int])
swatches
where
swCtx = field "swatch" (return . itemBody)
-- @$license-url-resolved$@: an explicit @license-url:@ frontmatter
-- value when present, otherwise a canonical URL looked up from the
-- @license:@ string for known licenses (CC variants, CC0, public
-- domain). Returns @noResult@ when neither is set, so
-- @$if(license-url-resolved)$@ gates the link wrapper.
--
-- Frontmatter @license:@ itself flows through @defaultContext@ as
-- @$license$@; the template renders the license name as link text
-- and uses @$license-url-resolved$@ as @href@.
licenseUrlField :: Context String
licenseUrlField = field "license-url-resolved" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "license-url" meta of
Just u | not (null (trim u)) -> return (trim u)
_ -> case lookupString "license" meta of
Nothing -> noResult "no license"
Just l -> case canonicalLicenseUrl l of
Just u -> return u
Nothing -> noResult "license not in canonical lookup"
-- @links:@ frontmatter — outbound links to other surfaces where
-- the photograph appears or can be acquired (Wikimedia Commons,
-- Flickr, exhibition catalog, print-sale page, etc.). Each entry
-- uses the same @"Name | URL"@ pipe syntax as @authors:@ /
-- @affiliation:@ — the existing site convention.
--
-- Each item exposes @$link-name$@ and @$link-url$@. Entries
-- without a URL are dropped (no point linking to nothing). Returns
-- @noResult@ on empty so @$if(photo-links)$@ guards the wrapper.
photoLinksField :: Context String
photoLinksField = listFieldWith "photo-links" lkCtx $ \item -> do
meta <- getMetadata (itemIdentifier item)
let entries = fromMaybe [] (lookupStringList "links" meta)
parsed = filter (not . null . snd) (map parseEntry entries)
if null parsed
then noResult "no outbound links"
else return $ map (Item (fromFilePath "")) parsed
where
lkCtx = field "link-name" (return . fst . itemBody)
<> field "link-url" (return . snd . itemBody)
parseEntry s = case break (== '|') s of
(name, '|' : url) -> (trim name, trim url)
(name, _) -> (trim name, "")

View File

@ -1,6 +1,7 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Image filter: lazy loading, lightbox markers, and WebP <picture> wrappers.
-- | Image filter: lazy loading, lightbox markers, WebP <picture>
-- wrappers, and CLS-preventing width/height attrs.
--
-- For local raster images (JPG, JPEG, PNG, GIF) whose @.webp@ companion
-- exists on disk at build time, emits a @<picture>@ element with a WebP
@ -17,16 +18,29 @@
--
-- SVG files and external URLs are passed through with only lazy loading
-- (and lightbox markers for standalone images).
--
-- Width / height attrs are looked up from @{image}.dims.yaml@ sidecars
-- produced by @tools/extract-dimensions.py@ at build time, on the same
-- path-resolution rules as the WebP companion check (absolute paths
-- under @static/@, relative under the source-file directory). When a
-- sidecar is missing the filter emits an attr-free <img> rather than
-- guessing — partial dimensions are worse than no dimensions, since
-- the browser would then size the image wrong on first paint.
module Filters.Images (apply) where
import Data.Char (toLower)
import Data.Default (def)
import Data.List (isPrefixOf)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Scientific as Sci
import qualified Data.Yaml as Y
import Text.Pandoc.Definition
import qualified Text.Pandoc as Pandoc
import Text.Pandoc.Walk (walkM)
import System.Directory (doesFileExist)
import System.FilePath (replaceExtension, takeExtension, (</>))
import Text.Pandoc.Definition
import Text.Pandoc.Walk (walkM)
import qualified Utils as U
-- | Apply image attribute injection and WebP wrapping to the entire document.
@ -35,13 +49,76 @@ import qualified Utils as U
-- relative image paths when probing for the corresponding @.webp@
-- companion file. Absolute paths (leading @/@) are resolved against
-- @static/@ instead, matching the layout @convert-images.sh@ writes to.
--
-- Two-pass walk:
--
-- 1. Block-level pass (@transformBlock@) intercepts standalone
-- figures so we can synthesize the entire @<figure>@ ourselves
-- when WebP wrapping kicks in. Without this pass, replacing the
-- inner @Image@ with a @RawInline@ would break Pandoc's
-- alt-vs-caption comparison and we'd lose the
-- @aria-hidden="true"@ hint on identical-text figcaptions.
-- 2. Inline-level pass (@transformInline@) handles every remaining
-- @Image@ — inline-in-prose, inside @Link@s, etc. Pandoc's writer
-- still applies its accessibility heuristics for figures we
-- didn't synthesize (notably the no-WebP case).
apply :: FilePath -> Pandoc -> IO Pandoc
apply srcDir = walkM (transformInline srcDir)
apply srcDir doc = do
doc' <- walkM (transformBlock srcDir) doc
walkM (transformInline srcDir) doc'
-- ---------------------------------------------------------------------------
-- Core transformation
-- Core transformations
-- ---------------------------------------------------------------------------
-- | Block-level pass. Currently only acts on the simple-figure shape
-- that Pandoc's Markdown reader produces for @![alt](src)@ standalone:
--
-- @Figure attr caption [Plain [Image imgAttr alt target]]@
--
-- When the image has a WebP companion on disk, we replace the whole
-- Figure with a @RawBlock@ containing the equivalent HTML — but with
-- the @<picture>@ wrapper inside and a manually-emitted
-- @aria-hidden="true"@ on the figcaption when alt text equals the
-- caption text. Anything more exotic (multi-image figures, mixed
-- block content inside the figure, no-WebP images) is left to
-- Pandoc's default emission, which is already correct for those
-- cases.
transformBlock :: FilePath -> Block -> IO Block
transformBlock srcDir b@(Figure figAttr caption [Plain [Image imgAttr alt target]]) = do
let src = T.unpack (fst target)
if not (isLocalRaster src)
then pure b
else do
hasWebp <- doesFileExist (webpPhysicalPath srcDir (fst target))
if not hasWebp
then pure b -- Pandoc handles aria-hidden naturally on the no-WebP path.
else synthesizeFigure srcDir figAttr caption imgAttr alt target
transformBlock _ b = pure b
-- | Build a @<figure>@ block from an Image and its surrounding
-- metadata. Used only on the WebP branch; the no-WebP branch leaves
-- Pandoc to emit the figure naturally.
--
-- Aria-hiding rule: when the caption's plain-text content equals the
-- alt text and both are non-empty, mark the @<figcaption>@ with
-- @aria-hidden="true"@. Screen readers then announce the alt
-- (via the @<img>@) and skip the figcaption (which would just
-- duplicate it). Non-matching captions render as visible content.
--
-- Caption inline rendering goes through Pandoc's HTML writer, so
-- formatting (italic, links, code, etc.) is preserved.
synthesizeFigure :: FilePath -> Attr -> Caption -> Attr -> [Inline] -> Target -> IO Block
synthesizeFigure srcDir figAttr caption imgAttr alt target = do
dims <- readDims srcDir (fst target)
let pictureHtml = renderPicture imgAttr alt target True dims
capInlines = captionInlines caption
capText = stringify capInlines
altText = stringify alt
useAriaHide = capText == altText && not (T.null altText)
pure $ RawBlock (Format "html") $
renderFigure figAttr pictureHtml (renderFigcaption capInlines useAriaHide)
transformInline :: FilePath -> Inline -> IO Inline
transformInline srcDir (Link lAttr ils lTarget) = do
-- Recurse into link contents; images inside a link get no lightbox marker.
@ -60,21 +137,41 @@ wrapLinkedImg _ x = pure x
-- * Local raster with webp companion on disk → @<picture>@ with WebP @<source>@
-- * Local raster without companion → plain @<img>@ (graceful degradation)
-- * Everything else (SVG, URL) → plain @<img>@ with loading/lightbox attrs
--
-- In all three branches, when a @{image}.dims.yaml@ sidecar is
-- present, @width@ and @height@ attrs are emitted on the rendered
-- @<img>@. The sidecar lookup is skipped for non-local sources
-- (HTTP URLs, data URIs) since there's no local file to measure.
renderImg :: FilePath -> Attr -> [Inline] -> Target -> Bool -> IO Inline
renderImg srcDir attr alt target@(src, _) lightbox
| isLocalRaster (T.unpack src) = do
hasWebp <- doesFileExist (webpPhysicalPath srcDir src)
if hasWebp
then pure $ RawInline (Format "html")
(renderPicture attr alt target lightbox)
else pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr))
alt target
| otherwise =
pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) alt target
renderImg srcDir attr alt target@(src, _) lightbox = do
let s = T.unpack src
isRaster = isLocalRaster s
local = not (isUrl s)
dims <- if local then readDims srcDir src else pure Nothing
if isRaster
then do
hasWebp <- doesFileExist (webpPhysicalPath srcDir src)
if hasWebp
then pure $ RawInline (Format "html")
(renderPicture attr alt target lightbox dims)
else pure $ Image (commonAttrs dims) alt target
else
pure $ Image (commonAttrs dims) alt target
where
commonAttrs dims =
withDims dims
$ addAttr "decoding" "async"
$ addLightbox lightbox
$ addAttr "loading" "lazy" attr
addLightbox True a = addAttr "data-lightbox" "true" a
addLightbox False a = a
withDims Nothing a = a
withDims (Just (w, h)) a =
addAttr "width" (T.pack (show w))
(addAttr "height" (T.pack (show h)) a)
-- | Physical on-disk path of the @.webp@ companion for a Markdown image src.
--
-- Absolute paths (@/images/foo.jpg@) resolve under @static/@ because that
@ -89,13 +186,49 @@ webpPhysicalPath srcDir src =
else srcDir </> s
in replaceExtension physical ".webp"
-- | Physical on-disk path of the @.dims.yaml@ sidecar for a Markdown
-- image src. Same path-resolution rules as 'webpPhysicalPath'; the
-- sidecar lives next to the original image with the literal
-- extension @.dims.yaml@ appended.
dimsPhysicalPath :: FilePath -> Text -> FilePath
dimsPhysicalPath srcDir src =
let s = T.unpack src
physical = if "/" `isPrefixOf` s
then "static" ++ s
else srcDir </> s
in physical ++ ".dims.yaml"
-- | Read the @{image}.dims.yaml@ sidecar and return @(width, height)@
-- when present and parseable. Returns 'Nothing' on absent file,
-- parse error, missing keys, or non-integer values — all of which
-- cause the filter to emit no width/height attrs (rather than a
-- guess that would size the image wrong on first paint).
readDims :: FilePath -> Text -> IO (Maybe (Int, Int))
readDims srcDir src = do
let path = dimsPhysicalPath srcDir src
exists <- doesFileExist path
if not exists
then pure Nothing
else do
decoded <- Y.decodeFileEither path
pure $ case decoded of
Right (Y.Object obj) -> do
w <- intValue =<< KM.lookup "width" obj
h <- intValue =<< KM.lookup "height" obj
Just (w, h)
_ -> Nothing
where
intValue :: Y.Value -> Maybe Int
intValue (Y.Number n) = Sci.toBoundedInteger n
intValue _ = Nothing
-- ---------------------------------------------------------------------------
-- <picture> rendering
-- ---------------------------------------------------------------------------
-- | Emit a @<picture>@ element with a WebP @<source>@ and an @<img>@ fallback.
renderPicture :: Attr -> [Inline] -> Target -> Bool -> Text
renderPicture (ident, classes, kvs) alt (src, title) lightbox =
renderPicture :: Attr -> [Inline] -> Target -> Bool -> Maybe (Int, Int) -> Text
renderPicture (ident, classes, kvs) alt (src, title) lightbox dims =
T.concat
[ "<picture>"
, "<source srcset=\"", T.pack webpSrc, "\" type=\"image/webp\">"
@ -105,7 +238,9 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox =
, " src=\"", esc src, "\""
, attrAlt alt
, attrTitle title
, dimsAttrs dims
, " loading=\"lazy\""
, " decoding=\"async\""
, if lightbox then " data-lightbox=\"true\"" else ""
, renderKvs passedKvs
, ">"
@ -114,13 +249,81 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox =
where
webpSrc = replaceExtension (T.unpack src) ".webp"
-- Strip attrs we handle explicitly above (id/class/alt/title) and the
-- attrs we always emit ourselves (loading, data-lightbox), so they don't
-- appear twice on the <img>.
-- attrs we always emit ourselves (loading, decoding, data-lightbox,
-- width, height), so they don't appear twice on the <img>.
passedKvs = filter
(\(k, _) -> k `notElem`
["loading", "data-lightbox", "id", "class", "alt", "title", "src"])
[ "loading", "decoding", "data-lightbox"
, "id", "class", "alt", "title", "src"
, "width", "height"
])
kvs
dimsAttrs Nothing = ""
dimsAttrs (Just (w, h)) =
" width=\"" <> T.pack (show w)
<> "\" height=\"" <> T.pack (show h) <> "\""
-- ---------------------------------------------------------------------------
-- <figure> synthesis (Block walk, WebP path only)
-- ---------------------------------------------------------------------------
-- | Build a @<figure>@ HTML element wrapping pre-rendered inner
-- content (typically a @<picture>@) and a pre-rendered figcaption.
-- Preserves any id / classes / kvs from the surrounding Pandoc
-- 'Figure' attr.
renderFigure :: Attr -> Text -> Text -> Text
renderFigure (figId, figClasses, figKvs) inner figcaption =
T.concat
[ "<figure"
, attrId figId
, attrClasses figClasses
, renderKvs figKvs
, ">\n"
, inner
, "\n"
, figcaption
, "\n</figure>"
]
-- | Build a @<figcaption>@ element. When @ariaHidden@ is true, emits
-- @aria-hidden="true"@ — used when the caption text exactly
-- duplicates the image alt (so screen readers don't announce the
-- same content twice). Caption inlines render through Pandoc's HTML
-- writer to preserve formatting.
renderFigcaption :: [Inline] -> Bool -> Text
renderFigcaption ils ariaHidden =
let body = renderInlinesToHtml ils
attrs = if ariaHidden then " aria-hidden=\"true\"" else ""
in "<figcaption" <> attrs <> ">" <> body <> "</figcaption>"
-- | Pandoc 'Caption' has a long form (@[Block]@) and an optional short
-- form (@Maybe ShortCaption@). We use the long form, flattening any
-- @Plain@ / @Para@ blocks into a single inline list. Multi-block
-- captions (rare) collapse to the inlines of their text-bearing
-- blocks; non-text blocks (like nested lists) are dropped, since
-- they don't make sense in a figcaption anyway.
captionInlines :: Caption -> [Inline]
captionInlines (Caption _ blocks) = concatMap go blocks
where
go (Plain ils) = ils
go (Para ils) = ils
go _ = []
-- | Render Pandoc 'Inline' nodes to HTML using Pandoc's own writer.
-- Wrapping the inlines in a @Plain@ block (rather than @Para@)
-- avoids the surrounding @<p>@ tag the writer would otherwise emit.
-- On writer failure (extremely unlikely for inline-only input),
-- falls back to the plain-text 'stringify' rendering — a worse but
-- still safe figcaption.
renderInlinesToHtml :: [Inline] -> Text
renderInlinesToHtml ils =
case Pandoc.runPure (Pandoc.writeHtml5String def doc) of
Right t -> T.strip t
Left _ -> stringify ils
where
doc = Pandoc mempty [Plain ils]
attrId :: Text -> Text
attrId t = if T.null t then "" else " id=\"" <> esc t <> "\""

View File

@ -16,6 +16,8 @@ module Patterns
, poetryPattern
, fictionPattern
, musicPattern
, photographyPattern
, allPhotoEntries
, standalonePagesPattern
-- * Aggregated patterns
, allWritings -- essays + blog + poetry + fiction
@ -66,6 +68,44 @@ fictionPattern = "content/fiction/*.md"
musicPattern :: Pattern
musicPattern = "content/music/*/index.md"
-- | All photo entries — flat singles plus directory-form entries.
--
-- Two shapes:
-- * flat: @content/photography/<slug>.md@
-- * directory: @content/photography/<slug>/index.md@
--
-- The section landing page at @content/photography/index.md@ is
-- excluded; it routes via 'Photography.photographyLandingRules' as the
-- section landing, not as a photo entry.
--
-- Directory-form @index.md@ files are treated as either single-photo
-- entries (when the directory has no @.md@ siblings) or series landings
-- (when it does). 'Photography.photographyEntryRules' branches on that
-- structurally — no @series: true@ frontmatter flag is needed.
photographyPattern :: Pattern
photographyPattern =
("content/photography/*.md" .&&. complement "content/photography/index.md")
.||. "content/photography/*/index.md"
-- | Every photographic entry, including children of series. Distinct
-- from 'photographyPattern' (which enumerates only top-level entries
-- and series landings) for surfaces that should enumerate every
-- photograph individually:
--
-- * @/photography/by-year/<year>/@ — one frame per file
-- * @/photography/contact-sheet/@ — every frame in the roll
-- * @/photography/map.json@ — one pin per geotagged photo
-- * @/photography/feed.xml@ — one entry per shot
-- * Tag indexes — siblings have their own tags
--
-- The main @/photography/@ landing and the library shelf use
-- 'photographyPattern' instead, so a series shows up as a single
-- aggregate card rather than once for the landing plus once per child.
allPhotoEntries :: Pattern
allPhotoEntries =
photographyPattern
.||. ("content/photography/*/*.md" .&&. complement "content/photography/*/index.md")
-- | Top-level standalone pages (about, colophon, current, gpg, …).
standalonePagesPattern :: Pattern
standalonePagesPattern = "content/*.md"
@ -95,6 +135,14 @@ allContent =
authorIndexable :: Pattern
authorIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion
-- | Content shown on tag index pages — essays + blog posts.
-- | Content shown on tag index pages — essays + blog posts + every
-- photographic entry (including sibling photos in series).
-- Photography sub-tags (@photography/landscape@, @photography/film@,
-- …) generate proper @/<sub-tag>/@ pages from this pattern; the
-- bare @photography@ top-level tag is filtered out in
-- 'Tags.getExpandedTags' to avoid colliding with the section
-- landing's route at @/photography/@.
tagIndexable :: Pattern
tagIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion
tagIndexable =
(essayPattern .||. blogPattern .||. allPhotoEntries)
.&&. hasNoVersion

572
build/Photography.hs Normal file
View File

@ -0,0 +1,572 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Photography section — routing and per-page compilation.
--
-- Activates only when @content/photography/@ exists in the project
-- tree (gated in 'Site.rules'). Photographers who don't want a
-- photography section simply leave the directory absent and pay zero
-- cost — no rules, no generated pages, no feed.
--
-- Surfaces:
--
-- * Single-photo entries — flat (@content/photography/<slug>.md@)
-- and directory form (@content/photography/<slug>/index.md@).
-- * Series — a directory with siblings, e.g.
-- @content/photography/<series>/<photo>.md@. Series detection is
-- structural; no @series: true@ frontmatter flag is needed.
-- * Section landing at @/photography/@.
-- * Map at @/photography/map/@ with @map.json@ for the Leaflet client.
-- * By-year indexes at @/photography/by-year/{year}/@.
-- * Contact sheet at @/photography/contact-sheet/@.
-- * Atom feed at @/photography/feed.xml@.
--
-- See @PHOTOGRAPHY.md@ in the upstream levineuwirth.org repo for the
-- full design rationale.
module Photography
( photographyRules
) where
import Control.Monad (forM, forM_)
import Data.List (sortBy)
import qualified Data.Map.Strict as Map
import Data.Map.Strict (Map)
import Data.Maybe (mapMaybe, fromMaybe, catMaybes)
import qualified Data.Set as Set
import Data.Set (Set)
import Data.Ord (Down (..), comparing)
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
import qualified Data.Aeson as Aeson
import Data.Aeson (Value (..), (.=))
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Encoding as TLE
import qualified Data.Vector as V
import qualified Data.Scientific as Sci
import Hakyll
import qualified Config
import Compilers (pageCompiler, photographyCompiler)
import Contexts (photographyCtx, pageCtx, siteCtx)
import qualified Patterns as P
-- ---------------------------------------------------------------------------
-- Rules
-- ---------------------------------------------------------------------------
-- | All photography rules. Called from 'Site.rules' once, and only
-- when @content/photography/@ exists.
--
-- Order is intentional:
--
-- 1. Co-located assets first (so the photo file is in @_site/@
-- before any page that references it is compiled — Hakyll's
-- dependency tracker handles this anyway, but the surface
-- ordering reads top-down by data flow).
-- 2. Single-photo entries (flat + directory form).
-- 3. Section landing at @/photography/@ — loaded after the
-- photo entries so its @loadAll photographyPattern@ resolves
-- each photo's frontmatter through 'photographyCtx'.
photographyRules :: Rules ()
photographyRules = do
-- A directory is a "series" iff it has @.md@ siblings alongside
-- its @index.md@. Collected once at rule-gen time so the entry
-- rule can branch on series-landing template selection without
-- re-globbing per item.
siblingIds <- getMatches
( "content/photography/*/*.md"
.&&. complement "content/photography/*/index.md"
)
let seriesSlugs :: Set String
seriesSlugs = Set.fromList
[ takeFileName (takeDirectory (toFilePath ident))
| ident <- siblingIds
]
photographyAssetRules
photographyEntryRules seriesSlugs
photographySeriesPhotoRules
photographyLandingRules
photographyMapDataRule
photographyMapPageRule
photographyFeedRule
photographyByYearRules
photographyContactSheetRule
-- ---------------------------------------------------------------------------
-- Assets
-- ---------------------------------------------------------------------------
-- | Co-located assets — the photo file itself, and the
-- build-generated @{photo}.exif.yaml@ + @{photo}.palette.yaml@
-- sidecars. Two patterns are matched in sequence:
--
-- * @content/photography/<asset>@ — flat-single co-located assets
-- * @content/photography/<slug>/<asset>@ — directory-form co-located assets
--
-- Markdown files are excluded from both rules; they're compiled by
-- 'photographyEntryRules' and 'photographyLandingRules'. The
-- @.exif.yaml@ / @.palette.yaml@ / @.dims.yaml@ sidecars are
-- excluded too — they're consumed by Hakyll at compile time and have
-- no role in the deployed site.
photographyAssetRules :: Rules ()
photographyAssetRules = do
-- Top-level non-Markdown files (flat-single co-located assets).
match ("content/photography/*"
.&&. complement "content/photography/*.md"
.&&. complement "content/photography/*.exif.yaml"
.&&. complement "content/photography/*.palette.yaml"
.&&. complement "content/photography/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- Directory-form entries' co-located assets.
match ("content/photography/*/*"
.&&. complement "content/photography/*/index.md"
.&&. complement "content/photography/*/*.md"
.&&. complement "content/photography/*/*.exif.yaml"
.&&. complement "content/photography/*/*.palette.yaml"
.&&. complement "content/photography/*/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- ---------------------------------------------------------------------------
-- Single-photo entries
-- ---------------------------------------------------------------------------
-- | Compile each single-photo entry. Routing follows the essay
-- convention so the URL shape is predictable:
--
-- * @content/photography/<slug>.md@ → @photography/<slug>.html@
-- * @content/photography/<slug>/index.md@ → @photography/<slug>/index.html@
--
-- The @"content"@ snapshot is saved so @/photography/feed.xml@ can
-- render the rendered body as feed entry content.
photographyEntryRules :: Set String -> Rules ()
photographyEntryRules seriesSlugs =
match P.photographyPattern $ do
route photoEntryRoute
compile $ do
ident <- getUnderlying
let fp = toFilePath ident
isIndex = takeFileName fp == "index.md"
slug = takeFileName (takeDirectory fp)
isSeriesLanding = isIndex && slug `Set.member` seriesSlugs
template
| isSeriesLanding = "templates/photography-series.html"
| otherwise = "templates/photography.html"
ctx
| isSeriesLanding = seriesCtx
| otherwise = photographyCtx
photographyCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate template ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- | Sibling photos inside a series directory:
-- @content/photography/<series>/<photo>.md@. Compiled with the
-- single-photo template; routed to @<series>/<photo>/index.html@
-- so the URL is the canonical directory form (matches the rest of
-- the photography section's URL shape).
--
-- Series landings (@<series>/index.md@) are handled by
-- 'photographyEntryRules' with the @photographyPattern@ match;
-- they're explicitly excluded here so the two rules don't double-route.
photographySeriesPhotoRules :: Rules ()
photographySeriesPhotoRules =
match ("content/photography/*/*.md"
.&&. complement "content/photography/*/index.md") $ do
route $ customRoute $ \ident ->
-- Drop @"content/"@ prefix and @".md"@ suffix, then append
-- @"/index.html"@ to get directory-style URLs.
let fp = toFilePath ident
rel = drop (length contentPrefix) fp
stripped = take (length rel - 3) rel
in stripped ++ "/index.html"
compile $ photographyCompiler
>>= saveSnapshot "content"
>>= loadAndApplyTemplate "templates/photography.html" photographyCtx
>>= loadAndApplyTemplate "templates/default.html" photographyCtx
>>= relativizeUrls
where
contentPrefix = "content/" :: String
-- | Context for series-landing pages. Extends 'photographyCtx' with a
-- @series-photos@ list field that loads the directory's sibling
-- photos (the @<series>/<photo>.md@ files), most-recent-first.
--
-- The @is-series@ const flag lets the consuming template branch on
-- whether to render single-photo chrome (figure + EXIF dl + body)
-- or series chrome (intro + photo grid + body).
seriesCtx :: Context String
seriesCtx =
constField "is-series" "true"
<> listFieldWith "series-photos" photographyCtx loadSeriesChildren
<> photographyCtx
where
loadSeriesChildren parent = do
let ident = itemIdentifier parent
slug = takeFileName (takeDirectory (toFilePath ident))
pat = fromGlob ("content/photography/" ++ slug ++ "/*.md")
.&&. complement
(fromGlob ("content/photography/" ++ slug ++ "/index.md"))
.&&. hasNoVersion
recentFirst =<< loadAll pat
-- | Route a photography entry to its public URL. The pattern check on
-- @takeFileName@ distinguishes flat (@content/photography/<slug>.md@)
-- from directory-form (@content/photography/<slug>/index.md@) without
-- re-globbing, since Hakyll has already pre-filtered to entries
-- matching 'P.photographyPattern'.
photoEntryRoute :: Routes
photoEntryRoute = customRoute $ \ident ->
let fp = toFilePath ident
fname = takeFileName fp
isIndex = fname == "index.md"
in if isIndex
-- content/photography/<slug>/index.md
-- → photography/<slug>/index.html
then replaceExtension (drop (length contentPrefix) fp) "html"
-- content/photography/<slug>.md → photography/<slug>.html
else "photography/" ++ replaceExtension fname "html"
where
contentPrefix :: String
contentPrefix = "content/"
-- ---------------------------------------------------------------------------
-- Landing page
-- ---------------------------------------------------------------------------
-- | Section landing at @/photography/@. Loads all photo entries
-- resolved against 'photographyCtx' so each card has access to
-- slug / photo-url / captured-display / palette swatches.
photographyLandingRules :: Rules ()
photographyLandingRules =
match "content/photography/index.md" $ do
route $ constRoute "photography/index.html"
compile $ do
photos <- recentFirst
=<< loadAll (P.photographyPattern .&&. hasNoVersion)
let ctx =
listField "photos" photographyCtx (return photos)
<> constField "photography" "true"
<> constField "list-page" "true"
<> pageCtx
pageCompiler
>>= loadAndApplyTemplate "templates/photography-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Map data
-- ---------------------------------------------------------------------------
--
-- Two artifacts together:
--
-- * @/photography/map.json@ — JSON array of pin objects, fetched
-- by @static/js/photography-map.js@ at view time. Built directly
-- from frontmatter; no Python dependency.
-- * @/photography/map/@ — the page that renders the Leaflet
-- viewport. Lightweight HTML; the heavy lifting lives in the JS.
--
-- Privacy: every coordinate is rounded to the precision the author
-- declares in @geo-precision:@ (default @"city"@) BEFORE it leaves
-- this build step. Full-precision coords never reach @map.json@.
-- @geo-precision: hidden@ omits the entry entirely.
-- | Strip a trailing @"index.html"@ component so a Hakyll route
-- like @"photography/foo/index.html"@ becomes @"photography/foo/"@.
-- Used for map.json click-through URLs.
stripIndexHtml :: String -> String
stripIndexHtml r
| suffixMatches = take (length r - 10) r -- 10 = length "index.html"
| otherwise = r
where
suffix = "/index.html" :: String
suffixMatches = suffix == drop (length r - length suffix) r
-- | Round a decimal coordinate to the precision that matches the
-- author's @geo-precision:@ declaration.
--
-- * @exact@: 4 decimal places (~10 m)
-- * @km@ : 2 decimal places (~1 km)
-- * @city@ : 1 decimal place (~10 km) — default
-- * other : treated as @city@
--
-- @hidden@ is handled at the call site by skipping the pin entirely.
roundCoord :: String -> Double -> Double
roundCoord prec x =
let n = case prec of
"exact" -> 4
"km" -> 2
"city" -> 1
_ -> 1
scale = 10 ^^ (n :: Int) :: Double
in fromIntegral (round (x * scale) :: Integer) / scale
-- | Extract @[lat, lon]@ from a frontmatter @geo:@ list. Accepts only
-- exactly two numeric entries — anything else returns 'Nothing' so
-- the entry is silently skipped on the map.
parseGeo :: Aeson.Object -> Maybe (Double, Double)
parseGeo meta = case KM.lookup "geo" meta of
Just (Array vec) | V.length vec == 2 ->
case (asDouble (vec V.! 0), asDouble (vec V.! 1)) of
(Just lat, Just lon) -> Just (lat, lon)
_ -> Nothing
_ -> Nothing
where
asDouble (Number n) = Just (Sci.toRealFloat n)
asDouble _ = Nothing
-- | Build a single pin object from a photo entry. Returns 'Nothing'
-- when:
-- * the entry has no @geo:@ frontmatter, or
-- * it has @geo-precision: hidden@, or
-- * the entry has no resolvable route.
buildPin :: Item String -> Compiler (Maybe Value)
buildPin item = do
let ident = itemIdentifier item
meta <- getMetadata ident
mRoute <- getRoute ident
case (parseGeo meta, lookupString "geo-precision" meta, mRoute) of
(_, Just "hidden", _) -> return Nothing
(Just (lat, lon), prec, Just r) ->
let prec' = fromMaybe "city" prec
rLat = roundCoord prec' lat
rLon = roundCoord prec' lon
fp = toFilePath ident
slug = takeFileName (takeDirectory fp)
title = fromMaybe slug (lookupString "title" meta)
photo = lookupString "photo" meta
url = "/" ++ stripIndexHtml r
thumb = case photo of
Just p | not (null p) ->
"/photography/" ++ slug ++ "/" ++ p
_ -> ""
captured = lookupString "captured" meta
in return $ Just $ Aeson.object $
[ "slug" .= slug
, "title" .= title
, "url" .= url
, "lat" .= rLat
, "lon" .= rLon
] ++ (if null thumb then [] else ["thumb" .= thumb])
++ maybe [] (\c -> ["captured" .= c]) captured
_ -> return Nothing
-- | @/photography/map.json@ — JSON array of geo-tagged photo pins
-- for the Leaflet client.
photographyMapDataRule :: Rules ()
photographyMapDataRule =
create ["photography/map.json"] $ do
route idRoute
compile $ do
photos <- loadAll (P.allPhotoEntries .&&. hasNoVersion)
:: Compiler [Item String]
pins <- mapMaybe id <$> mapM buildPin photos
-- Decode through Text so non-ASCII (em-dashes, accents) are
-- preserved correctly in titles instead of being mojibake'd.
makeItem $ TL.unpack $ TLE.decodeUtf8 $ Aeson.encode pins
-- ---------------------------------------------------------------------------
-- Map page
-- ---------------------------------------------------------------------------
-- | @/photography/map/@ — the Leaflet-driven map view. Synthesised
-- page; no Markdown source. The @photography-map@ context flag
-- gates Leaflet CSS / JS loading in @head.html@ and @default.html@,
-- so other photography pages stay lightweight.
photographyMapPageRule :: Rules ()
photographyMapPageRule =
create ["photography/map/index.html"] $ do
route idRoute
compile $ do
let ctx = constField "title" "Map · Photography"
<> constField "photography" "true"
<> constField "photography-map" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate "templates/photography-map.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Atom feed
-- ---------------------------------------------------------------------------
-- | Configuration for the photography-only Atom feed. Distinct from
-- the main feed so text-primary subscribers don't unexpectedly get
-- image-heavy entries in their reader.
photographyFeedConfig :: FeedConfiguration
photographyFeedConfig = FeedConfiguration
{ feedTitle = T.unpack (Config.siteName Config.siteConfig) ++ " — Photography"
, feedDescription = "New photographs"
, feedAuthorName = T.unpack (Config.authorName Config.siteConfig)
, feedAuthorEmail = T.unpack (Config.authorEmail Config.siteConfig)
, feedRoot = T.unpack (Config.siteUrl Config.siteConfig)
}
-- | Description field for Atom feed entries: prepends an absolute-URL
-- @<img>@ tag (so the photograph displays inline in the reader) to
-- the rendered prose body. Composed ABOVE 'bodyField' so it wins
-- when @$description$@ is consumed by the Atom template.
photographyFeedDescription :: Context String
photographyFeedDescription = field "description" $ \item -> do
let ident = itemIdentifier item
body <- itemBody <$> (loadSnapshot ident "content" :: Compiler (Item String))
meta <- getMetadata ident
let fp = toFilePath ident
isDir = takeFileName fp == "index.md"
slug = takeFileName (takeDirectory fp)
photo = lookupString "photo" meta
siteUrlStr = T.unpack (Config.siteUrl Config.siteConfig)
imgTag = case (isDir, photo) of
(True, Just p) | not (null p) ->
"<p><img src=\"" ++ siteUrlStr ++ "/photography/"
++ slug ++ "/" ++ p ++ "\" alt=\"\"></p>\n"
_ -> ""
return (imgTag ++ body)
-- | @/photography/feed.xml@ — Atom feed of the most recent 30 photo
-- entries, with each photograph embedded inline at the top of its
-- entry description.
photographyFeedRule :: Rules ()
photographyFeedRule =
create ["photography/feed.xml"] $ do
route idRoute
compile $ do
photos <- fmap (take 30) . recentFirst
=<< loadAllSnapshots
(P.allPhotoEntries .&&. hasNoVersion)
"content"
let feedCtx =
dateField "updated" "%Y-%m-%dT%H:%M:%SZ"
<> dateField "published" "%Y-%m-%dT%H:%M:%SZ"
<> photographyFeedDescription
<> bodyField "description"
<> defaultContext
renderAtom photographyFeedConfig feedCtx photos
-- ---------------------------------------------------------------------------
-- By-year pages
-- ---------------------------------------------------------------------------
--
-- @/photography/by-year/@ is the index of years that have photos;
-- @/photography/by-year/<year>/@ lists each year's photos
-- chronologically. Year is taken from @captured:@ frontmatter
-- (when present), falling back to @date:@. Photos with neither
-- field — or with a malformed date — are silently dropped from this
-- surface; they remain visible on the main grid and any tag pages
-- their frontmatter produces.
-- | Extract a four-digit year from a frontmatter @captured:@ or
-- @date:@ field. Returns 'Nothing' when neither is set or both are
-- shorter than four characters.
yearOfPhoto :: Metadata -> Maybe String
yearOfPhoto meta =
let firstFour s = if length s >= 4 then Just (take 4 s) else Nothing
in case lookupString "captured" meta >>= firstFour of
Just yr -> Just yr
Nothing -> lookupString "date" meta >>= firstFour
-- | All by-year rules: collect (year, identifier) pairs once, then
-- build the index page and one page per year.
photographyByYearRules :: Rules ()
photographyByYearRules = do
photoIds <- getMatches (P.allPhotoEntries .&&. hasNoVersion)
pairs <- forM photoIds $ \ident -> do
meta <- getMetadata ident
return $ fmap (\yr -> (yr, ident)) (yearOfPhoto meta)
let yearMap :: Map String [Identifier]
yearMap = Map.fromListWith (++) [(yr, [i]) | (yr, i) <- catMaybes pairs]
-- Years sorted descending so the most recent appear first.
years = map fst $ sortBy (comparing (Down . fst)) (Map.toList yearMap)
photographyByYearIndexRule yearMap years
forM_ years $ \yr -> photographyByYearPageRule yr (yearMap Map.! yr)
-- | @/photography/by-year/@ — top-level index. Lists each year that
-- has photos with the count, linking to the per-year page.
photographyByYearIndexRule :: Map String [Identifier] -> [String] -> Rules ()
photographyByYearIndexRule yearMap years =
create ["photography/by-year/index.html"] $ do
route idRoute
compile $ do
let yearItems =
[ Item (fromFilePath ("year-" ++ yr))
(yr, length (Map.findWithDefault [] yr yearMap))
| yr <- years
]
yrCtx =
field "year" (return . fst . itemBody)
<> field "year-url" (\i -> return $ "/photography/by-year/"
++ fst (itemBody i) ++ "/")
<> field "year-count"
(return . show . snd . itemBody)
ctx =
listField "years" yrCtx (return yearItems)
<> constField "title" "Photography by year"
<> constField "photography" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate
"templates/photography-by-year-index.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- | @/photography/by-year/<year>/@ — list of photos captured that year.
photographyByYearPageRule :: String -> [Identifier] -> Rules ()
photographyByYearPageRule yr idents =
create [fromFilePath ("photography/by-year/" ++ yr ++ "/index.html")] $ do
route idRoute
compile $ do
photos <- recentFirst
=<< mapM (\i -> load i :: Compiler (Item String)) idents
let ctx =
listField "photos" photographyCtx (return photos)
<> constField "title" ("Photography · " ++ yr)
<> constField "year" yr
<> constField "photography" "true"
<> constField "list-page" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate
"templates/photography-by-year.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Contact sheet
-- ---------------------------------------------------------------------------
-- | @/photography/contact-sheet/@ — alternate view of every photo in
-- a film-strip aesthetic: thin white-bordered frames, frame numbers
-- in the corner, slightly grainy backdrop. Distinct from the main
-- grid views; deep cut rather than primary surface.
--
-- Sort order: chronological by display date (asc). The contact-sheet
-- convention reads top-to-bottom in capture order — a roll of film,
-- not a recency feed. Each frame's index doubles as its frame
-- number. The CSS handles the frame numbering via a CSS counter so
-- we don't have to thread the index through the template.
photographyContactSheetRule :: Rules ()
photographyContactSheetRule =
create ["photography/contact-sheet/index.html"] $ do
route idRoute
compile $ do
-- Reverse the recent-first sort to get oldest-first
-- (capture chronology), matching the contact-sheet
-- convention.
photos <- reverse <$> (recentFirst
=<< loadAll (P.allPhotoEntries .&&. hasNoVersion)
:: Compiler [Item String])
let ctx =
listField "photos" photographyCtx (return photos)
<> constField "title" "Contact sheet · Photography"
<> constField "photography" "true"
<> siteCtx
makeItem ""
>>= loadAndApplyTemplate
"templates/photography-contact-sheet.html" ctx
>>= loadAndApplyTemplate "templates/default.html" ctx
>>= relativizeUrls

View File

@ -5,6 +5,7 @@ module Site (rules) where
import Control.Monad (filterM, when)
import Data.List (isPrefixOf)
import Data.Maybe (fromMaybe)
import System.Directory (doesDirectoryExist)
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
import qualified Data.Aeson as Aeson
@ -21,6 +22,7 @@ import Commonplace (commonplaceCtx)
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx,
contentKindField)
import qualified Patterns as P
import Photography (photographyRules)
import Tags (buildAllTags, applyTagRules)
import Pagination (blogPaginateRules)
import Stats (statsRules)
@ -99,8 +101,16 @@ rules = do
route $ gsubRoute "static/" (const "")
compile compressCssCompiler
-- All other static files (fonts, JS, images, …)
match ("static/**" .&&. complement "static/css/*") $ do
-- All other static files (fonts, JS, images, …). Build-time
-- sidecars produced by the Python tooling (.dims.yaml, .exif.yaml,
-- .palette.yaml) are excluded — they're consumed by Hakyll at
-- compile time and have no role in the deployed site.
match ( "static/**"
.&&. complement "static/css/*"
.&&. complement "static/**/*.dims.yaml"
.&&. complement "static/**/*.exif.yaml"
.&&. complement "static/**/*.palette.yaml"
) $ do
route $ gsubRoute "static/" (const "")
compile copyFileCompiler
@ -192,17 +202,21 @@ rules = do
>>= loadAndApplyTemplate "templates/default.html" essayCtx
>>= relativizeUrls
-- Static assets co-located with directory-based essays (figures, data, PDFs, …)
-- Static assets co-located with directory-based essays (figures, data, PDFs, …).
-- Build-time dimension sidecars are excluded; they're consumed by
-- Filters/Images.hs at compile time, not shipped.
match ("content/essays/**"
.&&. complement "content/essays/*.md"
.&&. complement "content/essays/*/index.md") $ do
.&&. complement "content/essays/*/index.md"
.&&. complement "content/essays/**/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
-- Static assets co-located with draft essays (dev-only).
when isDev $ match ("content/drafts/essays/**"
.&&. complement "content/drafts/essays/*.md"
.&&. complement "content/drafts/essays/*/index.md") $ do
.&&. complement "content/drafts/essays/*/index.md"
.&&. complement "content/drafts/essays/**/*.dims.yaml") $ do
route $ gsubRoute "content/" (const "")
compile copyFileCompiler
@ -312,6 +326,15 @@ rules = do
compositionCtx
>>= relativizeUrls
-- ---------------------------------------------------------------------------
-- Photography — opt-in. Activates only when `content/photography/`
-- exists at the project root. Photographers who don't want a photo
-- section pay zero cost: no rules registered, no pages generated,
-- no feed. See build/Photography.hs.
-- ---------------------------------------------------------------------------
hasPhotos <- preprocess $ doesDirectoryExist "content/photography"
when hasPhotos photographyRules
-- ---------------------------------------------------------------------------
-- Blog index (paginated)
-- ---------------------------------------------------------------------------
@ -388,7 +411,10 @@ rules = do
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
poetry <- loadAll (allPoetry .&&. hasNoVersion)
let allItems = essays ++ posts ++ fiction ++ poetry
photos <- if hasPhotos
then loadAll (P.photographyPattern .&&. hasNoVersion)
else return []
let allItems = essays ++ posts ++ fiction ++ poetry ++ photos
mapM (buildPortal allItems) (Config.portals Config.siteConfig)
ctx = portalsField

View File

@ -42,9 +42,33 @@ expandTag t =
let segs = wordsBy (== '/') t
in [ intercalate "/" (take n segs) | n <- [1 .. length segs] ]
-- | Top-level tags that own a section URL outside the tag system, and
-- therefore must NOT be created as tag pages — doing so would
-- collide with a section landing route. The literal @"photography"@
-- is the only one currently affected: every photo's @tags:@ list
-- begins with the bare @"photography"@ portal tag (per the section's
-- convention), and 'tagIdentifier' would route that to
-- @"photography/index.html"@ — already owned by
-- @photographyLandingRules@.
--
-- Sub-tags (@photography/landscape@, @photography/film@, …) are
-- unaffected; they keep their tag pages because no section landing
-- claims those URLs.
--
-- Other portal tags (@music@, @poetry@, @fiction@, …) don't appear
-- here because their content types don't currently feed
-- 'tagIndexable', so the top-level tag never enters the tag system.
-- Add to this set if that ever changes.
sectionOwnedTopLevelTags :: [String]
sectionOwnedTopLevelTags = ["photography"]
-- | All expanded tags for an item (reads the "tags" metadata field).
-- Filters out any 'sectionOwnedTopLevelTags' to prevent route
-- collisions with section landings.
getExpandedTags :: MonadMetadata m => Identifier -> m [String]
getExpandedTags ident = nub . concatMap expandTag <$> getTags ident
getExpandedTags ident =
filter (`notElem` sectionOwnedTopLevelTags) . nub . concatMap expandTag
<$> getTags ident
-- ---------------------------------------------------------------------------

View File

@ -100,9 +100,9 @@ constraints: any.Glob ==0.10.2,
http-conduit +aeson,
any.http-date ==0.0.11,
any.http-types ==0.12.4,
any.http2 ==5.1.0,
any.http2 ==5.1.1,
any.indexed-traversable ==0.1.4,
any.indexed-traversable-instances ==0.1.2,
any.indexed-traversable-instances ==0.1.2.1,
any.integer-conversion ==0.1.1,
any.integer-gmp ==1.1,
any.integer-logarithms ==1.0.4,
@ -181,7 +181,7 @@ constraints: any.Glob ==0.10.2,
any.text ==2.0.2,
any.text-conversions ==0.3.1.1,
any.text-icu ==0.8.0.5,
any.text-iso8601 ==0.1.1,
any.text-iso8601 ==0.1.1.1,
any.text-short ==0.1.6.1,
any.th-abstraction ==0.6.0.0,
any.th-compat ==0.1.7,

View File

@ -22,6 +22,7 @@ executable site
Config
Contexts
Patterns
Photography
Stats
Stability
Tags
@ -53,6 +54,7 @@ executable site
directory >= 1.3 && < 1.4,
time >= 1.12 && < 1.15,
aeson >= 2.1 && < 2.3,
scientific >= 0.3 && < 0.4,
vector >= 0.12 && < 0.14,
yaml >= 0.11 && < 0.12,
bytestring >= 0.11 && < 0.13,

View File

@ -18,6 +18,14 @@ dependencies = [
"beautifulsoup4>=4.12,<5",
# CPU-only torch — avoids pulling ~3 GB of CUDA libraries
"torch>=2.5,<3",
# Photography pipeline (only used when content/photography/ exists)
# Pillow handles EXIF reading when exiftool is not installed (the
# preferred path); colorthief computes the 5-color palette strip.
# PyYAML is used to write the sidecar files alongside each photo.
"pillow>=10.0,<12",
"colorthief>=0.2,<1",
"pyyaml>=6.0,<7",
]
[[tool.uv.index]]

View File

@ -79,3 +79,8 @@ portals:
- { slug: "writing", name: "Writing" }
- { slug: "code", name: "Code" }
- { slug: "notes", name: "Notes" }
# Uncomment when you start a photography section. The framework's
# photography rules activate automatically as soon as
# `content/photography/` exists; adding the portal here makes
# photos appear on /library.html grouped under their own shelf.
# - { slug: "photography", name: "Photography" }

652
static/css/photography.css Normal file
View File

@ -0,0 +1,652 @@
/* Photography section Phase 1 minimal styles.
*
* Phase 2 will introduce the masonry / grid / chronological mode toggle,
* darkroom lightbox treatment, and richer card hover states. For now the
* goal is structural correctness and clean defaults so a single photo
* page reads end-to-end.
*
* Style scope: applied via $if(photography)$ gate in templates/partials/head.html,
* so these rules only load on photography pages.
*/
/* ---------------------------------------------------------------------------
* Single-photo page
* --------------------------------------------------------------------------- */
.photo-entry {
max-width: 60rem;
margin: 0 auto;
}
.photo-header {
margin-bottom: 1.5rem;
}
.photo-figure {
margin: 1rem 0 1.25rem;
text-align: center;
}
.photo-figure img {
max-width: 100%;
height: auto;
cursor: zoom-in;
}
.photo-caption {
margin-top: 0.5rem;
font-style: italic;
color: var(--text-muted);
font-size: 0.95em;
}
.photo-captured {
color: var(--text-muted);
font-size: 0.95em;
}
.photo-location {
font-style: italic;
}
/* Color palette strip (Phase 1 stub; populated by Phase 3 auto-extraction
* once tools/extract-palette.py lands). Renders only when frontmatter has
* a non-empty palette: list. */
.photo-palette {
display: flex;
gap: 2px;
margin: 1rem 0 1.5rem;
height: 0.75rem;
}
.photo-swatch {
flex: 1;
border-radius: 1px;
}
/* Per-photo metadata block camera, lens, exposure, etc. Two-column
* description list, definition-list semantics for accessibility. */
.photo-meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 1rem;
margin: 1.25rem 0;
padding: 0.75rem 1rem;
border-left: 2px solid var(--border-muted);
background: var(--bg-muted);
font-size: 0.9em;
}
.photo-meta dt {
font-weight: 600;
color: var(--text-muted);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.photo-meta dd {
margin: 0;
}
.photo-license {
color: inherit;
}
.photo-external-links {
margin: 0.75rem 0 1.25rem;
font-size: 0.9em;
color: var(--text-muted);
}
.photo-external-links .meta-label {
margin-right: 0.5rem;
text-transform: lowercase;
letter-spacing: 0.02em;
}
.photo-external-link {
color: var(--text);
}
/* ---------------------------------------------------------------------------
* Section landing /photography/
* --------------------------------------------------------------------------- */
.photography-intro {
max-width: 36rem;
margin: 0 auto 2rem;
color: var(--text-muted);
}
.photography-empty {
text-align: center;
color: var(--text-faint);
font-style: italic;
margin: 4rem auto;
}
/* ---------------------------------------------------------------------------
* Mode toggle UI
* --------------------------------------------------------------------------- */
.photography-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.photography-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.photography-mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
font-size: 0.85em;
}
.photography-mode-toggle .mode-btn {
padding: 0.35rem 0.75rem;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: inherit;
letter-spacing: 0.02em;
transition: background 120ms ease, color 120ms ease;
}
.photography-mode-toggle .mode-btn:last-child {
border-right: none;
}
.photography-mode-toggle .mode-btn:hover {
color: var(--text);
}
.photography-mode-toggle .mode-btn.is-active {
background: var(--text);
color: var(--bg);
}
/* ---------------------------------------------------------------------------
* Grid container base + per-mode rules.
*
* The same .photography-grid markup feeds three layout strategies; the
* data-photography-mode attribute (set by photography-modes.js) keys all
* per-mode rules. JS also sets inline grid-row spans on each card in
* masonry mode; clearing the attribute clears the inline style.
* --------------------------------------------------------------------------- */
.photography-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
}
/* Masonry variable-height cells respecting native aspect ratios.
* grid-auto-rows + JS-applied grid-row spans synthesize true masonry. */
.photography-grid[data-photography-mode="masonry"] {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr));
grid-auto-rows: 8px; /* must match ROW_UNIT in photography-modes.js */
gap: 8px; /* must match ROW_GAP in photography-modes.js */
align-items: stretch;
}
.photography-grid[data-photography-mode="masonry"] .photo-card-img {
width: 100%;
height: auto;
display: block;
}
/* Uniform grid — square cells, object-fit: cover. Compare side-by-side. */
.photography-grid[data-photography-mode="grid"] {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
gap: 0.75rem;
}
.photography-grid[data-photography-mode="grid"] .photo-card-img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
background: var(--bg-muted);
}
/* Chronological — single column, large, contemplative. */
.photography-grid[data-photography-mode="chronological"] {
grid-template-columns: minmax(0, 48rem);
justify-content: center;
gap: 3rem;
}
.photography-grid[data-photography-mode="chronological"] .photo-card-img {
width: 100%;
height: auto;
display: block;
}
.photography-grid[data-photography-mode="chronological"] .photo-card-meta {
margin-top: 0.75rem;
font-size: 1em;
}
/* ---------------------------------------------------------------------------
* Card chrome shared across all three modes
* --------------------------------------------------------------------------- */
.photo-card {
margin: 0;
}
.photo-card-link {
display: block;
text-decoration: none;
color: inherit;
overflow: hidden;
}
.photo-card-img {
background: var(--bg-muted);
}
.photo-card-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-top: 0.4rem;
font-size: 0.9em;
}
.photo-card-title {
font-weight: 500;
}
.photo-card-date {
color: var(--text-muted);
font-variant-numeric: oldstyle-nums;
flex-shrink: 0;
}
/* ---------------------------------------------------------------------------
* Darkroom lightbox photography pages only
*
* Augments the existing lightbox (static/css/images.css) when the JS
* detects body[data-page-type="photography"] and adds the .darkroom
* class to the overlay. Non-photography lightbox styling is unaffected.
* --------------------------------------------------------------------------- */
.lightbox-overlay.darkroom {
background: #000;
}
.lightbox-overlay.darkroom .lightbox-vignette {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0) 35%,
rgba(0, 0, 0, 0.55) 100%
);
transition: opacity 200ms ease;
}
.lightbox-overlay:not(.darkroom) .lightbox-vignette,
.lightbox-overlay:not(.darkroom) .lightbox-info-toggle,
.lightbox-overlay:not(.darkroom) .lightbox-info-panel {
display: none;
}
.lightbox-overlay.darkroom .lightbox-caption {
color: rgba(255, 255, 255, 0.78);
font-style: italic;
font-size: 0.95em;
max-width: 48rem;
margin: 1rem auto 0;
text-align: center;
}
.lightbox-overlay.darkroom .lightbox-close {
color: rgba(255, 255, 255, 0.8);
}
/* "i" toggle button — top-right corner alongside the close button. */
.lightbox-info-toggle {
position: absolute;
top: 0.75rem;
right: 3.5rem;
z-index: 2;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 50%;
color: rgba(255, 255, 255, 0.8);
font-size: 1rem;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.lightbox-info-toggle:hover,
.lightbox-info-toggle[aria-pressed="true"] {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.7);
}
/* Info panel — slides in from below; muted serif for the EXIF strip. */
.lightbox-info-panel {
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 100%);
max-width: 48rem;
width: calc(100% - 2rem);
padding: 1rem 1.25rem;
background: rgba(0, 0, 0, 0.85);
color: rgba(255, 255, 255, 0.85);
border-top: 1px solid rgba(255, 255, 255, 0.12);
transition: transform 220ms ease;
pointer-events: none;
}
.lightbox-overlay.is-info-visible .lightbox-info-panel {
transform: translate(-50%, 0);
pointer-events: auto;
}
.lightbox-info-panel dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 1.25rem;
margin: 0;
font-size: 0.9em;
}
.lightbox-info-panel dt {
font-weight: 500;
color: rgba(255, 255, 255, 0.55);
text-transform: lowercase;
letter-spacing: 0.04em;
}
.lightbox-info-panel dd {
margin: 0;
color: rgba(255, 255, 255, 0.92);
}
/* ---------------------------------------------------------------------------
* Map page /photography/map/
*
* The viewport is sized in viewport-relative units so the map fills
* a useful portion of the screen without dominating it; the
* surrounding page chrome (header, attribution paragraph, footer)
* stays visible at typical desktop heights.
*
* Leaflet's own stylesheet handles tile / control / popup styling;
* these rules cover only the page-level shell, the tooltip content
* we render via tooltipHtml(), and the no-pin / error fallback states.
* --------------------------------------------------------------------------- */
.photography-map {
height: 70vh;
min-height: 32rem;
margin: 1.5rem 0 1rem;
border: 1px solid var(--border);
background: var(--bg-muted);
}
.photography-map--empty,
.photography-map--error {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.photography-map-empty,
.photography-map-error,
.photography-map-fallback {
max-width: 32rem;
margin: 0 auto;
color: var(--text-muted);
font-style: italic;
}
.photography-map-note {
margin: 0.5rem 0 2rem;
color: var(--text-faint);
font-size: 0.85em;
max-width: 48rem;
}
.photography-map-note code {
background: var(--bg-muted);
padding: 0 0.3em;
border-radius: 2px;
font-size: 0.9em;
}
/* Tooltip content rendered by photography-map.js inside Leaflet's
* default tooltip wrapper. The wrapper class .photography-map-tooltip-wrap
* lets us tighten Leaflet's default padding without bleeding into the
* other Leaflet popups. */
.leaflet-tooltip.photography-map-tooltip-wrap {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
font-family: inherit;
}
/* Leaflet draws tooltip arrows via ::before; recolor to match. */
.leaflet-tooltip.photography-map-tooltip-wrap::before {
border-top-color: var(--border);
}
.photography-map-tooltip {
width: 14rem;
max-width: 16rem;
}
.photography-map-tooltip-img {
display: block;
width: 100%;
height: 8rem;
object-fit: cover;
margin: 0;
border-bottom: 1px solid var(--border);
}
.photography-map-tooltip-title {
padding: 0.4rem 0.6rem 0.1rem;
font-weight: 500;
line-height: 1.2;
}
.photography-map-tooltip-date {
padding: 0 0.6rem 0.4rem;
color: var(--text-muted);
font-size: 0.85em;
font-variant-numeric: oldstyle-nums;
}
/* ---------------------------------------------------------------------------
* By-year index /photography/by-year/
*
* A simple narrow column of years, each link a small card with the
* year (large oldstyle figures) and a count (muted small caps).
* --------------------------------------------------------------------------- */
.photography-header--narrow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.photography-by-year-back {
margin: 0 0 1rem;
color: var(--text-muted);
font-size: 0.9em;
}
.photography-by-year-list {
list-style: none;
padding: 0;
margin: 1rem 0 2rem;
max-width: 24rem;
}
.photography-by-year-item {
margin: 0;
}
.photography-by-year-link {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-muted);
text-decoration: none;
color: inherit;
transition: color 120ms ease;
}
.photography-by-year-link:hover {
color: var(--text);
}
.photography-by-year-year {
font-size: 1.4em;
font-variant-numeric: oldstyle-nums;
}
.photography-by-year-count {
color: var(--text-muted);
font-size: 0.85em;
text-transform: lowercase;
letter-spacing: 0.04em;
}
.photography-by-year-count::before {
content: "";
display: inline-block;
width: 0;
}
.photography-by-year-count::after {
content: " photographs";
}
/* ---------------------------------------------------------------------------
* Contact sheet /photography/contact-sheet/
*
* Frame-numbered grid in the visual register of an analog contact
* print: small uniform thumbnails, thin white border per frame,
* frame number in the corner via a CSS counter. The dark page
* backdrop is a soft "film-room" gray rather than full black so
* the white frames don't ring against the surrounding page chrome
* too hard.
* --------------------------------------------------------------------------- */
.photography-contact-sheet {
counter-reset: contact-frame-no;
list-style: none;
padding: 1.5rem;
margin: 1rem 0 2rem;
background: #1c1c1c;
border: 1px solid #2a2a2a;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
gap: 1.25rem;
}
.contact-frame {
counter-increment: contact-frame-no;
margin: 0;
background: #f5f3ee;
padding: 0.5rem 0.5rem 0.4rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
transition: transform 120ms ease;
}
.contact-frame:hover {
transform: scale(1.015);
}
.contact-frame-link {
display: block;
color: #1c1c1c;
text-decoration: none;
position: relative;
}
.contact-frame-img {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
display: block;
background: #2a2a2a;
}
.contact-frame-link::before {
/* Frame number analog convention: oldstyle figures, mono,
* small, top-left of the print under the image. */
content: counter(contact-frame-no, decimal-leading-zero);
position: absolute;
top: -1.4em;
left: 0;
font-family: var(--font-mono, monospace);
font-size: 0.75em;
color: rgba(245, 243, 238, 0.55);
letter-spacing: 0.05em;
}
.contact-frame-label {
display: block;
margin-top: 0.35rem;
font-size: 0.75em;
color: #4a4a4a;
line-height: 1.2;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------------------------------------------------------------------------
* Series landing /photography/<series>/
* --------------------------------------------------------------------------- */
.photo-series-abstract {
margin: 1rem 0 0;
font-size: 1.05em;
line-height: 1.5;
color: var(--text);
max-width: 36rem;
}
.photo-series-prose {
max-width: 36rem;
margin: 1.5rem 0;
color: var(--text);
}

View File

@ -28,11 +28,82 @@
closeBtn.setAttribute('aria-label', 'Close lightbox');
closeBtn.textContent = '×';
// Darkroom-mode: photography pages only. Three additional
// elements layered behind / beneath the photo: a vignette
// overlay, an info panel showing EXIF-style metadata, and an
// "i" toggle button for revealing/hiding the panel.
// All three sit dormant on non-photography pages — the
// "darkroom" class on the overlay gates their visibility in CSS.
var vignette = document.createElement('div');
vignette.className = 'lightbox-vignette';
vignette.setAttribute('aria-hidden', 'true');
var infoPanel = document.createElement('div');
infoPanel.className = 'lightbox-info-panel';
infoPanel.setAttribute('aria-hidden', 'true');
var infoBtn = document.createElement('button');
infoBtn.className = 'lightbox-info-toggle';
infoBtn.setAttribute('aria-label', 'Toggle photo metadata');
infoBtn.setAttribute('aria-pressed', 'false');
infoBtn.textContent = ''; // — information source
overlay.appendChild(vignette);
overlay.appendChild(closeBtn);
overlay.appendChild(infoBtn);
overlay.appendChild(img);
overlay.appendChild(caption);
overlay.appendChild(infoPanel);
document.body.appendChild(overlay);
// ----------------------------------------------------------------
// Darkroom helpers — populate / clear info panel
// ----------------------------------------------------------------
function isDarkroomPage() {
return document.body.dataset.pageType === 'photography';
}
// Mapping from data-photo-* attribute to the human-readable
// label shown in the panel. Order is the rendering order; only
// attributes present on the trigger image produce panel rows.
var PANEL_FIELDS = [
['photoCaptured', 'Captured'],
['photoLocation', 'Location'],
['photoCamera', 'Camera'],
['photoLens', 'Lens'],
['photoFilm', 'Film'],
['photoExposure', 'Exposure']
];
function populateInfoPanel(triggerImg) {
infoPanel.innerHTML = '';
if (!triggerImg || !triggerImg.dataset) return false;
var dl = document.createElement('dl');
var any = false;
PANEL_FIELDS.forEach(function (entry) {
var key = entry[0];
var label = entry[1];
var value = triggerImg.dataset[key];
if (!value) return;
any = true;
var dt = document.createElement('dt');
dt.textContent = label;
var dd = document.createElement('dd');
dd.textContent = value;
dl.appendChild(dt);
dl.appendChild(dd);
});
if (any) infoPanel.appendChild(dl);
return any;
}
function setInfoVisible(visible) {
overlay.classList.toggle('is-info-visible', visible);
infoPanel.setAttribute('aria-hidden', visible ? 'false' : 'true');
infoBtn.setAttribute('aria-pressed', visible ? 'true' : 'false');
}
// ----------------------------------------------------------------
// Open / close helpers
// ----------------------------------------------------------------
@ -48,6 +119,17 @@
img.alt = alt || captionText || 'Lightbox image';
caption.textContent = captionText || '';
caption.hidden = !captionText;
// Darkroom mode is keyed off body data-page-type. The
// class is set BEFORE is-open so the dark backdrop is in
// place at the start of the transition rather than fading
// in over the existing one.
var darkroom = isDarkroomPage();
overlay.classList.toggle('darkroom', darkroom);
var hasInfo = darkroom ? populateInfoPanel(triggerEl) : false;
infoBtn.hidden = !hasInfo;
setInfoVisible(false);
overlay.classList.add('is-open');
document.documentElement.style.overflow = 'hidden';
closeBtn.focus();
@ -55,18 +137,23 @@
function close() {
overlay.classList.remove('is-open');
setInfoVisible(false);
document.documentElement.style.overflow = '';
if (triggerEl) {
triggerEl.focus();
triggerEl = null;
}
// Clear src after transition to stop background loading
// Clear src after transition to stop background loading.
// The darkroom class is also cleared on the same delay so
// the page chrome doesn't re-appear on top of a fading
// black backdrop.
var delay = parseFloat(
getComputedStyle(overlay).transitionDuration || '0'
) * 1000;
setTimeout(function () {
if (!overlay.classList.contains('is-open')) {
img.src = '';
overlay.classList.remove('darkroom');
}
}, delay + 50);
}
@ -106,10 +193,21 @@
}
});
// Escape key
// Info-panel button (darkroom only — gated by .darkroom class
// on overlay; CSS hides the button on non-photography pages).
infoBtn.addEventListener('click', function () {
setInfoVisible(!overlay.classList.contains('is-info-visible'));
});
// Escape closes; "i" toggles info panel (darkroom only).
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('is-open')) {
if (!overlay.classList.contains('is-open')) return;
if (e.key === 'Escape') {
close();
} else if ((e.key === 'i' || e.key === 'I')
&& overlay.classList.contains('darkroom')
&& !infoBtn.hidden) {
setInfoVisible(!overlay.classList.contains('is-info-visible'));
}
});

View File

@ -0,0 +1,175 @@
/* Photography section Leaflet map.
*
* Loaded only on /photography/map/ via the photography-map context flag
* gating in templates/partials/head.html and templates/default.html.
*
* Pin source: /photography/map.json emitted by the Hakyll
* photographyMapDataRule, with city-precision (or per-photo override)
* coordinate rounding applied at build time. Full-precision coords
* never reach the client.
*
* Tile source: CartoDB Positron free for all volumes; required
* attribution is wired in below. Subdomains a-d are load-balanced.
*
* Marker behavior:
* * Click: navigate to the photo entry page.
* * Hover: tooltip with thumbnail + title + captured date.
* * Dense areas: leaflet.markercluster groups overlapping pins,
* expanding on click.
*
* The page chrome (header, toggle, attribution paragraph) renders
* pre-JS so search engines and no-JS readers see the orientation
* copy. Only the map viewport itself depends on Leaflet loading.
*/
(function () {
'use strict';
var MAP_DATA_URL = '/photography/map.json';
var MAP_ELEMENT = 'photography-map';
var TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILE_ATTRIB = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> '
+ 'contributors &copy; <a href="https://carto.com/attributions">CARTO</a>';
var TILE_SUBDOMS = 'abcd';
var FALLBACK_VIEW = [20, 0]; // [lat, lon] when there are zero pins
var FALLBACK_ZOOM = 2;
// Override the default Leaflet marker icon paths so they resolve
// to the vendored copy under /leaflet/images/. Leaflet's default
// resolution uses the URL of leaflet.js, which fails for vendored
// setups since the script lives in /js/, not /leaflet/.
function configureMarkerIconPaths() {
if (typeof L === 'undefined' || !L.Icon || !L.Icon.Default) return;
L.Icon.Default.mergeOptions({
iconRetinaUrl: '/leaflet/images/marker-icon-2x.png',
iconUrl: '/leaflet/images/marker-icon.png',
shadowUrl: '/leaflet/images/marker-shadow.png'
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function tooltipHtml(pin) {
var thumb = pin.thumb
? '<img class="photography-map-tooltip-img" src="' + escapeHtml(pin.thumb) + '" alt="" loading="lazy">'
: '';
var date = pin.captured
? '<div class="photography-map-tooltip-date">' + escapeHtml(pin.captured) + '</div>'
: '';
return ''
+ '<div class="photography-map-tooltip">'
+ thumb
+ '<div class="photography-map-tooltip-title">' + escapeHtml(pin.title || '(untitled)') + '</div>'
+ date
+ '</div>';
}
function renderEmptyState(container) {
container.classList.add('photography-map--empty');
container.innerHTML =
'<p class="photography-map-empty">'
+ 'No geo-tagged photographs yet. Photos with a '
+ '<code>geo:</code> frontmatter field will appear here.'
+ '</p>';
}
function renderErrorState(container, message) {
container.classList.add('photography-map--error');
container.innerHTML =
'<p class="photography-map-error">'
+ escapeHtml(message || 'Could not load the map.')
+ '</p>';
}
document.addEventListener('DOMContentLoaded', function () {
var container = document.getElementById(MAP_ELEMENT);
if (!container) return;
// Leaflet must be present; the conditional script load in
// default.html should guarantee this on /photography/map/, but
// a defensive fallback is cheap.
if (typeof L === 'undefined') {
renderErrorState(container, 'Map library failed to load.');
return;
}
configureMarkerIconPaths();
fetch(MAP_DATA_URL, { cache: 'force-cache' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function (pins) {
if (!Array.isArray(pins) || pins.length === 0) {
renderEmptyState(container);
return;
}
var map = L.map(container, {
scrollWheelZoom: false, // require explicit interaction
zoomControl: true,
attributionControl: true
}).setView(FALLBACK_VIEW, FALLBACK_ZOOM);
L.tileLayer(TILE_URL, {
attribution: TILE_ATTRIB,
subdomains: TILE_SUBDOMS,
maxZoom: 19
}).addTo(map);
// markercluster — groups overlapping pins; falls back
// to plain L.featureGroup if the plugin failed to load.
var hasCluster = typeof L.markerClusterGroup === 'function';
var layer = hasCluster ? L.markerClusterGroup() : L.featureGroup();
pins.forEach(function (pin) {
if (typeof pin.lat !== 'number' || typeof pin.lon !== 'number') return;
var marker = L.marker([pin.lat, pin.lon]);
marker.bindTooltip(tooltipHtml(pin), {
direction: 'top',
offset: [0, -36],
className: 'photography-map-tooltip-wrap',
opacity: 1
});
if (pin.url) {
marker.on('click', function () {
window.location.href = pin.url;
});
}
layer.addLayer(marker);
});
map.addLayer(layer);
// Frame the visible pins with a small padding. Single-
// pin portfolios get a moderate zoom rather than the
// hard-coded zoom level so the marker doesn't feel
// marooned in negative space.
if (pins.length === 1) {
map.setView([pins[0].lat, pins[0].lon], 8);
} else {
var bounds = layer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.15));
}
}
// Allow scroll-wheel zoom only after the user clicks
// into the map — prevents the page from "trapping" the
// scroll on someone passing through.
map.once('focus', function () { map.scrollWheelZoom.enable(); });
map.on('blur', function () { map.scrollWheelZoom.disable(); });
})
.catch(function (err) {
renderErrorState(container, 'Could not load map data: ' + err.message);
});
});
}());

View File

@ -0,0 +1,199 @@
/* Photography section view mode toggle and masonry row-span computation.
*
* Mode toggle:
* - Three modes: masonry (default), grid, chronological.
* - Selection persists to localStorage under "photography-mode".
* - Mode is applied as data-photography-mode on .photography-grid;
* CSS keys all per-mode rules off this attribute.
*
* Masonry row-spans:
* - In masonry mode, .photography-grid uses CSS grid with
* grid-auto-rows: 1px so each card can occupy a precise integer
* number of "row units" matching its image's natural aspect.
* - For each photo card we set inline grid-row: span N once the
* image's natural dimensions are known. Pre-load, the card uses
* orientation-derived defaults from data-orientation so initial
* paint is roughly the right shape.
*
* No-op gracefully:
* - If the page has no .photography-grid (i.e. we're not on the
* /photography/ landing) the script returns early.
* - If localStorage is unavailable, mode toggling still works for
* the duration of the visit; just no persistence.
*/
(function () {
'use strict';
var STORAGE_KEY = 'photography-mode';
// Modes applied inline on /photography/ (CSS toggle). "map" is a
// separate page and is excluded here so it follows normal navigation.
var VALID_MODES = ['masonry', 'grid', 'chronological'];
var DEFAULT_MODE = 'masonry';
// Row unit (px) — must match grid-auto-rows in photography.css.
// Smaller unit = finer granularity = better aspect-ratio matching
// at a small cost in inline-style verbosity. 8px is a common sweet
// spot for masonry.
var ROW_UNIT = 8;
// Vertical gap between rows in masonry mode (px). Must match
// photography.css .photography-grid[data-photography-mode="masonry"] gap.
var ROW_GAP = 8;
// Approximate aspect ratios for each declared orientation; used as
// a placeholder span before the image's natural dimensions arrive.
var ORIENTATION_RATIO = {
portrait: 3 / 2, // height / width — taller than wide
landscape: 2 / 3,
square: 1
};
document.addEventListener('DOMContentLoaded', function () {
var toggleButtons = document.querySelectorAll('.photography-mode-toggle .mode-btn');
var grid = document.querySelector('.photography-grid');
// The script is loaded site-wide on photography pages but only
// does work when there's a toggle (and, separately, when there's
// a grid for masonry row-spans). Bail early if neither is present.
if (toggleButtons.length === 0 && !grid) return;
// ----------------------------------------------------------------
// localStorage helpers (shared by the toggle and any future
// mode-aware behaviour on photography pages)
// ----------------------------------------------------------------
function readStoredMode() {
try {
var stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && VALID_MODES.indexOf(stored) !== -1) {
return stored;
}
} catch (e) { /* localStorage unavailable */ }
return DEFAULT_MODE;
}
function writeStoredMode(mode) {
try { window.localStorage.setItem(STORAGE_KEY, mode); }
catch (e) { /* localStorage unavailable */ }
}
// ----------------------------------------------------------------
// Mode toggle
// ----------------------------------------------------------------
function applyMode(mode) {
if (!grid) return;
grid.setAttribute('data-photography-mode', mode);
toggleButtons.forEach(function (btn) {
var match = btn.dataset.mode === mode;
btn.classList.toggle('is-active', match);
btn.setAttribute('aria-pressed', match ? 'true' : 'false');
});
if (mode === 'masonry') {
applyAllRowSpans();
} else {
clearAllRowSpans();
}
}
toggleButtons.forEach(function (btn) {
btn.addEventListener('click', function (e) {
var mode = btn.dataset.mode;
if (!mode) return;
// Persist the chosen mode regardless of whether we're
// applying it inline or following a link. This is what
// makes the round-trip through /photography/map/ remember
// which grid view to land on.
if (VALID_MODES.indexOf(mode) !== -1) {
writeStoredMode(mode);
}
// On the landing page (where the grid is in the DOM),
// apply masonry/grid/chronological inline and suppress
// the anchor's default navigation. The "map" link
// ALWAYS navigates — there's no inline alternative.
if (grid && VALID_MODES.indexOf(mode) !== -1) {
e.preventDefault();
applyMode(mode);
}
});
});
// ----------------------------------------------------------------
// Masonry row-spans
// ----------------------------------------------------------------
function rowSpanForRatio(ratio, contentWidth) {
// ratio = naturalHeight / naturalWidth; contentWidth = rendered width
var imageHeight = ratio * contentWidth;
// Allow ~1.4em for the meta strip (title + date below image).
var metaHeight = 28;
var totalHeight = imageHeight + metaHeight;
return Math.max(1, Math.ceil((totalHeight + ROW_GAP) / (ROW_UNIT + ROW_GAP)));
}
function applyRowSpan(card) {
var img = card.querySelector('.photo-card-img');
if (!img) return;
var width = card.clientWidth;
if (!width) return; /* not visible yet — wait for resize */
var ratio;
if (img.naturalWidth && img.naturalHeight) {
ratio = img.naturalHeight / img.naturalWidth;
} else {
var orient = card.dataset.orientation || 'landscape';
ratio = ORIENTATION_RATIO[orient] || ORIENTATION_RATIO.landscape;
}
card.style.gridRowEnd = 'span ' + rowSpanForRatio(ratio, width);
}
function applyAllRowSpans() {
grid.querySelectorAll('.photo-card').forEach(applyRowSpan);
}
function clearAllRowSpans() {
grid.querySelectorAll('.photo-card').forEach(function (card) {
card.style.gridRowEnd = '';
});
}
// Refine spans once each image's natural dimensions are known.
// No-op when the grid is absent (e.g. on the map page).
if (grid) {
grid.querySelectorAll('.photo-card-img').forEach(function (img) {
if (img.complete && img.naturalWidth) {
applyRowSpan(img.closest('.photo-card'));
} else {
img.addEventListener('load', function () {
if (grid.getAttribute('data-photography-mode') === 'masonry') {
applyRowSpan(img.closest('.photo-card'));
}
});
}
});
// Re-flow on resize — cell width changes affect height.
var resizeRaf = null;
window.addEventListener('resize', function () {
if (resizeRaf) cancelAnimationFrame(resizeRaf);
resizeRaf = requestAnimationFrame(function () {
if (grid.getAttribute('data-photography-mode') === 'masonry') {
applyAllRowSpans();
}
});
});
}
// ----------------------------------------------------------------
// Boot — only applies a mode when we're on a page that has the
// grid. The map page has the toggle but no grid; for it,
// localStorage is read/written by the click handler above and
// by the grid page's boot when the user navigates back.
// ----------------------------------------------------------------
if (grid) applyMode(readStoredMode());
});
}());

View File

@ -3,7 +3,7 @@
<head>
$partial("templates/partials/head.html")$
</head>
<body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$>
<body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$$if(photography)$ data-page-type="photography"$endif$>
<a class="skip-link" href="#markdownBody">Skip to content</a>
$partial("templates/partials/nav.html")$
$if(search)$
@ -26,6 +26,10 @@ $partial("templates/partials/footer.html")$
<script src="/js/lightbox.js" defer></script>
$if(home)$<script src="/js/random.js" defer></script>$endif$
$if(reading)$<script src="/js/reading.js" defer></script>$endif$
$if(photography)$<script src="/js/photography-modes.js" defer></script>$endif$
$if(photography-map)$<script src="/leaflet/leaflet.js" defer></script>$endif$
$if(photography-map)$<script src="/leaflet/leaflet.markercluster.js" defer></script>$endif$
$if(photography-map)$<script src="/js/photography-map.js" defer></script>$endif$
$for(page-scripts)$<script src="/$script-src$" defer></script>$endfor$
$if(math)$
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>

View File

@ -20,6 +20,10 @@ $if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$
$if(build)$<link rel="stylesheet" href="/css/build.css">$endif$
$if(reading)$<link rel="stylesheet" href="/css/reading.css">$endif$
$if(composition)$<link rel="stylesheet" href="/css/score-reader.css">$endif$
$if(photography)$<link rel="stylesheet" href="/css/photography.css">$endif$
$if(photography-map)$<link rel="stylesheet" href="/leaflet/leaflet.css">$endif$
$if(photography-map)$<link rel="stylesheet" href="/leaflet/MarkerCluster.css">$endif$
$if(photography-map)$<link rel="stylesheet" href="/leaflet/MarkerCluster.Default.css">$endif$
<link rel="stylesheet" href="/css/print.css" media="print">
$if(math)$
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">

View File

@ -0,0 +1,13 @@
<li class="photo-card"$if(orientation)$ data-orientation="$orientation$"$endif$>
<a class="photo-card-link" href="$url$">
$if(photo-url)$
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img class="photo-card-img" src="$photo-url$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$></picture>
$else$$if(photo)$
<img class="photo-card-img" src="$photo$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$>
$endif$$endif$
<div class="photo-card-meta">
<span class="photo-card-title">$title$</span>
$if(captured-iso)$<time class="photo-card-date" datetime="$captured-iso$">$captured-display$</time>$endif$
</div>
</a>
</li>

View File

@ -0,0 +1,25 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header photography-header--narrow">
<h1 class="page-title">$title$</h1>
<p class="photography-by-year-back">
<a href="/photography/">← all photographs</a>
</p>
</header>
$if(years)$
<ul class="photography-by-year-list">
$for(years)$
<li class="photography-by-year-item">
<a class="photography-by-year-link" href="$year-url$">
<span class="photography-by-year-year">$year$</span>
<span class="photography-by-year-count">$year-count$</span>
</a>
</li>
$endfor$
</ul>
$else$
<p class="photography-empty">No years yet — photographs need a <code>captured:</code> or <code>date:</code> frontmatter field to be grouped by year.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,22 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header photography-header--narrow">
<h1 class="page-title">$title$</h1>
<p class="photography-by-year-back">
<a href="/photography/">← all photographs</a>
·
<a href="/photography/by-year/">other years</a>
</p>
</header>
$if(photos)$
<ul class="photography-grid" data-photography-mode="masonry">
$for(photos)$
$partial("templates/partials/photo-card.html")$
$endfor$
</ul>
$else$
<p class="photography-empty">No photographs in $year$.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,29 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header photography-header--narrow">
<h1 class="page-title">Contact sheet</h1>
<p class="photography-by-year-back">
<a href="/photography/">← all photographs</a>
</p>
</header>
$if(photos)$
<ol class="photography-contact-sheet">
$for(photos)$
<li class="contact-frame">
<a class="contact-frame-link" href="$url$">
$if(photo-url)$
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img class="contact-frame-img" src="$photo-url$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$></picture>
$else$$if(photo)$
<img class="contact-frame-img" src="$photo$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$>
$endif$$endif$
<span class="contact-frame-label">$title$</span>
</a>
</li>
$endfor$
</ol>
$else$
<p class="photography-empty">No photographs to print.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,29 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header">
<h1 class="page-title">$title$</h1>
$if(photos)$
<div class="photography-controls" role="toolbar" aria-label="Browsing mode">
<div class="photography-mode-toggle" role="tablist" aria-label="Photography view mode">
<a class="mode-btn is-active" role="tab" data-mode="masonry" href="/photography/" aria-pressed="true" aria-label="Masonry view">Masonry</a>
<a class="mode-btn" role="tab" data-mode="grid" href="/photography/" aria-pressed="false" aria-label="Uniform grid view">Grid</a>
<a class="mode-btn" role="tab" data-mode="chronological" href="/photography/" aria-pressed="false" aria-label="Chronological view">Chronological</a>
<a class="mode-btn" role="tab" data-mode="map" href="/photography/map/" aria-pressed="false" aria-label="Map view">Map</a>
</div>
</div>
$endif$
</header>
$if(body)$<div class="photography-intro">$body$</div>$endif$
$if(photos)$
<ul class="photography-grid" data-photography-mode="masonry">
$for(photos)$
$partial("templates/partials/photo-card.html")$
$endfor$
</ul>
$else$
<p class="photography-empty">No photographs published yet.</p>
$endif$
</main>
</div>

View File

@ -0,0 +1,34 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<header class="photography-header">
<h1 class="page-title">Photography</h1>
<div class="photography-controls" role="toolbar" aria-label="Browsing mode">
<div class="photography-mode-toggle" role="tablist" aria-label="Photography view mode">
<a class="mode-btn" role="tab" data-mode="masonry" href="/photography/" aria-pressed="false">Masonry</a>
<a class="mode-btn" role="tab" data-mode="grid" href="/photography/" aria-pressed="false">Grid</a>
<a class="mode-btn" role="tab" data-mode="chronological" href="/photography/" aria-pressed="false">Chronological</a>
<a class="mode-btn is-active" role="tab" aria-current="page" data-mode="map" href="/photography/map/" aria-pressed="true">Map</a>
</div>
</div>
</header>
<div id="photography-map" class="photography-map" aria-label="Map of geo-tagged photographs">
<noscript>
<p class="photography-map-fallback">
The map view requires JavaScript. Browse the
<a href="/photography/">photography portfolio</a>
or jump to a specific photo from the
<a href="/photography/">grid view</a>.
</p>
</noscript>
</div>
<p class="photography-map-note">
Pin coordinates are rounded to the precision each photograph's
<code>geo-precision</code> field declares — typically the
nearest ten kilometres. Photos with no <code>geo:</code>
frontmatter (or with <code>geo-precision: hidden</code>) are
omitted from this map by design.
</p>
</main>
</div>

View File

@ -0,0 +1,37 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<article class="photo-series">
<header class="photo-header">
<h1 class="page-title">$title$</h1>
$if(photography-tags)$
<div class="meta-row meta-tags">
$for(photography-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
</div>
$endif$
<div class="meta-row meta-authors">
<span class="meta-label">by</span>$for(author-links)$<a href="$author-url$">$author-name$</a>$sep$, $endfor$
</div>
$if(captured-display)$
<div class="meta-row photo-captured">
<span class="meta-label">captured</span>
<time datetime="$captured-iso$">$captured-display$</time>
$if(location)$ · <span class="photo-location">$location$</span>$endif$
</div>
$endif$
$if(abstract)$<p class="photo-series-abstract">$abstract$</p>$endif$
</header>
$if(body)$<div class="photo-series-prose">$body$</div>$endif$
$if(series-photos)$
<ul class="photography-grid" data-photography-mode="masonry">
$for(series-photos)$
$partial("templates/partials/photo-card.html")$
$endfor$
</ul>
$else$
<p class="photography-empty">This series has no photographs yet.</p>
$endif$
</article>
</main>
</div>

View File

@ -0,0 +1,65 @@
<div id="content">
<main id="markdownBody" data-pagefind-body>
<article class="photo-entry">
<header class="photo-header">
<h1 class="page-title">$title$</h1>
$if(essay-tags)$
<div class="meta-row meta-tags">
$for(essay-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
</div>
$endif$
$if(photography-tags)$
<div class="meta-row meta-tags">
$for(photography-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
</div>
$endif$
<div class="meta-row meta-authors">
<span class="meta-label">by</span>$for(author-links)$<a href="$author-url$">$author-name$</a>$sep$, $endfor$
</div>
$if(captured-display)$
<div class="meta-row photo-captured">
<span class="meta-label">captured</span>
<time datetime="$captured-iso$">$captured-display$</time>
$if(location)$ · <span class="photo-location">$location$</span>$endif$
</div>
$endif$
</header>
<figure class="photo-figure">
$if(photo-url)$
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img src="$photo-url$" alt="$title$" loading="lazy" decoding="async" data-lightbox="true"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$$if(camera)$ data-photo-camera="$camera$"$endif$$if(lens)$ data-photo-lens="$lens$"$endif$$if(film)$ data-photo-film="$film$"$endif$$if(exposure)$ data-photo-exposure="$exposure$"$endif$$if(captured-display)$ data-photo-captured="$captured-display$"$endif$$if(location)$ data-photo-location="$location$"$endif$></picture>
$else$$if(photo)$
<img src="$photo$" alt="$title$" loading="lazy" decoding="async" data-lightbox="true"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$$if(camera)$ data-photo-camera="$camera$"$endif$$if(lens)$ data-photo-lens="$lens$"$endif$$if(film)$ data-photo-film="$film$"$endif$$if(exposure)$ data-photo-exposure="$exposure$"$endif$$if(captured-display)$ data-photo-captured="$captured-display$"$endif$$if(location)$ data-photo-location="$location$"$endif$>
$endif$$endif$
$if(abstract)$<figcaption class="photo-caption">$abstract$</figcaption>$endif$
</figure>
$if(palette-swatches)$
<div class="photo-palette" aria-label="Color palette">
$for(palette-swatches)$<span class="photo-swatch" style="background:$swatch$" title="$swatch$"></span>$endfor$
</div>
$endif$
<dl class="photo-meta">
$if(camera)$<dt>Camera</dt><dd>$camera$</dd>$endif$
$if(lens)$<dt>Lens</dt><dd>$lens$</dd>$endif$
$if(film)$<dt>Film</dt><dd>$film$</dd>$endif$
$if(exposure)$<dt>Exposure</dt><dd>$exposure$</dd>$endif$
$if(process)$<dt>Process</dt><dd>$process$</dd>$endif$
$if(license)$
<dt>License</dt>
<dd>$if(license-url-resolved)$<a class="photo-license" href="$license-url-resolved$" rel="license noopener">$license$</a>$else$<span class="photo-license">$license$</span>$endif$</dd>
$endif$
</dl>
$if(photo-links)$
<div class="photo-external-links">
<span class="meta-label">also at</span>
$for(photo-links)$<a class="photo-external-link" href="$link-url$" rel="noopener">$link-name$</a>$sep$ · $endfor$
</div>
$endif$
$body$
</article>
</main>
</div>

85
tools/download-leaflet.sh Executable file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# download-leaflet.sh — Vendor Leaflet + leaflet.markercluster into static/leaflet/.
#
# The /photography/map/ page (build/Photography.hs photographyMapRules)
# loads Leaflet from /leaflet/ to render a map of geo-tagged photos.
# This vendoring keeps the page CDN-independent: the assets ship with
# the site, no third-party request at view time.
#
# Run once before deploying. The vendored copy is gitignored
# (~150 KB total); re-running is safe — the script skips when the
# files already exist.
#
# To bump the pinned versions, set LEAFLET_VERSION / MARKERCLUSTER_VERSION,
# re-run, then update tools/leaflet-checksums.sha256 with the new hashes.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LEAFLET_DIR="$REPO_ROOT/static/leaflet"
CHECKSUMS="$REPO_ROOT/tools/leaflet-checksums.sha256"
LEAFLET_VERSION="${LEAFLET_VERSION:-1.9.4}"
MARKERCLUSTER_VERSION="${MARKERCLUSTER_VERSION:-1.5.3}"
UNPKG_LEAFLET="https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist"
UNPKG_MC="https://unpkg.com/leaflet.markercluster@${MARKERCLUSTER_VERSION}/dist"
# Files to vendor: (URL_BASE LOCAL_PATH SOURCE_FILENAME PIN_KEY)
# PIN_KEY is the version-prefixed name in the checksum file so future
# version bumps don't accidentally accept files matching an older hash.
files_to_fetch=(
"$UNPKG_LEAFLET|leaflet.js|leaflet-${LEAFLET_VERSION}-leaflet.js"
"$UNPKG_LEAFLET|leaflet.css|leaflet-${LEAFLET_VERSION}-leaflet.css"
"$UNPKG_LEAFLET/images|images/marker-icon.png|leaflet-${LEAFLET_VERSION}-marker-icon.png"
"$UNPKG_LEAFLET/images|images/marker-icon-2x.png|leaflet-${LEAFLET_VERSION}-marker-icon-2x.png"
"$UNPKG_LEAFLET/images|images/marker-shadow.png|leaflet-${LEAFLET_VERSION}-marker-shadow.png"
"$UNPKG_MC|leaflet.markercluster.js|leaflet.markercluster-${MARKERCLUSTER_VERSION}-leaflet.markercluster.js"
"$UNPKG_MC|MarkerCluster.css|leaflet.markercluster-${MARKERCLUSTER_VERSION}-MarkerCluster.css"
"$UNPKG_MC|MarkerCluster.Default.css|leaflet.markercluster-${MARKERCLUSTER_VERSION}-MarkerCluster.Default.css"
)
# Skip the whole step if the canonical entry-point already exists.
# Force a re-fetch by removing the directory.
if [ -f "$LEAFLET_DIR/leaflet.js" ] && [ -f "$LEAFLET_DIR/leaflet.markercluster.js" ]; then
echo "leaflet: already vendored at $LEAFLET_DIR (skipping)"
exit 0
fi
mkdir -p "$LEAFLET_DIR/images"
verify_or_warn() {
local file="$1"
local pin_key="$2"
if [ ! -f "$CHECKSUMS" ]; then
echo "leaflet: $CHECKSUMS not found — skipping sha256 verification" >&2
return 0
fi
local want
want="$(awk -v p="$pin_key" '$2 == p { print $1; exit }' "$CHECKSUMS")"
if [ -z "$want" ]; then
echo "leaflet: no pinned checksum for $pin_key — skipping verification" >&2
return 0
fi
local got
got="$(sha256sum "$file" | awk '{ print $1 }')"
if [ "$got" != "$want" ]; then
echo "leaflet: sha256 mismatch for $pin_key" >&2
echo " expected $want" >&2
echo " got $got" >&2
return 1
fi
}
for entry in "${files_to_fetch[@]}"; do
IFS='|' read -r url_base local_path pin_key <<<"$entry"
src_name="${local_path##*/}"
target="$LEAFLET_DIR/$local_path"
mkdir -p "$(dirname "$target")"
echo "leaflet: fetching $local_path ($pin_key)"
curl -fsSL --progress-bar "$url_base/$src_name" -o "$target"
verify_or_warn "$target" "$pin_key"
done
echo "leaflet: vendored to $LEAFLET_DIR"

127
tools/extract-dimensions.py Executable file
View File

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
extract-dimensions.py Build-time pixel-dimension sidecar generator.
Walks @static/images/@ and @content/**@ for raster image files
(JPEG / PNG / GIF) and writes a @{image}.dims.yaml@ sidecar alongside
each one containing the file's pixel width and height. Consumed by
@build/Filters/Images.hs@, which attaches matching @width@ and
@height@ attributes to every <img> tag at compile time preventing
cumulative layout shift while images load.
This is the body-image counterpart to @extract-exif.py@, which writes
photography-specific @{image}.exif.yaml@ sidecars (containing
dimensions plus camera / lens / etc.). The two complement each other:
photography templates read width / height through the EXIF sidecar
via @photographyCtx@; everything else (essay figures, blog images,
inline images) gets dimensions through @{image}.dims.yaml@ via the
filter.
Strategy:
* Pillow's @Image.size@ is independent of EXIF, so synthetic
images (ImageMagick gradients, GIMP exports) and EXIF-stripped
JPEGs both yield correct dimensions.
* Staleness check: skip when sidecar mtime > image mtime.
* Per-image failures are logged and the walk continues; the build
never fails on a dimensions extraction error.
Called by `make build` when .venv exists. Failures on individual
images are logged and the rest of the walk continues.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import yaml
REPO_ROOT = Path(__file__).parent.parent
# Roots to walk. content/photography/ also gets visited (its photos
# become double-sidecared with both .exif.yaml and .dims.yaml) — that's
# harmless and keeps the contract uniform: "every raster file has a
# .dims.yaml". The few extra bytes of YAML are immaterial.
WALK_ROOTS = [
REPO_ROOT / "static" / "images",
REPO_ROOT / "content",
]
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif"}
def _sidecar_path(image: Path) -> Path:
return image.with_suffix(image.suffix + ".dims.yaml")
def _is_stale(image: Path, sidecar: Path) -> bool:
if not sidecar.exists():
return True
return image.stat().st_mtime > sidecar.stat().st_mtime
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
# Preserve a stable key order (width before height) so a manual
# diff stays easy to read across regenerations.
ordered = {k: data[k] for k in ("width", "height") if k in data}
yaml.safe_dump(ordered, f, sort_keys=False, allow_unicode=True)
tmp.replace(path)
def _read_dimensions(image: Path) -> dict[str, int]:
from PIL import Image
with Image.open(image) as img:
width, height = img.size
return {"width": int(width), "height": int(height)}
def _walk_one_root(root: Path, counters: dict[str, int]) -> None:
if not root.exists():
return
for image in sorted(root.rglob("*")):
if image.suffix.lower() not in IMAGE_EXTS:
continue
# Skip dotfiles, tmp files, and the .webp companions produced
# by tools/convert-images.sh (their extension is .webp so they
# already wouldn't match IMAGE_EXTS, but be explicit).
if image.name.startswith(".") or image.name.endswith(".tmp"):
continue
sidecar = _sidecar_path(image)
if not _is_stale(image, sidecar):
counters["skipped"] += 1
continue
try:
data = _read_dimensions(image)
except Exception as e: # noqa: BLE001 — keep walking
print(f"extract-dimensions: {image}: {e}", file=sys.stderr)
counters["failed"] += 1
continue
_atomic_write_yaml(sidecar, data)
counters["written"] += 1
def main() -> int:
counters = {"written": 0, "skipped": 0, "failed": 0}
for root in WALK_ROOTS:
_walk_one_root(root, counters)
print(
"extract-dimensions: "
f"{counters['written']} written, "
f"{counters['skipped']} skipped, "
f"{counters['failed']} failed",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())

444
tools/extract-exif.py Executable file
View File

@ -0,0 +1,444 @@
#!/usr/bin/env python3
"""
extract-exif.py Build-time EXIF sidecar generator for photography.
Walks content/photography/**/*.{jpg,jpeg,png} and writes a
{photo}.exif.yaml sidecar alongside each image. The Hakyll context in
build/Contexts.hs reads these sidecars and merges their fields into
the photographyCtx so authors don't have to hand-write camera / lens /
exposure / captured-date in frontmatter for digital photos.
Frontmatter always wins. The sidecar is a strict fallback present
to populate fields the author chose not to write. Film scans typically
have no EXIF; the sidecar is still written but with an empty body, and
the author hand-writes the relevant fields in frontmatter.
Strategy:
1. Prefer exiftool when available Perl-based, ships in distro
repos, handles every camera vendor's tag dialect (incl. RAW).
2. Fall back to Pillow's EXIF reader — pure Python, narrower
coverage, but always available via the project's .venv.
Staleness check: skips an image whose sidecar mtime > image mtime.
This means re-running the tool is idempotent and cheap.
GPS coordinates are written to the sidecar at full precision; the
geo-precision rounding (`exact | km | city | hidden`) is applied in
Hakyll at the consuming end, against each photo's frontmatter
`geo-precision:` value. The sidecar is the source of truth; the
consumer is the privacy gate.
Called by `make build` when .venv exists. Failures on individual
images are logged and the rest of the walk continues.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from fractions import Fraction
from pathlib import Path
from typing import Any
import yaml
REPO_ROOT = Path(__file__).parent.parent
CONTENT_DIR = REPO_ROOT / "content" / "photography"
IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
# ---------------------------------------------------------------------------
# Field normalisation
# ---------------------------------------------------------------------------
# Mapping from EXIF field names (as exposed by exiftool / Pillow) to the
# sidecar keys consumed by Hakyll. Hakyll's fields are deliberately
# lowercase-with-hyphens, matching the photographyCtx convention.
SIDECAR_KEYS = [
"camera",
"lens",
"exposure",
"shutter",
"aperture",
"iso",
"focal-length",
"captured",
"geo",
# Pixel dimensions of the delivered (resized, EXIF-stripped) JPEG.
# Threaded through to the Hakyll photographyCtx and emitted as
# width / height attrs on every <img> tag — prevents cumulative
# layout shift while photos load.
"width",
"height",
]
def _format_shutter(speed: float) -> str:
"""Render shutter speed as "1/125" or "0.5s" depending on magnitude."""
if speed <= 0:
return ""
if speed >= 1.0:
return f"{speed:g}s"
denom = round(1.0 / speed)
return f"1/{denom}"
def _format_aperture(value: float) -> str:
if value <= 0:
return ""
# Common aperture values display with at most one decimal place.
if abs(value - round(value)) < 0.05:
return f"f/{int(round(value))}"
return f"f/{value:.1f}"
def _format_focal(value: float) -> str:
if value <= 0:
return ""
return f"{int(round(value))}mm"
def _build_exposure_string(
shutter: str | None,
aperture: str | None,
iso: int | None,
) -> str | None:
"""Compose "1/125 f/8 ISO 400" from individual fields when present."""
parts: list[str] = []
if shutter:
parts.append(shutter)
if aperture:
parts.append(aperture)
if iso:
parts.append(f"ISO {iso}")
return " ".join(parts) if parts else None
# ---------------------------------------------------------------------------
# exiftool path
# ---------------------------------------------------------------------------
def _exiftool_available() -> bool:
return shutil.which("exiftool") is not None
def _read_exif_via_exiftool(image: Path) -> dict[str, Any]:
"""Invoke exiftool and return a dict of normalised sidecar keys.
exiftool's `-json` output is a list of objects; we parse the first
entry. Numeric values come through as numbers; text values as
strings. We accept missing keys silently.
"""
result = subprocess.run(
[
"exiftool",
"-json",
"-Make",
"-Model",
"-LensModel",
"-LensSpec",
"-LensInfo",
"-ExposureTime",
"-FNumber",
"-ISO",
"-FocalLength",
"-FocalLengthIn35mmFormat",
"-DateTimeOriginal",
"-CreateDate",
"-GPSLatitude",
"-GPSLongitude",
"-GPSLatitudeRef",
"-GPSLongitudeRef",
"-ImageWidth",
"-ImageHeight",
"-n", # numeric output for shutter/aperture/GPS/dimensions
str(image),
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return {}
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return {}
if not data:
return {}
raw = data[0]
out: dict[str, Any] = {}
make = (raw.get("Make") or "").strip()
model = (raw.get("Model") or "").strip()
if make and model and not model.lower().startswith(make.lower()):
out["camera"] = f"{make} {model}".strip()
elif model:
out["camera"] = model
elif make:
out["camera"] = make
lens = (
raw.get("LensModel")
or raw.get("LensSpec")
or raw.get("LensInfo")
or ""
).strip()
if lens:
out["lens"] = lens
shutter_secs = raw.get("ExposureTime")
if isinstance(shutter_secs, (int, float)) and shutter_secs > 0:
out["shutter"] = _format_shutter(float(shutter_secs))
aperture = raw.get("FNumber")
if isinstance(aperture, (int, float)) and aperture > 0:
out["aperture"] = _format_aperture(float(aperture))
iso = raw.get("ISO")
if isinstance(iso, int) and iso > 0:
out["iso"] = iso
focal = raw.get("FocalLength")
if isinstance(focal, (int, float)) and focal > 0:
out["focal-length"] = _format_focal(float(focal))
captured_raw = raw.get("DateTimeOriginal") or raw.get("CreateDate")
if isinstance(captured_raw, str) and captured_raw:
# exiftool format is "YYYY:MM:DD HH:MM:SS"; we want ISO date only.
date_part = captured_raw.split(" ", 1)[0].replace(":", "-")
if len(date_part) == 10:
out["captured"] = date_part
lat = raw.get("GPSLatitude")
lon = raw.get("GPSLongitude")
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
# exiftool with -n returns signed decimals already.
out["geo"] = [round(float(lat), 6), round(float(lon), 6)]
width = raw.get("ImageWidth")
height = raw.get("ImageHeight")
if isinstance(width, int) and width > 0:
out["width"] = width
if isinstance(height, int) and height > 0:
out["height"] = height
exposure = _build_exposure_string(
out.get("shutter"), out.get("aperture"), out.get("iso")
)
if exposure:
out["exposure"] = exposure
return out
# ---------------------------------------------------------------------------
# Pillow fallback path
# ---------------------------------------------------------------------------
def _pillow_rational(value: Any) -> float | None:
"""Pillow can return EXIF rationals as IFDRational, tuples, or floats."""
if value is None:
return None
try:
if isinstance(value, tuple) and len(value) == 2:
num, den = value
return float(num) / float(den) if den else None
return float(Fraction(value).limit_denominator())
except (TypeError, ValueError, ZeroDivisionError):
try:
return float(value)
except (TypeError, ValueError):
return None
def _gps_to_decimal(coord: Any, ref: Any) -> float | None:
"""Pillow GPS coords come as ((deg_n, deg_d), (min_n, min_d), (sec_n, sec_d))."""
if not coord:
return None
try:
deg = float(coord[0])
minutes = float(coord[1])
seconds = float(coord[2])
decimal = deg + minutes / 60.0 + seconds / 3600.0
if isinstance(ref, str) and ref in ("S", "W"):
decimal = -decimal
return decimal
except (TypeError, ValueError, IndexError):
return None
def _read_exif_via_pillow(image: Path) -> dict[str, Any]:
from PIL import Image, ExifTags
out: dict[str, Any] = {}
# Pixel dimensions are extracted unconditionally (separate from
# EXIF) — every readable raster file has them, even synthetic
# placeholders or photos that have had their EXIF stripped.
try:
with Image.open(image) as img:
width, height = img.size
if isinstance(width, int) and width > 0:
out["width"] = width
if isinstance(height, int) and height > 0:
out["height"] = height
exif = img._getexif() or {}
except Exception: # noqa: BLE001 — corrupt EXIF should not abort the walk
return out
if not exif:
return out
tag_name = {v: k for k, v in ExifTags.TAGS.items()}
gps_name = {v: k for k, v in ExifTags.GPSTAGS.items()}
def _g(name: str) -> Any:
return exif.get(tag_name.get(name, -1))
make = (_g("Make") or "").strip()
model = (_g("Model") or "").strip()
if make and model and not model.lower().startswith(make.lower()):
out["camera"] = f"{make} {model}".strip()
elif model:
out["camera"] = model
elif make:
out["camera"] = make
lens = (_g("LensModel") or _g("LensMake") or "").strip()
if lens:
out["lens"] = lens
shutter_secs = _pillow_rational(_g("ExposureTime"))
if shutter_secs and shutter_secs > 0:
out["shutter"] = _format_shutter(shutter_secs)
aperture = _pillow_rational(_g("FNumber"))
if aperture and aperture > 0:
out["aperture"] = _format_aperture(aperture)
iso_raw = _g("ISOSpeedRatings") or _g("PhotographicSensitivity")
if isinstance(iso_raw, int) and iso_raw > 0:
out["iso"] = iso_raw
elif isinstance(iso_raw, tuple) and iso_raw and isinstance(iso_raw[0], int):
out["iso"] = iso_raw[0]
focal = _pillow_rational(_g("FocalLength"))
if focal and focal > 0:
out["focal-length"] = _format_focal(focal)
captured_raw = _g("DateTimeOriginal") or _g("DateTime")
if isinstance(captured_raw, str) and captured_raw:
date_part = captured_raw.split(" ", 1)[0].replace(":", "-")
if len(date_part) == 10:
out["captured"] = date_part
gps_idx = tag_name.get("GPSInfo", -1)
gps_info = exif.get(gps_idx) or {}
if isinstance(gps_info, dict) and gps_info:
# Pillow exposes GPSInfo by integer-keyed dict; remap.
named = {gps_name.get(k, str(k)): v for k, v in gps_info.items()}
lat = _gps_to_decimal(named.get("GPSLatitude"), named.get("GPSLatitudeRef"))
lon = _gps_to_decimal(named.get("GPSLongitude"), named.get("GPSLongitudeRef"))
if lat is not None and lon is not None:
out["geo"] = [round(lat, 6), round(lon, 6)]
exposure = _build_exposure_string(
out.get("shutter"), out.get("aperture"), out.get("iso")
)
if exposure:
out["exposure"] = exposure
return out
# ---------------------------------------------------------------------------
# Walk + write
# ---------------------------------------------------------------------------
def _sidecar_path(image: Path) -> Path:
return image.with_suffix(image.suffix + ".exif.yaml")
def _is_stale(image: Path, sidecar: Path) -> bool:
if not sidecar.exists():
return True
return image.stat().st_mtime > sidecar.stat().st_mtime
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
# Preserve the SIDECAR_KEYS order so a manual diff is easy to read.
ordered = {k: data[k] for k in SIDECAR_KEYS if k in data}
yaml.safe_dump(ordered, f, sort_keys=False, allow_unicode=True)
tmp.replace(path)
def _read_one(image: Path) -> dict[str, Any]:
if _exiftool_available():
data = _read_exif_via_exiftool(image)
if data:
return data
return _read_exif_via_pillow(image)
def main() -> int:
if not CONTENT_DIR.exists():
print(f"extract-exif: {CONTENT_DIR} does not exist — skipping.", file=sys.stderr)
return 0
using_exiftool = _exiftool_available()
print(
"extract-exif: source ="
f" {'exiftool' if using_exiftool else 'Pillow (exiftool not installed)'}",
file=sys.stderr,
)
written = 0
skipped = 0
failed = 0
for image in sorted(CONTENT_DIR.rglob("*")):
if image.suffix.lower() not in IMAGE_EXTS:
continue
# Skip the WebP companions (extension wouldn't match anyway, but
# be explicit) and any tmp / hidden files.
if image.name.startswith(".") or image.name.endswith(".tmp"):
continue
sidecar = _sidecar_path(image)
if not _is_stale(image, sidecar):
skipped += 1
continue
try:
data = _read_one(image)
except Exception as e: # noqa: BLE001 — keep walking
print(f"extract-exif: {image}: {e}", file=sys.stderr)
failed += 1
continue
# Always write a sidecar — even if it's empty — so the consumer
# doesn't need to branch on existence. An empty sidecar is the
# explicit signal that "we tried; nothing to extract" (typical
# for film scans).
_atomic_write_yaml(sidecar, data)
written += 1
print(
f"extract-exif: {written} written, {skipped} skipped, {failed} failed",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())

121
tools/extract-palette.py Executable file
View File

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
extract-palette.py Build-time 5-color palette sidecar for photography.
Walks content/photography/**/*.{jpg,jpeg,png} and writes a
{photo}.palette.yaml sidecar alongside each image, containing five
hex colors derived from the photograph via colorthief's k-means-like
quantisation. The sidecar is consumed by photographyCtx in Hakyll
and rendered as the thin <div class="photo-palette"> strip beneath
each photo.
Frontmatter `palette:` always wins. Authors can override the auto
extraction for artistic reasons (e.g. exposing brand-aligned tones
that aren't statistically dominant in the pixels). The sidecar is
the fallback so authors don't need to write hex codes by hand.
Staleness check: skips an image whose sidecar mtime > image mtime.
Called by `make build` when .venv exists. Per-image failures are
logged and the rest of the walk continues; the build never fails on
a palette extraction error.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import yaml
from colorthief import ColorThief
REPO_ROOT = Path(__file__).parent.parent
CONTENT_DIR = REPO_ROOT / "content" / "photography"
IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
# Number of swatches in the rendered strip. Five matches the design in
# PHOTOGRAPHY.md and the existing `photo-palette` CSS, which sets
# `display: flex; height: 0.75rem;` and divides the bar evenly. Bumping
# this requires a CSS revisit — the bar reads as a unified strip up to
# about 7 swatches; beyond that the bands become too narrow to perceive.
N_SWATCHES = 5
# colorthief's quality knob: lower = better palette but slower. The
# default of 10 is a reasonable trade-off; 1 is exhaustive.
QUALITY = 10
def _hex(rgb: tuple[int, int, int]) -> str:
return "#{:02x}{:02x}{:02x}".format(*rgb)
def _sidecar_path(image: Path) -> Path:
return image.with_suffix(image.suffix + ".palette.yaml")
def _is_stale(image: Path, sidecar: Path) -> bool:
if not sidecar.exists():
return True
return image.stat().st_mtime > sidecar.stat().st_mtime
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
tmp.replace(path)
def _extract_palette(image: Path) -> list[str]:
"""Return up to N_SWATCHES hex colors, in colorthief's dominance order."""
ct = ColorThief(str(image))
palette = ct.get_palette(color_count=N_SWATCHES, quality=QUALITY)
# colorthief sometimes returns one fewer entry than requested for
# very low-color images; just take what we got.
return [_hex(rgb) for rgb in palette[:N_SWATCHES]]
def main() -> int:
if not CONTENT_DIR.exists():
print(
f"extract-palette: {CONTENT_DIR} does not exist — skipping.",
file=sys.stderr,
)
return 0
written = 0
skipped = 0
failed = 0
for image in sorted(CONTENT_DIR.rglob("*")):
if image.suffix.lower() not in IMAGE_EXTS:
continue
if image.name.startswith(".") or image.name.endswith(".tmp"):
continue
sidecar = _sidecar_path(image)
if not _is_stale(image, sidecar):
skipped += 1
continue
try:
palette = _extract_palette(image)
except Exception as e: # noqa: BLE001 — keep walking
print(f"extract-palette: {image}: {e}", file=sys.stderr)
failed += 1
continue
_atomic_write_yaml(sidecar, {"palette": palette})
written += 1
print(
f"extract-palette: {written} written, {skipped} skipped, {failed} failed",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())

204
tools/import-photo.sh Executable file
View File

@ -0,0 +1,204 @@
#!/usr/bin/env bash
# import-photo.sh — Author-facing import workflow for photography entries.
#
# Given a path to an original photograph and a target slug, this script:
#
# 1. Creates content/photography/<slug>/.
# 2. Resizes the original to ≤2400px on the long edge, JPEG quality 85,
# sRGB. EXIF is preserved at this step so the extractor can read it.
# 3. Runs tools/extract-exif.py to produce the {photo}.exif.yaml sidecar.
# 4. Strips EXIF from the delivered JPEG (the sidecar now holds the
# metadata; the file shipped to viewers carries no GPS, no serial
# numbers, no Lightroom edit history).
# 5. Runs tools/extract-palette.py to produce the {photo}.palette.yaml
# sidecar.
# 6. Scaffolds an index.md frontmatter stub ready for editing.
#
# Usage:
# tools/import-photo.sh <original-path> <slug> [--title "Title"]
#
# Examples:
# tools/import-photo.sh ~/Photos/IMG_4421.jpg reykjavik-rooftops
# tools/import-photo.sh ~/Photos/IMG_4421.jpg reykjavik-rooftops --title "Reykjavík Rooftops"
#
# Requirements:
# * ImageMagick (`magick`) for resize / strip / colorspace conversion
# * uv + .venv (Pillow + colorthief + pyyaml) for sidecar extraction
#
# Originals are NEVER copied into the repo verbatim — only the resized
# delivery JPEG. Per PHOTOGRAPHY.md, originals live outside source
# control (your local archive, NAS, or backup).
set -euo pipefail
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
if [ "$#" -lt 2 ]; then
cat <<EOF >&2
Usage: $(basename "$0") <original-path> <slug> [--title "Title"]
Imports a photograph into content/photography/<slug>/, producing:
photo.jpg resized, sRGB, EXIF-stripped (delivery copy)
photo.jpg.exif.yaml extracted EXIF metadata (sidecar)
photo.jpg.palette.yaml 5-color palette (sidecar)
index.md frontmatter stub ready for editing
EOF
exit 1
fi
ORIGINAL="$1"
SLUG="$2"
shift 2
TITLE=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title)
TITLE="$2"
shift 2
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [ ! -f "$ORIGINAL" ]; then
echo "import-photo: original not found: $ORIGINAL" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Tool availability checks
# ---------------------------------------------------------------------------
if ! command -v magick >/dev/null 2>&1; then
echo "import-photo: ImageMagick ('magick') is required but not installed." >&2
echo " Arch: pacman -S imagemagick" >&2
echo " Debian: apt install imagemagick" >&2
exit 1
fi
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if [ ! -d "$REPO_ROOT/.venv" ]; then
echo "import-photo: .venv not found at $REPO_ROOT/.venv" >&2
echo " Run: uv sync" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
ENTRY_DIR="$REPO_ROOT/content/photography/$SLUG"
TARGET="$ENTRY_DIR/photo.jpg"
EXIF_SIDECAR="$TARGET.exif.yaml"
PALETTE_SIDECAR="$TARGET.palette.yaml"
INDEX_MD="$ENTRY_DIR/index.md"
if [ -e "$ENTRY_DIR" ]; then
echo "import-photo: $ENTRY_DIR already exists. Refusing to overwrite." >&2
echo " Either choose a new slug or remove the existing entry first." >&2
exit 1
fi
mkdir -p "$ENTRY_DIR"
# ---------------------------------------------------------------------------
# Step 1: resize + colorspace, EXIF preserved (so the extractor can read it)
# ---------------------------------------------------------------------------
echo "import-photo: resizing to ≤2400px JPEG q85 sRGB → $TARGET"
magick "$ORIGINAL" \
-auto-orient \
-resize '2400x2400>' \
-colorspace sRGB \
-quality 85 \
"$TARGET"
chmod 644 "$TARGET"
# ---------------------------------------------------------------------------
# Step 2: extract EXIF (reads from the resized file, which still has EXIF)
# ---------------------------------------------------------------------------
echo "import-photo: extracting EXIF sidecar..."
( cd "$REPO_ROOT" && uv run python tools/extract-exif.py ) || true
if [ ! -f "$EXIF_SIDECAR" ]; then
# Empty sidecar so the consuming Hakyll field has something to read
# (an absent sidecar is also handled, but a present-but-empty file
# signals "extraction was attempted" — useful for film scans where
# there's intentionally no EXIF to find).
echo '{}' > "$EXIF_SIDECAR"
fi
# ---------------------------------------------------------------------------
# Step 3: strip EXIF from the delivered JPEG (sidecar already has it)
# ---------------------------------------------------------------------------
echo "import-photo: stripping EXIF from delivered file..."
magick mogrify -strip "$TARGET"
# ---------------------------------------------------------------------------
# Step 4: extract palette (does its own walk; idempotent on already-done photos)
# ---------------------------------------------------------------------------
echo "import-photo: extracting palette sidecar..."
( cd "$REPO_ROOT" && uv run python tools/extract-palette.py ) || true
# ---------------------------------------------------------------------------
# Step 5: scaffold index.md
# ---------------------------------------------------------------------------
if [ -z "$TITLE" ]; then
TITLE="$(echo "$SLUG" | tr '-' ' ' | awk '{
for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2);
print
}')"
fi
TODAY="$(date -u +%Y-%m-%d)"
# Probe the resized file's pixel dimensions so we can suggest an
# orientation; the author can override in frontmatter.
DIMS="$(magick identify -format '%w %h' "$TARGET")"
WIDTH="${DIMS%% *}"
HEIGHT="${DIMS##* }"
if [ "$WIDTH" -gt "$HEIGHT" ]; then
ORIENTATION="landscape"
elif [ "$HEIGHT" -gt "$WIDTH" ]; then
ORIENTATION="portrait"
else
ORIENTATION="square"
fi
cat > "$INDEX_MD" <<EOF
---
title: "$TITLE"
date: $TODAY
abstract: >
TODO — short caption for this photograph.
tags: [photography]
photo: photo.jpg
orientation: $ORIENTATION
# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves
# location: "" # human-readable, e.g. "Reykjavík, Iceland"
# camera, lens, exposure are auto-filled from the EXIF sidecar — only
# add them here to override what was extracted.
---
EOF
chmod 644 "$INDEX_MD"
echo
echo "import-photo: done."
echo " Entry: $INDEX_MD"
echo " Photo: $TARGET ($WIDTH × $HEIGHT, $ORIENTATION)"
echo " Sidecars: $(basename "$EXIF_SIDECAR"), $(basename "$PALETTE_SIDECAR")"
echo
echo "Next: edit $INDEX_MD to fill in title / abstract / tags, then 'make dev'."

View File

@ -0,0 +1,12 @@
# Pinned SHA-256 hashes for the Leaflet + leaflet.markercluster files
# vendored by tools/download-leaflet.sh. Bumping a version: replace
# the version in download-leaflet.sh, re-run, then update this file.
# Format: <sha256> <pin-key>
db49d009c841f5ca34a888c96511ae936fd9f5533e90d8b2c4d57596f4e5641a leaflet-1.9.4-leaflet.js
a7837102824184820dfa198d1ebcd109ff6d0ff9a2672a074b9a1b4d147d04c6 leaflet-1.9.4-leaflet.css
574c3a5cca85f4114085b6841596d62f00d7c892c7b03f28cbfa301deb1dc437 leaflet-1.9.4-marker-icon.png
00179c4c1ee830d3a108412ae0d294f55776cfeb085c60129a39aa6fc4ae2528 leaflet-1.9.4-marker-icon-2x.png
264f5c640339f042dd729062cfc04c17f8ea0f29882b538e3848ed8f10edb4da leaflet-1.9.4-marker-shadow.png
1e4e1d22972a3926f48598e0caf14e3fe7049835d428a344fed4f9e3665b3508 leaflet.markercluster-1.5.3-leaflet.markercluster.js
614dea0a98ff3f4ead74f04918f6b1d1b9ba435c25b5fc23b21a394d1e3e4d87 leaflet.markercluster-1.5.3-MarkerCluster.css
61258232d98d64dc2a7b1e02130d67421bc5b9bda5994eef70228ff97570c170 leaflet.markercluster-1.5.3-MarkerCluster.Default.css