From 09119ae6dc0bb0396db06c6287e72ec038feccb2 Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Fri, 17 Jul 2026 18:15:48 +0530 Subject: [PATCH] ghost integrated --- .env.example | 11 ++ .gitignore | 2 + blog/.gitignore | 8 + blog/_post.template.html | 254 +++++++++++++++++++++++++++ blog/index.html | 40 +---- package-lock.json | 371 +++++++++++++++++++++++++++++++++++++++ package.json | 5 +- src/ghost/build.ts | 100 +++++++++++ src/ghost/client.ts | 110 ++++++++++++ src/ghost/normalize.ts | 150 ++++++++++++++++ src/ghost/render.ts | 217 +++++++++++++++++++++++ src/ghost/types.ts | 91 ++++++++++ src/styles.css | 3 + tsconfig.json | 18 ++ vite.config.js | 26 --- vite.config.ts | 92 ++++++++++ 16 files changed, 1437 insertions(+), 61 deletions(-) create mode 100644 .env.example create mode 100644 blog/.gitignore create mode 100644 blog/_post.template.html create mode 100644 src/ghost/build.ts create mode 100644 src/ghost/client.ts create mode 100644 src/ghost/normalize.ts create mode 100644 src/ghost/render.ts create mode 100644 src/ghost/types.ts create mode 100644 tsconfig.json delete mode 100644 vite.config.js create mode 100644 vite.config.ts 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 @@ + + + + + +{{HEAD}} + + + +
+ +
+ + + + +
+ + +
+
+ +
+ {{TAG_LABEL}} +

{{TITLE}}

+ +
+ {{COVER}} +
+
+ +
+
+{{CONTENT}} +
+ + {{TAGS}} + +
+ ✓ SEE IT ON YOUR OWN NUMBERS +

Run the same comparison on your last job.

+

Book a 30-minute walkthrough and we'll build the before/after timeline using one of your own recent files.

+ +
+ +
+ {{FOOT_NAV}} +
+
+ +
+ + + + + diff --git a/blog/index.html b/blog/index.html index aa3b639..ab66d39 100644 --- a/blog/index.html +++ b/blog/index.html @@ -70,41 +70,13 @@

MORE FIELD NOTES ADDED REGULARLY

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(//gi, "") + .replace(//gi, "") + .replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, "") + .replace(/\son[a-z]+\s*=\s*'[^']*'/gi, "") + .replace(/\son[a-z]+\s*=\s*[^\s>]+/gi, "") + .replace(/(]*\bhref\s*=\s*")\s*javascript:[^"]*(")/gi, "$1#$2"); +} + +/** + * Tags each top-level `

` 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(/]*)>([\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 `${inner}

`; + }); +} + +/** Adds native lazy-loading and async decoding to images inside Ghost content. */ +function optimizeContentImages(html: string): string { + return html.replace(/]*)>/gi, (match, attrs: string) => { + let next = attrs; + if (!/\bloading=/i.test(next)) next += ' loading="lazy"'; + if (!/\bdecoding=/i.test(next)) next += ' decoding="async"'; + return ``; + }); +} + +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 + ? `${escapeHtml(post.featureImageAlt)}` + : `${escapeHtml(post.title)}`; + + return ` +
+ ${media} + ${caption} +
+
+

${escapeHtml(post.title)}

+

${escapeHtml(post.excerpt)}

+ Read the field note +
+
`; +} + +/** + * 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 `
+ No field notes published yet +
`; +} + +function renderMetaTag(attr: "name" | "property", key: string, value: string | null): string { + if (!value) return ""; + return ``; +} + +/** Article structured data (requirement 6). */ +function renderArticleSchema(post: BlogPost): string { + const schema: Record = { + "@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(", "); + + // 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 `
+ ${escapeHtml(post.featureImageAlt)} +
`; +} + +function renderPostMeta(post: BlogPost): string { + const parts = [`${escapeHtml(post.author)}`]; + if (post.publishedLabel) { + parts.push(``); + } + 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 ``; +} + +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