diff --git a/.env.example b/.env.example index 36f30d8..9912f68 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ -# Copy this file to .env and fill in the values. -# .env is gitignored — never commit it. +# Copy this file to .env and fill in the values, then run: +# chmod 600 .env +# so other local users cannot read your VPS path / token. .env is +# gitignored — never commit it. The auto-snapshot in `make build` +# uses an explicit pathspec under content/ to keep stray .env files +# out of the snapshot, but **/.env is also in .gitignore as a backstop. # -# `make deploy` rsyncs the built _site/ to the VPS, then pushes the -# repository to GitHub. The Makefile aborts with a clear error if any -# of VPS_USER / VPS_HOST / VPS_PATH is unset. +# `make deploy` pushes to GitHub first, then rsyncs the built _site/ +# to the VPS. The Makefile aborts with a clear error if any of +# VPS_USER / VPS_HOST / VPS_PATH is unset, if VPS_PATH points at an +# obviously dangerous parent directory, or if _site/index.html does +# not exist (a sign of a broken build). # --- VPS deployment target ------------------------------------------------- # SSH user on the deployment VPS. @@ -15,8 +21,10 @@ VPS_PATH= # --- GitHub mirror push ---------------------------------------------------- # A GitHub fine-grained personal access token with Contents: read+write -# on the levineuwirth.org repository. -# Generate at: https://github.com/settings/tokens +# on the levineuwirth.org repository. Currently optional — `make deploy` +# uses your local git credential helper for `git push`, so this is only +# needed if you wire token-based push into a credential helper yourself. +# Generate at: https://github.com/settings/personal-access-tokens/new GITHUB_TOKEN= # The GitHub repository in owner/repo format. diff --git a/.gitignore b/.gitignore index 15c9b73..08dea64 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ _site/ _cache/ .DS_Store .env +# Defense-in-depth: catch any stray .env / .env.* anywhere in the tree +# (the auto-snapshot in the Makefile stages content/ on every build). +**/.env +**/.env.* # Editor backup/swap files *~ diff --git a/Makefile b/Makefile index 9d38c05..bafec16 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,12 @@ .PHONY: build deploy sign download-model download-pdfjs compress-assets convert-images pdf-thumbs pdfs watch clean dev -# Source .env for GITHUB_TOKEN and GITHUB_REPO if it exists. +# Source .env for deploy / GitHub config if it exists. # .env format: KEY=value (one per line, no `export` prefix, no quotes needed). +# Only the variables explicitly listed below are exported to recipe +# subprocesses — bare `export` would leak every .env key (including any +# future GITHUB_TOKEN) into every child process. -include .env -export +export VPS_USER VPS_HOST VPS_PATH GITHUB_REPO build: # Auto-snapshot any uncommitted content/ changes BEFORE the build @@ -12,8 +15,20 @@ build: # the history — that's intentional. The next successful build # either reuses it (no new content/ changes) or appends another # snapshot on top, so failures don't disappear from the log. - @git add content/ - @git diff --cached --quiet || git commit -m "auto: $$(date -u +%Y-%m-%dT%H:%M:%SZ)" + # + # Pathspec is explicit (not `git add content/`) so a stray .env, + # credential file, or other non-content artifact dropped under + # content/ is NOT auto-staged. The :(glob) magic prefix makes `**` + # match across path components (git default fnmatch does not). + # Add new extensions here if a new asset type is introduced. + @git add ':(glob)content/**/*.md' ':(glob)content/**/*.html' ':(glob)content/**/*.bib' \ + ':(glob)content/**/*.png' ':(glob)content/**/*.jpg' ':(glob)content/**/*.jpeg' \ + ':(glob)content/**/*.svg' ':(glob)content/**/*.gif' ':(glob)content/**/*.pdf' \ + ':(glob)content/**/*.mp3' ':(glob)content/**/*.ogg' ':(glob)content/**/*.flac' \ + ':(glob)content/**/*.yaml' ':(glob)content/**/*.yml' ':(glob)content/**/*.json' \ + ':(glob)content/**/*.css' ':(glob)content/**/*.tex' + @git diff --cached --quiet || git commit -m "auto: $$(date -u +%Y-%m-%dT%H:%M:%SZ) [skip ci]" + @mkdir -p data @date +%s > data/build-start.txt @./tools/convert-images.sh @$(MAKE) -s pdf-thumbs @@ -29,7 +44,8 @@ build: > IGNORE.txt @BUILD_END=$$(date +%s); \ BUILD_START=$$(cat data/build-start.txt); \ - echo $$((BUILD_END - BUILD_START)) > data/last-build-seconds.txt + echo $$((BUILD_END - BUILD_START)) > data/last-build-seconds.txt.tmp && \ + mv data/last-build-seconds.txt.tmp data/last-build-seconds.txt sign: @./tools/sign-site.sh @@ -99,9 +115,19 @@ deploy: clean build sign @test -n "$(VPS_USER)" || (echo "deploy: VPS_USER not set in .env" >&2; exit 1) @test -n "$(VPS_HOST)" || (echo "deploy: VPS_HOST not set in .env" >&2; exit 1) @test -n "$(VPS_PATH)" || (echo "deploy: VPS_PATH not set in .env" >&2; exit 1) - @command -v notify-send >/dev/null 2>&1 && notify-send "make deploy" "Ready to rsync — waiting for SSH auth" || true - rsync -avz --delete _site/ $(VPS_USER)@$(VPS_HOST):$(VPS_PATH)/ + # Refuse to deploy a manifestly broken build. _site/index.html must + # exist and be non-empty before we run rsync --delete on the VPS. + @test -s _site/index.html || { echo "deploy: _site/index.html is missing or empty — refusing to rsync" >&2; exit 1; } + # Defense-in-depth: refuse rsync --delete to obviously dangerous + # parents in case VPS_PATH was typo'd (e.g. trailing-slash mistake). + @case "$(VPS_PATH)" in /|/srv|/srv/http|/var|/var/www|/home|/root|"") echo "deploy: VPS_PATH=$(VPS_PATH) looks unsafe — refusing" >&2; exit 1 ;; esac + @command -v notify-send >/dev/null 2>&1 && notify-send "make deploy" "Ready to push & rsync — waiting for auth" || true + # Push first: a successful push is cheap to roll back, while a + # half-completed rsync is harder to recover from. If the push + # fails (auth, branch protection, network), abort before touching + # the VPS so the public source repo and the live site stay in sync. git push -u origin main + rsync -avz --delete _site/ $(VPS_USER)@$(VPS_HOST):$(VPS_PATH)/ watch: export SITE_ENV = dev watch: @@ -117,4 +143,4 @@ dev: export SITE_ENV = dev dev: cabal run site -- clean cabal run site -- build - python3 -m http.server 8000 --directory _site + python3 -m http.server 8000 --bind 127.0.0.1 --directory _site diff --git a/build/Contexts.hs b/build/Contexts.hs index 10a9cf3..91b6617 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -10,6 +10,7 @@ module Contexts , compositionCtx , contentKindField , abstractField + , descriptionField , tagLinksField , tagLinksFieldExcludingScope , tagLinksFieldExcludingTopSegment @@ -34,7 +35,7 @@ import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM) import System.FilePath (takeDirectory, takeFileName) import Text.Read (readMaybe) import qualified Data.Text as T -import Text.Pandoc (runPure, readMarkdown, writeHtml5String, Pandoc(..), Block(..), Inline(..)) +import Text.Pandoc (runPure, readMarkdown, writeHtml5String, writePlain, Pandoc(..), Block(..), Inline(..)) import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..)) import Hakyll hiding (trim) import Backlinks (backlinksField) @@ -348,6 +349,44 @@ abstractField = field "abstract" $ \item -> do isPara (Para _) = True isPara _ = False +-- --------------------------------------------------------------------------- +-- Description field +-- --------------------------------------------------------------------------- + +-- | Renders the @abstract@ frontmatter key as plain text suitable for use in +-- @@, @og:description@, and @twitter:description@. +-- Strips Pandoc markup, collapses internal whitespace, truncates to ~200 +-- chars, and HTML-escapes attribute-special characters. Returns @noResult@ +-- when no @abstract@ is present (so @$if(description)$@ short-circuits). +descriptionField :: Context String +descriptionField = field "description" $ \item -> do + meta <- getMetadata (itemIdentifier item) + case lookupString "abstract" meta of + Nothing -> fail "no abstract" + Just src -> do + let pandocResult = runPure $ do + doc <- readMarkdown defaultHakyllReaderOptions (T.pack src) + writePlain defaultHakyllWriterOptions doc + case pandocResult of + Left err -> fail $ "Pandoc error rendering description: " ++ show err + Right txt -> + let collapsed = T.unwords (T.words txt) + capped = if T.length collapsed > 200 + then T.take 197 collapsed <> T.pack "\x2026" + else collapsed + in return (attrEscape (T.unpack capped)) + +-- | HTML-escape characters that would break out of an attribute value. +attrEscape :: String -> String +attrEscape = concatMap esc + where + esc '&' = "&" + esc '<' = "<" + esc '>' = ">" + esc '"' = """ + esc '\'' = "'" + esc c = [c] + -- --------------------------------------------------------------------------- -- Summary field -- --------------------------------------------------------------------------- @@ -377,6 +416,7 @@ siteCtx = <> buildTimeField <> pageScriptsField <> abstractField + <> descriptionField <> summaryField <> dingbatField <> defaultContext diff --git a/build/Stats.hs b/build/Stats.hs index 9984617..3390428 100644 --- a/build/Stats.hs +++ b/build/Stats.hs @@ -619,7 +619,10 @@ renderDistribution wcs = ] counts = foldr (\w acc -> Map.insertWith (+) (bucketOf w) (1 :: Int) acc) (Map.fromList [(i, 0 :: Int) | i <- [0 .. 4]]) wcs - buckets = [(labels !! i, fromMaybe 0 (Map.lookup i counts)) | i <- [0 .. 4]] + -- Pair labels with bucket indices via @zip@ rather than @(!!)@ to keep + -- the function total even if the bucket count and @labels@ list ever + -- drift out of sync (matching the discipline used in 'median'). + buckets = [(lbl, fromMaybe 0 (Map.lookup i counts)) | (i, lbl) <- zip [0 :: Int ..] labels] maxCount = max 1 (maximum (map snd buckets)) bar (lbl, n) = let pct = n * 100 `div` maxCount diff --git a/content/essays/empirical-musings-spaced-repetition.md b/content/essays/empirical-musings-spaced-repetition.md index 3d94719..852e743 100644 --- a/content/essays/empirical-musings-spaced-repetition.md +++ b/content/essays/empirical-musings-spaced-repetition.md @@ -17,6 +17,6 @@ evidence: 2 scope: broad novelty: idiosyncratic practicality: high -confidence history: +confidence-history: - 65 --- diff --git a/content/essays/notes-from-underground.md b/content/essays/notes-from-underground.md index 7356609..dbc5698 100644 --- a/content/essays/notes-from-underground.md +++ b/content/essays/notes-from-underground.md @@ -8,7 +8,7 @@ tags: - nonfiction/philosophy authors: - "Levi Neuwirth | /me.html" -revised: +history: - date: "2026-04-17" note: "expanded section on Shestov's divergence from Nietzsche" - date: "2025-12-03" diff --git a/content/library.md b/content/library.md index d61dc6d..afd25e1 100644 --- a/content/library.md +++ b/content/library.md @@ -1,3 +1,8 @@ +--- +title: Library +library: true +--- + ::: {lang="es"} > *El universo (que otros llaman la Biblioteca) se compone de un número indefinido, y tal vez infinito, de galerías hexagonales, con vastos pozos de ventilación en el medio, cercados por barandas bajísimas.* > diff --git a/static/js/popups.js b/static/js/popups.js index 249db9e..19aad11 100644 --- a/static/js/popups.js +++ b/static/js/popups.js @@ -294,20 +294,32 @@ } /* 1. Citations — synchronous DOM lookup; supports multi-citation groups - via data-cite-keys (space-separated list of ref-* IDs). */ + via data-cite-keys (space-separated list of ref-* IDs). + Returns a DocumentFragment of cloned bibliography entries instead + of stringifying innerHTML, so a malicious or malformed cite target + cannot smuggle markup through the popup's innerHTML setter. */ function citationContent(target) { return new Promise(function (resolve) { var keysAttr = target.getAttribute('data-cite-keys'); var ids = keysAttr ? keysAttr.trim().split(/\s+/) : [(target.getAttribute('href') || '').slice(1)]; - var parts = ids.map(function (id) { - var entry = document.getElementById(id); - return entry ? '