Marks II: broader monogram coverage + audit-marks tool

Extends the Phase-1 monogram mark system to every long-form content
type (essays, blog posts, poems, fiction, music) and introduces a
coverage audit so gaps are visible.

* build/Marks.hs gains hasMonogram (predicate), monogramSvgFieldFor +
  hasMonogramFieldFor (for explicit-path callers like the /build/ and
  /stats/ pages). Contexts.hs exports hasMonogramField as a siteCtx
  boolean so templates can conditionally render the slot without
  emitting an empty <div>.
* essay.html, blog-post.html, reading.html: hoist the frontmatter
  block out of <main id="markdownBody"> so the monogram + epistemic
  marks render as wrapper chrome rather than indexable prose; left
  + right mark slots are now unconditional (CSS handles the empty
  state) so the layout is grid-stable across pieces.
* templates/partials/item-card.html: optional monogram chip on cards
  (item-card--has-monogram modifier), gated on $has-monogram$ so
  monogram-less pieces stay flush.
* build/Stats.hs grows a "Marks coverage" telemetry section: per-type
  pieces / monogram / epistemic-figure counts + a coverage rollup,
  rendered between epistemic and output on /build/.
* tools/audit-marks.py: coverage report (ASCII table) walking
  content/**/*.md, plus a pre-commit hook at
  tools/hooks/pre-commit-marks.sh that runs the same scan against
  newly-staged .md files. New `make audit-marks` runs the report
  manually; the hook gates commits.
* static/css/marks.css: layout for the new frontmatter slots and the
  item-card monogram chip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-23 12:05:08 -04:00
parent 77e31efdae
commit 154b47a4cb
11 changed files with 622 additions and 79 deletions

View File

@ -1,4 +1,4 @@
.PHONY: build deploy sign download-model download-pdfjs download-leaflet compress-assets convert-images pdf-thumbs pdfs watch clean dev archive-gc archive-wayback archive-check .PHONY: build deploy sign download-model download-pdfjs download-leaflet compress-assets convert-images pdf-thumbs pdfs watch clean dev audit-marks archive-gc archive-wayback archive-check
# Source .env for deploy / GitHub config 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). # .env format: KEY=value (one per line, no `export` prefix, no quotes needed).
@ -163,6 +163,18 @@ watch:
clean: clean:
cabal run site -- clean cabal run site -- clean
# Report which content pieces are missing a monogram (mark.svg) and / or
# the epistemic figure (status: frontmatter). Exits 0 unconditionally;
# this is a coverage report, not a build gate. The pre-commit hook at
# tools/hooks/pre-commit-marks.sh runs the same script for newly-staged
# .md files.
audit-marks:
@if [ -d .venv ]; then \
uv run python tools/audit-marks.py; \
else \
python3 tools/audit-marks.py; \
fi
# Evict archived works: delete archive/<slug>/ directories whose slug is # Evict archived works: delete archive/<slug>/ directories whose slug is
# recorded in archive/removed.yaml. Opt-in — NEVER run by `make build`. # recorded in archive/removed.yaml. Opt-in — NEVER run by `make build`.
# Orphan directories (not in manifest.yaml, not in removed.yaml) are # Orphan directories (not in manifest.yaml, not in removed.yaml) are

View File

@ -48,7 +48,7 @@ import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..))
import Hakyll hiding (trim) import Hakyll hiding (trim)
import Backlinks (backlinksField) import Backlinks (backlinksField)
import Dingbat (dingbatField) import Dingbat (dingbatField)
import Marks (monogramSvgField, epistemicSvgField) import Marks (monogramSvgField, hasMonogramField, epistemicSvgField)
import SimilarLinks (similarLinksField) import SimilarLinks (similarLinksField)
import Stability (stabilityField, lastReviewedField, lastReviewedIsoField, import Stability (stabilityField, lastReviewedField, lastReviewedIsoField,
versionHistoryField, versionHistoryField,
@ -437,6 +437,7 @@ siteCtx =
<> summaryField <> summaryField
<> dingbatField <> dingbatField
<> monogramSvgField <> monogramSvgField
<> hasMonogramField
<> defaultContext <> defaultContext
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------

View File

@ -16,7 +16,11 @@
-- byte-identical SVGs, so the GPG signing pipeline is undisturbed. -- byte-identical SVGs, so the GPG signing pipeline is undisturbed.
module Marks module Marks
( monogramSvgField ( monogramSvgField
, hasMonogramField
, monogramSvgFieldFor
, hasMonogramFieldFor
, epistemicSvgField , epistemicSvgField
, hasMonogram
) where ) where
import Control.Exception (IOException, try) import Control.Exception (IOException, try)
@ -52,6 +56,24 @@ monogramCandidates fp =
then [dir </> "mark.svg"] then [dir </> "mark.svg"]
else [dir </> takeBaseName fp ++ ".mark.svg"] else [dir </> takeBaseName fp ++ ".mark.svg"]
-- | Predicate form of 'resolveMonogramPath' — used by Stats.hs to
-- compute monogram coverage on @/build/@. Returns 'True' when at
-- least one of the dual-form candidate paths exists on disk.
hasMonogram :: Item a -> Compiler Bool
hasMonogram item = isJust <$> resolveMonogramPath item
-- | @$has-monogram$@ — present (renders as @"true"@) only when the
-- item has an actual @mark.svg@ on disk; 'noResult' for the
-- placeholder-roundel case. Templates that don't want to display
-- placeholder roundels (e.g. item-card listings, popup previews)
-- gate on this flag instead of @$monogramSvg$@, which the
-- frontmatter header relies on always rendering for symmetric
-- column layout.
hasMonogramField :: Context String
hasMonogramField = field "has-monogram" $ \item -> do
has <- hasMonogram item
if has then return "true" else noResult "no real monogram"
-- | Return the first candidate path that exists on disk, or 'Nothing'. -- | Return the first candidate path that exists on disk, or 'Nothing'.
resolveMonogramPath :: Item a -> Compiler (Maybe FilePath) resolveMonogramPath :: Item a -> Compiler (Maybe FilePath)
resolveMonogramPath item = resolveMonogramPath item =
@ -72,13 +94,17 @@ resolveMonogramPath item =
-- tools may produce hardcoded blacks; the contract still holds), strips -- tools may produce hardcoded blacks; the contract still holds), strips
-- the @width@/@height@ presentation attributes from the root @<svg>@, -- the @width@/@height@ presentation attributes from the root @<svg>@,
-- and wraps the result in @<figure class="frontmatter-mark -- and wraps the result in @<figure class="frontmatter-mark
-- frontmatter-mark--monogram">@. Returns 'noResult' when no candidate -- frontmatter-mark--monogram">@.
-- exists; warns and returns 'noResult' on read failure. --
-- When no @mark.svg@ exists, returns the placeholder roundel — an
-- empty outer ring at lower opacity that visually balances the
-- epistemic-figure column and signals "monogram not yet authored".
-- Read failures fall back to the same placeholder.
monogramSvgField :: Context String monogramSvgField :: Context String
monogramSvgField = field "monogramSvg" $ \item -> do monogramSvgField = field "monogramSvg" $ \item -> do
mPath <- resolveMonogramPath item mPath <- resolveMonogramPath item
case mPath of case mPath of
Nothing -> noResult "no mark.svg" Nothing -> return $ T.unpack monogramPlaceholder
Just path -> do Just path -> do
result <- unsafeCompiler $ try (TIO.readFile path) result <- unsafeCompiler $ try (TIO.readFile path)
:: Compiler (Either IOException T.Text) :: Compiler (Either IOException T.Text)
@ -87,9 +113,24 @@ monogramSvgField = field "monogramSvg" $ \item -> do
unsafeCompiler $ hPutStrLn stderr $ unsafeCompiler $ hPutStrLn stderr $
"[Marks] " ++ toFilePath (itemIdentifier item) ++ "[Marks] " ++ toFilePath (itemIdentifier item) ++
": failed to read " ++ path ++ ": " ++ show e ": failed to read " ++ path ++ ": " ++ show e
noResult "monogram read failed" return $ T.unpack monogramPlaceholder
Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg) Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg)
-- | Empty-roundel placeholder used while a piece's monogram is still
-- to be authored (Phase 2 of MARKS.md). The @--placeholder@ modifier
-- class lets CSS render it at reduced opacity so it reads as a
-- neutral frame rather than a finished glyph.
monogramPlaceholder :: T.Text
monogramPlaceholder = T.concat
[ "<figure class=\"frontmatter-mark frontmatter-mark--monogram"
, " frontmatter-mark--placeholder\" aria-hidden=\"true\">"
, "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 280 280\">"
, "<circle cx=\"140\" cy=\"140\" r=\"128\" fill=\"none\""
, " stroke=\"currentColor\" stroke-width=\"0.6\"/>"
, "</svg>"
, "</figure>"
]
-- | Wrap inlined monogram SVG in its outer figure element. -- | Wrap inlined monogram SVG in its outer figure element.
wrapMonogram :: T.Text -> T.Text wrapMonogram :: T.Text -> T.Text
wrapMonogram svg = T.concat wrapMonogram svg = T.concat
@ -98,6 +139,33 @@ wrapMonogram svg = T.concat
, "</figure>" , "</figure>"
] ]
-- | @$monogramSvg$@ override for synthesized pages whose item identifier
-- doesn't live under @content/@ (e.g. @/build/@, @/stats/@), so the
-- auto-resolver in 'monogramSvgField' can't find a co-located mark.
-- Reads from the supplied path; falls back to the placeholder roundel
-- when the file is absent or unreadable.
monogramSvgFieldFor :: FilePath -> Context a
monogramSvgFieldFor path = field "monogramSvg" $ \_ -> do
exists <- unsafeCompiler $ doesFileExist path
if not exists
then return $ T.unpack monogramPlaceholder
else do
result <- unsafeCompiler $ try (TIO.readFile path)
:: Compiler (Either IOException T.Text)
case result of
Left e -> do
unsafeCompiler $ hPutStrLn stderr $
"[Marks] failed to read " ++ path ++ ": " ++ show e
return $ T.unpack monogramPlaceholder
Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg)
-- | @$has-monogram$@ override paired with 'monogramSvgFieldFor'. Present
-- (as @"true"@) only when the path exists; 'noResult' otherwise.
hasMonogramFieldFor :: FilePath -> Context a
hasMonogramFieldFor path = field "has-monogram" $ \_ -> do
exists <- unsafeCompiler $ doesFileExist path
if exists then return "true" else noResult "no real monogram"
-- | Replace hardcoded black fills/strokes with @currentColor@ and strip -- | Replace hardcoded black fills/strokes with @currentColor@ and strip
-- the root @<svg>@'s @width@/@height@ attributes (presentation lives -- the root @<svg>@'s @width@/@height@ attributes (presentation lives
-- in CSS via the @.frontmatter-mark svg@ selector). Mirrors the color -- in CSS via the @.frontmatter-mark svg@ selector). Mirrors the color

View File

@ -39,6 +39,8 @@ import qualified Text.Blaze.Internal as BI
import Hakyll import Hakyll
import Archive (archiveBuildStats) import Archive (archiveBuildStats)
import Contexts (siteCtx, authorLinksField) import Contexts (siteCtx, authorLinksField)
import Marks (hasMonogram, monogramSvgFieldFor,
hasMonogramFieldFor)
import qualified Patterns as P import qualified Patterns as P
import Utils (readingTime) import Utils (readingTime)
@ -676,6 +678,39 @@ renderEpistemic total ws wc wi we =
, txt (pctStr n total) , txt (pctStr n total)
] ]
-- | Per-content-type counts feeding 'renderMarks'. @mrCount@ is the
-- denominator (total pieces of that type), @mrMonogram@ is the count
-- with a co-located @mark.svg@, and @mrFigure@ is the count with
-- @status:@ frontmatter (which is what triggers the epistemic figure
-- per MARKS.md §3.1).
data MarkRow = MarkRow
{ mrLabel :: String
, mrCount :: Int
, mrMonogram :: Int
, mrFigure :: Int
}
renderMarks :: [MarkRow] -> H.Html
renderMarks rows =
section "marks" "Marks coverage" $
table
["Type", "Pieces", "Monogram", "Epistemic figure"]
(map row rows)
(Just [ "Total"
, txt (commaInt totalCount)
, txt (commaInt totalMono ++ " (" ++ pctStr totalMono totalCount ++ ")")
, txt (commaInt totalFig ++ " (" ++ pctStr totalFig totalCount ++ ")")
])
where
totalCount = sum (map mrCount rows)
totalMono = sum (map mrMonogram rows)
totalFig = sum (map mrFigure rows)
row r = [ txt (mrLabel r)
, txt (commaInt (mrCount r))
, txt (commaInt (mrMonogram r) ++ " (" ++ pctStr (mrMonogram r) (mrCount r) ++ ")")
, txt (commaInt (mrFigure r) ++ " (" ++ pctStr (mrFigure r) (mrCount r) ++ ")")
]
renderOutput :: Map.Map String (Int, Integer) -> Int -> Integer -> H.Html renderOutput :: Map.Map String (Int, Integer) -> Int -> Integer -> H.Html
renderOutput grouped totalFiles totalSize = renderOutput grouped totalFiles totalSize =
section "output" "Output" $ section "output" "Output" $
@ -734,6 +769,7 @@ pageTOC = H.ol $ mapM_ item sections
, ("tags", "Tags") , ("tags", "Tags")
, ("links", "Links") , ("links", "Links")
, ("epistemic", "Epistemic coverage") , ("epistemic", "Epistemic coverage")
, ("marks", "Marks coverage")
, ("output", "Output") , ("output", "Output")
, ("archive", "Link archive") , ("archive", "Link archive")
, ("repository", "Repository") , ("repository", "Repository")
@ -844,8 +880,11 @@ statsRules tags = do
-- ---------------------------------------------------------------- -- ----------------------------------------------------------------
-- Epistemic coverage (essays + posts) -- Epistemic coverage (essays + posts)
-- ---------------------------------------------------------------- -- ----------------------------------------------------------------
essayMetas <- mapM (getMetadata . itemIdentifier) essays essayMetas <- mapM (getMetadata . itemIdentifier) essays
postMetas <- mapM (getMetadata . itemIdentifier) posts postMetas <- mapM (getMetadata . itemIdentifier) posts
poemMetas <- mapM (getMetadata . itemIdentifier) poems
fictionMetas <- mapM (getMetadata . itemIdentifier) fiction
compMetas <- mapM (getMetadata . itemIdentifier) comps
let epMetas = essayMetas ++ postMetas let epMetas = essayMetas ++ postMetas
epTotal = length epMetas epTotal = length epMetas
ep f = length (filter (isJust . f) epMetas) ep f = length (filter (isJust . f) epMetas)
@ -854,6 +893,38 @@ statsRules tags = do
withImp = ep (lookupString "importance") withImp = ep (lookupString "importance")
withEv = ep (lookupString "evidence") withEv = ep (lookupString "evidence")
-- ----------------------------------------------------------------
-- Marks coverage (per-portal monogram + epistemic-figure counts)
--
-- Monogram presence is a disk lookup via Marks.hasMonogram;
-- epistemic-figure presence is the same trigger as the figure
-- generator itself (status: set in frontmatter).
-- ----------------------------------------------------------------
essayMonos <- mapM hasMonogram essays
postMonos <- mapM hasMonogram posts
poemMonos <- mapM hasMonogram poems
fictionMonos <- mapM hasMonogram fiction
compMonos <- mapM hasMonogram comps
let countTrue = length . filter id
countStat = length . filter (isJust . lookupString "status")
markRows =
[ MarkRow "Essays" (length essays)
(countTrue essayMonos)
(countStat essayMetas)
, MarkRow "Blog posts" (length posts)
(countTrue postMonos)
(countStat postMetas)
, MarkRow "Poems" (length poems)
(countTrue poemMonos)
(countStat poemMetas)
, MarkRow "Fiction" (length fiction)
(countTrue fictionMonos)
(countStat fictionMetas)
, MarkRow "Compositions" (length comps)
(countTrue compMonos)
(countStat compMetas)
]
-- ---------------------------------------------------------------- -- ----------------------------------------------------------------
-- Output directory stats -- Output directory stats
-- ---------------------------------------------------------------- -- ----------------------------------------------------------------
@ -893,6 +964,7 @@ statsRules tags = do
renderTagsSection topTags uniqueTags renderTagsSection topTags uniqueTags
renderLinks mostLinkedInfo orphanCount (length allPIs) renderLinks mostLinkedInfo orphanCount (length allPIs)
renderEpistemic epTotal withStatus withConf withImp withEv renderEpistemic epTotal withStatus withConf withImp withEv
renderMarks markRows
renderOutput outputGrouped totalFiles totalSize renderOutput outputGrouped totalFiles totalSize
renderArchive archiveMetrics renderArchive archiveMetrics
renderRepository hf hl cf cl jf jl commits firstDate renderRepository hf hl cf cl jf jl commits firstDate
@ -909,6 +981,8 @@ statsRules tags = do
\link analysis, epistemic coverage, output metrics, \ \link analysis, epistemic coverage, output metrics, \
\repository overview, and build timing." \repository overview, and build timing."
<> constField "build" "true" <> constField "build" "true"
<> monogramSvgFieldFor "content/build.mark.svg"
<> hasMonogramFieldFor "content/build.mark.svg"
<> authorLinksField <> authorLinksField
<> siteCtx <> siteCtx
@ -985,6 +1059,8 @@ statsRules tags = do
<> constField "abstract" "Writing activity, corpus breakdown, \ <> constField "abstract" "Writing activity, corpus breakdown, \
\and tag distribution computed at build time." \and tag distribution computed at build time."
<> constField "build" "true" <> constField "build" "true"
<> monogramSvgFieldFor "content/stats.mark.svg"
<> hasMonogramFieldFor "content/stats.mark.svg"
<> authorLinksField <> authorLinksField
<> siteCtx <> siteCtx

