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.
# .env format: KEY=value (one per line, no `export` prefix, no quotes needed).
@ -163,6 +163,18 @@ watch:
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
# recorded in archive/removed.yaml. Opt-in — NEVER run by `make build`.
# 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 Backlinks (backlinksField)
import Dingbat (dingbatField)
import Marks (monogramSvgField, epistemicSvgField)
import Marks (monogramSvgField, hasMonogramField, epistemicSvgField)
import SimilarLinks (similarLinksField)
import Stability (stabilityField, lastReviewedField, lastReviewedIsoField,
versionHistoryField,
@ -437,6 +437,7 @@ siteCtx =
<> summaryField
<> dingbatField
<> monogramSvgField
<> hasMonogramField
<> defaultContext
-- ---------------------------------------------------------------------------

View File

@ -16,7 +16,11 @@
-- byte-identical SVGs, so the GPG signing pipeline is undisturbed.
module Marks
( monogramSvgField
, hasMonogramField
, monogramSvgFieldFor
, hasMonogramFieldFor
, epistemicSvgField
, hasMonogram
) where
import Control.Exception (IOException, try)
@ -52,6 +56,24 @@ monogramCandidates fp =
then [dir </> "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'.
resolveMonogramPath :: Item a -> Compiler (Maybe FilePath)
resolveMonogramPath item =
@ -72,13 +94,17 @@ resolveMonogramPath item =
-- tools may produce hardcoded blacks; the contract still holds), strips
-- the @width@/@height@ presentation attributes from the root @<svg>@,
-- and wraps the result in @<figure class="frontmatter-mark
-- frontmatter-mark--monogram">@. Returns 'noResult' when no candidate
-- exists; warns and returns 'noResult' on read failure.
-- frontmatter-mark--monogram">@.
--
-- 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 = field "monogramSvg" $ \item -> do
mPath <- resolveMonogramPath item
case mPath of
Nothing -> noResult "no mark.svg"
Nothing -> return $ T.unpack monogramPlaceholder
Just path -> do
result <- unsafeCompiler $ try (TIO.readFile path)
:: Compiler (Either IOException T.Text)
@ -87,9 +113,24 @@ monogramSvgField = field "monogramSvg" $ \item -> do
unsafeCompiler $ hPutStrLn stderr $
"[Marks] " ++ toFilePath (itemIdentifier item) ++
": failed to read " ++ path ++ ": " ++ show e
noResult "monogram read failed"
return $ T.unpack monogramPlaceholder
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.
wrapMonogram :: T.Text -> T.Text
wrapMonogram svg = T.concat
@ -98,6 +139,33 @@ wrapMonogram svg = T.concat
, "</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
-- the root @<svg>@'s @width@/@height@ attributes (presentation lives
-- 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 Archive (archiveBuildStats)
import Contexts (siteCtx, authorLinksField)
import Marks (hasMonogram, monogramSvgFieldFor,
hasMonogramFieldFor)
import qualified Patterns as P
import Utils (readingTime)
@ -676,6 +678,39 @@ renderEpistemic total ws wc wi we =
, 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 grouped totalFiles totalSize =
section "output" "Output" $
@ -734,6 +769,7 @@ pageTOC = H.ol $ mapM_ item sections
, ("tags", "Tags")
, ("links", "Links")
, ("epistemic", "Epistemic coverage")
, ("marks", "Marks coverage")
, ("output", "Output")
, ("archive", "Link archive")
, ("repository", "Repository")
@ -844,8 +880,11 @@ statsRules tags = do
-- ----------------------------------------------------------------
-- Epistemic coverage (essays + posts)
-- ----------------------------------------------------------------
essayMetas <- mapM (getMetadata . itemIdentifier) essays
postMetas <- mapM (getMetadata . itemIdentifier) posts
essayMetas <- mapM (getMetadata . itemIdentifier) essays
postMetas <- mapM (getMetadata . itemIdentifier) posts
poemMetas <- mapM (getMetadata . itemIdentifier) poems
fictionMetas <- mapM (getMetadata . itemIdentifier) fiction
compMetas <- mapM (getMetadata . itemIdentifier) comps
let epMetas = essayMetas ++ postMetas
epTotal = length epMetas
ep f = length (filter (isJust . f) epMetas)
@ -854,6 +893,38 @@ statsRules tags = do
withImp = ep (lookupString "importance")
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
-- ----------------------------------------------------------------
@ -893,6 +964,7 @@ statsRules tags = do
renderTagsSection topTags uniqueTags
renderLinks mostLinkedInfo orphanCount (length allPIs)
renderEpistemic epTotal withStatus withConf withImp withEv
renderMarks markRows
renderOutput outputGrouped totalFiles totalSize
renderArchive archiveMetrics
renderRepository hf hl cf cl jf jl commits firstDate
@ -909,6 +981,8 @@ statsRules tags = do
\link analysis, epistemic coverage, output metrics, \
\repository overview, and build timing."
<> constField "build" "true"
<> monogramSvgFieldFor "content/build.mark.svg"
<> hasMonogramFieldFor "content/build.mark.svg"
<> authorLinksField
<> siteCtx
@ -985,6 +1059,8 @@ statsRules tags = do
<> constField "abstract" "Writing activity, corpus breakdown, \
\and tag distribution computed at build time."
<> constField "build" "true"
<> monogramSvgFieldFor "content/stats.mark.svg"
<> hasMonogramFieldFor "content/stats.mark.svg"
<> authorLinksField
<> siteCtx

View File

@ -9,40 +9,106 @@
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 ]
The 1fr column absorbs all extra width. Mark slots are
sized to their content via grid auto-placement. When the
template guard suppresses one or both slot divs, the column
simply does not exist for layout purposes. */
The header lives outside #content so the grid stretches
edge-to-edge; the side columns are pinned to the same
`clamp()` width as the marks themselves so the middle
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 {
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);
row-gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
padding: 2rem var(--page-padding);
width: 100%;
}
/* Reading variant (poetry / fiction) and blog variant share the
same grid; declared explicitly so future tweaks can diverge. */
.essay-frontmatter--reading,
.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 {
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 {
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
competing with it. Kept restrained so existing essays without
a subtitle render unchanged. */
@ -81,8 +147,6 @@
#markdownBody .frontmatter-mark {
margin: 0;
padding: 0;
width: 170px;
height: 170px;
max-width: none;
background: none;
border: none;
@ -91,6 +155,30 @@
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
global `img, video, svg { max-width: 100%; height: auto }` in
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>
<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$
$if(backlinks)$
<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">
<aside id="toc" aria-label="Table of contents" data-pagefind-ignore="all">
<div class="toc-header">
@ -9,31 +34,6 @@
</nav>
</aside>
<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$
</main>
</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>
$if(has-monogram)$
<span class="item-card-monogram" aria-hidden="true">$monogramSvg$</span>
$endif$
<div class="item-card-main">
<div class="item-card-header">
<a class="item-card-title" href="$url$">$title$</a>

View File

@ -1,15 +1,13 @@
<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">
$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>
</header>
<header class="essay-frontmatter essay-frontmatter--reading">
<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>
</header>
<div class="essay-tailmatter">
$partial("templates/partials/metadata-tail.html")$
$if(summary)$
<div class="essay-summary" data-pagefind-ignore="all">
@ -17,9 +15,11 @@
$summary$
</div>
$endif$
<div class="content-divider" aria-hidden="true">
<a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
</div>
<div class="content-divider content-divider--frontmatter" aria-hidden="true">
<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$
</main>
$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