ghost integrated
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(/ /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));
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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")} · ${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(" · ");
|
||||
|
||||
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">▸</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="../">← All field notes</a>`];
|
||||
if (next) {
|
||||
links.push(
|
||||
`<a href="../${escapeHtml(next.slug)}/">Next: ${escapeHtml(next.title)} →</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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user