View File

@ -9,40 +9,106 @@
suppress the slot div entirely when its SVG is empty). suppress the slot div entirely when its SVG is empty).
============================================================ */ ============================================================ */
/* Three-column grid: /* Three-column grid spanning the full viewport width:
[ monogram ] [ title block 1fr ] [ epistemic figure ] [ monogram ] [ title block 1fr ] [ epistemic figure ]
The 1fr column absorbs all extra width. Mark slots are The header lives outside #content so the grid stretches
sized to their content via grid auto-placement. When the edge-to-edge; the side columns are pinned to the same
template guard suppresses one or both slot divs, the column `clamp()` width as the marks themselves so the middle
simply does not exist for layout purposes. */ column stays symmetric in the viewport even when only one
slot has content (e.g. essays without status: render only
the monogram, but the right column still reserves its
width without this, the title block would centre in an
off-axis 1fr cell and visibly slide right). The padding
mirrors #content's `2rem var(--page-padding)` so the
header aligns with the body below. */
.essay-frontmatter { .essay-frontmatter {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns:
clamp(170px, 17vw, 280px)
minmax(0, 1fr)
clamp(170px, 17vw, 280px);
column-gap: clamp(0.75rem, 2vw, 1.75rem); column-gap: clamp(0.75rem, 2vw, 1.75rem);
row-gap: 0.75rem; row-gap: 0.75rem;
align-items: center; align-items: center;
margin-bottom: 1rem; padding: 2rem var(--page-padding);
width: 100%;
} }
/* Reading variant (poetry / fiction) and blog variant share the /* Reading variant (poetry / fiction) and blog variant share the
same grid; declared explicitly so future tweaks can diverge. */ same grid; declared explicitly so future tweaks can diverge. */
.essay-frontmatter--reading, .essay-frontmatter--reading,
.essay-frontmatter--blog { .essay-frontmatter--blog {
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns:
clamp(170px, 17vw, 280px)
minmax(0, 1fr)
clamp(170px, 17vw, 280px);
} }
/* Title block stays in the centre; never shrinks below 0. */ /* Title block: centred in its grid column, capped to roughly
the body column's measure so prose lines stay readable on
ultrawide viewports. `justify-self: center` is the explicit
override of grid items' default `stretch`; without it auto
margins do not centre because the item is already filling
the cell at max-width. */
.frontmatter-title { .frontmatter-title {
min-width: 0; min-width: 0;
width: 100%;
max-width: var(--body-max-width);
justify-self: center;
text-align: center;
} }
/* Centre the title and metadata under the H1 matches the
visual rhythm of the reference mockup, where the byline,
abstract, and compact strip sit in a stacked column. */
.frontmatter-title > .page-title { .frontmatter-title > .page-title {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
/* Abstract paragraph reads better left-aligned even inside a
centred title block multi-line prose with ragged-right
on a centred axis becomes hard to track. */
.frontmatter-title .meta-description {
text-align: left;
}
/* Compact-strip chips sit comfortably wider than the abstract;
centre them so the row balances visually under the title. */
.frontmatter-title .meta-epistemic-strip {
justify-content: center;
}
/* Tailmatter: the body-level wrapper that hosts the metadata-tail
row (tags + keywords + affiliation + pagelinks). Constrained to
the body column's measure and centred so its contents read at the
same width they did before the layout split, while sitting
*above* #content so the TOC sidebar starts right under the
frontmatter divider rather than competing with it for the top
of the page. */
.essay-tailmatter {
width: min(var(--body-max-width), 100%);
margin: 0 auto;
padding: 0 var(--page-padding);
box-sizing: border-box;
}
/* The cursive-L frontmatter divider runs edge-to-edge of the
viewport the dashed lines on either side of the L use the
existing `flex: 1` rule on `.content-divider::before` and
`::after` to fill whatever container they sit inside, so as a
body-level child the divider naturally spans the full page
width. The page-padding keeps the dashes off the literal edge
while still reading as a full-width separator. */
.content-divider--frontmatter {
padding: 0 var(--page-padding);
box-sizing: border-box;
}
/* Monogram placeholder (rendered when no mark.svg exists for the
piece see Marks.monogramPlaceholder). Lower opacity so it reads
as a neutral frame, balancing the epistemic-figure column without
being mistaken for an authored glyph. */
.frontmatter-mark--placeholder svg {
opacity: 0.35;
}
/* Subtitle: a short secondary line, lighter than the H1, never /* Subtitle: a short secondary line, lighter than the H1, never
competing with it. Kept restrained so existing essays without competing with it. Kept restrained so existing essays without
a subtitle render unchanged. */ a subtitle render unchanged. */
@ -81,8 +147,6 @@
#markdownBody .frontmatter-mark { #markdownBody .frontmatter-mark {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 170px;
height: 170px;
max-width: none; max-width: none;
background: none; background: none;
border: none; border: none;
@ -91,6 +155,30 @@
color: var(--text); color: var(--text);
} }
/* Frontmatter header context: scale with viewport. 170 px floor on
narrow desktops, 280 px cap on ultrawide displays (matches the
monogram viewBox so the placeholder roundel reaches its native
edge). 17vw is the slope between the two at ~1000 px it equals
the floor, at ~1650 px it equals the cap. */
.essay-frontmatter .frontmatter-mark {
width: clamp(170px, 17vw, 280px);
height: clamp(170px, 17vw, 280px);
}
/* Item-card context: small inline glyph beside the kind badge.
Sized to read as a marker, not a competing figure. */
.item-card-monogram {
flex-shrink: 0;
line-height: 0;
color: var(--text-muted);
margin-top: 0.15em;
}
.item-card-monogram .frontmatter-mark {
width: 72px;
height: 72px;
}
/* SVG fills its parent figure exactly. !important defeats the /* SVG fills its parent figure exactly. !important defeats the
global `img, video, svg { max-width: 100%; height: auto }` in global `img, video, svg { max-width: 100%; height: auto }` in
base.css for our specific case (which would otherwise leave the base.css for our specific case (which would otherwise leave the

View File

@ -1,21 +1,19 @@
<header class="essay-frontmatter essay-frontmatter--blog">
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$if(date)$
<p class="post-date"><time class="date-hover" datetime="$date-iso$" data-date-start="$date-iso$">$date$</time></p>
$endif$
</div>
$if(epistemicSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--right">
<a href="#epistemic" aria-label="Jump to epistemic profile">$epistemicSvg$</a>
</div>
$endif$
</header>
<main id="markdownBody" data-pagefind-body> <main id="markdownBody" data-pagefind-body>
<header class="essay-frontmatter essay-frontmatter--blog">
$if(monogramSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
$endif$
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$if(date)$
<p class="post-date"><time class="date-hover" datetime="$date-iso$" data-date-start="$date-iso$">$date$</time></p>
$endif$
</div>
$if(epistemicSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--right">
<a href="#epistemic" aria-label="Jump to epistemic profile">$epistemicSvg$</a>
</div>
$endif$
</header>
$body$ $body$
$if(backlinks)$ $if(backlinks)$
<footer class="page-meta-footer"> <footer class="page-meta-footer">

View File

@ -1,3 +1,28 @@
<header class="essay-frontmatter">
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$partial("templates/partials/metadata-header.html")$
</div>
$if(epistemicSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--right">
<a href="#epistemic" aria-label="Jump to epistemic profile">$epistemicSvg$</a>
</div>
$endif$
</header>
<div class="essay-tailmatter">
$partial("templates/partials/metadata-tail.html")$
$if(summary)$
<div class="essay-summary" data-pagefind-ignore="all">
<div class="essay-summary-label">Summary</div>
$summary$
</div>
$endif$
</div>
<div class="content-divider content-divider--frontmatter" aria-hidden="true">
<a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
<div id="content"> <div id="content">
<aside id="toc" aria-label="Table of contents" data-pagefind-ignore="all"> <aside id="toc" aria-label="Table of contents" data-pagefind-ignore="all">
<div class="toc-header"> <div class="toc-header">
@ -9,31 +34,6 @@
</nav> </nav>
</aside> </aside>
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$> <main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$>
<header class="essay-frontmatter">
$if(monogramSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
$endif$
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$partial("templates/partials/metadata-header.html")$
</div>
$if(epistemicSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--right">
<a href="#epistemic" aria-label="Jump to epistemic profile">$epistemicSvg$</a>
</div>
$endif$
</header>
$partial("templates/partials/metadata-tail.html")$
$if(summary)$
<div class="essay-summary" data-pagefind-ignore="all">
<div class="essay-summary-label">Summary</div>
$summary$
</div>
$endif$
<div class="content-divider" aria-hidden="true">
<a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
$body$ $body$
</main> </main>
</div> </div>

View File

@ -1,5 +1,8 @@
<li class="item-card"> <li class="item-card$if(has-monogram)$ item-card--has-monogram$endif$">
<span class="item-card-kind">$item-kind$</span> <span class="item-card-kind">$item-kind$</span>
$if(has-monogram)$
<span class="item-card-monogram" aria-hidden="true">$monogramSvg$</span>
$endif$
<div class="item-card-main"> <div class="item-card-main">
<div class="item-card-header"> <div class="item-card-header">
<a class="item-card-title" href="$url$">$title$</a> <a class="item-card-title" href="$url$">$title$</a>

View File

@ -1,15 +1,13 @@
<div id="reading-progress" aria-hidden="true"></div> <div id="reading-progress" aria-hidden="true"></div>
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$> <header class="essay-frontmatter essay-frontmatter--reading">
<header class="essay-frontmatter essay-frontmatter--reading"> <div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
$if(monogramSvg)$ <div class="frontmatter-title">
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div> <h1 class="page-title">$title$</h1>
$endif$ $if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
<div class="frontmatter-title"> $partial("templates/partials/metadata-header.html")$
<h1 class="page-title">$title$</h1> </div>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$ </header>
$partial("templates/partials/metadata-header.html")$ <div class="essay-tailmatter">
</div>
</header>
$partial("templates/partials/metadata-tail.html")$ $partial("templates/partials/metadata-tail.html")$
$if(summary)$ $if(summary)$
<div class="essay-summary" data-pagefind-ignore="all"> <div class="essay-summary" data-pagefind-ignore="all">
@ -17,9 +15,11 @@
$summary$ $summary$
</div> </div>
$endif$ $endif$
<div class="content-divider" aria-hidden="true"> </div>
<a href="/new.html" class="content-divider-logo" aria-label="New"></a> <div class="content-divider content-divider--frontmatter" aria-hidden="true">
</div> <a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$>
$body$ $body$
</main> </main>
$partial("templates/partials/page-footer.html")$ $partial("templates/partials/page-footer.html")$

225
tools/audit-marks.py Executable file
View File

@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Audit frontmatter marks (monograms + epistemic figures).
Walks ``content/**/*.md``, resolves each piece's monogram candidate
path, checks whether ``mark.svg`` exists and whether ``status:`` is
set, and emits a table plus corpus-wide coverage percentages. Output
is pure ASCII so it pipes / scrolls cleanly.
Run as::
make audit-marks
or directly via::
uv run python tools/audit-marks.py
Exit code is always 0; this is a report tool, not a gate.
The dual-form path resolver matches ``build/Marks.hs``:
* ``content/essays/foo.md`` -> ``content/essays/foo.mark.svg``
* ``content/essays/foo/index.md`` -> ``content/essays/foo/mark.svg``
Photography is excluded: visual content doesn't carry monograms or
epistemic figures by design (see PHOTOGRAPHY.md).
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
import yaml
CONTENT_ROOT = Path("content")
# Sections that ship marks by design — these get a coverage line in
# the summary even when empty (so a regression is visible). Other
# sections appear in the summary only when they contain pieces.
PRIMARY_SECTIONS = ("essays", "blog", "poetry", "fiction", "music")
# Excluded entirely: visual content (PHOTOGRAPHY.md), in-progress
# drafts, and the per-portal tag-meta sidecar tree (which is metadata
# infrastructure, not authored pieces).
SKIPPED_DIRS = ("photography", "drafts", "tag-meta")
@dataclass
class AuditRow:
"""One row of audit output for a single source file."""
path: Path
section: str
has_monogram: bool
has_status: bool
@property
def suggestion(self) -> str:
actions = []
if not self.has_monogram:
actions.append("add mark.svg")
if not self.has_status:
actions.append("set status:")
return ", ".join(actions)
def parse_frontmatter(md_path: Path) -> dict:
"""Extract the YAML frontmatter block from a Markdown file.
Returns an empty dict on parse failure or when no frontmatter is
present. Errors are non-fatal the audit reports what it can."""
try:
text = md_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {}
if not text.startswith("---"):
return {}
end = text.find("\n---", 3)
if end == -1:
return {}
fm_block = text[3:end]
try:
data = yaml.safe_load(fm_block)
except yaml.YAMLError:
return {}
return data if isinstance(data, dict) else {}
def monogram_path(md_path: Path) -> Path:
"""Resolve the candidate ``mark.svg`` path for a Markdown source.
Mirrors ``Marks.monogramCandidates`` in build/Marks.hs."""
if md_path.name == "index.md":
return md_path.parent / "mark.svg"
return md_path.with_suffix(".mark.svg")
def section_of(path: Path) -> str:
"""Bucket a content path under its top-level section name.
Returns ``"standalone"`` for files directly under ``content/``."""
rel = path.relative_to(CONTENT_ROOT)
if len(rel.parts) == 1:
return "standalone"
return rel.parts[0]
def collect() -> list[AuditRow]:
"""Walk content/ and return one AuditRow per published source file."""
rows: list[AuditRow] = []
for md_path in CONTENT_ROOT.rglob("*.md"):
rel = md_path.relative_to(CONTENT_ROOT)
if rel.parts and rel.parts[0] in SKIPPED_DIRS:
continue
# Skip tag-meta sidecars (they're not authored pages).
if md_path.name == "_tag-meta.md":
continue
fm = parse_frontmatter(md_path)
rows.append(
AuditRow(
path=md_path,
section=section_of(md_path),
has_monogram=monogram_path(md_path).is_file(),
has_status="status" in fm and bool(str(fm["status"]).strip()),
)
)
rows.sort(
key=lambda r: (
r.section != "standalone", # standalone last
r.section,
not r.has_status,
not r.has_monogram,
str(r.path),
)
)
return rows
def fmt_check(present: bool) -> str:
return "OK" if present else "--"
def render_table(rows: list[AuditRow]) -> None:
if not rows:
print("No content files found under content/.")
return
path_w = max(len(str(r.path)) for r in rows)
path_w = min(path_w, 60) # cap so suggestions stay on the same line
header = f"{'PATH':<{path_w}} {'MONO':<5} {'EPIS':<5} SUGGESTION"
print(header)
print("-" * len(header))
current_section = None
for r in rows:
if r.section != current_section:
current_section = r.section
print(f"\n# {current_section}")
path_str = str(r.path)
if len(path_str) > path_w:
path_str = path_str[: path_w - 1] + "..."
print(
f"{path_str:<{path_w}} "
f"{fmt_check(r.has_monogram):<5} "
f"{fmt_check(r.has_status):<5} "
f"{r.suggestion}"
)
def render_summary(rows: list[AuditRow]) -> None:
print()
print("# Coverage")
print("-" * 60)
by_section: dict[str, list[AuditRow]] = {}
for r in rows:
by_section.setdefault(r.section, []).append(r)
def line(label: str, group: list[AuditRow]) -> None:
n = len(group)
if n == 0:
return
m = sum(1 for r in group if r.has_monogram)
e = sum(1 for r in group if r.has_status)
print(
f"{label:<14} {n:>3} pieces "
f"monogram {m:>3}/{n:<3} ({m * 100 // n:>3}%) "
f"epistemic {e:>3}/{n:<3} ({e * 100 // n:>3}%)"
)
rendered: set[str] = set()
for section in PRIMARY_SECTIONS:
if section in by_section:
line(section, by_section[section])
rendered.add(section)
other_sections = sorted(s for s in by_section if s not in rendered)
for section in other_sections:
line(section, by_section[section])
print("-" * 60)
line("total", rows)
def main() -> int:
if not CONTENT_ROOT.is_dir():
print(f"error: {CONTENT_ROOT}/ not found (run from repo root)",
file=sys.stderr)
return 1
rows = collect()
render_table(rows)
render_summary(rows)
return 0
if __name__ == "__main__":
raise SystemExit(main())

