ghost integrated

This commit is contained in:
Mayur Shinde
2026-07-17 18:15:48 +05:30
parent 59e05a858f
commit 09119ae6dc
16 changed files with 1437 additions and 61 deletions
+100
View File
@@ -0,0 +1,100 @@
/**
* Build-time generation of the Ghost-backed blog.
*
* Runs under Node from vite.config.ts. For each post tagged `lynkeduppro-new`
* it writes blog/<slug>/index.html from the shared shell template, so every post
* ships as real static HTML with its SEO tags present in the source.
*/
import fs from "node:fs";
import path from "node:path";
import { fetchSitePosts, readGhostConfig } from "./client";
import { normalizePosts } from "./normalize";
import { renderPostCard, renderPostPage } from "./render";
import type { BlogPost } from "./types";
const BLOG_DIR = path.resolve("blog");
const TEMPLATE_PATH = path.join(BLOG_DIR, "_post.template.html");
const MANIFEST_PATH = path.join(BLOG_DIR, ".ghost-generated.json");
/**
* Hand-authored posts that predate the Ghost integration. They own their routes:
* their directories are never generated or removed, and a Ghost post reusing one
* of these slugs is skipped rather than overwriting the static page.
*/
export const STATIC_POST_SLUGS = [
"cost-of-paperwork",
"48-hour-storm-checklist",
"why-claims-get-denied",
];
export interface GhostBuildResult {
posts: BlogPost[];
/** Rendered cards for injection into blog/index.html. */
cardsHtml: string;
/** Absolute paths of generated pages, for Vite's rollup inputs. */
generatedPages: string[];
}
/** Removes pages generated by a previous run so deleted/untagged posts disappear. */
function cleanPreviousRun(): void {
let previous: string[] = [];
try {
previous = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")) as string[];
} catch {
return;
}
for (const slug of previous) {
if (STATIC_POST_SLUGS.includes(slug)) continue;
fs.rmSync(path.join(BLOG_DIR, slug), { recursive: true, force: true });
}
}
/**
* Fetches, renders and writes every Ghost post for this site.
*
* @param allowStaleFallback Use the last cached response if Ghost is unreachable
* (dev only).
*/
export async function buildGhostBlog(
env: Record<string, string | undefined>,
{ allowStaleFallback = false }: { allowStaleFallback?: boolean } = {}
): Promise<GhostBuildResult> {
const config = readGhostConfig(env);
const raw = await fetchSitePosts(config, { allowStaleFallback });
const posts = normalizePosts(raw, config, STATIC_POST_SLUGS);
const skipped = raw.length - posts.length;
if (skipped > 0) {
console.warn(
`[ghost] skipped ${skipped} post(s): slug missing, or colliding with a static post ` +
`(${STATIC_POST_SLUGS.join(", ")}).`
);
}
cleanPreviousRun();
const template = fs.readFileSync(TEMPLATE_PATH, "utf8");
const generatedPages: string[] = [];
posts.forEach((post, i) => {
const dir = path.join(BLOG_DIR, post.slug);
const file = path.join(dir, "index.html");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(file, renderPostPage(template, post, posts[i + 1] ?? null), "utf8");
generatedPages.push(file);
});
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(posts.map((p) => p.slug), null, 2), "utf8");
console.log(
`[ghost] generated ${posts.length} post page(s) from tag "lynkeduppro-new"` +
(posts.length ? `: ${posts.map((p) => p.slug).join(", ")}` : "")
);
return {
posts,
cardsHtml: posts.map(renderPostCard).join("\n\n"),
generatedPages,
};
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Ghost Content API client. Runs at build time under Node only — the API key is
* read from the environment here and never reaches the browser bundle.
*
* Direct fetch is used rather than @tryghost/content-api: this site needs one
* read-only endpoint, and the SDK would add a dependency for a single GET.
*/
import fs from "node:fs";
import path from "node:path";
import type { GhostConfig, GhostPost, GhostPostsResponse } from "./types";
/** Only posts carrying this tag are published to this site. */
export const SITE_TAG = "lynkeduppro-new";
const CACHE_DIR = path.resolve("node_modules/.cache/ghost");
const CACHE_FILE = path.join(CACHE_DIR, `posts-${SITE_TAG}.json`);
/**
* Reads Ghost credentials from the environment.
* @throws if either required variable is missing.
*/
export function readGhostConfig(env: Record<string, string | undefined>): GhostConfig {
const apiUrl = env.GHOST_API_URL?.trim();
const contentApiKey = env.GHOST_CONTENT_API_KEY?.trim();
const missing = [
!apiUrl && "GHOST_API_URL",
!contentApiKey && "GHOST_CONTENT_API_KEY",
].filter(Boolean);
if (missing.length) {
throw new Error(
`Missing Ghost environment variable(s): ${missing.join(", ")}. ` +
`Copy .env.example to .env and fill them in (see README of .env.example).`
);
}
return {
apiUrl: apiUrl!.replace(/\/+$/, ""),
contentApiKey: contentApiKey!,
siteUrl: (env.SITE_URL?.trim() || "https://lynkeduppro.com").replace(/\/+$/, ""),
};
}
function readCache(): GhostPost[] | null {
try {
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")) as GhostPost[];
} catch {
return null;
}
}
function writeCache(posts: GhostPost[]): void {
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
fs.writeFileSync(CACHE_FILE, JSON.stringify(posts), "utf8");
} catch {
/* Cache is an optimisation; a failure to persist must not fail the build. */
}
}
/**
* Fetches every published post tagged {@link SITE_TAG}, newest first.
*
* Always hits Ghost — one request per dev-server start or build is necessary, not
* wasteful, and serving a TTL-cached copy here would silently hide freshly
* published posts. The on-disk copy is only a fallback for when Ghost is
* unreachable, so a dropped connection doesn't stop local work.
*
* @param allowStaleFallback Fall back to the last good response if the fetch
* fails. Dev only: a production build must fail loudly rather than publish
* stale content.
*/
export async function fetchSitePosts(
config: GhostConfig,
{ allowStaleFallback = false }: { allowStaleFallback?: boolean } = {}
): Promise<GhostPost[]> {
const url = new URL(`${config.apiUrl}/ghost/api/content/posts/`);
url.searchParams.set("key", config.contentApiKey);
url.searchParams.set("filter", `tag:${SITE_TAG}`);
url.searchParams.set("include", "tags,authors");
url.searchParams.set("limit", "all");
url.searchParams.set("order", "published_at DESC");
try {
const res = await fetch(url, { headers: { "Accept-Version": "v5.0" } });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`Ghost API responded ${res.status} ${res.statusText} for ${config.apiUrl}. ${body.slice(0, 300)}`
);
}
const data = (await res.json()) as GhostPostsResponse;
const posts = data.posts ?? [];
writeCache(posts);
return posts;
} catch (error) {
if (!allowStaleFallback) throw error;
const cached = readCache();
if (!cached) throw error;
console.warn(
`[ghost] fetch failed (${error instanceof Error ? error.message : String(error)}).\n` +
`[ghost] Falling back to the last cached response (${cached.length} post(s)) — may be out of date.`
);
return cached;
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Turns raw Ghost payloads into {@link BlogPost} values that are always safe to
* render: missing authors, images, excerpts and SEO fields are resolved to
* sensible fallbacks here so the templates stay branch-free.
*/
import type { BlogPost, GhostConfig, GhostPost, GhostTag, PostSeo } from "./types";
import { SITE_TAG } from "./client";
const FALLBACK_AUTHOR = "LynkedUp Pro Team";
const FALLBACK_EXCERPT =
"Field notes for contractors from the LynkedUp Pro team.";
/** Strips HTML tags and collapses whitespace — used to derive text from Ghost HTML. */
function toPlainText(html: string): string {
return html
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
const cut = text.slice(0, max);
const lastSpace = cut.lastIndexOf(" ");
return `${cut.slice(0, lastSpace > 0 ? lastSpace : max).trimEnd()}`;
}
/**
* Rewrites a Ghost image URL to a resized variant. Ghost serves derivatives from
* `/content/images/size/w<width>/...`; any non-Ghost or already-sized URL is
* returned untouched.
*/
export function ghostImage(url: string | null, width: number): string | null {
if (!url) return null;
if (!url.includes("/content/images/")) return url;
if (/\/content\/images\/size\/w\d+\//.test(url)) return url;
return url.replace("/content/images/", `/content/images/size/w${width}/`);
}
/** Builds a `srcset` across Ghost's derivative widths for responsive loading. */
export function ghostSrcset(url: string | null, widths: number[]): string | null {
if (!url || !url.includes("/content/images/")) return null;
return widths
.map((w) => `${ghostImage(url, w)} ${w}w`)
.join(", ");
}
/** Ghost's estimate, or a ~200wpm fallback derived from the body text. */
function resolveReadingTime(post: GhostPost, plain: string): number {
if (post.reading_time && post.reading_time > 0) return post.reading_time;
const words = plain ? plain.split(" ").length : 0;
return Math.max(1, Math.round(words / 200));
}
function resolveAuthor(post: GhostPost): string {
const name = post.primary_author?.name?.trim() || post.authors?.[0]?.name?.trim();
return name || FALLBACK_AUTHOR;
}
/** Public tags, with the routing tag and Ghost's internal `#` tags removed. */
function resolveTags(post: GhostPost): GhostTag[] {
return (post.tags ?? []).filter(
(t) => t.slug !== SITE_TAG && t.visibility === "public" && !t.name.startsWith("#")
);
}
function resolveExcerpt(post: GhostPost, plain: string): string {
const raw = post.custom_excerpt?.trim() || post.excerpt?.trim();
if (raw) return truncate(raw.replace(/\s+/g, " "), 200);
if (plain) return truncate(plain, 200);
return FALLBACK_EXCERPT;
}
/** Prefers Ghost's SEO fields, falling back to post content. */
function resolveSeo(
post: GhostPost,
config: GhostConfig,
title: string,
excerpt: string
): PostSeo {
const metaTitle = post.meta_title?.trim() || `${title} - LynkedUp Pro Blog`;
const metaDescription = post.meta_description?.trim() || excerpt;
const ogImage = ghostImage(post.og_image || post.feature_image, 1200);
return {
metaTitle,
metaDescription,
ogTitle: post.og_title?.trim() || metaTitle,
ogDescription: post.og_description?.trim() || metaDescription,
ogImage,
twitterTitle: post.twitter_title?.trim() || metaTitle,
twitterDescription: post.twitter_description?.trim() || metaDescription,
twitterImage: ghostImage(post.twitter_image, 1200) || ogImage,
// Ghost's canonical_url wins when an editor has set one (e.g. syndicated
// posts); otherwise the canonical is this site's own URL for the slug.
canonicalUrl: post.canonical_url?.trim() || `${config.siteUrl}/blog/${post.slug}/`,
};
}
const DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
export function normalizePost(post: GhostPost, config: GhostConfig): BlogPost {
const html = post.html ?? "";
const plain = toPlainText(html);
const title = post.title?.trim() || "Untitled";
const excerpt = resolveExcerpt(post, plain);
const tags = resolveTags(post);
const publishedAt = post.published_at ? new Date(post.published_at) : null;
const validDate = publishedAt && !Number.isNaN(publishedAt.getTime()) ? publishedAt : null;
return {
slug: post.slug,
title,
html,
excerpt,
featureImage: post.feature_image,
featureImageAlt: post.feature_image_alt?.trim() || title,
author: resolveAuthor(post),
publishedAt: validDate,
publishedLabel: validDate ? DATE_FORMAT.format(validDate) : "",
publishedIso: validDate ? validDate.toISOString() : "",
readingTime: resolveReadingTime(post, plain),
tags,
primaryTagLabel: (tags[0]?.name || "Field notes").toUpperCase(),
seo: resolveSeo(post, config, title, excerpt),
};
}
/**
* Normalises a batch, dropping posts Ghost returned without a slug (they have no
* addressable URL) and de-duplicating against the hand-authored static posts,
* which own their routes.
*/
export function normalizePosts(
posts: GhostPost[],
config: GhostConfig,
reservedSlugs: string[] = []
): BlogPost[] {
const reserved = new Set(reservedSlugs);
return posts
.filter((p) => Boolean(p.slug) && !reserved.has(p.slug))
.map((p) => normalizePost(p, config));
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Renders Ghost posts into the site's existing markup. Every class name used
* here already exists in src/styles.css — this module reproduces the structure
* of the hand-authored posts rather than introducing any new design.
*/
import type { BlogPost } from "./types";
import { ghostImage, ghostSrcset } from "./normalize";
/** Escapes text destined for an HTML text node or a double-quoted attribute. */
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* Defence-in-depth cleanup of Ghost's HTML. Ghost content is first-party
* (authored by the team in our own CMS), so this strips the script vectors a
* compromised or careless editor could introduce rather than attempting to be a
* full sanitiser for untrusted input.
*/
export function sanitizeGhostHtml(html: string): string {
return html
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, "")
.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, "")
.replace(/\son[a-z]+\s*=\s*[^\s>]+/gi, "")
.replace(/(<a\b[^>]*\bhref\s*=\s*")\s*javascript:[^"]*(")/gi, "$1#$2");
}
/**
* Tags each top-level `<h2>` with `data-rail` so the existing section-rail
* script (src/blog.js) builds its index from Ghost content the same way it does
* for the hand-authored posts.
*/
function addRailAnchors(html: string): string {
let index = 1;
return html.replace(/<h2\b([^>]*)>([\s\S]*?)<\/h2>/gi, (match, attrs: string, inner: string) => {
if (/\bdata-rail=/i.test(attrs)) return match;
index += 1;
const label = inner.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
const short = label.length > 18 ? `${label.slice(0, 18).trimEnd()}` : label;
const rail = `${String(index).padStart(2, "0")} &middot; ${escapeHtml(short.toUpperCase())}`;
return `<h2${attrs} data-rail="${rail}">${inner}</h2>`;
});
}
/** Adds native lazy-loading and async decoding to images inside Ghost content. */
function optimizeContentImages(html: string): string {
return html.replace(/<img\b([^>]*)>/gi, (match, attrs: string) => {
let next = attrs;
if (!/\bloading=/i.test(next)) next += ' loading="lazy"';
if (!/\bdecoding=/i.test(next)) next += ' decoding="async"';
return `<img${next}>`;
});
}
export function renderPostContent(post: BlogPost): string {
return optimizeContentImages(addRailAnchors(sanitizeGhostHtml(post.html)));
}
/** The card markup for the listing grid — mirrors blog/index.html exactly. */
export function renderPostCard(post: BlogPost): string {
const caption = [post.primaryTagLabel, post.publishedLabel.toUpperCase(), `${post.readingTime} MIN READ`]
.filter(Boolean)
.join(" &middot; ");
const src = ghostImage(post.featureImage, 900);
const srcset = ghostSrcset(post.featureImage, [600, 900, 1200]);
// Posts without a feature image keep the card's media block (and so its
// aspect ratio and grid rhythm) by falling back to the site OG image.
const media = src
? `<img class="feat-thumb" src="${escapeHtml(src)}"${
srcset ? ` srcset="${escapeHtml(srcset)}" sizes="(max-width: 900px) 100vw, 900px"` : ""
} alt="${escapeHtml(post.featureImageAlt)}" loading="lazy" decoding="async" width="900" height="510">`
: `<img class="feat-thumb" src="../og.jpg" alt="${escapeHtml(post.title)}" loading="lazy" decoding="async" width="900" height="510">`;
return ` <a class="feat blog-card reveal" href="./${escapeHtml(post.slug)}/" aria-label="Read: ${escapeHtml(post.title)}">
<div class="feat-media">
${media}
<span class="cap">${caption}</span>
</div>
<div class="feat-body">
<h3>${escapeHtml(post.title)}</h3>
<p>${escapeHtml(post.excerpt)}</p>
<span class="feat-link">Read the field note <span aria-hidden="true">&#9656;</span></span>
</div>
</a>`;
}
/**
* Shown in place of the card grid when Ghost returns no posts. The listing no
* longer has hand-authored cards to fall back on, so without this the grid would
* render as a blank gap.
*/
export function renderEmptyState(): string {
return ` <div class="blog-empty reveal">
<span class="tag">No field notes published yet</span>
</div>`;
}
function renderMetaTag(attr: "name" | "property", key: string, value: string | null): string {
if (!value) return "";
return `<meta ${attr}="${key}" content="${escapeHtml(value)}">`;
}
/** Article structured data (requirement 6). */
function renderArticleSchema(post: BlogPost): string {
const schema: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.seo.metaDescription,
author: { "@type": "Person", name: post.author },
publisher: { "@type": "Organization", name: "LynkedUp Pro" },
mainEntityOfPage: { "@type": "WebPage", "@id": post.seo.canonicalUrl },
url: post.seo.canonicalUrl,
};
if (post.seo.ogImage) schema.image = post.seo.ogImage;
if (post.publishedIso) schema.datePublished = post.publishedIso;
if (post.tags.length) schema.keywords = post.tags.map((t) => t.name).join(", ");
// </script> inside JSON would close the tag early.
return JSON.stringify(schema, null, 2).replace(/</g, "\\u003c");
}
function renderHead(post: BlogPost): string {
const { seo } = post;
return [
`<title>${escapeHtml(seo.metaTitle)}</title>`,
renderMetaTag("name", "description", seo.metaDescription),
`<link rel="canonical" href="${escapeHtml(seo.canonicalUrl)}">`,
renderMetaTag("property", "og:title", seo.ogTitle),
renderMetaTag("property", "og:description", seo.ogDescription),
renderMetaTag("property", "og:type", "article"),
renderMetaTag("property", "og:url", seo.canonicalUrl),
renderMetaTag("property", "og:image", seo.ogImage ?? "../../og.jpg"),
post.publishedIso
? renderMetaTag("property", "article:published_time", post.publishedIso)
: "",
renderMetaTag("name", "twitter:card", "summary_large_image"),
renderMetaTag("name", "twitter:title", seo.twitterTitle),
renderMetaTag("name", "twitter:description", seo.twitterDescription),
renderMetaTag("name", "twitter:image", seo.twitterImage ?? "../../og.jpg"),
renderMetaTag("name", "theme-color", "#0B0D10"),
renderMetaTag("name", "author", post.author),
`<script type="application/ld+json">\n${renderArticleSchema(post)}\n</script>`,
]
.filter(Boolean)
.join("\n");
}
function renderCover(post: BlogPost): string {
if (!post.featureImage) return "";
const src = ghostImage(post.featureImage, 1600);
const srcset = ghostSrcset(post.featureImage, [800, 1200, 1600, 2000]);
return `<figure class="post-cover reveal">
<img src="${escapeHtml(src!)}"${
srcset ? ` srcset="${escapeHtml(srcset)}" sizes="(max-width: 1100px) 100vw, 1100px"` : ""
} alt="${escapeHtml(post.featureImageAlt)}" loading="eager" fetchpriority="high" width="1600" height="762">
</figure>`;
}
function renderPostMeta(post: BlogPost): string {
const parts = [`<span>${escapeHtml(post.author)}</span>`];
if (post.publishedLabel) {
parts.push(`<span><time datetime="${escapeHtml(post.publishedIso)}">${escapeHtml(post.publishedLabel)}</time></span>`);
}
parts.push(`<span>${post.readingTime} min read</span>`);
return parts.join("<i></i>");
}
/** Tag chips, reusing the existing `.tag` pill style. */
function renderTags(post: BlogPost): string {
if (!post.tags.length) return "";
return `<div class="post-tags">${post.tags
.map((t) => `<span class="tag">${escapeHtml(t.name)}</span>`)
.join("")}</div>`;
}
function renderFootNav(post: BlogPost, next: BlogPost | null): string {
const links = [`<a href="../">&larr; All field notes</a>`];
if (next) {
links.push(
`<a href="../${escapeHtml(next.slug)}/">Next: ${escapeHtml(next.title)} &rarr;</a>`
);
}
return links.join("\n ");
}
/**
* Fills the shared post shell (blog/_post.template.html) for one post.
* @param next The following post, linked from the footer nav; null for the last.
*/
export function renderPostPage(template: string, post: BlogPost, next: BlogPost | null): string {
const replacements: Record<string, string> = {
HEAD: renderHead(post),
RAIL_LABEL: escapeHtml(post.primaryTagLabel),
TAG_LABEL: escapeHtml(post.tags[0]?.name || "Field notes"),
TITLE: escapeHtml(post.title),
POST_META: renderPostMeta(post),
COVER: renderCover(post),
CONTENT: renderPostContent(post),
TAGS: renderTags(post),
FOOT_NAV: renderFootNav(post, next),
};
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
key in replacements ? replacements[key] : match
);
}
+91
View File
@@ -0,0 +1,91 @@
/** Shapes returned by the Ghost Content API (only the fields this site consumes). */
export interface GhostTag {
id: string;
name: string;
slug: string;
visibility: string;
}
export interface GhostAuthor {
id: string;
name: string;
slug: string;
profile_image: string | null;
}
export interface GhostPost {
id: string;
uuid: string;
title: string;
slug: string;
html: string | null;
excerpt: string | null;
custom_excerpt: string | null;
feature_image: string | null;
feature_image_alt: string | null;
published_at: string | null;
updated_at: string | null;
reading_time: number | null;
url: string | null;
canonical_url: string | null;
meta_title: string | null;
meta_description: string | null;
og_title: string | null;
og_description: string | null;
og_image: string | null;
twitter_title: string | null;
twitter_description: string | null;
twitter_image: string | null;
tags?: GhostTag[];
primary_author?: GhostAuthor | null;
authors?: GhostAuthor[];
}
export interface GhostPostsResponse {
posts: GhostPost[];
}
/**
* A post normalised for rendering: every field is resolved to a usable value so
* templates never have to branch on missing Ghost data.
*/
export interface BlogPost {
slug: string;
title: string;
html: string;
excerpt: string;
featureImage: string | null;
featureImageAlt: string;
author: string;
publishedAt: Date | null;
/** Pre-formatted for display, e.g. "Jun 2, 2026". Empty when unpublished. */
publishedLabel: string;
/** ISO-8601, for <time datetime> and Article schema. */
publishedIso: string;
readingTime: number;
/** Public tags only; the routing tag is stripped out. */
tags: GhostTag[];
/** Uppercased primary tag used in the card caption, e.g. "OPERATIONS". */
primaryTagLabel: string;
seo: PostSeo;
}
export interface PostSeo {
metaTitle: string;
metaDescription: string;
ogTitle: string;
ogDescription: string;
ogImage: string | null;
twitterTitle: string;
twitterDescription: string;
twitterImage: string | null;
canonicalUrl: string;
}
export interface GhostConfig {
apiUrl: string;
contentApiKey: string;
/** Public origin of this site, used to build canonical URLs. */
siteUrl: string;
}
+3
View File
@@ -1716,6 +1716,7 @@
.blog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:8px}
a.feat.blog-card{text-decoration:none;color:inherit}
.blog-foot-note{margin-top:36px;text-align:center}
.blog-empty{grid-column:1/-1;text-align:center;padding:56px 24px;border:1px dashed var(--border);border-radius:10px}
/* ---------- blog post: header, cover, prose ---------- */
.post-meta{
@@ -1747,6 +1748,8 @@
}
.post-foot-nav a{color:var(--sky);font-family:var(--mono);font-size:12px;letter-spacing:.04em;transition:color .12s ease}
.post-foot-nav a:hover{color:var(--sky-bright)}
.post-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:40px;max-width:680px}
.post-tags .tag{border:1px solid var(--border);border-radius:999px;padding:5px 11px}
/* ---------- terms: quicknav + numbered clause cards with icon thumbnails ---------- */
.terms-grid{display:grid;gap:16px;max-width:800px;margin-top:8px}