diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f02c0b7
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,11 @@
+# Ghost CMS — read at build time only (Node), never bundled into client JS.
+# Copy to .env for local dev; set the same vars in the Vercel project settings.
+
+# Base URL of the Ghost instance, no trailing slash.
+GHOST_API_URL=https://your-site.ghost.io
+
+# Ghost Content API key (read-only). Ghost Admin → Settings → Integrations.
+GHOST_CONTENT_API_KEY=your_content_api_key
+
+# Public origin of this site, used for canonical URLs and Article schema.
+SITE_URL=https://lynkeduppro.com
diff --git a/.gitignore b/.gitignore
index 3a12c35..afbfd6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,8 @@ pnpm-debug.log*
.pnp.*
.vercel
.env*
+# Re-included last: the .env* rule above would otherwise ignore the template.
+!.env.example
# Playwright
/test-results/
diff --git a/blog/.gitignore b/blog/.gitignore
new file mode 100644
index 0000000..3cae47b
--- /dev/null
+++ b/blog/.gitignore
@@ -0,0 +1,8 @@
+# Post pages generated from Ghost at build time (see src/ghost/build.ts).
+# Ignore every post directory, then re-include the hand-authored ones.
+/*/
+!/cost-of-paperwork/
+!/48-hour-storm-checklist/
+!/why-claims-get-denied/
+
+.ghost-generated.json
diff --git a/blog/_post.template.html b/blog/_post.template.html
new file mode 100644
index 0000000..d8b5a4d
--- /dev/null
+++ b/blog/_post.template.html
@@ -0,0 +1,254 @@
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index cbe215a..1e9d9c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,8 @@
},
"devDependencies": {
"@playwright/test": "^1.61.1",
+ "@types/node": "^26.1.1",
+ "typescript": "^7.0.2",
"vite": "^5.3.1"
}
},
@@ -799,6 +801,335 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.1.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
+ "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1019,6 +1350,46 @@
"integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==",
"license": "MIT"
},
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/package.json b/package.json
index 8ff6207..b6869e9 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/inter": "^5.0.18",
@@ -17,6 +18,8 @@
},
"devDependencies": {
"@playwright/test": "^1.61.1",
+ "@types/node": "^26.1.1",
+ "typescript": "^7.0.2",
"vite": "^5.3.1"
}
}
diff --git a/src/ghost/build.ts b/src/ghost/build.ts
new file mode 100644
index 0000000..2eff1a9
--- /dev/null
+++ b/src/ghost/build.ts
@@ -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/
/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,
+ { allowStaleFallback = false }: { allowStaleFallback?: boolean } = {}
+): Promise {
+ 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,
+ };
+}
diff --git a/src/ghost/client.ts b/src/ghost/client.ts
new file mode 100644
index 0000000..45e7021
--- /dev/null
+++ b/src/ghost/client.ts
@@ -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): 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 {
+ 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;
+ }
+}
diff --git a/src/ghost/normalize.ts b/src/ghost/normalize.ts
new file mode 100644
index 0000000..f03c1d5
--- /dev/null
+++ b/src/ghost/normalize.ts
@@ -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/...`; 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));
+}
diff --git a/src/ghost/render.ts b/src/ghost/render.ts
new file mode 100644
index 0000000..70eef44
--- /dev/null
+++ b/src/ghost/render.ts
@@ -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, "'");
+}
+
+/**
+ * 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(/ inside JSON would close the tag early.
+ return JSON.stringify(schema, null, 2).replace(/${escapeHtml(seo.metaTitle)}`,
+ renderMetaTag("name", "description", seo.metaDescription),
+ ` `,
+ 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),
+ ``,
+ ]
+ .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 `
+
+ `;
+}
+
+function renderPostMeta(post: BlogPost): string {
+ const parts = [`${escapeHtml(post.author)} `];
+ if (post.publishedLabel) {
+ parts.push(`${escapeHtml(post.publishedLabel)} `);
+ }
+ parts.push(`${post.readingTime} min read `);
+ return parts.join(" ");
+}
+
+/** Tag chips, reusing the existing `.tag` pill style. */
+function renderTags(post: BlogPost): string {
+ if (!post.tags.length) return "";
+ return `${post.tags
+ .map((t) => `${escapeHtml(t.name)} `)
+ .join("")}
`;
+}
+
+function renderFootNav(post: BlogPost, next: BlogPost | null): string {
+ const links = [`← All field notes `];
+ if (next) {
+ links.push(
+ `Next: ${escapeHtml(next.title)} → `
+ );
+ }
+ 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 = {
+ 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
+ );
+}
diff --git a/src/ghost/types.ts b/src/ghost/types.ts
new file mode 100644
index 0000000..c6cb6ed
--- /dev/null
+++ b/src/ghost/types.ts
@@ -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 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;
+}
diff --git a/src/styles.css b/src/styles.css
index 077adbc..c862839 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..6784635
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM"],
+ "types": ["node"],
+ "strict": true,
+ "noUncheckedIndexedAccess": false,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "allowJs": false
+ },
+ "include": ["src/ghost/**/*.ts", "vite.config.ts"]
+}
diff --git a/vite.config.js b/vite.config.js
deleted file mode 100644
index 0b49e02..0000000
--- a/vite.config.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { defineConfig } from "vite";
-import { fileURLToPath } from "node:url";
-import path from "node:path";
-
-const root = path.dirname(fileURLToPath(import.meta.url));
-
-export default defineConfig({
- base: "./",
- server: { port: 4190 },
- build: {
- rollupOptions: {
- input: {
- main: path.resolve(root, "index.html"),
- faq: path.resolve(root, "faq.html"),
- blog: path.resolve(root, "blog/index.html"),
- blogCostOfPaperwork: path.resolve(root, "blog/cost-of-paperwork/index.html"),
- blogStormChecklist: path.resolve(root, "blog/48-hour-storm-checklist/index.html"),
- blogClaimsDenied: path.resolve(root, "blog/why-claims-get-denied/index.html"),
- terms: path.resolve(root, "terms/index.html"),
- },
- output: {
- manualChunks: { three: ["three"], gsap: ["gsap"] },
- },
- },
- },
-});
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..277807d
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,92 @@
+import { defineConfig, loadEnv, type Plugin } from "vite";
+import { fileURLToPath } from "node:url";
+import fs from "node:fs";
+import path from "node:path";
+import { buildGhostBlog } from "./src/ghost/build";
+import { renderEmptyState } from "./src/ghost/render";
+
+const root = path.dirname(fileURLToPath(import.meta.url));
+
+/** Marker in blog/index.html where Ghost cards are injected. */
+const CARDS_MARKER = "";
+
+/**
+ * Every blog//index.html — the hand-authored posts plus the pages just
+ * generated from Ghost. Globbed rather than listed so new posts need no config
+ * change.
+ */
+function blogPageInputs(): Record {
+ const blogDir = path.resolve(root, "blog");
+ const inputs: Record = {};
+
+ for (const entry of fs.readdirSync(blogDir, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const file = path.join(blogDir, entry.name, "index.html");
+ if (fs.existsSync(file)) inputs[`blog-${entry.name}`] = file;
+ }
+ return inputs;
+}
+
+/** Injects the rendered Ghost cards into the blog listing grid. */
+function ghostListingPlugin(cardsHtml: string): Plugin {
+ return {
+ name: "ghost-listing",
+ transformIndexHtml: {
+ order: "pre",
+ handler(html, ctx) {
+ if (!ctx.filename.replace(/\\/g, "/").endsWith("/blog/index.html")) return html;
+ if (!html.includes(CARDS_MARKER)) {
+ console.warn(`[ghost] ${CARDS_MARKER} not found in blog/index.html; cards not injected.`);
+ return html;
+ }
+ // Empty when Ghost has no tagged posts, or (in dev) when it was
+ // unreachable. The grid has no static cards behind it, so show the
+ // empty state rather than a blank gap.
+ return html.replace(CARDS_MARKER, cardsHtml || renderEmptyState());
+ },
+ },
+ };
+}
+
+export default defineConfig(async ({ mode, command }) => {
+ // Third arg "" loads unprefixed vars too: these are read here in Node at build
+ // time and never inlined into the client bundle, so the Ghost key stays server-side.
+ const env = { ...process.env, ...loadEnv(mode, root, "") };
+
+ let cardsHtml = "";
+ try {
+ // Always fetch fresh, so a newly published post shows up on the next dev
+ // restart or deploy. In dev only, fall back to the last cached response if
+ // Ghost is unreachable.
+ ({ cardsHtml } = await buildGhostBlog(env, { allowStaleFallback: command === "serve" }));
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (command === "build") {
+ // Fail the build rather than publish a blog with posts silently missing.
+ // On Vercel a failed build leaves the previous deployment serving.
+ throw new Error(`[ghost] blog generation failed: ${message}`);
+ }
+ // In dev, keep the site usable — the hand-authored posts still render.
+ console.warn(`[ghost] ${message}\n[ghost] Continuing without Ghost posts (dev only).`);
+ }
+
+ return {
+ base: "./",
+ server: { port: 4190 },
+ plugins: [ghostListingPlugin(cardsHtml)],
+ build: {
+ rollupOptions: {
+ input: {
+ main: path.resolve(root, "index.html"),
+ faq: path.resolve(root, "faq.html"),
+ blog: path.resolve(root, "blog/index.html"),
+ terms: path.resolve(root, "terms/index.html"),
+ ...blogPageInputs(),
+ },
+ output: {
+ manualChunks: { three: ["three"], gsap: ["gsap"] },
+ },
+ },
+ },
+ };
+});