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:
parent
77e31efdae
commit
154b47a4cb
14
Makefile
14
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -846,6 +882,9 @@ statsRules tags = do
|
|||
-- ----------------------------------------------------------------
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
<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$
|
||||
|
|
@ -16,6 +13,7 @@
|
|||
</div>
|
||||
$endif$
|
||||
</header>
|
||||
<main id="markdownBody" data-pagefind-body>
|
||||
$body$
|
||||
$if(backlinks)$
|
||||
<footer class="page-meta-footer">
|
||||
|
|
|
|||
|
|
@ -1,18 +1,5 @@
|
|||
<div id="content">
|
||||
<aside id="toc" aria-label="Table of contents" data-pagefind-ignore="all">
|
||||
<div class="toc-header">
|
||||
<span class="toc-active-label">Contents</span>
|
||||
<button class="toc-toggle" aria-label="Toggle table of contents" aria-expanded="true">▾</button>
|
||||
</div>
|
||||
<nav class="toc-nav">
|
||||
$toc$
|
||||
</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$
|
||||
|
|
@ -24,6 +11,7 @@
|
|||
</div>
|
||||
$endif$
|
||||
</header>
|
||||
<div class="essay-tailmatter">
|
||||
$partial("templates/partials/metadata-tail.html")$
|
||||
$if(summary)$
|
||||
<div class="essay-summary" data-pagefind-ignore="all">
|
||||
|
|
@ -31,9 +19,21 @@
|
|||
$summary$
|
||||
</div>
|
||||
$endif$
|
||||
<div class="content-divider" aria-hidden="true">
|
||||
</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">
|
||||
<span class="toc-active-label">Contents</span>
|
||||
<button class="toc-toggle" aria-label="Toggle table of contents" aria-expanded="true">▾</button>
|
||||
</div>
|
||||
<nav class="toc-nav">
|
||||
$toc$
|
||||
</nav>
|
||||
</aside>
|
||||
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$>
|
||||
$body$
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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">
|
||||
</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")$
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue