Port upcoming levineuwirth.org photography systems to Ozymandias
This commit is contained in:
parent
34708f4568
commit
cf0f9f37c3
|
|
@ -58,7 +58,41 @@ IGNORE.txt
|
|||
# Download with: make download-model
|
||||
static/models/
|
||||
|
||||
# Vendored Leaflet + leaflet.markercluster (~150 KB total, pinned in
|
||||
# tools/download-leaflet.sh). Used by the /photography/map/ page only.
|
||||
# Downloaded by `make build` when content/photography/ exists.
|
||||
static/leaflet/
|
||||
|
||||
# Generated WebP companions (produced by tools/convert-images.sh at build time).
|
||||
# To intentionally commit a WebP, use: git add -f path/to/file.webp
|
||||
static/**/*.webp
|
||||
content/**/*.webp
|
||||
content/**/*.webp
|
||||
|
||||
# Photography sidecars (produced by tools/extract-exif.py and
|
||||
# tools/extract-palette.py at build time; consumed by Hakyll). Recreated
|
||||
# from the photo file on every `make build`, so they don't belong in
|
||||
# version control — committing them would just produce churn.
|
||||
content/photography/**/*.exif.yaml
|
||||
content/photography/**/*.palette.yaml
|
||||
|
||||
# Image-dimension sidecars (produced by tools/extract-dimensions.py at
|
||||
# build time; consumed by build/Filters/Images.hs to emit width / height
|
||||
# attrs on every <img> for CLS prevention). Same churn-avoidance reasons
|
||||
# as the photography sidecars above; recreated on every `make build`.
|
||||
**/*.dims.yaml
|
||||
|
||||
# Photography: defense-in-depth — refuse to commit RAW or oversize
|
||||
# originals. Per the photography section's design, only ≤2400px
|
||||
# web-optimized JPEGs are committed; originals stay outside the repo.
|
||||
# To intentionally commit one of these formats (rare), use
|
||||
# `git add -f path/to/file`.
|
||||
content/photography/**/*.cr2
|
||||
content/photography/**/*.cr3
|
||||
content/photography/**/*.nef
|
||||
content/photography/**/*.arw
|
||||
content/photography/**/*.dng
|
||||
content/photography/**/*.raf
|
||||
content/photography/**/*.orf
|
||||
content/photography/**/*.tif
|
||||
content/photography/**/*.tiff
|
||||
content/photography/**/*.psd
|
||||
21
Makefile
21
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:
|
||||
|
|
|
|||
22
README.md
22
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/<slug>/index.md` | composition.html|
|
||||
| Page | `content/*.md` | page.html |
|
||||
| Type | Path | Template |
|
||||
|:------------|:-------------------------------------------|:------------------|
|
||||
| Essay | `content/essays/*.md` | essay.html |
|
||||
| Blog post | `content/blog/*.md` | blog-post.html |
|
||||
| Poetry | `content/poetry/*.md` | reading.html |
|
||||
| Fiction | `content/fiction/*.md` | reading.html |
|
||||
| Composition | `content/music/<slug>/index.md` | composition.html |
|
||||
| Photo | `content/photography/<slug>/index.md` | photography.html |
|
||||
| Page | `content/*.md` | page.html |
|
||||
|
||||
The photography section is opt-in: leave `content/photography/` absent and zero photo rules run, no leaflet is downloaded, no sidecars are generated. To turn it on, create the directory with an `index.md` (the section landing) and a first photo entry. Add `{ slug: "photography", name: "Photography" }` to `site.yaml`'s `portals` to surface a library shelf. Each photo entry can specify a JPEG file, EXIF and palette sidecars are auto-generated at build time (Pillow + colorthief), and a geo-precision-rounded `map.json` feeds the Leaflet view at `/photography/map/`.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ module Compilers
|
|||
, poetryCompiler
|
||||
, fictionCompiler
|
||||
, compositionCompiler
|
||||
, photographyCompiler
|
||||
, readerOpts
|
||||
, writerOpts
|
||||
) where
|
||||
|
|
@ -200,6 +201,25 @@ fictionCompiler = essayCompiler
|
|||
compositionCompiler :: Compiler (Item String)
|
||||
compositionCompiler = essayCompiler
|
||||
|
||||
-- | Compiler for photography pages: body prose runs through the same
|
||||
-- source preprocessors and AST filters as other content (so wikilinks,
|
||||
-- smallcaps, sidenotes, image @<picture>@ wrapping, etc. all work in
|
||||
-- caption / process-note prose), but skips TOC, word-count,
|
||||
-- reading-time, citations, and further-reading. Visual content has no
|
||||
-- meaningful word count, and the epistemic / bibliography surfaces in
|
||||
-- 'essayCtx' don't apply here.
|
||||
photographyCompiler :: Compiler (Item String)
|
||||
photographyCompiler = do
|
||||
body <- getResourceBody
|
||||
let src = itemBody body
|
||||
body' = itemSetBody (preprocessSource src) body
|
||||
filePath <- getResourceFilePath
|
||||
let srcDir = takeDirectory filePath
|
||||
pandocItem <- readPandocWith readerOpts body'
|
||||
pandocFiltered <- unsafeCompiler $ applyAll srcDir (itemBody pandocItem)
|
||||
let pandocItem' = itemSetBody pandocFiltered pandocItem
|
||||
return (writePandocWith writerOpts pandocItem')
|
||||
|
||||
-- | Compiler for simple pages: filters applied, no TOC snapshot.
|
||||
pageCompiler :: Compiler (Item String)
|
||||
pageCompiler = do
|
||||
|
|
|
|||
|
|
@ -8,23 +8,30 @@ module Contexts
|
|||
, poetryCtx
|
||||
, fictionCtx
|
||||
, compositionCtx
|
||||
, photographyCtx
|
||||
, contentKindField
|
||||
, abstractField
|
||||
, tagLinksField
|
||||
, authorLinksField
|
||||
, affiliationField
|
||||
) where
|
||||
|
||||
import Data.Aeson (Value (..))
|
||||
import qualified Data.Aeson as Aeson
|
||||
import qualified Data.Aeson.Key as AK
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.Vector as V
|
||||
import Data.List (intercalate, isPrefixOf)
|
||||
import Data.Maybe (fromMaybe)
|
||||
import qualified Data.Scientific as Sci
|
||||
import Data.Time.Calendar (toGregorian)
|
||||
import Data.Time.Clock (getCurrentTime, utctDay)
|
||||
import Data.Time.Format (formatTime, defaultTimeLocale)
|
||||
import System.FilePath (takeDirectory, takeFileName)
|
||||
import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
|
||||
import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM)
|
||||
import System.Directory (doesFileExist)
|
||||
import System.FilePath (takeDirectory, takeFileName, (</>))
|
||||
import Text.Read (readMaybe)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Yaml as Y
|
||||
import qualified Config
|
||||
import Text.Pandoc (runPure, readMarkdown, writeHtml5String, Pandoc(..), Block(..), Inline(..))
|
||||
import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..))
|
||||
|
|
@ -99,12 +106,13 @@ contentKindField = field "item-kind" $ \item -> do
|
|||
return $ case r of
|
||||
Nothing -> "Page"
|
||||
Just r'
|
||||
| "essays/" `isPrefixOf` r' -> "Essay"
|
||||
| "blog/" `isPrefixOf` r' -> "Post"
|
||||
| "poetry/" `isPrefixOf` r' -> "Poem"
|
||||
| "fiction/" `isPrefixOf` r' -> "Fiction"
|
||||
| "music/" `isPrefixOf` r' -> "Composition"
|
||||
| otherwise -> "Page"
|
||||
| "essays/" `isPrefixOf` r' -> "Essay"
|
||||
| "blog/" `isPrefixOf` r' -> "Post"
|
||||
| "poetry/" `isPrefixOf` r' -> "Poem"
|
||||
| "fiction/" `isPrefixOf` r' -> "Fiction"
|
||||
| "music/" `isPrefixOf` r' -> "Composition"
|
||||
| "photography/" `isPrefixOf` r' -> "Photo"
|
||||
| otherwise -> "Page"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Site-wide context
|
||||
|
|
@ -570,3 +578,394 @@ compositionCtx =
|
|||
<> field "has-audio"
|
||||
(\i -> maybe (fail "no audio") (const (return "true"))
|
||||
(movAudio (itemBody i)))
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Photography context
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Extract the photo entry's slug from its identifier.
|
||||
--
|
||||
-- * Flat single @content/photography/<slug>.md@ → @<slug>@
|
||||
-- * Directory @content/photography/<slug>/index.md@ → @<slug>@
|
||||
--
|
||||
-- The slug is the URL segment under @/photography/@ and the directory
|
||||
-- name into which co-located assets (the photo, EXIF + palette
|
||||
-- sidecars) are copied by the asset rule.
|
||||
photoSlug :: Item a -> String
|
||||
photoSlug item =
|
||||
let fp = toFilePath (itemIdentifier item)
|
||||
fname = takeFileName fp
|
||||
in if fname == "index.md"
|
||||
then takeFileName (takeDirectory fp)
|
||||
else takeWhile (/= '.') fname
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Sidecar reader
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- @{photo}.exif.yaml@ and @{photo}.palette.yaml@ are produced by the
|
||||
-- Python tools at @make build@ time (see @tools/extract-exif.py@ and
|
||||
-- @tools/extract-palette.py@). They live alongside the photo file
|
||||
-- under @content/photography/<slug>/@ and back-fill metadata that the
|
||||
-- author chose not to write in frontmatter.
|
||||
--
|
||||
-- Read strategy: 'unsafeCompiler' + 'doesFileExist'. Sidecars are NOT
|
||||
-- registered as Hakyll items, so this read bypasses the dependency
|
||||
-- tracker. That is acceptable because:
|
||||
--
|
||||
-- * The Python tools always run before @cabal run site -- build@
|
||||
-- (the Makefile orders them that way).
|
||||
-- * Re-running the EXIF / palette extractor invalidates only those
|
||||
-- fields' rendered output; rebuilding @make build@ from scratch
|
||||
-- covers the dependency-edge case for free.
|
||||
--
|
||||
-- Resolution rule for every sidecar-backed field: frontmatter wins;
|
||||
-- if frontmatter is absent OR empty, fall back to sidecar; if neither
|
||||
-- supplies a value, return 'noResult' so the consuming template's
|
||||
-- @$if(...)$@ guard suppresses the row.
|
||||
|
||||
-- | Compute the sidecar path for a photo entry.
|
||||
--
|
||||
-- @suffix@ is @".exif.yaml"@ or @".palette.yaml"@.
|
||||
-- Returns @Nothing@ when the entry has no @photo:@ frontmatter or
|
||||
-- when the entry is flat-form (no co-located asset directory).
|
||||
photoSidecarPath :: String -> Item a -> Compiler (Maybe FilePath)
|
||||
photoSidecarPath suffix item = do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
let fp = toFilePath (itemIdentifier item)
|
||||
isDir = takeFileName fp == "index.md"
|
||||
case (isDir, lookupString "photo" meta) of
|
||||
(True, Just photo) | not (null photo) ->
|
||||
return $ Just $ takeDirectory fp </> photo ++ suffix
|
||||
_ -> return Nothing
|
||||
|
||||
-- | Load a sidecar YAML file as an Aeson Object (same shape Hakyll
|
||||
-- uses for frontmatter). Returns 'KM.empty' when the file is
|
||||
-- missing or fails to parse — sidecars are advisory, never fatal.
|
||||
loadSidecar :: FilePath -> IO Aeson.Object
|
||||
loadSidecar path = do
|
||||
exists <- doesFileExist path
|
||||
if not exists
|
||||
then return KM.empty
|
||||
else do
|
||||
decoded <- Y.decodeFileEither path
|
||||
case decoded of
|
||||
Right (Object obj) -> return obj
|
||||
_ -> return KM.empty
|
||||
|
||||
-- | Read a sidecar object for a given suffix. Returns the empty object
|
||||
-- when the entry has no resolvable sidecar path or when the file is
|
||||
-- absent / malformed.
|
||||
readPhotoSidecar :: String -> Item a -> Compiler Aeson.Object
|
||||
readPhotoSidecar suffix item = do
|
||||
mPath <- photoSidecarPath suffix item
|
||||
case mPath of
|
||||
Nothing -> return KM.empty
|
||||
Just path -> unsafeCompiler (loadSidecar path)
|
||||
|
||||
-- | Coerce a YAML scalar value to a plain String for template
|
||||
-- interpolation. Integers render without a trailing @.0@; structures
|
||||
-- and arrays return 'Nothing' (callers needing those should branch
|
||||
-- on 'Value' directly).
|
||||
yamlAsString :: Value -> Maybe String
|
||||
yamlAsString (String t) =
|
||||
let s = T.unpack t
|
||||
in if null (trim s) then Nothing else Just (trim s)
|
||||
yamlAsString (Number n) =
|
||||
case Sci.floatingOrInteger n :: Either Double Integer of
|
||||
Right i -> Just (show i)
|
||||
Left d -> Just (show d)
|
||||
yamlAsString _ = Nothing
|
||||
|
||||
-- | Look up a key in a sidecar object, coercing scalar values to
|
||||
-- String. Returns 'Nothing' for missing keys, empty strings, and
|
||||
-- structural values (arrays / nested objects).
|
||||
sidecarLookupString :: String -> Aeson.Object -> Maybe String
|
||||
sidecarLookupString key obj = yamlAsString =<< KM.lookup (AK.fromString key) obj
|
||||
|
||||
-- | Generic frontmatter > EXIF-sidecar fallback field.
|
||||
--
|
||||
-- @key@ is the YAML key — same name on both sides. Frontmatter
|
||||
-- wins when present and non-empty; otherwise the matching key in
|
||||
-- @{photo}.exif.yaml@. 'noResult' fires when neither supplies a
|
||||
-- value, so the consuming template's @$if(key)$@ guard suppresses
|
||||
-- the row.
|
||||
exifBackedField :: String -> Context String
|
||||
exifBackedField key = field key $ \item -> do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
case lookupString key meta of
|
||||
Just v | not (null (trim v)) -> return (trim v)
|
||||
_ -> do
|
||||
obj <- readPhotoSidecar ".exif.yaml" item
|
||||
case sidecarLookupString key obj of
|
||||
Just v -> return v
|
||||
Nothing -> noResult ("no " ++ key ++ " in frontmatter or EXIF sidecar")
|
||||
|
||||
-- | Canonical URL for a known license name.
|
||||
--
|
||||
-- The frontmatter @license:@ string is normalized — lowercased, with
|
||||
-- internal whitespace collapsed — before lookup, so any of these all
|
||||
-- resolve identically:
|
||||
--
|
||||
-- * @"CC BY-SA 4.0"@
|
||||
-- * @"cc by-sa 4.0"@
|
||||
-- * @" CC BY-SA 4.0 "@
|
||||
--
|
||||
-- For licenses not in this table (e.g. a custom license, or "All
|
||||
-- Rights Reserved" which has no URL), the author can supply their
|
||||
-- own @license-url:@ frontmatter field; the field-level resolver
|
||||
-- (@licenseUrlField@) prefers explicit @license-url@ and falls back
|
||||
-- to this lookup only when the author hasn't provided one.
|
||||
canonicalLicenseUrl :: String -> Maybe String
|
||||
canonicalLicenseUrl raw =
|
||||
case unwords (words (map (\c -> if c == '_' then ' ' else toLowerC c) raw)) of
|
||||
"cc by 4.0" -> Just "https://creativecommons.org/licenses/by/4.0/"
|
||||
"cc by-sa 4.0" -> Just "https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
"cc by-nc 4.0" -> Just "https://creativecommons.org/licenses/by-nc/4.0/"
|
||||
"cc by-nc-sa 4.0" -> Just "https://creativecommons.org/licenses/by-nc-sa/4.0/"
|
||||
"cc by-nd 4.0" -> Just "https://creativecommons.org/licenses/by-nd/4.0/"
|
||||
"cc by-nc-nd 4.0" -> Just "https://creativecommons.org/licenses/by-nc-nd/4.0/"
|
||||
"cc0" -> Just "https://creativecommons.org/publicdomain/zero/1.0/"
|
||||
"cc0 1.0" -> Just "https://creativecommons.org/publicdomain/zero/1.0/"
|
||||
"public domain" -> Just "https://creativecommons.org/publicdomain/mark/1.0/"
|
||||
_ -> Nothing
|
||||
where
|
||||
toLowerC c
|
||||
| c >= 'A' && c <= 'Z' = toEnum (fromEnum c + 32)
|
||||
| otherwise = c
|
||||
|
||||
-- | Context for photography pages and photo cards.
|
||||
--
|
||||
-- Frontmatter fields win when present; auto-extracted EXIF + palette
|
||||
-- sidecars produced by @tools/extract-exif.py@ /
|
||||
-- @tools/extract-palette.py@ fill in the gaps.
|
||||
--
|
||||
-- Photography pages do not include the essay context's epistemic,
|
||||
-- bibliography, backlinks, similar-links, TOC, word-count, or
|
||||
-- reading-time fields — none of those apply to visual content.
|
||||
--
|
||||
-- Exposed template variables:
|
||||
-- @$photography$@ — flag, gates @photography.css@ in head.html
|
||||
-- and the @data-page-type@ body attribute used
|
||||
-- by the darkroom-mode lightbox
|
||||
-- @$slug$@ — URL slug under @/photography/@
|
||||
-- @$photo-url$@ — absolute URL of the photo file. Built as
|
||||
-- @/photography/<slug>/<photo>@ when the entry
|
||||
-- is directory-form; @noResult@ for flat
|
||||
-- singles (templates use the @photo@
|
||||
-- frontmatter directly there).
|
||||
-- @$captured-display$@, @$captured-iso$@ — capture date in
|
||||
-- human-readable and ISO forms; @noResult@
|
||||
-- when @captured:@ is absent. Distinct from
|
||||
-- the publication @date:@ shown in card lists.
|
||||
-- @$photography-tags$@ — listField of @{tag-name, tag-url}@.
|
||||
-- @$palette-swatches$@ — listField of @{swatch}@ (hex string).
|
||||
-- @noResult@ when the @palette:@ frontmatter
|
||||
-- is absent or empty so the template's
|
||||
-- @$if(palette-swatches)$@ gate suppresses an
|
||||
-- empty strip.
|
||||
photographyCtx :: Context String
|
||||
photographyCtx =
|
||||
constField "photography" "true"
|
||||
<> slugField
|
||||
<> photoUrlField
|
||||
<> photoWebpUrlField
|
||||
-- EXIF-backed fields. Each prefers frontmatter and falls back to
|
||||
-- @{photo}.exif.yaml@ produced by @tools/extract-exif.py@. Sidecars
|
||||
-- absent on film scans (no EXIF on a film negative) is fine —
|
||||
-- noResult propagates and the template's @$if(...)$@ gate hides
|
||||
-- the row.
|
||||
<> exifBackedField "camera"
|
||||
<> exifBackedField "lens"
|
||||
<> exifBackedField "exposure"
|
||||
<> exifBackedField "shutter"
|
||||
<> exifBackedField "aperture"
|
||||
<> exifBackedField "iso"
|
||||
<> exifBackedField "focal-length"
|
||||
-- Pixel dimensions for CLS-prevention width/height attrs on every
|
||||
-- <img>. Read from the EXIF sidecar produced by extract-exif.py;
|
||||
-- frontmatter wins if the author wants to override (e.g., to
|
||||
-- declare a different rendered size).
|
||||
<> exifBackedField "width"
|
||||
<> exifBackedField "height"
|
||||
<> capturedDisplayField
|
||||
<> capturedIsoField
|
||||
<> paletteSwatchesField
|
||||
<> licenseUrlField
|
||||
<> photoLinksField
|
||||
<> tagLinksField "photography-tags"
|
||||
<> authorLinksField
|
||||
<> affiliationField
|
||||
<> dateField "date" "%-d %B %Y"
|
||||
<> dateField "date-iso" "%Y-%m-%d"
|
||||
<> siteCtx
|
||||
where
|
||||
slugField :: Context String
|
||||
slugField = field "slug" (return . photoSlug)
|
||||
|
||||
-- Build @/photography/<slug>/<photo>@ when both the directory-form
|
||||
-- entry and a @photo:@ frontmatter key are present. Flat singles
|
||||
-- have no co-located asset directory, so @noResult@ there — the
|
||||
-- template falls back to interpreting the @photo:@ frontmatter
|
||||
-- as a literal URL.
|
||||
photoUrlField :: Context String
|
||||
photoUrlField = field "photo-url" $ \item -> do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
let fp = toFilePath (itemIdentifier item)
|
||||
isDir = takeFileName fp == "index.md"
|
||||
case (isDir, lookupString "photo" meta) of
|
||||
(True, Just photo) ->
|
||||
return $ "/photography/" ++ photoSlug item ++ "/" ++ photo
|
||||
_ -> noResult "no co-located photo (flat single, or photo: key absent)"
|
||||
|
||||
-- WebP companion URL, mirroring 'photoUrlField'. Returns 'noResult'
|
||||
-- when the @.webp@ companion doesn't exist on disk at compile time
|
||||
-- (cwebp not installed, conversion not yet run, or this image
|
||||
-- failed to convert) so the template's @$if(photo-webp-url)$@
|
||||
-- guard suppresses the @<source>@ — the @<picture>@ then degrades
|
||||
-- to a plain @<img>@ on the original-format src. Browsers do NOT
|
||||
-- fall back from a 404'd @<source>@ to the nested @<img>@; the
|
||||
-- file-existence check at build time is load-bearing.
|
||||
photoWebpUrlField :: Context String
|
||||
photoWebpUrlField = field "photo-webp-url" $ \item -> do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
let fp = toFilePath (itemIdentifier item)
|
||||
isDir = takeFileName fp == "index.md"
|
||||
case (isDir, lookupString "photo" meta) of
|
||||
(True, Just photo) | not (null photo) -> do
|
||||
let entryDir = takeDirectory fp
|
||||
webpDisk = entryDir </> photoToWebp photo
|
||||
exists <- unsafeCompiler (doesFileExist webpDisk)
|
||||
if exists
|
||||
then return $ "/photography/" ++ photoSlug item
|
||||
++ "/" ++ photoToWebp photo
|
||||
else noResult "no webp companion on disk"
|
||||
_ -> noResult "no co-located photo (flat single, or photo: key absent)"
|
||||
where
|
||||
photoToWebp :: String -> String
|
||||
photoToWebp p =
|
||||
let dotIdx = lastDotIndex p
|
||||
in case dotIdx of
|
||||
Just i -> take i p ++ ".webp"
|
||||
Nothing -> p ++ ".webp"
|
||||
|
||||
lastDotIndex :: String -> Maybe Int
|
||||
lastDotIndex s = go (length s - 1)
|
||||
where
|
||||
go i
|
||||
| i < 0 = Nothing
|
||||
| s !! i == '/' = Nothing -- crossed a path boundary
|
||||
| s !! i == '.' = Just i
|
||||
| otherwise = go (i - 1)
|
||||
|
||||
-- Resolve the @captured:@ ISO date with frontmatter > sidecar
|
||||
-- precedence. Centralised so the display and ISO fields stay in
|
||||
-- agreement on which source they read from.
|
||||
resolveCapturedIso :: Item a -> Compiler (Maybe String)
|
||||
resolveCapturedIso item = do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
case lookupString "captured" meta of
|
||||
Just v | not (null (trim v)) -> return (Just (trim v))
|
||||
_ -> do
|
||||
obj <- readPhotoSidecar ".exif.yaml" item
|
||||
return (sidecarLookupString "captured" obj)
|
||||
|
||||
-- @captured:@ as "15 March 2026". Reads frontmatter, falls back to
|
||||
-- the EXIF sidecar's @captured:@ key. Returns @noResult@ when
|
||||
-- absent so @$if(captured-display)$@ gates the metadata row.
|
||||
capturedDisplayField :: Context String
|
||||
capturedDisplayField = field "captured-display" $ \item -> do
|
||||
mIso <- resolveCapturedIso item
|
||||
case mIso of
|
||||
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
|
||||
Just iso ->
|
||||
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
|
||||
:: Maybe UTCTime of
|
||||
Just t -> return (formatTime defaultTimeLocale "%-d %B %Y" t)
|
||||
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
|
||||
|
||||
-- ISO form passed through unchanged (after a parse-validate round-trip
|
||||
-- so a malformed value in either source doesn't reach the template).
|
||||
capturedIsoField :: Context String
|
||||
capturedIsoField = field "captured-iso" $ \item -> do
|
||||
mIso <- resolveCapturedIso item
|
||||
case mIso of
|
||||
Nothing -> noResult "no captured date in frontmatter or EXIF sidecar"
|
||||
Just iso ->
|
||||
case parseTimeM True defaultTimeLocale "%Y-%m-%d" iso
|
||||
:: Maybe UTCTime of
|
||||
Just t -> return (formatTime defaultTimeLocale "%Y-%m-%d" t)
|
||||
Nothing -> noResult "captured date does not parse as YYYY-MM-DD"
|
||||
|
||||
-- @palette:@ list field. Frontmatter wins; otherwise pull the
|
||||
-- list from @{photo}.palette.yaml@ (the @palette:@ key, an array
|
||||
-- of hex strings produced by @tools/extract-palette.py@). Each
|
||||
-- swatch exposes @$swatch$@.
|
||||
paletteSwatchesField :: Context String
|
||||
paletteSwatchesField = listFieldWith "palette-swatches" swCtx $ \item -> do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
let fmEntries = fromMaybe [] (lookupStringList "palette" meta)
|
||||
fmVisible = filter (not . null . trim) fmEntries
|
||||
swatches <- if null fmVisible
|
||||
then do
|
||||
obj <- readPhotoSidecar ".palette.yaml" item
|
||||
case KM.lookup "palette" obj of
|
||||
Just (Array vec) ->
|
||||
return [ trim s
|
||||
| val <- V.toList vec
|
||||
, Just s <- [yamlAsString val]
|
||||
, not (null (trim s)) ]
|
||||
_ -> return []
|
||||
else return fmVisible
|
||||
if null swatches
|
||||
then noResult "no palette swatches in frontmatter or palette sidecar"
|
||||
else return $ zipWith
|
||||
(\i s -> Item (fromFilePath ("palette-" ++ show i)) s)
|
||||
([0 ..] :: [Int])
|
||||
swatches
|
||||
where
|
||||
swCtx = field "swatch" (return . itemBody)
|
||||
|
||||
-- @$license-url-resolved$@: an explicit @license-url:@ frontmatter
|
||||
-- value when present, otherwise a canonical URL looked up from the
|
||||
-- @license:@ string for known licenses (CC variants, CC0, public
|
||||
-- domain). Returns @noResult@ when neither is set, so
|
||||
-- @$if(license-url-resolved)$@ gates the link wrapper.
|
||||
--
|
||||
-- Frontmatter @license:@ itself flows through @defaultContext@ as
|
||||
-- @$license$@; the template renders the license name as link text
|
||||
-- and uses @$license-url-resolved$@ as @href@.
|
||||
licenseUrlField :: Context String
|
||||
licenseUrlField = field "license-url-resolved" $ \item -> do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
case lookupString "license-url" meta of
|
||||
Just u | not (null (trim u)) -> return (trim u)
|
||||
_ -> case lookupString "license" meta of
|
||||
Nothing -> noResult "no license"
|
||||
Just l -> case canonicalLicenseUrl l of
|
||||
Just u -> return u
|
||||
Nothing -> noResult "license not in canonical lookup"
|
||||
|
||||
-- @links:@ frontmatter — outbound links to other surfaces where
|
||||
-- the photograph appears or can be acquired (Wikimedia Commons,
|
||||
-- Flickr, exhibition catalog, print-sale page, etc.). Each entry
|
||||
-- uses the same @"Name | URL"@ pipe syntax as @authors:@ /
|
||||
-- @affiliation:@ — the existing site convention.
|
||||
--
|
||||
-- Each item exposes @$link-name$@ and @$link-url$@. Entries
|
||||
-- without a URL are dropped (no point linking to nothing). Returns
|
||||
-- @noResult@ on empty so @$if(photo-links)$@ guards the wrapper.
|
||||
photoLinksField :: Context String
|
||||
photoLinksField = listFieldWith "photo-links" lkCtx $ \item -> do
|
||||
meta <- getMetadata (itemIdentifier item)
|
||||
let entries = fromMaybe [] (lookupStringList "links" meta)
|
||||
parsed = filter (not . null . snd) (map parseEntry entries)
|
||||
if null parsed
|
||||
then noResult "no outbound links"
|
||||
else return $ map (Item (fromFilePath "")) parsed
|
||||
where
|
||||
lkCtx = field "link-name" (return . fst . itemBody)
|
||||
<> field "link-url" (return . snd . itemBody)
|
||||
parseEntry s = case break (== '|') s of
|
||||
(name, '|' : url) -> (trim name, trim url)
|
||||
(name, _) -> (trim name, "")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
-- | Image filter: lazy loading, lightbox markers, and WebP <picture> wrappers.
|
||||
-- | Image filter: lazy loading, lightbox markers, WebP <picture>
|
||||
-- wrappers, and CLS-preventing width/height attrs.
|
||||
--
|
||||
-- For local raster images (JPG, JPEG, PNG, GIF) whose @.webp@ companion
|
||||
-- exists on disk at build time, emits a @<picture>@ element with a WebP
|
||||
|
|
@ -17,16 +18,29 @@
|
|||
--
|
||||
-- SVG files and external URLs are passed through with only lazy loading
|
||||
-- (and lightbox markers for standalone images).
|
||||
--
|
||||
-- Width / height attrs are looked up from @{image}.dims.yaml@ sidecars
|
||||
-- produced by @tools/extract-dimensions.py@ at build time, on the same
|
||||
-- path-resolution rules as the WebP companion check (absolute paths
|
||||
-- under @static/@, relative under the source-file directory). When a
|
||||
-- sidecar is missing the filter emits an attr-free <img> rather than
|
||||
-- guessing — partial dimensions are worse than no dimensions, since
|
||||
-- the browser would then size the image wrong on first paint.
|
||||
module Filters.Images (apply) where
|
||||
|
||||
import Data.Char (toLower)
|
||||
import Data.Default (def)
|
||||
import Data.List (isPrefixOf)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.Scientific as Sci
|
||||
import qualified Data.Yaml as Y
|
||||
import Text.Pandoc.Definition
|
||||
import qualified Text.Pandoc as Pandoc
|
||||
import Text.Pandoc.Walk (walkM)
|
||||
import System.Directory (doesFileExist)
|
||||
import System.FilePath (replaceExtension, takeExtension, (</>))
|
||||
import Text.Pandoc.Definition
|
||||
import Text.Pandoc.Walk (walkM)
|
||||
import qualified Utils as U
|
||||
|
||||
-- | Apply image attribute injection and WebP wrapping to the entire document.
|
||||
|
|
@ -35,13 +49,76 @@ import qualified Utils as U
|
|||
-- relative image paths when probing for the corresponding @.webp@
|
||||
-- companion file. Absolute paths (leading @/@) are resolved against
|
||||
-- @static/@ instead, matching the layout @convert-images.sh@ writes to.
|
||||
--
|
||||
-- Two-pass walk:
|
||||
--
|
||||
-- 1. Block-level pass (@transformBlock@) intercepts standalone
|
||||
-- figures so we can synthesize the entire @<figure>@ ourselves
|
||||
-- when WebP wrapping kicks in. Without this pass, replacing the
|
||||
-- inner @Image@ with a @RawInline@ would break Pandoc's
|
||||
-- alt-vs-caption comparison and we'd lose the
|
||||
-- @aria-hidden="true"@ hint on identical-text figcaptions.
|
||||
-- 2. Inline-level pass (@transformInline@) handles every remaining
|
||||
-- @Image@ — inline-in-prose, inside @Link@s, etc. Pandoc's writer
|
||||
-- still applies its accessibility heuristics for figures we
|
||||
-- didn't synthesize (notably the no-WebP case).
|
||||
apply :: FilePath -> Pandoc -> IO Pandoc
|
||||
apply srcDir = walkM (transformInline srcDir)
|
||||
apply srcDir doc = do
|
||||
doc' <- walkM (transformBlock srcDir) doc
|
||||
walkM (transformInline srcDir) doc'
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Core transformation
|
||||
-- Core transformations
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Block-level pass. Currently only acts on the simple-figure shape
|
||||
-- that Pandoc's Markdown reader produces for @@ standalone:
|
||||
--
|
||||
-- @Figure attr caption [Plain [Image imgAttr alt target]]@
|
||||
--
|
||||
-- When the image has a WebP companion on disk, we replace the whole
|
||||
-- Figure with a @RawBlock@ containing the equivalent HTML — but with
|
||||
-- the @<picture>@ wrapper inside and a manually-emitted
|
||||
-- @aria-hidden="true"@ on the figcaption when alt text equals the
|
||||
-- caption text. Anything more exotic (multi-image figures, mixed
|
||||
-- block content inside the figure, no-WebP images) is left to
|
||||
-- Pandoc's default emission, which is already correct for those
|
||||
-- cases.
|
||||
transformBlock :: FilePath -> Block -> IO Block
|
||||
transformBlock srcDir b@(Figure figAttr caption [Plain [Image imgAttr alt target]]) = do
|
||||
let src = T.unpack (fst target)
|
||||
if not (isLocalRaster src)
|
||||
then pure b
|
||||
else do
|
||||
hasWebp <- doesFileExist (webpPhysicalPath srcDir (fst target))
|
||||
if not hasWebp
|
||||
then pure b -- Pandoc handles aria-hidden naturally on the no-WebP path.
|
||||
else synthesizeFigure srcDir figAttr caption imgAttr alt target
|
||||
transformBlock _ b = pure b
|
||||
|
||||
-- | Build a @<figure>@ block from an Image and its surrounding
|
||||
-- metadata. Used only on the WebP branch; the no-WebP branch leaves
|
||||
-- Pandoc to emit the figure naturally.
|
||||
--
|
||||
-- Aria-hiding rule: when the caption's plain-text content equals the
|
||||
-- alt text and both are non-empty, mark the @<figcaption>@ with
|
||||
-- @aria-hidden="true"@. Screen readers then announce the alt
|
||||
-- (via the @<img>@) and skip the figcaption (which would just
|
||||
-- duplicate it). Non-matching captions render as visible content.
|
||||
--
|
||||
-- Caption inline rendering goes through Pandoc's HTML writer, so
|
||||
-- formatting (italic, links, code, etc.) is preserved.
|
||||
synthesizeFigure :: FilePath -> Attr -> Caption -> Attr -> [Inline] -> Target -> IO Block
|
||||
synthesizeFigure srcDir figAttr caption imgAttr alt target = do
|
||||
dims <- readDims srcDir (fst target)
|
||||
let pictureHtml = renderPicture imgAttr alt target True dims
|
||||
capInlines = captionInlines caption
|
||||
capText = stringify capInlines
|
||||
altText = stringify alt
|
||||
useAriaHide = capText == altText && not (T.null altText)
|
||||
pure $ RawBlock (Format "html") $
|
||||
renderFigure figAttr pictureHtml (renderFigcaption capInlines useAriaHide)
|
||||
|
||||
transformInline :: FilePath -> Inline -> IO Inline
|
||||
transformInline srcDir (Link lAttr ils lTarget) = do
|
||||
-- Recurse into link contents; images inside a link get no lightbox marker.
|
||||
|
|
@ -60,21 +137,41 @@ wrapLinkedImg _ x = pure x
|
|||
-- * Local raster with webp companion on disk → @<picture>@ with WebP @<source>@
|
||||
-- * Local raster without companion → plain @<img>@ (graceful degradation)
|
||||
-- * Everything else (SVG, URL) → plain @<img>@ with loading/lightbox attrs
|
||||
--
|
||||
-- In all three branches, when a @{image}.dims.yaml@ sidecar is
|
||||
-- present, @width@ and @height@ attrs are emitted on the rendered
|
||||
-- @<img>@. The sidecar lookup is skipped for non-local sources
|
||||
-- (HTTP URLs, data URIs) since there's no local file to measure.
|
||||
renderImg :: FilePath -> Attr -> [Inline] -> Target -> Bool -> IO Inline
|
||||
renderImg srcDir attr alt target@(src, _) lightbox
|
||||
| isLocalRaster (T.unpack src) = do
|
||||
hasWebp <- doesFileExist (webpPhysicalPath srcDir src)
|
||||
if hasWebp
|
||||
then pure $ RawInline (Format "html")
|
||||
(renderPicture attr alt target lightbox)
|
||||
else pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr))
|
||||
alt target
|
||||
| otherwise =
|
||||
pure $ Image (addLightbox lightbox (addAttr "loading" "lazy" attr)) alt target
|
||||
renderImg srcDir attr alt target@(src, _) lightbox = do
|
||||
let s = T.unpack src
|
||||
isRaster = isLocalRaster s
|
||||
local = not (isUrl s)
|
||||
dims <- if local then readDims srcDir src else pure Nothing
|
||||
if isRaster
|
||||
then do
|
||||
hasWebp <- doesFileExist (webpPhysicalPath srcDir src)
|
||||
if hasWebp
|
||||
then pure $ RawInline (Format "html")
|
||||
(renderPicture attr alt target lightbox dims)
|
||||
else pure $ Image (commonAttrs dims) alt target
|
||||
else
|
||||
pure $ Image (commonAttrs dims) alt target
|
||||
where
|
||||
commonAttrs dims =
|
||||
withDims dims
|
||||
$ addAttr "decoding" "async"
|
||||
$ addLightbox lightbox
|
||||
$ addAttr "loading" "lazy" attr
|
||||
|
||||
addLightbox True a = addAttr "data-lightbox" "true" a
|
||||
addLightbox False a = a
|
||||
|
||||
withDims Nothing a = a
|
||||
withDims (Just (w, h)) a =
|
||||
addAttr "width" (T.pack (show w))
|
||||
(addAttr "height" (T.pack (show h)) a)
|
||||
|
||||
-- | Physical on-disk path of the @.webp@ companion for a Markdown image src.
|
||||
--
|
||||
-- Absolute paths (@/images/foo.jpg@) resolve under @static/@ because that
|
||||
|
|
@ -89,13 +186,49 @@ webpPhysicalPath srcDir src =
|
|||
else srcDir </> s
|
||||
in replaceExtension physical ".webp"
|
||||
|
||||
-- | Physical on-disk path of the @.dims.yaml@ sidecar for a Markdown
|
||||
-- image src. Same path-resolution rules as 'webpPhysicalPath'; the
|
||||
-- sidecar lives next to the original image with the literal
|
||||
-- extension @.dims.yaml@ appended.
|
||||
dimsPhysicalPath :: FilePath -> Text -> FilePath
|
||||
dimsPhysicalPath srcDir src =
|
||||
let s = T.unpack src
|
||||
physical = if "/" `isPrefixOf` s
|
||||
then "static" ++ s
|
||||
else srcDir </> s
|
||||
in physical ++ ".dims.yaml"
|
||||
|
||||
-- | Read the @{image}.dims.yaml@ sidecar and return @(width, height)@
|
||||
-- when present and parseable. Returns 'Nothing' on absent file,
|
||||
-- parse error, missing keys, or non-integer values — all of which
|
||||
-- cause the filter to emit no width/height attrs (rather than a
|
||||
-- guess that would size the image wrong on first paint).
|
||||
readDims :: FilePath -> Text -> IO (Maybe (Int, Int))
|
||||
readDims srcDir src = do
|
||||
let path = dimsPhysicalPath srcDir src
|
||||
exists <- doesFileExist path
|
||||
if not exists
|
||||
then pure Nothing
|
||||
else do
|
||||
decoded <- Y.decodeFileEither path
|
||||
pure $ case decoded of
|
||||
Right (Y.Object obj) -> do
|
||||
w <- intValue =<< KM.lookup "width" obj
|
||||
h <- intValue =<< KM.lookup "height" obj
|
||||
Just (w, h)
|
||||
_ -> Nothing
|
||||
where
|
||||
intValue :: Y.Value -> Maybe Int
|
||||
intValue (Y.Number n) = Sci.toBoundedInteger n
|
||||
intValue _ = Nothing
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- <picture> rendering
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Emit a @<picture>@ element with a WebP @<source>@ and an @<img>@ fallback.
|
||||
renderPicture :: Attr -> [Inline] -> Target -> Bool -> Text
|
||||
renderPicture (ident, classes, kvs) alt (src, title) lightbox =
|
||||
renderPicture :: Attr -> [Inline] -> Target -> Bool -> Maybe (Int, Int) -> Text
|
||||
renderPicture (ident, classes, kvs) alt (src, title) lightbox dims =
|
||||
T.concat
|
||||
[ "<picture>"
|
||||
, "<source srcset=\"", T.pack webpSrc, "\" type=\"image/webp\">"
|
||||
|
|
@ -105,7 +238,9 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox =
|
|||
, " src=\"", esc src, "\""
|
||||
, attrAlt alt
|
||||
, attrTitle title
|
||||
, dimsAttrs dims
|
||||
, " loading=\"lazy\""
|
||||
, " decoding=\"async\""
|
||||
, if lightbox then " data-lightbox=\"true\"" else ""
|
||||
, renderKvs passedKvs
|
||||
, ">"
|
||||
|
|
@ -114,13 +249,81 @@ renderPicture (ident, classes, kvs) alt (src, title) lightbox =
|
|||
where
|
||||
webpSrc = replaceExtension (T.unpack src) ".webp"
|
||||
-- Strip attrs we handle explicitly above (id/class/alt/title) and the
|
||||
-- attrs we always emit ourselves (loading, data-lightbox), so they don't
|
||||
-- appear twice on the <img>.
|
||||
-- attrs we always emit ourselves (loading, decoding, data-lightbox,
|
||||
-- width, height), so they don't appear twice on the <img>.
|
||||
passedKvs = filter
|
||||
(\(k, _) -> k `notElem`
|
||||
["loading", "data-lightbox", "id", "class", "alt", "title", "src"])
|
||||
[ "loading", "decoding", "data-lightbox"
|
||||
, "id", "class", "alt", "title", "src"
|
||||
, "width", "height"
|
||||
])
|
||||
kvs
|
||||
|
||||
dimsAttrs Nothing = ""
|
||||
dimsAttrs (Just (w, h)) =
|
||||
" width=\"" <> T.pack (show w)
|
||||
<> "\" height=\"" <> T.pack (show h) <> "\""
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- <figure> synthesis (Block walk, WebP path only)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Build a @<figure>@ HTML element wrapping pre-rendered inner
|
||||
-- content (typically a @<picture>@) and a pre-rendered figcaption.
|
||||
-- Preserves any id / classes / kvs from the surrounding Pandoc
|
||||
-- 'Figure' attr.
|
||||
renderFigure :: Attr -> Text -> Text -> Text
|
||||
renderFigure (figId, figClasses, figKvs) inner figcaption =
|
||||
T.concat
|
||||
[ "<figure"
|
||||
, attrId figId
|
||||
, attrClasses figClasses
|
||||
, renderKvs figKvs
|
||||
, ">\n"
|
||||
, inner
|
||||
, "\n"
|
||||
, figcaption
|
||||
, "\n</figure>"
|
||||
]
|
||||
|
||||
-- | Build a @<figcaption>@ element. When @ariaHidden@ is true, emits
|
||||
-- @aria-hidden="true"@ — used when the caption text exactly
|
||||
-- duplicates the image alt (so screen readers don't announce the
|
||||
-- same content twice). Caption inlines render through Pandoc's HTML
|
||||
-- writer to preserve formatting.
|
||||
renderFigcaption :: [Inline] -> Bool -> Text
|
||||
renderFigcaption ils ariaHidden =
|
||||
let body = renderInlinesToHtml ils
|
||||
attrs = if ariaHidden then " aria-hidden=\"true\"" else ""
|
||||
in "<figcaption" <> attrs <> ">" <> body <> "</figcaption>"
|
||||
|
||||
-- | Pandoc 'Caption' has a long form (@[Block]@) and an optional short
|
||||
-- form (@Maybe ShortCaption@). We use the long form, flattening any
|
||||
-- @Plain@ / @Para@ blocks into a single inline list. Multi-block
|
||||
-- captions (rare) collapse to the inlines of their text-bearing
|
||||
-- blocks; non-text blocks (like nested lists) are dropped, since
|
||||
-- they don't make sense in a figcaption anyway.
|
||||
captionInlines :: Caption -> [Inline]
|
||||
captionInlines (Caption _ blocks) = concatMap go blocks
|
||||
where
|
||||
go (Plain ils) = ils
|
||||
go (Para ils) = ils
|
||||
go _ = []
|
||||
|
||||
-- | Render Pandoc 'Inline' nodes to HTML using Pandoc's own writer.
|
||||
-- Wrapping the inlines in a @Plain@ block (rather than @Para@)
|
||||
-- avoids the surrounding @<p>@ tag the writer would otherwise emit.
|
||||
-- On writer failure (extremely unlikely for inline-only input),
|
||||
-- falls back to the plain-text 'stringify' rendering — a worse but
|
||||
-- still safe figcaption.
|
||||
renderInlinesToHtml :: [Inline] -> Text
|
||||
renderInlinesToHtml ils =
|
||||
case Pandoc.runPure (Pandoc.writeHtml5String def doc) of
|
||||
Right t -> T.strip t
|
||||
Left _ -> stringify ils
|
||||
where
|
||||
doc = Pandoc mempty [Plain ils]
|
||||
|
||||
attrId :: Text -> Text
|
||||
attrId t = if T.null t then "" else " id=\"" <> esc t <> "\""
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ module Patterns
|
|||
, poetryPattern
|
||||
, fictionPattern
|
||||
, musicPattern
|
||||
, photographyPattern
|
||||
, allPhotoEntries
|
||||
, standalonePagesPattern
|
||||
-- * Aggregated patterns
|
||||
, allWritings -- essays + blog + poetry + fiction
|
||||
|
|
@ -66,6 +68,44 @@ fictionPattern = "content/fiction/*.md"
|
|||
musicPattern :: Pattern
|
||||
musicPattern = "content/music/*/index.md"
|
||||
|
||||
-- | All photo entries — flat singles plus directory-form entries.
|
||||
--
|
||||
-- Two shapes:
|
||||
-- * flat: @content/photography/<slug>.md@
|
||||
-- * directory: @content/photography/<slug>/index.md@
|
||||
--
|
||||
-- The section landing page at @content/photography/index.md@ is
|
||||
-- excluded; it routes via 'Photography.photographyLandingRules' as the
|
||||
-- section landing, not as a photo entry.
|
||||
--
|
||||
-- Directory-form @index.md@ files are treated as either single-photo
|
||||
-- entries (when the directory has no @.md@ siblings) or series landings
|
||||
-- (when it does). 'Photography.photographyEntryRules' branches on that
|
||||
-- structurally — no @series: true@ frontmatter flag is needed.
|
||||
photographyPattern :: Pattern
|
||||
photographyPattern =
|
||||
("content/photography/*.md" .&&. complement "content/photography/index.md")
|
||||
.||. "content/photography/*/index.md"
|
||||
|
||||
-- | Every photographic entry, including children of series. Distinct
|
||||
-- from 'photographyPattern' (which enumerates only top-level entries
|
||||
-- and series landings) for surfaces that should enumerate every
|
||||
-- photograph individually:
|
||||
--
|
||||
-- * @/photography/by-year/<year>/@ — one frame per file
|
||||
-- * @/photography/contact-sheet/@ — every frame in the roll
|
||||
-- * @/photography/map.json@ — one pin per geotagged photo
|
||||
-- * @/photography/feed.xml@ — one entry per shot
|
||||
-- * Tag indexes — siblings have their own tags
|
||||
--
|
||||
-- The main @/photography/@ landing and the library shelf use
|
||||
-- 'photographyPattern' instead, so a series shows up as a single
|
||||
-- aggregate card rather than once for the landing plus once per child.
|
||||
allPhotoEntries :: Pattern
|
||||
allPhotoEntries =
|
||||
photographyPattern
|
||||
.||. ("content/photography/*/*.md" .&&. complement "content/photography/*/index.md")
|
||||
|
||||
-- | Top-level standalone pages (about, colophon, current, gpg, …).
|
||||
standalonePagesPattern :: Pattern
|
||||
standalonePagesPattern = "content/*.md"
|
||||
|
|
@ -95,6 +135,14 @@ allContent =
|
|||
authorIndexable :: Pattern
|
||||
authorIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion
|
||||
|
||||
-- | Content shown on tag index pages — essays + blog posts.
|
||||
-- | Content shown on tag index pages — essays + blog posts + every
|
||||
-- photographic entry (including sibling photos in series).
|
||||
-- Photography sub-tags (@photography/landscape@, @photography/film@,
|
||||
-- …) generate proper @/<sub-tag>/@ pages from this pattern; the
|
||||
-- bare @photography@ top-level tag is filtered out in
|
||||
-- 'Tags.getExpandedTags' to avoid colliding with the section
|
||||
-- landing's route at @/photography/@.
|
||||
tagIndexable :: Pattern
|
||||
tagIndexable = (essayPattern .||. blogPattern) .&&. hasNoVersion
|
||||
tagIndexable =
|
||||
(essayPattern .||. blogPattern .||. allPhotoEntries)
|
||||
.&&. hasNoVersion
|
||||
|
|
|
|||
|
|
@ -0,0 +1,572 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
-- | Photography section — routing and per-page compilation.
|
||||
--
|
||||
-- Activates only when @content/photography/@ exists in the project
|
||||
-- tree (gated in 'Site.rules'). Photographers who don't want a
|
||||
-- photography section simply leave the directory absent and pay zero
|
||||
-- cost — no rules, no generated pages, no feed.
|
||||
--
|
||||
-- Surfaces:
|
||||
--
|
||||
-- * Single-photo entries — flat (@content/photography/<slug>.md@)
|
||||
-- and directory form (@content/photography/<slug>/index.md@).
|
||||
-- * Series — a directory with siblings, e.g.
|
||||
-- @content/photography/<series>/<photo>.md@. Series detection is
|
||||
-- structural; no @series: true@ frontmatter flag is needed.
|
||||
-- * Section landing at @/photography/@.
|
||||
-- * Map at @/photography/map/@ with @map.json@ for the Leaflet client.
|
||||
-- * By-year indexes at @/photography/by-year/{year}/@.
|
||||
-- * Contact sheet at @/photography/contact-sheet/@.
|
||||
-- * Atom feed at @/photography/feed.xml@.
|
||||
--
|
||||
-- See @PHOTOGRAPHY.md@ in the upstream levineuwirth.org repo for the
|
||||
-- full design rationale.
|
||||
module Photography
|
||||
( photographyRules
|
||||
) where
|
||||
|
||||
import Control.Monad (forM, forM_)
|
||||
import Data.List (sortBy)
|
||||
import qualified Data.Map.Strict as Map
|
||||
import Data.Map.Strict (Map)
|
||||
import Data.Maybe (mapMaybe, fromMaybe, catMaybes)
|
||||
import qualified Data.Set as Set
|
||||
import Data.Set (Set)
|
||||
import Data.Ord (Down (..), comparing)
|
||||
import System.FilePath (takeDirectory, takeFileName, replaceExtension)
|
||||
import qualified Data.Aeson as Aeson
|
||||
import Data.Aeson (Value (..), (.=))
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Lazy as TL
|
||||
import qualified Data.Text.Lazy.Encoding as TLE
|
||||
import qualified Data.Vector as V
|
||||
import qualified Data.Scientific as Sci
|
||||
import Hakyll
|
||||
import qualified Config
|
||||
import Compilers (pageCompiler, photographyCompiler)
|
||||
import Contexts (photographyCtx, pageCtx, siteCtx)
|
||||
import qualified Patterns as P
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Rules
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | All photography rules. Called from 'Site.rules' once, and only
|
||||
-- when @content/photography/@ exists.
|
||||
--
|
||||
-- Order is intentional:
|
||||
--
|
||||
-- 1. Co-located assets first (so the photo file is in @_site/@
|
||||
-- before any page that references it is compiled — Hakyll's
|
||||
-- dependency tracker handles this anyway, but the surface
|
||||
-- ordering reads top-down by data flow).
|
||||
-- 2. Single-photo entries (flat + directory form).
|
||||
-- 3. Section landing at @/photography/@ — loaded after the
|
||||
-- photo entries so its @loadAll photographyPattern@ resolves
|
||||
-- each photo's frontmatter through 'photographyCtx'.
|
||||
photographyRules :: Rules ()
|
||||
photographyRules = do
|
||||
-- A directory is a "series" iff it has @.md@ siblings alongside
|
||||
-- its @index.md@. Collected once at rule-gen time so the entry
|
||||
-- rule can branch on series-landing template selection without
|
||||
-- re-globbing per item.
|
||||
siblingIds <- getMatches
|
||||
( "content/photography/*/*.md"
|
||||
.&&. complement "content/photography/*/index.md"
|
||||
)
|
||||
let seriesSlugs :: Set String
|
||||
seriesSlugs = Set.fromList
|
||||
[ takeFileName (takeDirectory (toFilePath ident))
|
||||
| ident <- siblingIds
|
||||
]
|
||||
|
||||
photographyAssetRules
|
||||
photographyEntryRules seriesSlugs
|
||||
photographySeriesPhotoRules
|
||||
photographyLandingRules
|
||||
photographyMapDataRule
|
||||
photographyMapPageRule
|
||||
photographyFeedRule
|
||||
photographyByYearRules
|
||||
photographyContactSheetRule
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Assets
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Co-located assets — the photo file itself, and the
|
||||
-- build-generated @{photo}.exif.yaml@ + @{photo}.palette.yaml@
|
||||
-- sidecars. Two patterns are matched in sequence:
|
||||
--
|
||||
-- * @content/photography/<asset>@ — flat-single co-located assets
|
||||
-- * @content/photography/<slug>/<asset>@ — directory-form co-located assets
|
||||
--
|
||||
-- Markdown files are excluded from both rules; they're compiled by
|
||||
-- 'photographyEntryRules' and 'photographyLandingRules'. The
|
||||
-- @.exif.yaml@ / @.palette.yaml@ / @.dims.yaml@ sidecars are
|
||||
-- excluded too — they're consumed by Hakyll at compile time and have
|
||||
-- no role in the deployed site.
|
||||
photographyAssetRules :: Rules ()
|
||||
photographyAssetRules = do
|
||||
-- Top-level non-Markdown files (flat-single co-located assets).
|
||||
match ("content/photography/*"
|
||||
.&&. complement "content/photography/*.md"
|
||||
.&&. complement "content/photography/*.exif.yaml"
|
||||
.&&. complement "content/photography/*.palette.yaml"
|
||||
.&&. complement "content/photography/*.dims.yaml") $ do
|
||||
route $ gsubRoute "content/" (const "")
|
||||
compile copyFileCompiler
|
||||
|
||||
-- Directory-form entries' co-located assets.
|
||||
match ("content/photography/*/*"
|
||||
.&&. complement "content/photography/*/index.md"
|
||||
.&&. complement "content/photography/*/*.md"
|
||||
.&&. complement "content/photography/*/*.exif.yaml"
|
||||
.&&. complement "content/photography/*/*.palette.yaml"
|
||||
.&&. complement "content/photography/*/*.dims.yaml") $ do
|
||||
route $ gsubRoute "content/" (const "")
|
||||
compile copyFileCompiler
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Single-photo entries
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Compile each single-photo entry. Routing follows the essay
|
||||
-- convention so the URL shape is predictable:
|
||||
--
|
||||
-- * @content/photography/<slug>.md@ → @photography/<slug>.html@
|
||||
-- * @content/photography/<slug>/index.md@ → @photography/<slug>/index.html@
|
||||
--
|
||||
-- The @"content"@ snapshot is saved so @/photography/feed.xml@ can
|
||||
-- render the rendered body as feed entry content.
|
||||
photographyEntryRules :: Set String -> Rules ()
|
||||
photographyEntryRules seriesSlugs =
|
||||
match P.photographyPattern $ do
|
||||
route photoEntryRoute
|
||||
compile $ do
|
||||
ident <- getUnderlying
|
||||
let fp = toFilePath ident
|
||||
isIndex = takeFileName fp == "index.md"
|
||||
slug = takeFileName (takeDirectory fp)
|
||||
isSeriesLanding = isIndex && slug `Set.member` seriesSlugs
|
||||
template
|
||||
| isSeriesLanding = "templates/photography-series.html"
|
||||
| otherwise = "templates/photography.html"
|
||||
ctx
|
||||
| isSeriesLanding = seriesCtx
|
||||
| otherwise = photographyCtx
|
||||
photographyCompiler
|
||||
>>= saveSnapshot "content"
|
||||
>>= loadAndApplyTemplate template ctx
|
||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- | Sibling photos inside a series directory:
|
||||
-- @content/photography/<series>/<photo>.md@. Compiled with the
|
||||
-- single-photo template; routed to @<series>/<photo>/index.html@
|
||||
-- so the URL is the canonical directory form (matches the rest of
|
||||
-- the photography section's URL shape).
|
||||
--
|
||||
-- Series landings (@<series>/index.md@) are handled by
|
||||
-- 'photographyEntryRules' with the @photographyPattern@ match;
|
||||
-- they're explicitly excluded here so the two rules don't double-route.
|
||||
photographySeriesPhotoRules :: Rules ()
|
||||
photographySeriesPhotoRules =
|
||||
match ("content/photography/*/*.md"
|
||||
.&&. complement "content/photography/*/index.md") $ do
|
||||
route $ customRoute $ \ident ->
|
||||
-- Drop @"content/"@ prefix and @".md"@ suffix, then append
|
||||
-- @"/index.html"@ to get directory-style URLs.
|
||||
let fp = toFilePath ident
|
||||
rel = drop (length contentPrefix) fp
|
||||
stripped = take (length rel - 3) rel
|
||||
in stripped ++ "/index.html"
|
||||
compile $ photographyCompiler
|
||||
>>= saveSnapshot "content"
|
||||
>>= loadAndApplyTemplate "templates/photography.html" photographyCtx
|
||||
>>= loadAndApplyTemplate "templates/default.html" photographyCtx
|
||||
>>= relativizeUrls
|
||||
where
|
||||
contentPrefix = "content/" :: String
|
||||
|
||||
-- | Context for series-landing pages. Extends 'photographyCtx' with a
|
||||
-- @series-photos@ list field that loads the directory's sibling
|
||||
-- photos (the @<series>/<photo>.md@ files), most-recent-first.
|
||||
--
|
||||
-- The @is-series@ const flag lets the consuming template branch on
|
||||
-- whether to render single-photo chrome (figure + EXIF dl + body)
|
||||
-- or series chrome (intro + photo grid + body).
|
||||
seriesCtx :: Context String
|
||||
seriesCtx =
|
||||
constField "is-series" "true"
|
||||
<> listFieldWith "series-photos" photographyCtx loadSeriesChildren
|
||||
<> photographyCtx
|
||||
where
|
||||
loadSeriesChildren parent = do
|
||||
let ident = itemIdentifier parent
|
||||
slug = takeFileName (takeDirectory (toFilePath ident))
|
||||
pat = fromGlob ("content/photography/" ++ slug ++ "/*.md")
|
||||
.&&. complement
|
||||
(fromGlob ("content/photography/" ++ slug ++ "/index.md"))
|
||||
.&&. hasNoVersion
|
||||
recentFirst =<< loadAll pat
|
||||
|
||||
-- | Route a photography entry to its public URL. The pattern check on
|
||||
-- @takeFileName@ distinguishes flat (@content/photography/<slug>.md@)
|
||||
-- from directory-form (@content/photography/<slug>/index.md@) without
|
||||
-- re-globbing, since Hakyll has already pre-filtered to entries
|
||||
-- matching 'P.photographyPattern'.
|
||||
photoEntryRoute :: Routes
|
||||
photoEntryRoute = customRoute $ \ident ->
|
||||
let fp = toFilePath ident
|
||||
fname = takeFileName fp
|
||||
isIndex = fname == "index.md"
|
||||
in if isIndex
|
||||
-- content/photography/<slug>/index.md
|
||||
-- → photography/<slug>/index.html
|
||||
then replaceExtension (drop (length contentPrefix) fp) "html"
|
||||
-- content/photography/<slug>.md → photography/<slug>.html
|
||||
else "photography/" ++ replaceExtension fname "html"
|
||||
where
|
||||
contentPrefix :: String
|
||||
contentPrefix = "content/"
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Landing page
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Section landing at @/photography/@. Loads all photo entries
|
||||
-- resolved against 'photographyCtx' so each card has access to
|
||||
-- slug / photo-url / captured-display / palette swatches.
|
||||
photographyLandingRules :: Rules ()
|
||||
photographyLandingRules =
|
||||
match "content/photography/index.md" $ do
|
||||
route $ constRoute "photography/index.html"
|
||||
compile $ do
|
||||
photos <- recentFirst
|
||||
=<< loadAll (P.photographyPattern .&&. hasNoVersion)
|
||||
let ctx =
|
||||
listField "photos" photographyCtx (return photos)
|
||||
<> constField "photography" "true"
|
||||
<> constField "list-page" "true"
|
||||
<> pageCtx
|
||||
pageCompiler
|
||||
>>= loadAndApplyTemplate "templates/photography-index.html" ctx
|
||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Map data
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- Two artifacts together:
|
||||
--
|
||||
-- * @/photography/map.json@ — JSON array of pin objects, fetched
|
||||
-- by @static/js/photography-map.js@ at view time. Built directly
|
||||
-- from frontmatter; no Python dependency.
|
||||
-- * @/photography/map/@ — the page that renders the Leaflet
|
||||
-- viewport. Lightweight HTML; the heavy lifting lives in the JS.
|
||||
--
|
||||
-- Privacy: every coordinate is rounded to the precision the author
|
||||
-- declares in @geo-precision:@ (default @"city"@) BEFORE it leaves
|
||||
-- this build step. Full-precision coords never reach @map.json@.
|
||||
-- @geo-precision: hidden@ omits the entry entirely.
|
||||
|
||||
-- | Strip a trailing @"index.html"@ component so a Hakyll route
|
||||
-- like @"photography/foo/index.html"@ becomes @"photography/foo/"@.
|
||||
-- Used for map.json click-through URLs.
|
||||
stripIndexHtml :: String -> String
|
||||
stripIndexHtml r
|
||||
| suffixMatches = take (length r - 10) r -- 10 = length "index.html"
|
||||
| otherwise = r
|
||||
where
|
||||
suffix = "/index.html" :: String
|
||||
suffixMatches = suffix == drop (length r - length suffix) r
|
||||
|
||||
-- | Round a decimal coordinate to the precision that matches the
|
||||
-- author's @geo-precision:@ declaration.
|
||||
--
|
||||
-- * @exact@: 4 decimal places (~10 m)
|
||||
-- * @km@ : 2 decimal places (~1 km)
|
||||
-- * @city@ : 1 decimal place (~10 km) — default
|
||||
-- * other : treated as @city@
|
||||
--
|
||||
-- @hidden@ is handled at the call site by skipping the pin entirely.
|
||||
roundCoord :: String -> Double -> Double
|
||||
roundCoord prec x =
|
||||
let n = case prec of
|
||||
"exact" -> 4
|
||||
"km" -> 2
|
||||
"city" -> 1
|
||||
_ -> 1
|
||||
scale = 10 ^^ (n :: Int) :: Double
|
||||
in fromIntegral (round (x * scale) :: Integer) / scale
|
||||
|
||||
-- | Extract @[lat, lon]@ from a frontmatter @geo:@ list. Accepts only
|
||||
-- exactly two numeric entries — anything else returns 'Nothing' so
|
||||
-- the entry is silently skipped on the map.
|
||||
parseGeo :: Aeson.Object -> Maybe (Double, Double)
|
||||
parseGeo meta = case KM.lookup "geo" meta of
|
||||
Just (Array vec) | V.length vec == 2 ->
|
||||
case (asDouble (vec V.! 0), asDouble (vec V.! 1)) of
|
||||
(Just lat, Just lon) -> Just (lat, lon)
|
||||
_ -> Nothing
|
||||
_ -> Nothing
|
||||
where
|
||||
asDouble (Number n) = Just (Sci.toRealFloat n)
|
||||
asDouble _ = Nothing
|
||||
|
||||
-- | Build a single pin object from a photo entry. Returns 'Nothing'
|
||||
-- when:
|
||||
-- * the entry has no @geo:@ frontmatter, or
|
||||
-- * it has @geo-precision: hidden@, or
|
||||
-- * the entry has no resolvable route.
|
||||
buildPin :: Item String -> Compiler (Maybe Value)
|
||||
buildPin item = do
|
||||
let ident = itemIdentifier item
|
||||
meta <- getMetadata ident
|
||||
mRoute <- getRoute ident
|
||||
case (parseGeo meta, lookupString "geo-precision" meta, mRoute) of
|
||||
(_, Just "hidden", _) -> return Nothing
|
||||
(Just (lat, lon), prec, Just r) ->
|
||||
let prec' = fromMaybe "city" prec
|
||||
rLat = roundCoord prec' lat
|
||||
rLon = roundCoord prec' lon
|
||||
fp = toFilePath ident
|
||||
slug = takeFileName (takeDirectory fp)
|
||||
title = fromMaybe slug (lookupString "title" meta)
|
||||
photo = lookupString "photo" meta
|
||||
url = "/" ++ stripIndexHtml r
|
||||
thumb = case photo of
|
||||
Just p | not (null p) ->
|
||||
"/photography/" ++ slug ++ "/" ++ p
|
||||
_ -> ""
|
||||
captured = lookupString "captured" meta
|
||||
in return $ Just $ Aeson.object $
|
||||
[ "slug" .= slug
|
||||
, "title" .= title
|
||||
, "url" .= url
|
||||
, "lat" .= rLat
|
||||
, "lon" .= rLon
|
||||
] ++ (if null thumb then [] else ["thumb" .= thumb])
|
||||
++ maybe [] (\c -> ["captured" .= c]) captured
|
||||
_ -> return Nothing
|
||||
|
||||
-- | @/photography/map.json@ — JSON array of geo-tagged photo pins
|
||||
-- for the Leaflet client.
|
||||
photographyMapDataRule :: Rules ()
|
||||
photographyMapDataRule =
|
||||
create ["photography/map.json"] $ do
|
||||
route idRoute
|
||||
compile $ do
|
||||
photos <- loadAll (P.allPhotoEntries .&&. hasNoVersion)
|
||||
:: Compiler [Item String]
|
||||
pins <- mapMaybe id <$> mapM buildPin photos
|
||||
-- Decode through Text so non-ASCII (em-dashes, accents) are
|
||||
-- preserved correctly in titles instead of being mojibake'd.
|
||||
makeItem $ TL.unpack $ TLE.decodeUtf8 $ Aeson.encode pins
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Map page
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | @/photography/map/@ — the Leaflet-driven map view. Synthesised
|
||||
-- page; no Markdown source. The @photography-map@ context flag
|
||||
-- gates Leaflet CSS / JS loading in @head.html@ and @default.html@,
|
||||
-- so other photography pages stay lightweight.
|
||||
photographyMapPageRule :: Rules ()
|
||||
photographyMapPageRule =
|
||||
create ["photography/map/index.html"] $ do
|
||||
route idRoute
|
||||
compile $ do
|
||||
let ctx = constField "title" "Map · Photography"
|
||||
<> constField "photography" "true"
|
||||
<> constField "photography-map" "true"
|
||||
<> siteCtx
|
||||
makeItem ""
|
||||
>>= loadAndApplyTemplate "templates/photography-map.html" ctx
|
||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Atom feed
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Configuration for the photography-only Atom feed. Distinct from
|
||||
-- the main feed so text-primary subscribers don't unexpectedly get
|
||||
-- image-heavy entries in their reader.
|
||||
photographyFeedConfig :: FeedConfiguration
|
||||
photographyFeedConfig = FeedConfiguration
|
||||
{ feedTitle = T.unpack (Config.siteName Config.siteConfig) ++ " — Photography"
|
||||
, feedDescription = "New photographs"
|
||||
, feedAuthorName = T.unpack (Config.authorName Config.siteConfig)
|
||||
, feedAuthorEmail = T.unpack (Config.authorEmail Config.siteConfig)
|
||||
, feedRoot = T.unpack (Config.siteUrl Config.siteConfig)
|
||||
}
|
||||
|
||||
-- | Description field for Atom feed entries: prepends an absolute-URL
|
||||
-- @<img>@ tag (so the photograph displays inline in the reader) to
|
||||
-- the rendered prose body. Composed ABOVE 'bodyField' so it wins
|
||||
-- when @$description$@ is consumed by the Atom template.
|
||||
photographyFeedDescription :: Context String
|
||||
photographyFeedDescription = field "description" $ \item -> do
|
||||
let ident = itemIdentifier item
|
||||
body <- itemBody <$> (loadSnapshot ident "content" :: Compiler (Item String))
|
||||
meta <- getMetadata ident
|
||||
let fp = toFilePath ident
|
||||
isDir = takeFileName fp == "index.md"
|
||||
slug = takeFileName (takeDirectory fp)
|
||||
photo = lookupString "photo" meta
|
||||
siteUrlStr = T.unpack (Config.siteUrl Config.siteConfig)
|
||||
imgTag = case (isDir, photo) of
|
||||
(True, Just p) | not (null p) ->
|
||||
"<p><img src=\"" ++ siteUrlStr ++ "/photography/"
|
||||
++ slug ++ "/" ++ p ++ "\" alt=\"\"></p>\n"
|
||||
_ -> ""
|
||||
return (imgTag ++ body)
|
||||
|
||||
-- | @/photography/feed.xml@ — Atom feed of the most recent 30 photo
|
||||
-- entries, with each photograph embedded inline at the top of its
|
||||
-- entry description.
|
||||
photographyFeedRule :: Rules ()
|
||||
photographyFeedRule =
|
||||
create ["photography/feed.xml"] $ do
|
||||
route idRoute
|
||||
compile $ do
|
||||
photos <- fmap (take 30) . recentFirst
|
||||
=<< loadAllSnapshots
|
||||
(P.allPhotoEntries .&&. hasNoVersion)
|
||||
"content"
|
||||
let feedCtx =
|
||||
dateField "updated" "%Y-%m-%dT%H:%M:%SZ"
|
||||
<> dateField "published" "%Y-%m-%dT%H:%M:%SZ"
|
||||
<> photographyFeedDescription
|
||||
<> bodyField "description"
|
||||
<> defaultContext
|
||||
renderAtom photographyFeedConfig feedCtx photos
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- By-year pages
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- @/photography/by-year/@ is the index of years that have photos;
|
||||
-- @/photography/by-year/<year>/@ lists each year's photos
|
||||
-- chronologically. Year is taken from @captured:@ frontmatter
|
||||
-- (when present), falling back to @date:@. Photos with neither
|
||||
-- field — or with a malformed date — are silently dropped from this
|
||||
-- surface; they remain visible on the main grid and any tag pages
|
||||
-- their frontmatter produces.
|
||||
|
||||
-- | Extract a four-digit year from a frontmatter @captured:@ or
|
||||
-- @date:@ field. Returns 'Nothing' when neither is set or both are
|
||||
-- shorter than four characters.
|
||||
yearOfPhoto :: Metadata -> Maybe String
|
||||
yearOfPhoto meta =
|
||||
let firstFour s = if length s >= 4 then Just (take 4 s) else Nothing
|
||||
in case lookupString "captured" meta >>= firstFour of
|
||||
Just yr -> Just yr
|
||||
Nothing -> lookupString "date" meta >>= firstFour
|
||||
|
||||
-- | All by-year rules: collect (year, identifier) pairs once, then
|
||||
-- build the index page and one page per year.
|
||||
photographyByYearRules :: Rules ()
|
||||
photographyByYearRules = do
|
||||
photoIds <- getMatches (P.allPhotoEntries .&&. hasNoVersion)
|
||||
pairs <- forM photoIds $ \ident -> do
|
||||
meta <- getMetadata ident
|
||||
return $ fmap (\yr -> (yr, ident)) (yearOfPhoto meta)
|
||||
let yearMap :: Map String [Identifier]
|
||||
yearMap = Map.fromListWith (++) [(yr, [i]) | (yr, i) <- catMaybes pairs]
|
||||
-- Years sorted descending so the most recent appear first.
|
||||
years = map fst $ sortBy (comparing (Down . fst)) (Map.toList yearMap)
|
||||
|
||||
photographyByYearIndexRule yearMap years
|
||||
forM_ years $ \yr -> photographyByYearPageRule yr (yearMap Map.! yr)
|
||||
|
||||
-- | @/photography/by-year/@ — top-level index. Lists each year that
|
||||
-- has photos with the count, linking to the per-year page.
|
||||
photographyByYearIndexRule :: Map String [Identifier] -> [String] -> Rules ()
|
||||
photographyByYearIndexRule yearMap years =
|
||||
create ["photography/by-year/index.html"] $ do
|
||||
route idRoute
|
||||
compile $ do
|
||||
let yearItems =
|
||||
[ Item (fromFilePath ("year-" ++ yr))
|
||||
(yr, length (Map.findWithDefault [] yr yearMap))
|
||||
| yr <- years
|
||||
]
|
||||
yrCtx =
|
||||
field "year" (return . fst . itemBody)
|
||||
<> field "year-url" (\i -> return $ "/photography/by-year/"
|
||||
++ fst (itemBody i) ++ "/")
|
||||
<> field "year-count"
|
||||
(return . show . snd . itemBody)
|
||||
ctx =
|
||||
listField "years" yrCtx (return yearItems)
|
||||
<> constField "title" "Photography by year"
|
||||
<> constField "photography" "true"
|
||||
<> siteCtx
|
||||
makeItem ""
|
||||
>>= loadAndApplyTemplate
|
||||
"templates/photography-by-year-index.html" ctx
|
||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- | @/photography/by-year/<year>/@ — list of photos captured that year.
|
||||
photographyByYearPageRule :: String -> [Identifier] -> Rules ()
|
||||
photographyByYearPageRule yr idents =
|
||||
create [fromFilePath ("photography/by-year/" ++ yr ++ "/index.html")] $ do
|
||||
route idRoute
|
||||
compile $ do
|
||||
photos <- recentFirst
|
||||
=<< mapM (\i -> load i :: Compiler (Item String)) idents
|
||||
let ctx =
|
||||
listField "photos" photographyCtx (return photos)
|
||||
<> constField "title" ("Photography · " ++ yr)
|
||||
<> constField "year" yr
|
||||
<> constField "photography" "true"
|
||||
<> constField "list-page" "true"
|
||||
<> siteCtx
|
||||
makeItem ""
|
||||
>>= loadAndApplyTemplate
|
||||
"templates/photography-by-year.html" ctx
|
||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Contact sheet
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | @/photography/contact-sheet/@ — alternate view of every photo in
|
||||
-- a film-strip aesthetic: thin white-bordered frames, frame numbers
|
||||
-- in the corner, slightly grainy backdrop. Distinct from the main
|
||||
-- grid views; deep cut rather than primary surface.
|
||||
--
|
||||
-- Sort order: chronological by display date (asc). The contact-sheet
|
||||
-- convention reads top-to-bottom in capture order — a roll of film,
|
||||
-- not a recency feed. Each frame's index doubles as its frame
|
||||
-- number. The CSS handles the frame numbering via a CSS counter so
|
||||
-- we don't have to thread the index through the template.
|
||||
photographyContactSheetRule :: Rules ()
|
||||
photographyContactSheetRule =
|
||||
create ["photography/contact-sheet/index.html"] $ do
|
||||
route idRoute
|
||||
compile $ do
|
||||
-- Reverse the recent-first sort to get oldest-first
|
||||
-- (capture chronology), matching the contact-sheet
|
||||
-- convention.
|
||||
photos <- reverse <$> (recentFirst
|
||||
=<< loadAll (P.allPhotoEntries .&&. hasNoVersion)
|
||||
:: Compiler [Item String])
|
||||
let ctx =
|
||||
listField "photos" photographyCtx (return photos)
|
||||
<> constField "title" "Contact sheet · Photography"
|
||||
<> constField "photography" "true"
|
||||
<> siteCtx
|
||||
makeItem ""
|
||||
>>= loadAndApplyTemplate
|
||||
"templates/photography-contact-sheet.html" ctx
|
||||
>>= loadAndApplyTemplate "templates/default.html" ctx
|
||||
>>= relativizeUrls
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,652 @@
|
|||
/* Photography section — Phase 1 minimal styles.
|
||||
*
|
||||
* Phase 2 will introduce the masonry / grid / chronological mode toggle,
|
||||
* darkroom lightbox treatment, and richer card hover states. For now the
|
||||
* goal is structural correctness and clean defaults so a single photo
|
||||
* page reads end-to-end.
|
||||
*
|
||||
* Style scope: applied via $if(photography)$ gate in templates/partials/head.html,
|
||||
* so these rules only load on photography pages.
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Single-photo page
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photo-entry {
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.photo-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.photo-figure {
|
||||
margin: 1rem 0 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.photo-figure img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
margin-top: 0.5rem;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.photo-captured {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.photo-location {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Color palette strip (Phase 1 stub; populated by Phase 3 auto-extraction
|
||||
* once tools/extract-palette.py lands). Renders only when frontmatter has
|
||||
* a non-empty palette: list. */
|
||||
.photo-palette {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin: 1rem 0 1.5rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.photo-swatch {
|
||||
flex: 1;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Per-photo metadata block — camera, lens, exposure, etc. Two-column
|
||||
* description list, definition-list semantics for accessibility. */
|
||||
.photo-meta {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
margin: 1.25rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 2px solid var(--border-muted);
|
||||
background: var(--bg-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.photo-meta dt {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.photo-meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.photo-license {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.photo-external-links {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.photo-external-links .meta-label {
|
||||
margin-right: 0.5rem;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.photo-external-link {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Section landing — /photography/
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photography-intro {
|
||||
max-width: 36rem;
|
||||
margin: 0 auto 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.photography-empty {
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
margin: 4rem auto;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Mode toggle UI
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photography-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.photography-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.photography-mode-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.photography-mode-toggle .mode-btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: inherit;
|
||||
letter-spacing: 0.02em;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.photography-mode-toggle .mode-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.photography-mode-toggle .mode-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.photography-mode-toggle .mode-btn.is-active {
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Grid container — base + per-mode rules.
|
||||
*
|
||||
* The same .photography-grid markup feeds three layout strategies; the
|
||||
* data-photography-mode attribute (set by photography-modes.js) keys all
|
||||
* per-mode rules. JS also sets inline grid-row spans on each card in
|
||||
* masonry mode; clearing the attribute clears the inline style.
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photography-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
/* Masonry — variable-height cells respecting native aspect ratios.
|
||||
* grid-auto-rows + JS-applied grid-row spans synthesize true masonry. */
|
||||
.photography-grid[data-photography-mode="masonry"] {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 18rem), 1fr));
|
||||
grid-auto-rows: 8px; /* must match ROW_UNIT in photography-modes.js */
|
||||
gap: 8px; /* must match ROW_GAP in photography-modes.js */
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.photography-grid[data-photography-mode="masonry"] .photo-card-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Uniform grid — square cells, object-fit: cover. Compare side-by-side. */
|
||||
.photography-grid[data-photography-mode="grid"] {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.photography-grid[data-photography-mode="grid"] .photo-card-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
/* Chronological — single column, large, contemplative. */
|
||||
.photography-grid[data-photography-mode="chronological"] {
|
||||
grid-template-columns: minmax(0, 48rem);
|
||||
justify-content: center;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.photography-grid[data-photography-mode="chronological"] .photo-card-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photography-grid[data-photography-mode="chronological"] .photo-card-meta {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Card chrome — shared across all three modes
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photo-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.photo-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-card-img {
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.photo-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.photo-card-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.photo-card-date {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: oldstyle-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Darkroom lightbox — photography pages only
|
||||
*
|
||||
* Augments the existing lightbox (static/css/images.css) when the JS
|
||||
* detects body[data-page-type="photography"] and adds the .darkroom
|
||||
* class to the overlay. Non-photography lightbox styling is unaffected.
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.lightbox-overlay.darkroom {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.lightbox-overlay.darkroom .lightbox-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0, 0, 0, 0) 35%,
|
||||
rgba(0, 0, 0, 0.55) 100%
|
||||
);
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.lightbox-overlay:not(.darkroom) .lightbox-vignette,
|
||||
.lightbox-overlay:not(.darkroom) .lightbox-info-toggle,
|
||||
.lightbox-overlay:not(.darkroom) .lightbox-info-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lightbox-overlay.darkroom .lightbox-caption {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-style: italic;
|
||||
font-size: 0.95em;
|
||||
max-width: 48rem;
|
||||
margin: 1rem auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lightbox-overlay.darkroom .lightbox-close {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* "i" toggle button — top-right corner alongside the close button. */
|
||||
.lightbox-info-toggle {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 3.5rem;
|
||||
z-index: 2;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 50%;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.lightbox-info-toggle:hover,
|
||||
.lightbox-info-toggle[aria-pressed="true"] {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Info panel — slides in from below; muted serif for the EXIF strip. */
|
||||
.lightbox-info-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 100%);
|
||||
max-width: 48rem;
|
||||
width: calc(100% - 2rem);
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: transform 220ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.lightbox-overlay.is-info-visible .lightbox-info-panel {
|
||||
transform: translate(-50%, 0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.lightbox-info-panel dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25rem 1.25rem;
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.lightbox-info-panel dt {
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.lightbox-info-panel dd {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Map page — /photography/map/
|
||||
*
|
||||
* The viewport is sized in viewport-relative units so the map fills
|
||||
* a useful portion of the screen without dominating it; the
|
||||
* surrounding page chrome (header, attribution paragraph, footer)
|
||||
* stays visible at typical desktop heights.
|
||||
*
|
||||
* Leaflet's own stylesheet handles tile / control / popup styling;
|
||||
* these rules cover only the page-level shell, the tooltip content
|
||||
* we render via tooltipHtml(), and the no-pin / error fallback states.
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photography-map {
|
||||
height: 70vh;
|
||||
min-height: 32rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.photography-map--empty,
|
||||
.photography-map--error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.photography-map-empty,
|
||||
.photography-map-error,
|
||||
.photography-map-fallback {
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.photography-map-note {
|
||||
margin: 0.5rem 0 2rem;
|
||||
color: var(--text-faint);
|
||||
font-size: 0.85em;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.photography-map-note code {
|
||||
background: var(--bg-muted);
|
||||
padding: 0 0.3em;
|
||||
border-radius: 2px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Tooltip content — rendered by photography-map.js inside Leaflet's
|
||||
* default tooltip wrapper. The wrapper class .photography-map-tooltip-wrap
|
||||
* lets us tighten Leaflet's default padding without bleeding into the
|
||||
* other Leaflet popups. */
|
||||
|
||||
.leaflet-tooltip.photography-map-tooltip-wrap {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Leaflet draws tooltip arrows via ::before; recolor to match. */
|
||||
.leaflet-tooltip.photography-map-tooltip-wrap::before {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
|
||||
.photography-map-tooltip {
|
||||
width: 14rem;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.photography-map-tooltip-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 8rem;
|
||||
object-fit: cover;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.photography-map-tooltip-title {
|
||||
padding: 0.4rem 0.6rem 0.1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.photography-map-tooltip-date {
|
||||
padding: 0 0.6rem 0.4rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: oldstyle-nums;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* By-year index — /photography/by-year/
|
||||
*
|
||||
* A simple narrow column of years, each link a small card with the
|
||||
* year (large oldstyle figures) and a count (muted small caps).
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photography-header--narrow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.photography-by-year-back {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.photography-by-year-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0 2rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.photography-by-year-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.photography-by-year-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-muted);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.photography-by-year-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.photography-by-year-year {
|
||||
font-size: 1.4em;
|
||||
font-variant-numeric: oldstyle-nums;
|
||||
}
|
||||
|
||||
.photography-by-year-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.photography-by-year-count::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.photography-by-year-count::after {
|
||||
content: " photographs";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Contact sheet — /photography/contact-sheet/
|
||||
*
|
||||
* Frame-numbered grid in the visual register of an analog contact
|
||||
* print: small uniform thumbnails, thin white border per frame,
|
||||
* frame number in the corner via a CSS counter. The dark page
|
||||
* backdrop is a soft "film-room" gray rather than full black so
|
||||
* the white frames don't ring against the surrounding page chrome
|
||||
* too hard.
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photography-contact-sheet {
|
||||
counter-reset: contact-frame-no;
|
||||
list-style: none;
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0 2rem;
|
||||
background: #1c1c1c;
|
||||
border: 1px solid #2a2a2a;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.contact-frame {
|
||||
counter-increment: contact-frame-no;
|
||||
margin: 0;
|
||||
background: #f5f3ee;
|
||||
padding: 0.5rem 0.5rem 0.4rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.contact-frame:hover {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
|
||||
.contact-frame-link {
|
||||
display: block;
|
||||
color: #1c1c1c;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.contact-frame-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.contact-frame-link::before {
|
||||
/* Frame number — analog convention: oldstyle figures, mono,
|
||||
* small, top-left of the print under the image. */
|
||||
content: counter(contact-frame-no, decimal-leading-zero);
|
||||
position: absolute;
|
||||
top: -1.4em;
|
||||
left: 0;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75em;
|
||||
color: rgba(245, 243, 238, 0.55);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.contact-frame-label {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.75em;
|
||||
color: #4a4a4a;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Series landing — /photography/<series>/
|
||||
* --------------------------------------------------------------------------- */
|
||||
|
||||
.photo-series-abstract {
|
||||
margin: 1rem 0 0;
|
||||
font-size: 1.05em;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.photo-series-prose {
|
||||
max-width: 36rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> '
|
||||
+ 'contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
var TILE_SUBDOMS = 'abcd';
|
||||
var FALLBACK_VIEW = [20, 0]; // [lat, lon] when there are zero pins
|
||||
var FALLBACK_ZOOM = 2;
|
||||
|
||||
// Override the default Leaflet marker icon paths so they resolve
|
||||
// to the vendored copy under /leaflet/images/. Leaflet's default
|
||||
// resolution uses the URL of leaflet.js, which fails for vendored
|
||||
// setups since the script lives in /js/, not /leaflet/.
|
||||
function configureMarkerIconPaths() {
|
||||
if (typeof L === 'undefined' || !L.Icon || !L.Icon.Default) return;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: '/leaflet/images/marker-icon-2x.png',
|
||||
iconUrl: '/leaflet/images/marker-icon.png',
|
||||
shadowUrl: '/leaflet/images/marker-shadow.png'
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function tooltipHtml(pin) {
|
||||
var thumb = pin.thumb
|
||||
? '<img class="photography-map-tooltip-img" src="' + escapeHtml(pin.thumb) + '" alt="" loading="lazy">'
|
||||
: '';
|
||||
var date = pin.captured
|
||||
? '<div class="photography-map-tooltip-date">' + escapeHtml(pin.captured) + '</div>'
|
||||
: '';
|
||||
return ''
|
||||
+ '<div class="photography-map-tooltip">'
|
||||
+ thumb
|
||||
+ '<div class="photography-map-tooltip-title">' + escapeHtml(pin.title || '(untitled)') + '</div>'
|
||||
+ date
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderEmptyState(container) {
|
||||
container.classList.add('photography-map--empty');
|
||||
container.innerHTML =
|
||||
'<p class="photography-map-empty">'
|
||||
+ 'No geo-tagged photographs yet. Photos with a '
|
||||
+ '<code>geo:</code> frontmatter field will appear here.'
|
||||
+ '</p>';
|
||||
}
|
||||
|
||||
function renderErrorState(container, message) {
|
||||
container.classList.add('photography-map--error');
|
||||
container.innerHTML =
|
||||
'<p class="photography-map-error">'
|
||||
+ escapeHtml(message || 'Could not load the map.')
|
||||
+ '</p>';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var container = document.getElementById(MAP_ELEMENT);
|
||||
if (!container) return;
|
||||
|
||||
// Leaflet must be present; the conditional script load in
|
||||
// default.html should guarantee this on /photography/map/, but
|
||||
// a defensive fallback is cheap.
|
||||
if (typeof L === 'undefined') {
|
||||
renderErrorState(container, 'Map library failed to load.');
|
||||
return;
|
||||
}
|
||||
|
||||
configureMarkerIconPaths();
|
||||
|
||||
fetch(MAP_DATA_URL, { cache: 'force-cache' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (pins) {
|
||||
if (!Array.isArray(pins) || pins.length === 0) {
|
||||
renderEmptyState(container);
|
||||
return;
|
||||
}
|
||||
|
||||
var map = L.map(container, {
|
||||
scrollWheelZoom: false, // require explicit interaction
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
}).setView(FALLBACK_VIEW, FALLBACK_ZOOM);
|
||||
|
||||
L.tileLayer(TILE_URL, {
|
||||
attribution: TILE_ATTRIB,
|
||||
subdomains: TILE_SUBDOMS,
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
// markercluster — groups overlapping pins; falls back
|
||||
// to plain L.featureGroup if the plugin failed to load.
|
||||
var hasCluster = typeof L.markerClusterGroup === 'function';
|
||||
var layer = hasCluster ? L.markerClusterGroup() : L.featureGroup();
|
||||
|
||||
pins.forEach(function (pin) {
|
||||
if (typeof pin.lat !== 'number' || typeof pin.lon !== 'number') return;
|
||||
var marker = L.marker([pin.lat, pin.lon]);
|
||||
marker.bindTooltip(tooltipHtml(pin), {
|
||||
direction: 'top',
|
||||
offset: [0, -36],
|
||||
className: 'photography-map-tooltip-wrap',
|
||||
opacity: 1
|
||||
});
|
||||
if (pin.url) {
|
||||
marker.on('click', function () {
|
||||
window.location.href = pin.url;
|
||||
});
|
||||
}
|
||||
layer.addLayer(marker);
|
||||
});
|
||||
|
||||
map.addLayer(layer);
|
||||
|
||||
// Frame the visible pins with a small padding. Single-
|
||||
// pin portfolios get a moderate zoom rather than the
|
||||
// hard-coded zoom level so the marker doesn't feel
|
||||
// marooned in negative space.
|
||||
if (pins.length === 1) {
|
||||
map.setView([pins[0].lat, pins[0].lon], 8);
|
||||
} else {
|
||||
var bounds = layer.getBounds();
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds.pad(0.15));
|
||||
}
|
||||
}
|
||||
|
||||
// Allow scroll-wheel zoom only after the user clicks
|
||||
// into the map — prevents the page from "trapping" the
|
||||
// scroll on someone passing through.
|
||||
map.once('focus', function () { map.scrollWheelZoom.enable(); });
|
||||
map.on('blur', function () { map.scrollWheelZoom.disable(); });
|
||||
})
|
||||
.catch(function (err) {
|
||||
renderErrorState(container, 'Could not load map data: ' + err.message);
|
||||
});
|
||||
});
|
||||
|
||||
}());
|
||||
|
|
@ -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());
|
||||
});
|
||||
|
||||
}());
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
$partial("templates/partials/head.html")$
|
||||
</head>
|
||||
<body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$>
|
||||
<body$if(reading)$ class="reading-mode$if(poetry)$ poetry$endif$$if(fiction)$ fiction$endif$"$endif$$if(photography)$ data-page-type="photography"$endif$>
|
||||
<a class="skip-link" href="#markdownBody">Skip to content</a>
|
||||
$partial("templates/partials/nav.html")$
|
||||
$if(search)$
|
||||
|
|
@ -26,6 +26,10 @@ $partial("templates/partials/footer.html")$
|
|||
<script src="/js/lightbox.js" defer></script>
|
||||
$if(home)$<script src="/js/random.js" defer></script>$endif$
|
||||
$if(reading)$<script src="/js/reading.js" defer></script>$endif$
|
||||
$if(photography)$<script src="/js/photography-modes.js" defer></script>$endif$
|
||||
$if(photography-map)$<script src="/leaflet/leaflet.js" defer></script>$endif$
|
||||
$if(photography-map)$<script src="/leaflet/leaflet.markercluster.js" defer></script>$endif$
|
||||
$if(photography-map)$<script src="/js/photography-map.js" defer></script>$endif$
|
||||
$for(page-scripts)$<script src="/$script-src$" defer></script>$endfor$
|
||||
$if(math)$
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ $if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$
|
|||
$if(build)$<link rel="stylesheet" href="/css/build.css">$endif$
|
||||
$if(reading)$<link rel="stylesheet" href="/css/reading.css">$endif$
|
||||
$if(composition)$<link rel="stylesheet" href="/css/score-reader.css">$endif$
|
||||
$if(photography)$<link rel="stylesheet" href="/css/photography.css">$endif$
|
||||
$if(photography-map)$<link rel="stylesheet" href="/leaflet/leaflet.css">$endif$
|
||||
$if(photography-map)$<link rel="stylesheet" href="/leaflet/MarkerCluster.css">$endif$
|
||||
$if(photography-map)$<link rel="stylesheet" href="/leaflet/MarkerCluster.Default.css">$endif$
|
||||
<link rel="stylesheet" href="/css/print.css" media="print">
|
||||
$if(math)$
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
<li class="photo-card"$if(orientation)$ data-orientation="$orientation$"$endif$>
|
||||
<a class="photo-card-link" href="$url$">
|
||||
$if(photo-url)$
|
||||
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img class="photo-card-img" src="$photo-url$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$></picture>
|
||||
$else$$if(photo)$
|
||||
<img class="photo-card-img" src="$photo$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$>
|
||||
$endif$$endif$
|
||||
<div class="photo-card-meta">
|
||||
<span class="photo-card-title">$title$</span>
|
||||
$if(captured-iso)$<time class="photo-card-date" datetime="$captured-iso$">$captured-display$</time>$endif$
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<div id="content">
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
<header class="photography-header photography-header--narrow">
|
||||
<h1 class="page-title">$title$</h1>
|
||||
<p class="photography-by-year-back">
|
||||
<a href="/photography/">← all photographs</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
$if(years)$
|
||||
<ul class="photography-by-year-list">
|
||||
$for(years)$
|
||||
<li class="photography-by-year-item">
|
||||
<a class="photography-by-year-link" href="$year-url$">
|
||||
<span class="photography-by-year-year">$year$</span>
|
||||
<span class="photography-by-year-count">$year-count$</span>
|
||||
</a>
|
||||
</li>
|
||||
$endfor$
|
||||
</ul>
|
||||
$else$
|
||||
<p class="photography-empty">No years yet — photographs need a <code>captured:</code> or <code>date:</code> frontmatter field to be grouped by year.</p>
|
||||
$endif$
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<div id="content">
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
<header class="photography-header photography-header--narrow">
|
||||
<h1 class="page-title">$title$</h1>
|
||||
<p class="photography-by-year-back">
|
||||
<a href="/photography/">← all photographs</a>
|
||||
·
|
||||
<a href="/photography/by-year/">other years</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
$if(photos)$
|
||||
<ul class="photography-grid" data-photography-mode="masonry">
|
||||
$for(photos)$
|
||||
$partial("templates/partials/photo-card.html")$
|
||||
$endfor$
|
||||
</ul>
|
||||
$else$
|
||||
<p class="photography-empty">No photographs in $year$.</p>
|
||||
$endif$
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<div id="content">
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
<header class="photography-header photography-header--narrow">
|
||||
<h1 class="page-title">Contact sheet</h1>
|
||||
<p class="photography-by-year-back">
|
||||
<a href="/photography/">← all photographs</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
$if(photos)$
|
||||
<ol class="photography-contact-sheet">
|
||||
$for(photos)$
|
||||
<li class="contact-frame">
|
||||
<a class="contact-frame-link" href="$url$">
|
||||
$if(photo-url)$
|
||||
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img class="contact-frame-img" src="$photo-url$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$></picture>
|
||||
$else$$if(photo)$
|
||||
<img class="contact-frame-img" src="$photo$" alt="$title$" loading="lazy" decoding="async"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$>
|
||||
$endif$$endif$
|
||||
<span class="contact-frame-label">$title$</span>
|
||||
</a>
|
||||
</li>
|
||||
$endfor$
|
||||
</ol>
|
||||
$else$
|
||||
<p class="photography-empty">No photographs to print.</p>
|
||||
$endif$
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<div id="content">
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
<header class="photography-header">
|
||||
<h1 class="page-title">$title$</h1>
|
||||
$if(photos)$
|
||||
<div class="photography-controls" role="toolbar" aria-label="Browsing mode">
|
||||
<div class="photography-mode-toggle" role="tablist" aria-label="Photography view mode">
|
||||
<a class="mode-btn is-active" role="tab" data-mode="masonry" href="/photography/" aria-pressed="true" aria-label="Masonry view">Masonry</a>
|
||||
<a class="mode-btn" role="tab" data-mode="grid" href="/photography/" aria-pressed="false" aria-label="Uniform grid view">Grid</a>
|
||||
<a class="mode-btn" role="tab" data-mode="chronological" href="/photography/" aria-pressed="false" aria-label="Chronological view">Chronological</a>
|
||||
<a class="mode-btn" role="tab" data-mode="map" href="/photography/map/" aria-pressed="false" aria-label="Map view">Map</a>
|
||||
</div>
|
||||
</div>
|
||||
$endif$
|
||||
</header>
|
||||
|
||||
$if(body)$<div class="photography-intro">$body$</div>$endif$
|
||||
|
||||
$if(photos)$
|
||||
<ul class="photography-grid" data-photography-mode="masonry">
|
||||
$for(photos)$
|
||||
$partial("templates/partials/photo-card.html")$
|
||||
$endfor$
|
||||
</ul>
|
||||
$else$
|
||||
<p class="photography-empty">No photographs published yet.</p>
|
||||
$endif$
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<div id="content">
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
<header class="photography-header">
|
||||
<h1 class="page-title">Photography</h1>
|
||||
<div class="photography-controls" role="toolbar" aria-label="Browsing mode">
|
||||
<div class="photography-mode-toggle" role="tablist" aria-label="Photography view mode">
|
||||
<a class="mode-btn" role="tab" data-mode="masonry" href="/photography/" aria-pressed="false">Masonry</a>
|
||||
<a class="mode-btn" role="tab" data-mode="grid" href="/photography/" aria-pressed="false">Grid</a>
|
||||
<a class="mode-btn" role="tab" data-mode="chronological" href="/photography/" aria-pressed="false">Chronological</a>
|
||||
<a class="mode-btn is-active" role="tab" aria-current="page" data-mode="map" href="/photography/map/" aria-pressed="true">Map</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="photography-map" class="photography-map" aria-label="Map of geo-tagged photographs">
|
||||
<noscript>
|
||||
<p class="photography-map-fallback">
|
||||
The map view requires JavaScript. Browse the
|
||||
<a href="/photography/">photography portfolio</a>
|
||||
or jump to a specific photo from the
|
||||
<a href="/photography/">grid view</a>.
|
||||
</p>
|
||||
</noscript>
|
||||
</div>
|
||||
|
||||
<p class="photography-map-note">
|
||||
Pin coordinates are rounded to the precision each photograph's
|
||||
<code>geo-precision</code> field declares — typically the
|
||||
nearest ten kilometres. Photos with no <code>geo:</code>
|
||||
frontmatter (or with <code>geo-precision: hidden</code>) are
|
||||
omitted from this map by design.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<div id="content">
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
<article class="photo-series">
|
||||
<header class="photo-header">
|
||||
<h1 class="page-title">$title$</h1>
|
||||
$if(photography-tags)$
|
||||
<div class="meta-row meta-tags">
|
||||
$for(photography-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
|
||||
</div>
|
||||
$endif$
|
||||
<div class="meta-row meta-authors">
|
||||
<span class="meta-label">by</span>$for(author-links)$<a href="$author-url$">$author-name$</a>$sep$, $endfor$
|
||||
</div>
|
||||
$if(captured-display)$
|
||||
<div class="meta-row photo-captured">
|
||||
<span class="meta-label">captured</span>
|
||||
<time datetime="$captured-iso$">$captured-display$</time>
|
||||
$if(location)$ · <span class="photo-location">$location$</span>$endif$
|
||||
</div>
|
||||
$endif$
|
||||
$if(abstract)$<p class="photo-series-abstract">$abstract$</p>$endif$
|
||||
</header>
|
||||
|
||||
$if(body)$<div class="photo-series-prose">$body$</div>$endif$
|
||||
|
||||
$if(series-photos)$
|
||||
<ul class="photography-grid" data-photography-mode="masonry">
|
||||
$for(series-photos)$
|
||||
$partial("templates/partials/photo-card.html")$
|
||||
$endfor$
|
||||
</ul>
|
||||
$else$
|
||||
<p class="photography-empty">This series has no photographs yet.</p>
|
||||
$endif$
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<div id="content">
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
<article class="photo-entry">
|
||||
<header class="photo-header">
|
||||
<h1 class="page-title">$title$</h1>
|
||||
$if(essay-tags)$
|
||||
<div class="meta-row meta-tags">
|
||||
$for(essay-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
|
||||
</div>
|
||||
$endif$
|
||||
$if(photography-tags)$
|
||||
<div class="meta-row meta-tags">
|
||||
$for(photography-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
|
||||
</div>
|
||||
$endif$
|
||||
<div class="meta-row meta-authors">
|
||||
<span class="meta-label">by</span>$for(author-links)$<a href="$author-url$">$author-name$</a>$sep$, $endfor$
|
||||
</div>
|
||||
$if(captured-display)$
|
||||
<div class="meta-row photo-captured">
|
||||
<span class="meta-label">captured</span>
|
||||
<time datetime="$captured-iso$">$captured-display$</time>
|
||||
$if(location)$ · <span class="photo-location">$location$</span>$endif$
|
||||
</div>
|
||||
$endif$
|
||||
</header>
|
||||
|
||||
<figure class="photo-figure">
|
||||
$if(photo-url)$
|
||||
<picture>$if(photo-webp-url)$<source srcset="$photo-webp-url$" type="image/webp">$endif$<img src="$photo-url$" alt="$title$" loading="lazy" decoding="async" data-lightbox="true"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$$if(camera)$ data-photo-camera="$camera$"$endif$$if(lens)$ data-photo-lens="$lens$"$endif$$if(film)$ data-photo-film="$film$"$endif$$if(exposure)$ data-photo-exposure="$exposure$"$endif$$if(captured-display)$ data-photo-captured="$captured-display$"$endif$$if(location)$ data-photo-location="$location$"$endif$></picture>
|
||||
$else$$if(photo)$
|
||||
<img src="$photo$" alt="$title$" loading="lazy" decoding="async" data-lightbox="true"$if(width)$ width="$width$"$endif$$if(height)$ height="$height$"$endif$$if(camera)$ data-photo-camera="$camera$"$endif$$if(lens)$ data-photo-lens="$lens$"$endif$$if(film)$ data-photo-film="$film$"$endif$$if(exposure)$ data-photo-exposure="$exposure$"$endif$$if(captured-display)$ data-photo-captured="$captured-display$"$endif$$if(location)$ data-photo-location="$location$"$endif$>
|
||||
$endif$$endif$
|
||||
$if(abstract)$<figcaption class="photo-caption">$abstract$</figcaption>$endif$
|
||||
</figure>
|
||||
|
||||
$if(palette-swatches)$
|
||||
<div class="photo-palette" aria-label="Color palette">
|
||||
$for(palette-swatches)$<span class="photo-swatch" style="background:$swatch$" title="$swatch$"></span>$endfor$
|
||||
</div>
|
||||
$endif$
|
||||
|
||||
<dl class="photo-meta">
|
||||
$if(camera)$<dt>Camera</dt><dd>$camera$</dd>$endif$
|
||||
$if(lens)$<dt>Lens</dt><dd>$lens$</dd>$endif$
|
||||
$if(film)$<dt>Film</dt><dd>$film$</dd>$endif$
|
||||
$if(exposure)$<dt>Exposure</dt><dd>$exposure$</dd>$endif$
|
||||
$if(process)$<dt>Process</dt><dd>$process$</dd>$endif$
|
||||
$if(license)$
|
||||
<dt>License</dt>
|
||||
<dd>$if(license-url-resolved)$<a class="photo-license" href="$license-url-resolved$" rel="license noopener">$license$</a>$else$<span class="photo-license">$license$</span>$endif$</dd>
|
||||
$endif$
|
||||
</dl>
|
||||
|
||||
$if(photo-links)$
|
||||
<div class="photo-external-links">
|
||||
<span class="meta-label">also at</span>
|
||||
$for(photo-links)$<a class="photo-external-link" href="$link-url$" rel="noopener">$link-name$</a>$sep$ · $endfor$
|
||||
</div>
|
||||
$endif$
|
||||
|
||||
$body$
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract-dimensions.py — Build-time pixel-dimension sidecar generator.
|
||||
|
||||
Walks @static/images/@ and @content/**@ for raster image files
|
||||
(JPEG / PNG / GIF) and writes a @{image}.dims.yaml@ sidecar alongside
|
||||
each one containing the file's pixel width and height. Consumed by
|
||||
@build/Filters/Images.hs@, which attaches matching @width@ and
|
||||
@height@ attributes to every <img> tag at compile time — preventing
|
||||
cumulative layout shift while images load.
|
||||
|
||||
This is the body-image counterpart to @extract-exif.py@, which writes
|
||||
photography-specific @{image}.exif.yaml@ sidecars (containing
|
||||
dimensions plus camera / lens / etc.). The two complement each other:
|
||||
photography templates read width / height through the EXIF sidecar
|
||||
via @photographyCtx@; everything else (essay figures, blog images,
|
||||
inline images) gets dimensions through @{image}.dims.yaml@ via the
|
||||
filter.
|
||||
|
||||
Strategy:
|
||||
* Pillow's @Image.size@ is independent of EXIF, so synthetic
|
||||
images (ImageMagick gradients, GIMP exports) and EXIF-stripped
|
||||
JPEGs both yield correct dimensions.
|
||||
* Staleness check: skip when sidecar mtime > image mtime.
|
||||
* Per-image failures are logged and the walk continues; the build
|
||||
never fails on a dimensions extraction error.
|
||||
|
||||
Called by `make build` when .venv exists. Failures on individual
|
||||
images are logged and the rest of the walk continues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
# Roots to walk. content/photography/ also gets visited (its photos
|
||||
# become double-sidecared with both .exif.yaml and .dims.yaml) — that's
|
||||
# harmless and keeps the contract uniform: "every raster file has a
|
||||
# .dims.yaml". The few extra bytes of YAML are immaterial.
|
||||
WALK_ROOTS = [
|
||||
REPO_ROOT / "static" / "images",
|
||||
REPO_ROOT / "content",
|
||||
]
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif"}
|
||||
|
||||
|
||||
def _sidecar_path(image: Path) -> Path:
|
||||
return image.with_suffix(image.suffix + ".dims.yaml")
|
||||
|
||||
|
||||
def _is_stale(image: Path, sidecar: Path) -> bool:
|
||||
if not sidecar.exists():
|
||||
return True
|
||||
return image.stat().st_mtime > sidecar.stat().st_mtime
|
||||
|
||||
|
||||
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
# Preserve a stable key order (width before height) so a manual
|
||||
# diff stays easy to read across regenerations.
|
||||
ordered = {k: data[k] for k in ("width", "height") if k in data}
|
||||
yaml.safe_dump(ordered, f, sort_keys=False, allow_unicode=True)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _read_dimensions(image: Path) -> dict[str, int]:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image) as img:
|
||||
width, height = img.size
|
||||
return {"width": int(width), "height": int(height)}
|
||||
|
||||
|
||||
def _walk_one_root(root: Path, counters: dict[str, int]) -> None:
|
||||
if not root.exists():
|
||||
return
|
||||
for image in sorted(root.rglob("*")):
|
||||
if image.suffix.lower() not in IMAGE_EXTS:
|
||||
continue
|
||||
# Skip dotfiles, tmp files, and the .webp companions produced
|
||||
# by tools/convert-images.sh (their extension is .webp so they
|
||||
# already wouldn't match IMAGE_EXTS, but be explicit).
|
||||
if image.name.startswith(".") or image.name.endswith(".tmp"):
|
||||
continue
|
||||
|
||||
sidecar = _sidecar_path(image)
|
||||
if not _is_stale(image, sidecar):
|
||||
counters["skipped"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
data = _read_dimensions(image)
|
||||
except Exception as e: # noqa: BLE001 — keep walking
|
||||
print(f"extract-dimensions: {image}: {e}", file=sys.stderr)
|
||||
counters["failed"] += 1
|
||||
continue
|
||||
|
||||
_atomic_write_yaml(sidecar, data)
|
||||
counters["written"] += 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
counters = {"written": 0, "skipped": 0, "failed": 0}
|
||||
|
||||
for root in WALK_ROOTS:
|
||||
_walk_one_root(root, counters)
|
||||
|
||||
print(
|
||||
"extract-dimensions: "
|
||||
f"{counters['written']} written, "
|
||||
f"{counters['skipped']} skipped, "
|
||||
f"{counters['failed']} failed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract-exif.py — Build-time EXIF sidecar generator for photography.
|
||||
|
||||
Walks content/photography/**/*.{jpg,jpeg,png} and writes a
|
||||
{photo}.exif.yaml sidecar alongside each image. The Hakyll context in
|
||||
build/Contexts.hs reads these sidecars and merges their fields into
|
||||
the photographyCtx so authors don't have to hand-write camera / lens /
|
||||
exposure / captured-date in frontmatter for digital photos.
|
||||
|
||||
Frontmatter always wins. The sidecar is a strict fallback — present
|
||||
to populate fields the author chose not to write. Film scans typically
|
||||
have no EXIF; the sidecar is still written but with an empty body, and
|
||||
the author hand-writes the relevant fields in frontmatter.
|
||||
|
||||
Strategy:
|
||||
|
||||
1. Prefer exiftool when available — Perl-based, ships in distro
|
||||
repos, handles every camera vendor's tag dialect (incl. RAW).
|
||||
2. Fall back to Pillow's EXIF reader — pure Python, narrower
|
||||
coverage, but always available via the project's .venv.
|
||||
|
||||
Staleness check: skips an image whose sidecar mtime > image mtime.
|
||||
This means re-running the tool is idempotent and cheap.
|
||||
|
||||
GPS coordinates are written to the sidecar at full precision; the
|
||||
geo-precision rounding (`exact | km | city | hidden`) is applied in
|
||||
Hakyll at the consuming end, against each photo's frontmatter
|
||||
`geo-precision:` value. The sidecar is the source of truth; the
|
||||
consumer is the privacy gate.
|
||||
|
||||
Called by `make build` when .venv exists. Failures on individual
|
||||
images are logged and the rest of the walk continues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
CONTENT_DIR = REPO_ROOT / "content" / "photography"
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field normalisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping from EXIF field names (as exposed by exiftool / Pillow) to the
|
||||
# sidecar keys consumed by Hakyll. Hakyll's fields are deliberately
|
||||
# lowercase-with-hyphens, matching the photographyCtx convention.
|
||||
SIDECAR_KEYS = [
|
||||
"camera",
|
||||
"lens",
|
||||
"exposure",
|
||||
"shutter",
|
||||
"aperture",
|
||||
"iso",
|
||||
"focal-length",
|
||||
"captured",
|
||||
"geo",
|
||||
# Pixel dimensions of the delivered (resized, EXIF-stripped) JPEG.
|
||||
# Threaded through to the Hakyll photographyCtx and emitted as
|
||||
# width / height attrs on every <img> tag — prevents cumulative
|
||||
# layout shift while photos load.
|
||||
"width",
|
||||
"height",
|
||||
]
|
||||
|
||||
|
||||
def _format_shutter(speed: float) -> str:
|
||||
"""Render shutter speed as "1/125" or "0.5s" depending on magnitude."""
|
||||
if speed <= 0:
|
||||
return ""
|
||||
if speed >= 1.0:
|
||||
return f"{speed:g}s"
|
||||
denom = round(1.0 / speed)
|
||||
return f"1/{denom}"
|
||||
|
||||
|
||||
def _format_aperture(value: float) -> str:
|
||||
if value <= 0:
|
||||
return ""
|
||||
# Common aperture values display with at most one decimal place.
|
||||
if abs(value - round(value)) < 0.05:
|
||||
return f"f/{int(round(value))}"
|
||||
return f"f/{value:.1f}"
|
||||
|
||||
|
||||
def _format_focal(value: float) -> str:
|
||||
if value <= 0:
|
||||
return ""
|
||||
return f"{int(round(value))}mm"
|
||||
|
||||
|
||||
def _build_exposure_string(
|
||||
shutter: str | None,
|
||||
aperture: str | None,
|
||||
iso: int | None,
|
||||
) -> str | None:
|
||||
"""Compose "1/125 f/8 ISO 400" from individual fields when present."""
|
||||
parts: list[str] = []
|
||||
if shutter:
|
||||
parts.append(shutter)
|
||||
if aperture:
|
||||
parts.append(aperture)
|
||||
if iso:
|
||||
parts.append(f"ISO {iso}")
|
||||
return " ".join(parts) if parts else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# exiftool path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _exiftool_available() -> bool:
|
||||
return shutil.which("exiftool") is not None
|
||||
|
||||
|
||||
def _read_exif_via_exiftool(image: Path) -> dict[str, Any]:
|
||||
"""Invoke exiftool and return a dict of normalised sidecar keys.
|
||||
|
||||
exiftool's `-json` output is a list of objects; we parse the first
|
||||
entry. Numeric values come through as numbers; text values as
|
||||
strings. We accept missing keys silently.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
"-json",
|
||||
"-Make",
|
||||
"-Model",
|
||||
"-LensModel",
|
||||
"-LensSpec",
|
||||
"-LensInfo",
|
||||
"-ExposureTime",
|
||||
"-FNumber",
|
||||
"-ISO",
|
||||
"-FocalLength",
|
||||
"-FocalLengthIn35mmFormat",
|
||||
"-DateTimeOriginal",
|
||||
"-CreateDate",
|
||||
"-GPSLatitude",
|
||||
"-GPSLongitude",
|
||||
"-GPSLatitudeRef",
|
||||
"-GPSLongitudeRef",
|
||||
"-ImageWidth",
|
||||
"-ImageHeight",
|
||||
"-n", # numeric output for shutter/aperture/GPS/dimensions
|
||||
str(image),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not data:
|
||||
return {}
|
||||
raw = data[0]
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
make = (raw.get("Make") or "").strip()
|
||||
model = (raw.get("Model") or "").strip()
|
||||
if make and model and not model.lower().startswith(make.lower()):
|
||||
out["camera"] = f"{make} {model}".strip()
|
||||
elif model:
|
||||
out["camera"] = model
|
||||
elif make:
|
||||
out["camera"] = make
|
||||
|
||||
lens = (
|
||||
raw.get("LensModel")
|
||||
or raw.get("LensSpec")
|
||||
or raw.get("LensInfo")
|
||||
or ""
|
||||
).strip()
|
||||
if lens:
|
||||
out["lens"] = lens
|
||||
|
||||
shutter_secs = raw.get("ExposureTime")
|
||||
if isinstance(shutter_secs, (int, float)) and shutter_secs > 0:
|
||||
out["shutter"] = _format_shutter(float(shutter_secs))
|
||||
|
||||
aperture = raw.get("FNumber")
|
||||
if isinstance(aperture, (int, float)) and aperture > 0:
|
||||
out["aperture"] = _format_aperture(float(aperture))
|
||||
|
||||
iso = raw.get("ISO")
|
||||
if isinstance(iso, int) and iso > 0:
|
||||
out["iso"] = iso
|
||||
|
||||
focal = raw.get("FocalLength")
|
||||
if isinstance(focal, (int, float)) and focal > 0:
|
||||
out["focal-length"] = _format_focal(float(focal))
|
||||
|
||||
captured_raw = raw.get("DateTimeOriginal") or raw.get("CreateDate")
|
||||
if isinstance(captured_raw, str) and captured_raw:
|
||||
# exiftool format is "YYYY:MM:DD HH:MM:SS"; we want ISO date only.
|
||||
date_part = captured_raw.split(" ", 1)[0].replace(":", "-")
|
||||
if len(date_part) == 10:
|
||||
out["captured"] = date_part
|
||||
|
||||
lat = raw.get("GPSLatitude")
|
||||
lon = raw.get("GPSLongitude")
|
||||
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
|
||||
# exiftool with -n returns signed decimals already.
|
||||
out["geo"] = [round(float(lat), 6), round(float(lon), 6)]
|
||||
|
||||
width = raw.get("ImageWidth")
|
||||
height = raw.get("ImageHeight")
|
||||
if isinstance(width, int) and width > 0:
|
||||
out["width"] = width
|
||||
if isinstance(height, int) and height > 0:
|
||||
out["height"] = height
|
||||
|
||||
exposure = _build_exposure_string(
|
||||
out.get("shutter"), out.get("aperture"), out.get("iso")
|
||||
)
|
||||
if exposure:
|
||||
out["exposure"] = exposure
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pillow fallback path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _pillow_rational(value: Any) -> float | None:
|
||||
"""Pillow can return EXIF rationals as IFDRational, tuples, or floats."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, tuple) and len(value) == 2:
|
||||
num, den = value
|
||||
return float(num) / float(den) if den else None
|
||||
return float(Fraction(value).limit_denominator())
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _gps_to_decimal(coord: Any, ref: Any) -> float | None:
|
||||
"""Pillow GPS coords come as ((deg_n, deg_d), (min_n, min_d), (sec_n, sec_d))."""
|
||||
if not coord:
|
||||
return None
|
||||
try:
|
||||
deg = float(coord[0])
|
||||
minutes = float(coord[1])
|
||||
seconds = float(coord[2])
|
||||
decimal = deg + minutes / 60.0 + seconds / 3600.0
|
||||
if isinstance(ref, str) and ref in ("S", "W"):
|
||||
decimal = -decimal
|
||||
return decimal
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _read_exif_via_pillow(image: Path) -> dict[str, Any]:
|
||||
from PIL import Image, ExifTags
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
# Pixel dimensions are extracted unconditionally (separate from
|
||||
# EXIF) — every readable raster file has them, even synthetic
|
||||
# placeholders or photos that have had their EXIF stripped.
|
||||
try:
|
||||
with Image.open(image) as img:
|
||||
width, height = img.size
|
||||
if isinstance(width, int) and width > 0:
|
||||
out["width"] = width
|
||||
if isinstance(height, int) and height > 0:
|
||||
out["height"] = height
|
||||
exif = img._getexif() or {}
|
||||
except Exception: # noqa: BLE001 — corrupt EXIF should not abort the walk
|
||||
return out
|
||||
|
||||
if not exif:
|
||||
return out
|
||||
|
||||
tag_name = {v: k for k, v in ExifTags.TAGS.items()}
|
||||
gps_name = {v: k for k, v in ExifTags.GPSTAGS.items()}
|
||||
|
||||
def _g(name: str) -> Any:
|
||||
return exif.get(tag_name.get(name, -1))
|
||||
|
||||
make = (_g("Make") or "").strip()
|
||||
model = (_g("Model") or "").strip()
|
||||
if make and model and not model.lower().startswith(make.lower()):
|
||||
out["camera"] = f"{make} {model}".strip()
|
||||
elif model:
|
||||
out["camera"] = model
|
||||
elif make:
|
||||
out["camera"] = make
|
||||
|
||||
lens = (_g("LensModel") or _g("LensMake") or "").strip()
|
||||
if lens:
|
||||
out["lens"] = lens
|
||||
|
||||
shutter_secs = _pillow_rational(_g("ExposureTime"))
|
||||
if shutter_secs and shutter_secs > 0:
|
||||
out["shutter"] = _format_shutter(shutter_secs)
|
||||
|
||||
aperture = _pillow_rational(_g("FNumber"))
|
||||
if aperture and aperture > 0:
|
||||
out["aperture"] = _format_aperture(aperture)
|
||||
|
||||
iso_raw = _g("ISOSpeedRatings") or _g("PhotographicSensitivity")
|
||||
if isinstance(iso_raw, int) and iso_raw > 0:
|
||||
out["iso"] = iso_raw
|
||||
elif isinstance(iso_raw, tuple) and iso_raw and isinstance(iso_raw[0], int):
|
||||
out["iso"] = iso_raw[0]
|
||||
|
||||
focal = _pillow_rational(_g("FocalLength"))
|
||||
if focal and focal > 0:
|
||||
out["focal-length"] = _format_focal(focal)
|
||||
|
||||
captured_raw = _g("DateTimeOriginal") or _g("DateTime")
|
||||
if isinstance(captured_raw, str) and captured_raw:
|
||||
date_part = captured_raw.split(" ", 1)[0].replace(":", "-")
|
||||
if len(date_part) == 10:
|
||||
out["captured"] = date_part
|
||||
|
||||
gps_idx = tag_name.get("GPSInfo", -1)
|
||||
gps_info = exif.get(gps_idx) or {}
|
||||
if isinstance(gps_info, dict) and gps_info:
|
||||
# Pillow exposes GPSInfo by integer-keyed dict; remap.
|
||||
named = {gps_name.get(k, str(k)): v for k, v in gps_info.items()}
|
||||
lat = _gps_to_decimal(named.get("GPSLatitude"), named.get("GPSLatitudeRef"))
|
||||
lon = _gps_to_decimal(named.get("GPSLongitude"), named.get("GPSLongitudeRef"))
|
||||
if lat is not None and lon is not None:
|
||||
out["geo"] = [round(lat, 6), round(lon, 6)]
|
||||
|
||||
exposure = _build_exposure_string(
|
||||
out.get("shutter"), out.get("aperture"), out.get("iso")
|
||||
)
|
||||
if exposure:
|
||||
out["exposure"] = exposure
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Walk + write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sidecar_path(image: Path) -> Path:
|
||||
return image.with_suffix(image.suffix + ".exif.yaml")
|
||||
|
||||
|
||||
def _is_stale(image: Path, sidecar: Path) -> bool:
|
||||
if not sidecar.exists():
|
||||
return True
|
||||
return image.stat().st_mtime > sidecar.stat().st_mtime
|
||||
|
||||
|
||||
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
# Preserve the SIDECAR_KEYS order so a manual diff is easy to read.
|
||||
ordered = {k: data[k] for k in SIDECAR_KEYS if k in data}
|
||||
yaml.safe_dump(ordered, f, sort_keys=False, allow_unicode=True)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _read_one(image: Path) -> dict[str, Any]:
|
||||
if _exiftool_available():
|
||||
data = _read_exif_via_exiftool(image)
|
||||
if data:
|
||||
return data
|
||||
return _read_exif_via_pillow(image)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CONTENT_DIR.exists():
|
||||
print(f"extract-exif: {CONTENT_DIR} does not exist — skipping.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
using_exiftool = _exiftool_available()
|
||||
print(
|
||||
"extract-exif: source ="
|
||||
f" {'exiftool' if using_exiftool else 'Pillow (exiftool not installed)'}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
written = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for image in sorted(CONTENT_DIR.rglob("*")):
|
||||
if image.suffix.lower() not in IMAGE_EXTS:
|
||||
continue
|
||||
# Skip the WebP companions (extension wouldn't match anyway, but
|
||||
# be explicit) and any tmp / hidden files.
|
||||
if image.name.startswith(".") or image.name.endswith(".tmp"):
|
||||
continue
|
||||
|
||||
sidecar = _sidecar_path(image)
|
||||
if not _is_stale(image, sidecar):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
data = _read_one(image)
|
||||
except Exception as e: # noqa: BLE001 — keep walking
|
||||
print(f"extract-exif: {image}: {e}", file=sys.stderr)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# Always write a sidecar — even if it's empty — so the consumer
|
||||
# doesn't need to branch on existence. An empty sidecar is the
|
||||
# explicit signal that "we tried; nothing to extract" (typical
|
||||
# for film scans).
|
||||
_atomic_write_yaml(sidecar, data)
|
||||
written += 1
|
||||
|
||||
print(
|
||||
f"extract-exif: {written} written, {skipped} skipped, {failed} failed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
extract-palette.py — Build-time 5-color palette sidecar for photography.
|
||||
|
||||
Walks content/photography/**/*.{jpg,jpeg,png} and writes a
|
||||
{photo}.palette.yaml sidecar alongside each image, containing five
|
||||
hex colors derived from the photograph via colorthief's k-means-like
|
||||
quantisation. The sidecar is consumed by photographyCtx in Hakyll
|
||||
and rendered as the thin <div class="photo-palette"> strip beneath
|
||||
each photo.
|
||||
|
||||
Frontmatter `palette:` always wins. Authors can override the auto
|
||||
extraction for artistic reasons (e.g. exposing brand-aligned tones
|
||||
that aren't statistically dominant in the pixels). The sidecar is
|
||||
the fallback so authors don't need to write hex codes by hand.
|
||||
|
||||
Staleness check: skips an image whose sidecar mtime > image mtime.
|
||||
|
||||
Called by `make build` when .venv exists. Per-image failures are
|
||||
logged and the rest of the walk continues; the build never fails on
|
||||
a palette extraction error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from colorthief import ColorThief
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
CONTENT_DIR = REPO_ROOT / "content" / "photography"
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
|
||||
|
||||
# Number of swatches in the rendered strip. Five matches the design in
|
||||
# PHOTOGRAPHY.md and the existing `photo-palette` CSS, which sets
|
||||
# `display: flex; height: 0.75rem;` and divides the bar evenly. Bumping
|
||||
# this requires a CSS revisit — the bar reads as a unified strip up to
|
||||
# about 7 swatches; beyond that the bands become too narrow to perceive.
|
||||
N_SWATCHES = 5
|
||||
|
||||
# colorthief's quality knob: lower = better palette but slower. The
|
||||
# default of 10 is a reasonable trade-off; 1 is exhaustive.
|
||||
QUALITY = 10
|
||||
|
||||
|
||||
def _hex(rgb: tuple[int, int, int]) -> str:
|
||||
return "#{:02x}{:02x}{:02x}".format(*rgb)
|
||||
|
||||
|
||||
def _sidecar_path(image: Path) -> Path:
|
||||
return image.with_suffix(image.suffix + ".palette.yaml")
|
||||
|
||||
|
||||
def _is_stale(image: Path, sidecar: Path) -> bool:
|
||||
if not sidecar.exists():
|
||||
return True
|
||||
return image.stat().st_mtime > sidecar.stat().st_mtime
|
||||
|
||||
|
||||
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def _extract_palette(image: Path) -> list[str]:
|
||||
"""Return up to N_SWATCHES hex colors, in colorthief's dominance order."""
|
||||
ct = ColorThief(str(image))
|
||||
palette = ct.get_palette(color_count=N_SWATCHES, quality=QUALITY)
|
||||
# colorthief sometimes returns one fewer entry than requested for
|
||||
# very low-color images; just take what we got.
|
||||
return [_hex(rgb) for rgb in palette[:N_SWATCHES]]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CONTENT_DIR.exists():
|
||||
print(
|
||||
f"extract-palette: {CONTENT_DIR} does not exist — skipping.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
written = 0
|
||||
skipped = 0
|
||||
failed = 0
|
||||
|
||||
for image in sorted(CONTENT_DIR.rglob("*")):
|
||||
if image.suffix.lower() not in IMAGE_EXTS:
|
||||
continue
|
||||
if image.name.startswith(".") or image.name.endswith(".tmp"):
|
||||
continue
|
||||
|
||||
sidecar = _sidecar_path(image)
|
||||
if not _is_stale(image, sidecar):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
palette = _extract_palette(image)
|
||||
except Exception as e: # noqa: BLE001 — keep walking
|
||||
print(f"extract-palette: {image}: {e}", file=sys.stderr)
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
_atomic_write_yaml(sidecar, {"palette": palette})
|
||||
written += 1
|
||||
|
||||
print(
|
||||
f"extract-palette: {written} written, {skipped} skipped, {failed} failed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
#!/usr/bin/env bash
|
||||
# import-photo.sh — Author-facing import workflow for photography entries.
|
||||
#
|
||||
# Given a path to an original photograph and a target slug, this script:
|
||||
#
|
||||
# 1. Creates content/photography/<slug>/.
|
||||
# 2. Resizes the original to ≤2400px on the long edge, JPEG quality 85,
|
||||
# sRGB. EXIF is preserved at this step so the extractor can read it.
|
||||
# 3. Runs tools/extract-exif.py to produce the {photo}.exif.yaml sidecar.
|
||||
# 4. Strips EXIF from the delivered JPEG (the sidecar now holds the
|
||||
# metadata; the file shipped to viewers carries no GPS, no serial
|
||||
# numbers, no Lightroom edit history).
|
||||
# 5. Runs tools/extract-palette.py to produce the {photo}.palette.yaml
|
||||
# sidecar.
|
||||
# 6. Scaffolds an index.md frontmatter stub ready for editing.
|
||||
#
|
||||
# Usage:
|
||||
# tools/import-photo.sh <original-path> <slug> [--title "Title"]
|
||||
#
|
||||
# Examples:
|
||||
# tools/import-photo.sh ~/Photos/IMG_4421.jpg reykjavik-rooftops
|
||||
# tools/import-photo.sh ~/Photos/IMG_4421.jpg reykjavik-rooftops --title "Reykjavík Rooftops"
|
||||
#
|
||||
# Requirements:
|
||||
# * ImageMagick (`magick`) for resize / strip / colorspace conversion
|
||||
# * uv + .venv (Pillow + colorthief + pyyaml) for sidecar extraction
|
||||
#
|
||||
# Originals are NEVER copied into the repo verbatim — only the resized
|
||||
# delivery JPEG. Per PHOTOGRAPHY.md, originals live outside source
|
||||
# control (your local archive, NAS, or backup).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
cat <<EOF >&2
|
||||
Usage: $(basename "$0") <original-path> <slug> [--title "Title"]
|
||||
|
||||
Imports a photograph into content/photography/<slug>/, producing:
|
||||
photo.jpg resized, sRGB, EXIF-stripped (delivery copy)
|
||||
photo.jpg.exif.yaml extracted EXIF metadata (sidecar)
|
||||
photo.jpg.palette.yaml 5-color palette (sidecar)
|
||||
index.md frontmatter stub ready for editing
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ORIGINAL="$1"
|
||||
SLUG="$2"
|
||||
shift 2
|
||||
|
||||
TITLE=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title)
|
||||
TITLE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -f "$ORIGINAL" ]; then
|
||||
echo "import-photo: original not found: $ORIGINAL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool availability checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if ! command -v magick >/dev/null 2>&1; then
|
||||
echo "import-photo: ImageMagick ('magick') is required but not installed." >&2
|
||||
echo " Arch: pacman -S imagemagick" >&2
|
||||
echo " Debian: apt install imagemagick" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
if [ ! -d "$REPO_ROOT/.venv" ]; then
|
||||
echo "import-photo: .venv not found at $REPO_ROOT/.venv" >&2
|
||||
echo " Run: uv sync" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ENTRY_DIR="$REPO_ROOT/content/photography/$SLUG"
|
||||
TARGET="$ENTRY_DIR/photo.jpg"
|
||||
EXIF_SIDECAR="$TARGET.exif.yaml"
|
||||
PALETTE_SIDECAR="$TARGET.palette.yaml"
|
||||
INDEX_MD="$ENTRY_DIR/index.md"
|
||||
|
||||
if [ -e "$ENTRY_DIR" ]; then
|
||||
echo "import-photo: $ENTRY_DIR already exists. Refusing to overwrite." >&2
|
||||
echo " Either choose a new slug or remove the existing entry first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ENTRY_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 1: resize + colorspace, EXIF preserved (so the extractor can read it)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "import-photo: resizing to ≤2400px JPEG q85 sRGB → $TARGET"
|
||||
magick "$ORIGINAL" \
|
||||
-auto-orient \
|
||||
-resize '2400x2400>' \
|
||||
-colorspace sRGB \
|
||||
-quality 85 \
|
||||
"$TARGET"
|
||||
chmod 644 "$TARGET"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 2: extract EXIF (reads from the resized file, which still has EXIF)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "import-photo: extracting EXIF sidecar..."
|
||||
( cd "$REPO_ROOT" && uv run python tools/extract-exif.py ) || true
|
||||
|
||||
if [ ! -f "$EXIF_SIDECAR" ]; then
|
||||
# Empty sidecar so the consuming Hakyll field has something to read
|
||||
# (an absent sidecar is also handled, but a present-but-empty file
|
||||
# signals "extraction was attempted" — useful for film scans where
|
||||
# there's intentionally no EXIF to find).
|
||||
echo '{}' > "$EXIF_SIDECAR"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 3: strip EXIF from the delivered JPEG (sidecar already has it)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "import-photo: stripping EXIF from delivered file..."
|
||||
magick mogrify -strip "$TARGET"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 4: extract palette (does its own walk; idempotent on already-done photos)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "import-photo: extracting palette sidecar..."
|
||||
( cd "$REPO_ROOT" && uv run python tools/extract-palette.py ) || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step 5: scaffold index.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ -z "$TITLE" ]; then
|
||||
TITLE="$(echo "$SLUG" | tr '-' ' ' | awk '{
|
||||
for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2);
|
||||
print
|
||||
}')"
|
||||
fi
|
||||
|
||||
TODAY="$(date -u +%Y-%m-%d)"
|
||||
|
||||
# Probe the resized file's pixel dimensions so we can suggest an
|
||||
# orientation; the author can override in frontmatter.
|
||||
DIMS="$(magick identify -format '%w %h' "$TARGET")"
|
||||
WIDTH="${DIMS%% *}"
|
||||
HEIGHT="${DIMS##* }"
|
||||
|
||||
if [ "$WIDTH" -gt "$HEIGHT" ]; then
|
||||
ORIENTATION="landscape"
|
||||
elif [ "$HEIGHT" -gt "$WIDTH" ]; then
|
||||
ORIENTATION="portrait"
|
||||
else
|
||||
ORIENTATION="square"
|
||||
fi
|
||||
|
||||
cat > "$INDEX_MD" <<EOF
|
||||
---
|
||||
title: "$TITLE"
|
||||
date: $TODAY
|
||||
abstract: >
|
||||
TODO — short caption for this photograph.
|
||||
tags: [photography]
|
||||
photo: photo.jpg
|
||||
orientation: $ORIENTATION
|
||||
# license: "CC BY-SA 4.0" # uncomment + set; canonical URL auto-resolves
|
||||
# location: "" # human-readable, e.g. "Reykjavík, Iceland"
|
||||
# camera, lens, exposure are auto-filled from the EXIF sidecar — only
|
||||
# add them here to override what was extracted.
|
||||
---
|
||||
|
||||
EOF
|
||||
chmod 644 "$INDEX_MD"
|
||||
|
||||
echo
|
||||
echo "import-photo: done."
|
||||
echo " Entry: $INDEX_MD"
|
||||
echo " Photo: $TARGET ($WIDTH × $HEIGHT, $ORIENTATION)"
|
||||
echo " Sidecars: $(basename "$EXIF_SIDECAR"), $(basename "$PALETTE_SIDECAR")"
|
||||
echo
|
||||
echo "Next: edit $INDEX_MD to fill in title / abstract / tags, then 'make dev'."
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Pinned SHA-256 hashes for the Leaflet + leaflet.markercluster files
|
||||
# vendored by tools/download-leaflet.sh. Bumping a version: replace
|
||||
# the version in download-leaflet.sh, re-run, then update this file.
|
||||
# Format: <sha256> <pin-key>
|
||||
db49d009c841f5ca34a888c96511ae936fd9f5533e90d8b2c4d57596f4e5641a leaflet-1.9.4-leaflet.js
|
||||
a7837102824184820dfa198d1ebcd109ff6d0ff9a2672a074b9a1b4d147d04c6 leaflet-1.9.4-leaflet.css
|
||||
574c3a5cca85f4114085b6841596d62f00d7c892c7b03f28cbfa301deb1dc437 leaflet-1.9.4-marker-icon.png
|
||||
00179c4c1ee830d3a108412ae0d294f55776cfeb085c60129a39aa6fc4ae2528 leaflet-1.9.4-marker-icon-2x.png
|
||||
264f5c640339f042dd729062cfc04c17f8ea0f29882b538e3848ed8f10edb4da leaflet-1.9.4-marker-shadow.png
|
||||
1e4e1d22972a3926f48598e0caf14e3fe7049835d428a344fed4f9e3665b3508 leaflet.markercluster-1.5.3-leaflet.markercluster.js
|
||||
614dea0a98ff3f4ead74f04918f6b1d1b9ba435c25b5fc23b21a394d1e3e4d87 leaflet.markercluster-1.5.3-MarkerCluster.css
|
||||
61258232d98d64dc2a7b1e02130d67421bc5b9bda5994eef70228ff97570c170 leaflet.markercluster-1.5.3-MarkerCluster.Default.css
|
||||
Loading…
Reference in New Issue