From cf0f9f37c3efa27c0bb51130a5a1b48c9081a0bc Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Apr 2026 20:52:31 -0400 Subject: [PATCH] Port upcoming levineuwirth.org photography systems to Ozymandias --- .gitignore | 36 +- Makefile | 21 +- README.md | 22 +- build/Compilers.hs | 20 + build/Contexts.hs | 417 ++++++++++++++- build/Filters/Images.hs | 243 ++++++++- build/Patterns.hs | 52 +- build/Photography.hs | 572 ++++++++++++++++++++ build/Site.hs | 38 +- build/Tags.hs | 26 +- cabal.project.freeze | 6 +- ozymandias.cabal | 2 + pyproject.toml | 8 + site.yaml | 5 + static/css/photography.css | 652 +++++++++++++++++++++++ static/js/lightbox.js | 104 +++- static/js/photography-map.js | 175 ++++++ static/js/photography-modes.js | 199 +++++++ templates/default.html | 6 +- templates/partials/head.html | 4 + templates/partials/photo-card.html | 13 + templates/photography-by-year-index.html | 25 + templates/photography-by-year.html | 22 + templates/photography-contact-sheet.html | 29 + templates/photography-index.html | 29 + templates/photography-map.html | 34 ++ templates/photography-series.html | 37 ++ templates/photography.html | 65 +++ tools/download-leaflet.sh | 85 +++ tools/extract-dimensions.py | 127 +++++ tools/extract-exif.py | 444 +++++++++++++++ tools/extract-palette.py | 121 +++++ tools/import-photo.sh | 204 +++++++ tools/leaflet-checksums.sha256 | 12 + 34 files changed, 3799 insertions(+), 56 deletions(-) create mode 100644 build/Photography.hs create mode 100644 static/css/photography.css create mode 100644 static/js/photography-map.js create mode 100644 static/js/photography-modes.js create mode 100644 templates/partials/photo-card.html create mode 100644 templates/photography-by-year-index.html create mode 100644 templates/photography-by-year.html create mode 100644 templates/photography-contact-sheet.html create mode 100644 templates/photography-index.html create mode 100644 templates/photography-map.html create mode 100644 templates/photography-series.html create mode 100644 templates/photography.html create mode 100755 tools/download-leaflet.sh create mode 100755 tools/extract-dimensions.py create mode 100755 tools/extract-exif.py create mode 100755 tools/extract-palette.py create mode 100755 tools/import-photo.sh create mode 100644 tools/leaflet-checksums.sha256 diff --git a/.gitignore b/.gitignore index b5d53f0..d6826c1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +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 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 \ No newline at end of file diff --git a/Makefile b/Makefile index 6dfb37f..6e44449 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/README.md b/README.md index 86e3c39..e4cbd81 100644 --- a/README.md +++ b/README.md @@ -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//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//index.md` | composition.html | +| Photo | `content/photography//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 diff --git a/build/Compilers.hs b/build/Compilers.hs index dfd3640..aec84b3 100644 --- a/build/Compilers.hs +++ b/build/Compilers.hs @@ -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 @@ 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 diff --git a/build/Contexts.hs b/build/Contexts.hs index 974c6cf..d205d4a 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -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/.md@ → @@ +-- * Directory @content/photography//index.md@ → @@ +-- +-- 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//@ 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//@ 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 + -- . 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//@ 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 @@ — the @@ then degrades + -- to a plain @@ on the original-format src. Browsers do NOT + -- fall back from a 404'd @@ to the nested @@; 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, "") diff --git a/build/Filters/Images.hs b/build/Filters/Images.hs index f81ccc2..7cf7e27 100644 --- a/build/Filters/Images.hs +++ b/build/Filters/Images.hs @@ -1,6 +1,7 @@ {-# LANGUAGE GHC2021 #-} {-# LANGUAGE OverloadedStrings #-} --- | Image filter: lazy loading, lightbox markers, and WebP wrappers. +-- | Image filter: lazy loading, lightbox markers, WebP +-- 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 @@ 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 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 @
@ 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 @@ 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 @
@ 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 @
@ with +-- @aria-hidden="true"@. Screen readers then announce the alt +-- (via the @@) 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 → @@ with WebP @@ -- * Local raster without companion → plain @@ (graceful degradation) -- * Everything else (SVG, URL) → plain @@ 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 +-- @@. 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 + -- --------------------------------------------------------------------------- -- rendering -- --------------------------------------------------------------------------- -- | Emit a @@ element with a WebP @@ and an @@ 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 [ "" , "" @@ -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 . + -- attrs we always emit ourselves (loading, decoding, data-lightbox, + -- width, height), so they don't appear twice on the . 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) <> "\"" + +-- --------------------------------------------------------------------------- +--
synthesis (Block walk, WebP path only) +-- --------------------------------------------------------------------------- + +-- | Build a @
@ HTML element wrapping pre-rendered inner +-- content (typically a @@) 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 + [ "\n" + , inner + , "\n" + , figcaption + , "\n
" + ] + +-- | Build a @
@ 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 " attrs <> ">" <> body <> "
" + +-- | 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 @

@ 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 <> "\"" diff --git a/build/Patterns.hs b/build/Patterns.hs index c05f8e2..dddf5dc 100644 --- a/build/Patterns.hs +++ b/build/Patterns.hs @@ -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/.md@ +-- * directory: @content/photography//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//@ — 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 @//@ 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 diff --git a/build/Photography.hs b/build/Photography.hs new file mode 100644 index 0000000..443b518 --- /dev/null +++ b/build/Photography.hs @@ -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/.md@) +-- and directory form (@content/photography//index.md@). +-- * Series — a directory with siblings, e.g. +-- @content/photography//.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/@ — flat-single co-located assets +-- * @content/photography//@ — 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/.md@ → @photography/.html@ +-- * @content/photography//index.md@ → @photography//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//.md@. Compiled with the +-- single-photo template; routed to @//index.html@ +-- so the URL is the canonical directory form (matches the rest of +-- the photography section's URL shape). +-- +-- Series landings (@/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 @/.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/.md@) +-- from directory-form (@content/photography//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//index.md + -- → photography//index.html + then replaceExtension (drop (length contentPrefix) fp) "html" + -- content/photography/.md → photography/.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 +-- @@ 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) -> + "

\"\"

\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//@ 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//@ — 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 diff --git a/build/Site.hs b/build/Site.hs index f84c587..ed95a4c 100644 --- a/build/Site.hs +++ b/build/Site.hs @@ -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 diff --git a/build/Tags.hs b/build/Tags.hs index b48b956..52b0896 100644 --- a/build/Tags.hs +++ b/build/Tags.hs @@ -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 -- --------------------------------------------------------------------------- diff --git a/cabal.project.freeze b/cabal.project.freeze index c4a3e57..dd9a624 100644 --- a/cabal.project.freeze +++ b/cabal.project.freeze @@ -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, diff --git a/ozymandias.cabal b/ozymandias.cabal index 5cdfa58..4729999 100644 --- a/ozymandias.cabal +++ b/ozymandias.cabal @@ -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, diff --git a/pyproject.toml b/pyproject.toml index b7c6af2..e2efdd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]] diff --git a/site.yaml b/site.yaml index 7654f8c..ee52498 100644 --- a/site.yaml +++ b/site.yaml @@ -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" } diff --git a/static/css/photography.css b/static/css/photography.css new file mode 100644 index 0000000..27f04dd --- /dev/null +++ b/static/css/photography.css @@ -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// + * --------------------------------------------------------------------------- */ + +.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); +} diff --git a/static/js/lightbox.js b/static/js/lightbox.js index 0760497..4889831 100644 --- a/static/js/lightbox.js +++ b/static/js/lightbox.js @@ -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')); } }); diff --git a/static/js/photography-map.js b/static/js/photography-map.js new file mode 100644 index 0000000..a78a743 --- /dev/null +++ b/static/js/photography-map.js @@ -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 = '© OpenStreetMap ' + + 'contributors © CARTO'; + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function tooltipHtml(pin) { + var thumb = pin.thumb + ? '' + : ''; + var date = pin.captured + ? '
' + escapeHtml(pin.captured) + '
' + : ''; + return '' + + '
' + + thumb + + '
' + escapeHtml(pin.title || '(untitled)') + '
' + + date + + '
'; + } + + function renderEmptyState(container) { + container.classList.add('photography-map--empty'); + container.innerHTML = + '

' + + 'No geo-tagged photographs yet. Photos with a ' + + 'geo: frontmatter field will appear here.' + + '

'; + } + + function renderErrorState(container, message) { + container.classList.add('photography-map--error'); + container.innerHTML = + '

' + + escapeHtml(message || 'Could not load the map.') + + '

'; + } + + 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); + }); + }); + +}()); diff --git a/static/js/photography-modes.js b/static/js/photography-modes.js new file mode 100644 index 0000000..f34fe94 --- /dev/null +++ b/static/js/photography-modes.js @@ -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()); + }); + +}()); diff --git a/templates/default.html b/templates/default.html index 28e4068..2882302 100644 --- a/templates/default.html +++ b/templates/default.html @@ -3,7 +3,7 @@ $partial("templates/partials/head.html")$ - + $partial("templates/partials/nav.html")$ $if(search)$ @@ -26,6 +26,10 @@ $partial("templates/partials/footer.html")$ $if(home)$$endif$ $if(reading)$$endif$ +$if(photography)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ $for(page-scripts)$$endfor$ $if(math)$ diff --git a/templates/partials/head.html b/templates/partials/head.html index 1f7d8f5..d623ada 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -20,6 +20,10 @@ $if(commonplace)$$endif$ $if(build)$$endif$ $if(reading)$$endif$ $if(composition)$$endif$ +$if(photography)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ +$if(photography-map)$$endif$ $if(math)$ diff --git a/templates/partials/photo-card.html b/templates/partials/photo-card.html new file mode 100644 index 0000000..11ac232 --- /dev/null +++ b/templates/partials/photo-card.html @@ -0,0 +1,13 @@ +
  • + + $if(photo-url)$ + $if(photo-webp-url)$$endif$$title$ + $else$$if(photo)$ + $title$ + $endif$$endif$ +
    + $title$ + $if(captured-iso)$$endif$ +
    +
    +
  • diff --git a/templates/photography-by-year-index.html b/templates/photography-by-year-index.html new file mode 100644 index 0000000..fd80bac --- /dev/null +++ b/templates/photography-by-year-index.html @@ -0,0 +1,25 @@ +
    +
    +
    +

    $title$

    +

    + ← all photographs +

    +
    + + $if(years)$ + + $else$ +

    No years yet — photographs need a captured: or date: frontmatter field to be grouped by year.

    + $endif$ +
    +
    diff --git a/templates/photography-by-year.html b/templates/photography-by-year.html new file mode 100644 index 0000000..7305453 --- /dev/null +++ b/templates/photography-by-year.html @@ -0,0 +1,22 @@ +
    +
    +
    +

    $title$

    +

    + ← all photographs + · + other years +

    +
    + + $if(photos)$ +
      + $for(photos)$ + $partial("templates/partials/photo-card.html")$ + $endfor$ +
    + $else$ +

    No photographs in $year$.

    + $endif$ +
    +
    diff --git a/templates/photography-contact-sheet.html b/templates/photography-contact-sheet.html new file mode 100644 index 0000000..218a45e --- /dev/null +++ b/templates/photography-contact-sheet.html @@ -0,0 +1,29 @@ +
    +
    +
    +

    Contact sheet

    +

    + ← all photographs +

    +
    + + $if(photos)$ +
      + $for(photos)$ +
    1. + + $if(photo-url)$ + $if(photo-webp-url)$$endif$$title$ + $else$$if(photo)$ + $title$ + $endif$$endif$ + $title$ + +
    2. + $endfor$ +
    + $else$ +

    No photographs to print.

    + $endif$ +
    +
    diff --git a/templates/photography-index.html b/templates/photography-index.html new file mode 100644 index 0000000..de0c173 --- /dev/null +++ b/templates/photography-index.html @@ -0,0 +1,29 @@ +
    +
    +
    +

    $title$

    + $if(photos)$ + + $endif$ +
    + + $if(body)$
    $body$
    $endif$ + + $if(photos)$ +
      + $for(photos)$ + $partial("templates/partials/photo-card.html")$ + $endfor$ +
    + $else$ +

    No photographs published yet.

    + $endif$ +
    +
    diff --git a/templates/photography-map.html b/templates/photography-map.html new file mode 100644 index 0000000..a9fff21 --- /dev/null +++ b/templates/photography-map.html @@ -0,0 +1,34 @@ +
    +
    +
    +

    Photography

    + +
    + +
    + +
    + +

    + Pin coordinates are rounded to the precision each photograph's + geo-precision field declares — typically the + nearest ten kilometres. Photos with no geo: + frontmatter (or with geo-precision: hidden) are + omitted from this map by design. +

    +
    +
    diff --git a/templates/photography-series.html b/templates/photography-series.html new file mode 100644 index 0000000..446a867 --- /dev/null +++ b/templates/photography-series.html @@ -0,0 +1,37 @@ +
    +
    +
    +
    +

    $title$

    + $if(photography-tags)$ +
    + $for(photography-tags)$$tag-name$$endfor$ +
    + $endif$ +
    + by$for(author-links)$$author-name$$sep$, $endfor$ +
    + $if(captured-display)$ +
    + captured + + $if(location)$ · $location$$endif$ +
    + $endif$ + $if(abstract)$

    $abstract$

    $endif$ +
    + + $if(body)$
    $body$
    $endif$ + + $if(series-photos)$ +
      + $for(series-photos)$ + $partial("templates/partials/photo-card.html")$ + $endfor$ +
    + $else$ +

    This series has no photographs yet.

    + $endif$ +
    +
    +
    diff --git a/templates/photography.html b/templates/photography.html new file mode 100644 index 0000000..b140181 --- /dev/null +++ b/templates/photography.html @@ -0,0 +1,65 @@ +
    +
    +
    +
    +

    $title$

    + $if(essay-tags)$ +
    + $for(essay-tags)$$tag-name$$endfor$ +
    + $endif$ + $if(photography-tags)$ +
    + $for(photography-tags)$$tag-name$$endfor$ +
    + $endif$ +
    + by$for(author-links)$$author-name$$sep$, $endfor$ +
    + $if(captured-display)$ +
    + captured + + $if(location)$ · $location$$endif$ +
    + $endif$ +
    + +
    + $if(photo-url)$ + $if(photo-webp-url)$$endif$$title$ + $else$$if(photo)$ + $title$ + $endif$$endif$ + $if(abstract)$
    $abstract$
    $endif$ +
    + + $if(palette-swatches)$ +
    + $for(palette-swatches)$$endfor$ +
    + $endif$ + +
    + $if(camera)$
    Camera
    $camera$
    $endif$ + $if(lens)$
    Lens
    $lens$
    $endif$ + $if(film)$
    Film
    $film$
    $endif$ + $if(exposure)$
    Exposure
    $exposure$
    $endif$ + $if(process)$
    Process
    $process$
    $endif$ + $if(license)$ +
    License
    +
    $if(license-url-resolved)$$license$$else$$license$$endif$
    + $endif$ +
    + + $if(photo-links)$ + + $endif$ + + $body$ +
    +
    +
    diff --git a/tools/download-leaflet.sh b/tools/download-leaflet.sh new file mode 100755 index 0000000..4be1dba --- /dev/null +++ b/tools/download-leaflet.sh @@ -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" diff --git a/tools/extract-dimensions.py b/tools/extract-dimensions.py new file mode 100755 index 0000000..d6a79d1 --- /dev/null +++ b/tools/extract-dimensions.py @@ -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 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()) diff --git a/tools/extract-exif.py b/tools/extract-exif.py new file mode 100755 index 0000000..86078b0 --- /dev/null +++ b/tools/extract-exif.py @@ -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 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()) diff --git a/tools/extract-palette.py b/tools/extract-palette.py new file mode 100755 index 0000000..7a05a25 --- /dev/null +++ b/tools/extract-palette.py @@ -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
    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()) diff --git a/tools/import-photo.sh b/tools/import-photo.sh new file mode 100755 index 0000000..c4598e4 --- /dev/null +++ b/tools/import-photo.sh @@ -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//. +# 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 [--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 <&2 +Usage: $(basename "$0") [--title "Title"] + +Imports a photograph into content/photography//, 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" < + 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'." diff --git a/tools/leaflet-checksums.sha256 b/tools/leaflet-checksums.sha256 new file mode 100644 index 0000000..de35d51 --- /dev/null +++ b/tools/leaflet-checksums.sha256 @@ -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: +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