ghost integrated
This commit is contained in:
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,254 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{{HEAD}}
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8.8 6 3.6 9v6l5.2 3 5.2-3V9z' fill='none' stroke='%232EC5DD' stroke-width='1.8'/%3E%3Cpath d='M15.2 6 10 9v6l5.2 3 5.2-3V9z' fill='none' stroke='%23FFB300' stroke-width='1.8'/%3E%3C/svg%3E">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="../../index.html#product">Product</a>
|
||||
<a href="../../index.html#chaos">Why it works</a>
|
||||
<a href="../../index.html#intel">Intelligence</a>
|
||||
<a href="../../index.html#pricing">Pricing</a>
|
||||
<a href="../../index.html#founder">About</a>
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mprog" aria-hidden="true"><i id="mprogBar"></i></div>
|
||||
|
||||
<nav class="rail" id="rail" aria-label="Section index">
|
||||
<div class="rail-track" id="railTrack">
|
||||
<div class="rail-carriage" id="railCar"><span class="rail-read" id="railRead"></span></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="top">
|
||||
|
||||
<!-- ============ POST HEADER ============ -->
|
||||
<div class="page-hero" id="intro" data-rail="01 · INTRO">
|
||||
<div class="wrap">
|
||||
<div class="dim-rule reveal" aria-hidden="true"><i class="l"></i><span>{{RAIL_LABEL}}</span><i class="r"></i></div>
|
||||
<div class="sec-head reveal">
|
||||
<span class="tag">{{TAG_LABEL}}</span>
|
||||
<h1>{{TITLE}}</h1>
|
||||
<p class="post-meta">
|
||||
{{POST_META}}
|
||||
</p>
|
||||
</div>
|
||||
{{COVER}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="post-body reveal">
|
||||
{{CONTENT}}
|
||||
</div>
|
||||
|
||||
{{TAGS}}
|
||||
|
||||
<div class="founder-card terms-cta reveal">
|
||||
<span class="stamp-tag ok">✓ SEE IT ON YOUR OWN NUMBERS</span>
|
||||
<h2>Run the <em>same comparison</em> on your last job.</h2>
|
||||
<p>Book a 30-minute walkthrough and we'll build the before/after timeline using one of your own recent files.</p>
|
||||
<div class="cta-alt">
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="../../index.html#chaos">See the Cost of Chaos</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-foot-nav">
|
||||
{{FOOT_NAV}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<svg class="skyline2" viewBox="0 0 1440 150" preserveAspectRatio="xMidYMax slice" aria-hidden="true">
|
||||
<g class="sk-ln">
|
||||
<line x1="0" y1="148" x2="1440" y2="148"/>
|
||||
<g>
|
||||
<rect x="52" y="78" width="86" height="70"/>
|
||||
<polygon points="47,78 95,52 143,78"/>
|
||||
<rect x="88" y="122" width="16" height="26"/>
|
||||
<rect class="w-on" x="62" y="88" width="10" height="12"/>
|
||||
<rect class="w-off" x="80" y="88" width="10" height="12"/>
|
||||
<rect class="w-on w-tw" x="112" y="88" width="10" height="12"/>
|
||||
<rect class="w-off" x="62" y="112" width="10" height="12"/>
|
||||
<rect class="w-on" x="112" y="112" width="10" height="12"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="186" y="104" width="112" height="44"/>
|
||||
<polygon points="180,104 242,80 304,104"/>
|
||||
<rect x="222" y="68" width="12" height="24"/>
|
||||
<rect class="w-off" x="198" y="114" width="11" height="12"/>
|
||||
<rect class="w-on" x="222" y="114" width="11" height="12"/>
|
||||
<rect class="w-off" x="246" y="114" width="11" height="12"/>
|
||||
<rect x="272" y="118" width="14" height="30"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="340" y1="148" x2="340" y2="102"/>
|
||||
<line x1="340" y1="102" x2="352" y2="102"/>
|
||||
<circle class="lamp" cx="354" cy="104" r="3"/>
|
||||
<circle class="lamp-halo" cx="354" cy="106" r="10"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="392" y="66" width="64" height="82"/>
|
||||
<polygon points="388,66 424,44 460,66"/>
|
||||
<rect class="w-on" x="402" y="76" width="10" height="12"/>
|
||||
<rect class="w-off" x="432" y="76" width="10" height="12"/>
|
||||
<rect class="w-off" x="402" y="102" width="10" height="12"/>
|
||||
<rect class="w-on w-tw2" x="432" y="102" width="10" height="12"/>
|
||||
<rect x="416" y="124" width="15" height="24"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="512" y="96" width="96" height="52"/>
|
||||
<polygon points="506,96 534,74 586,74 614,96"/>
|
||||
<rect class="w-on" x="524" y="108" width="11" height="12"/>
|
||||
<rect class="w-on" x="548" y="108" width="11" height="12"/>
|
||||
<rect class="w-off" x="572" y="108" width="11" height="12"/>
|
||||
<rect x="616" y="112" width="52" height="36"/>
|
||||
<polygon points="612,112 642,96 672,112"/>
|
||||
<rect class="w-off" x="630" y="122" width="24" height="20"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="712" y1="148" x2="712" y2="120"/>
|
||||
<circle cx="712" cy="106" r="16"/>
|
||||
<circle cx="700" cy="116" r="10"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="762" y="72" width="110" height="76"/>
|
||||
<polygon points="756,72 817,46 878,72"/>
|
||||
<line x1="758" y1="120" x2="876" y2="120"/>
|
||||
<line x1="770" y1="120" x2="770" y2="148"/>
|
||||
<line x1="817" y1="120" x2="817" y2="148"/>
|
||||
<line x1="864" y1="120" x2="864" y2="148"/>
|
||||
<rect class="w-on" x="774" y="82" width="10" height="12"/>
|
||||
<rect class="w-on" x="798" y="82" width="10" height="12"/>
|
||||
<rect class="w-off" x="826" y="82" width="10" height="12"/>
|
||||
<rect class="w-on" x="850" y="82" width="10" height="12"/>
|
||||
<rect class="w-off" x="774" y="128" width="10" height="12"/>
|
||||
<rect class="w-on w-tw" x="850" y="128" width="10" height="12"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="918" y="116" width="44" height="32"/>
|
||||
<polygon points="914,116 940,102 966,116"/>
|
||||
<rect class="w-off" x="932" y="124" width="16" height="16"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="1006" y1="148" x2="1006" y2="102"/>
|
||||
<line x1="1006" y1="102" x2="1018" y2="102"/>
|
||||
<circle class="lamp" cx="1020" cy="104" r="3"/>
|
||||
<circle class="lamp-halo" cx="1020" cy="106" r="10"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1058" y="92" width="84" height="56"/>
|
||||
<polygon points="1053,92 1100,66 1147,92"/>
|
||||
<rect class="w-on" x="1070" y="102" width="10" height="12"/>
|
||||
<rect class="w-off" x="1094" y="102" width="10" height="12"/>
|
||||
<rect class="w-on" x="1118" y="102" width="10" height="12"/>
|
||||
<rect x="1094" y="124" width="14" height="24"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1198" y="80" width="92" height="68"/>
|
||||
<polygon points="1192,80 1244,54 1296,80"/>
|
||||
<rect x="1258" y="42" width="12" height="26"/>
|
||||
<rect class="w-off" x="1210" y="92" width="10" height="12"/>
|
||||
<rect class="w-on w-tw2" x="1234" y="92" width="10" height="12"/>
|
||||
<rect class="w-on" x="1258" y="92" width="10" height="12"/>
|
||||
<rect class="w-off" x="1210" y="118" width="10" height="12"/>
|
||||
<rect class="w-on" x="1258" y="118" width="10" height="12"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="1352" y1="148" x2="1352" y2="118"/>
|
||||
<circle cx="1352" cy="102" r="18"/>
|
||||
<circle cx="1368" cy="114" r="11"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="wrap">
|
||||
<div class="foot-glass">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="../../index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="../../index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
<a href="../../index.html" aria-label="YouTube"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="12" rx="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.4 9.4v5.2l4.6-2.6Z" fill="currentColor"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-col">
|
||||
<b>Platform</b>
|
||||
<a href="../../index.html#hero">Instant estimate</a>
|
||||
<a href="../../index.html#intel">Intelligence map</a>
|
||||
<a href="../../index.html#product">For contractors</a>
|
||||
<a href="../../index.html#industries">For homeowners</a>
|
||||
<a href="../../index.html#pricing">Pricing</a>
|
||||
</div>
|
||||
<div class="foot-col">
|
||||
<b>Company</b>
|
||||
<a href="../../index.html#founder">About</a>
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../index.html#chaos">Why it works</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="../../index.html#book">Careers</a>
|
||||
<a href="../../index.html#book">Contact</a>
|
||||
</div>
|
||||
<div class="foot-col">
|
||||
<b>Resources & legal</b>
|
||||
<a href="../../faq.html">FAQ</a>
|
||||
<a href="../../index.html">Help center</a>
|
||||
<a href="../../index.html">Security</a>
|
||||
<a href="../../index.html">Privacy policy</a>
|
||||
<a href="../../terms/">Terms of service</a>
|
||||
<a href="../../index.html">Drone flight safety</a>
|
||||
</div>
|
||||
</div>
|
||||
<form class="foot-alerts" id="zipForm">
|
||||
<div class="fa-copy">
|
||||
<b>Hail alerts for your territory</b>
|
||||
<span class="tag">Radar-verified swaths for your ZIP, the morning after the storm</span>
|
||||
</div>
|
||||
<div class="fa-row">
|
||||
<input id="zipInput" inputmode="numeric" autocomplete="postal-code" maxlength="5" placeholder="75248" aria-label="ZIP code">
|
||||
<button class="btn solid" type="submit">Watch my ZIP</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="foot-base">
|
||||
<span class="tag">© 2026 LynkedUp Pro · All rights reserved · Powered by LynkedUp Technologies</span>
|
||||
<span class="tag">Zero-trust architecture · data encrypted at rest and in transit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="../../src/blog.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+6
-34
@@ -70,41 +70,13 @@
|
||||
<div class="wrap">
|
||||
<div class="blog-grid">
|
||||
|
||||
<a class="feat blog-card reveal" href="./cost-of-paperwork/" aria-label="Read: The Real Cost of Paperwork">
|
||||
<div class="feat-media">
|
||||
<img class="feat-thumb" src="../img/props/Storm_Worn_Roof.jpg" alt="Weathered roof shingles after storm exposure" loading="lazy" width="900" height="510">
|
||||
<span class="cap">OPERATIONS · JUN 2, 2026 · 6 MIN READ</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<h3>The Real Cost of Paperwork: Why Contractors Lose Margin Before the Job Even Starts</h3>
|
||||
<p>Every manual follow-up, delayed estimate, and missing document chips away at the number that actually matters. Here's exactly where the 34% goes.</p>
|
||||
<span class="feat-link">Read the field note <span aria-hidden="true">▸</span></span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- Every card here comes from Ghost (tag: lynkeduppro-new), injected at
|
||||
build time by the ghost-listing plugin in vite.config.ts.
|
||||
|
||||
<a class="feat blog-card reveal" href="./48-hour-storm-checklist/" aria-label="Read: The First 48 Hours After Hail">
|
||||
<div class="feat-media">
|
||||
<img class="feat-thumb" src="../img/props/Hail_Damaged_Shingles.jpg" alt="Close up of hail damaged shingles" loading="lazy" width="900" height="510">
|
||||
<span class="cap">STORM RESPONSE · JUN 9, 2026 · 7 MIN READ</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<h3>The First 48 Hours After Hail: A Field Checklist That Protects the Claim</h3>
|
||||
<p>The window between "storm passed" and "adjuster arrives" decides whether your evidence holds up. Here's exactly what to capture, and in what order.</p>
|
||||
<span class="feat-link">Read the field note <span aria-hidden="true">▸</span></span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="feat blog-card reveal" href="./why-claims-get-denied/" aria-label="Read: Why Adjusters Reject 40 Percent of Roof Claims">
|
||||
<div class="feat-media">
|
||||
<img class="feat-thumb" src="../img/props/Broken_Roof_Surface.jpg" alt="Damaged roof membrane surface" loading="lazy" width="900" height="510">
|
||||
<span class="cap">CLAIMS · JUN 16, 2026 · 5 MIN READ</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<h3>Why Adjusters Reject 40% of Roof Claims (And How to Fix It)</h3>
|
||||
<p>"Lack of maintenance" is the single most common denial reason in the industry — and it's almost always a documentation problem, not a roof problem.</p>
|
||||
<span class="feat-link">Read the field note <span aria-hidden="true">▸</span></span>
|
||||
</div>
|
||||
</a>
|
||||
The hand-authored posts under blog/<slug>/ are intentionally NOT listed
|
||||
here any more. Their pages are still built and their URLs still work —
|
||||
they are just no longer linked from this grid. -->
|
||||
<!-- GHOST_CARDS -->
|
||||
|
||||
</div>
|
||||
<p class="blog-foot-note tag reveal">MORE FIELD NOTES ADDED REGULARLY</p>
|
||||
|
||||
Generated
+371
@@ -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",
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 = "<!-- GHOST_CARDS -->";
|
||||
|
||||
/**
|
||||
* Every blog/<slug>/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<string, string> {
|
||||
const blogDir = path.resolve(root, "blog");
|
||||
const inputs: Record<string, string> = {};
|
||||
|
||||
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"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user