72
tools/hooks/pre-commit-marks.sh Executable file
View File

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Pre-commit advisory: warn when newly-added essay files are missing a
# monogram (mark.svg) or the epistemic status field. Warning only —
# this hook never blocks a commit. The author is the one staging, and
# the audit table at `make audit-marks` is the canonical view; this
# hook just nudges at the moment of commit.
#
# Install (one-time):
#
# ln -s ../../tools/hooks/pre-commit-marks.sh .git/hooks/pre-commit
#
# Or chain into an existing pre-commit:
#
# bash tools/hooks/pre-commit-marks.sh
#
# Scope: newly-added (status `A`) .md files under content/essays/.
# Modified files are not warned about — the author has presumably made
# a deliberate choice about marks by then.
set -u
# Newly-added .md files under content/essays/ in this commit.
mapfile -t added < <(
git diff --cached --name-status --diff-filter=A -- 'content/essays/*.md' \
| awk '{ print $2 }'
)
if [[ ${#added[@]} -eq 0 ]]; then
exit 0
fi
warnings=0
for path in "${added[@]}"; do
# Resolve the dual-form mark.svg candidate path. Mirrors
# build/Marks.hs and tools/audit-marks.py.
if [[ "$(basename -- "$path")" == "index.md" ]]; then
mark="$(dirname -- "$path")/mark.svg"
else
mark="${path%.md}.mark.svg"
fi
has_mark=0
has_status=0
[[ -f "$mark" ]] && has_mark=1
# Best-effort frontmatter probe: does any line in the YAML head
# block start with `status:`? Avoids a YAML dependency in the
# hook, which has to run before the build environment is sourced.
if awk '/^---$/{f++; next} f==1 && /^status:[[:space:]]*[^[:space:]]/{print; exit}' \
-- "$path" \
| grep -q .; then
has_status=1
fi
if [[ $has_mark -eq 0 || $has_status -eq 0 ]]; then
if [[ $warnings -eq 0 ]]; then
echo "[marks] advisory: newly-added essays missing marks:" >&2
fi
msgs=()
[[ $has_mark -eq 0 ]] && msgs+=("no mark.svg at $mark")
[[ $has_status -eq 0 ]] && msgs+=("no status: in frontmatter")
printf ' %s — %s\n' "$path" "$(IFS=, ; echo "${msgs[*]}")" >&2
warnings=$((warnings + 1))
fi
done
if [[ $warnings -gt 0 ]]; then
echo "[marks] (advisory only — commit not blocked. \`make audit-marks\` for the full report.)" >&2
fi
exit 0