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..963cc8f 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/ @@ -49,3 +51,4 @@ pnpm-debug.log* /playwright/.cache/ .vercel +.env*.local diff --git a/CampaignX_Sigil b/CampaignX_Sigil new file mode 160000 index 0000000..2a984ad --- /dev/null +++ b/CampaignX_Sigil @@ -0,0 +1 @@ +Subproject commit 2a984adfb35c8ae18618dc57bb9499298eebec6e 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/48-hour-storm-checklist/index.html b/blog/48-hour-storm-checklist/index.html index 24aa526..e51a1c6 100644 --- a/blog/48-hour-storm-checklist/index.html +++ b/blog/48-hour-storm-checklist/index.html @@ -31,16 +31,7 @@
@@ -151,7 +142,7 @@

Get hail alerts for your territory.

Radar-verified swaths land in your inbox the morning after the storm, so your crews are the first ones knocking.

- Book a demo + Book a demo See the intelligence map
@@ -272,14 +263,10 @@
+ +

The construction CRM that does the paperwork.

- 16990 Dallas Pkwy, Suite 206
Dallas, TX 75248
(866) 259-6533
Info@LynkedUpPro.com
+ Dallas, Texas
(866) 259-6533
Info@LynkedUpPro.com
diff --git a/blog/_post.template.html b/blog/_post.template.html new file mode 100644 index 0000000..58f65b3 --- /dev/null +++ b/blog/_post.template.html @@ -0,0 +1,241 @@ + + + + + +{{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/cost-of-paperwork/index.html b/blog/cost-of-paperwork/index.html index 0f9886d..5a8d358 100644 --- a/blog/cost-of-paperwork/index.html +++ b/blog/cost-of-paperwork/index.html @@ -31,16 +31,7 @@
@@ -152,7 +143,7 @@

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.

@@ -273,14 +264,10 @@
+ +

The construction CRM that does the paperwork.

- 16990 Dallas Pkwy, Suite 206
Dallas, TX 75248
(866) 259-6533
Info@LynkedUpPro.com
+ Dallas, Texas
(866) 259-6533
Info@LynkedUpPro.com
diff --git a/blog/index.html b/blog/index.html index 1b08f55..eb59cf0 100644 --- a/blog/index.html +++ b/blog/index.html @@ -22,16 +22,7 @@
@@ -70,41 +61,13 @@

MORE FIELD NOTES ADDED REGULARLY

@@ -221,14 +184,10 @@
+ +

The construction CRM that does the paperwork.

- 16990 Dallas Pkwy, Suite 206
Dallas, TX 75248
(866) 259-6533
Info@LynkedUpPro.com
+ Dallas, Texas
(866) 259-6533
Info@LynkedUpPro.com
diff --git a/blog/why-claims-get-denied/index.html b/blog/why-claims-get-denied/index.html index 8d8b4dd..351a9a0 100644 --- a/blog/why-claims-get-denied/index.html +++ b/blog/why-claims-get-denied/index.html @@ -31,16 +31,7 @@
@@ -150,7 +141,7 @@

Stop building the file after the denial.

See how a phone-based scan builds a claim-ready record on day one, before there's ever a dispute to fight.

@@ -271,14 +262,10 @@
+ +

The construction CRM that does the paperwork.

- 16990 Dallas Pkwy, Suite 206
Dallas, TX 75248
(866) 259-6533
Info@LynkedUpPro.com
+ Dallas, Texas
(866) 259-6533
Info@LynkedUpPro.com
diff --git a/faq.html b/faq.html index 4e52b85..876803f 100644 --- a/faq.html +++ b/faq.html @@ -37,16 +37,7 @@
@@ -462,7 +453,7 @@

Bring your real questions.

Get a 30-minute walkthrough of the platform, or a 1-hour buildout call if your workflow needs custom work.

@@ -579,14 +570,10 @@
+ +

The construction CRM that does the paperwork.

- 16990 Dallas Pkwy, Suite 206
Dallas, TX 75248
(866) 259-6533
Info@LynkedUpPro.com
+ Dallas, Texas
(866) 259-6533
Info@LynkedUpPro.com
diff --git a/index.html b/index.html index b7c5019..52c211d 100644 --- a/index.html +++ b/index.html @@ -53,16 +53,7 @@
@@ -102,7 +93,7 @@ built to keep every job moving and every dollar accounted for.

@@ -923,14 +914,22 @@
+ +

Disclaimer: All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.

+

Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.

+

The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.

+

SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.

+

Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.

+

Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.

+
- - + +
Key terms, explained simply

No jargon. Just what it means for your business.

@@ -996,7 +995,7 @@
- +
Do I need a drone? @@ -1093,12 +1092,41 @@

Whether you protect one home or manage a thousand roofs. We run your address live: mesh, measurements, and a signature-ready proposal before the call ends.

- - + +
+

Founders Lifetime License available · no credit card · works offline in the field

@@ -1227,14 +1255,10 @@
+ +

The construction CRM that does the paperwork.

- 16990 Dallas Pkwy, Suite 206
Dallas, TX 75248
(866) 259-6533
Info@LynkedUpPro.com
+ Dallas, Texas
(866) 259-6533
Info@LynkedUpPro.com
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/public/img/lynkedup-logo.png b/public/img/lynkedup-logo.png new file mode 100644 index 0000000..0458027 Binary files /dev/null and b/public/img/lynkedup-logo.png differ 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..88c987a --- /dev/null +++ b/src/ghost/normalize.ts @@ -0,0 +1,196 @@ +/** + * 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()}…`; +} + +/** + * Upgrades a Ghost-hosted `http://` URL to `https://`. + * + * Ghost builds asset URLs from its own configured `url` setting. If that is set + * to http (as api.lynkedup.dev currently is), every image comes back insecure and + * browsers block it as mixed content on our HTTPS pages — silently, before the + * server's 301 to https can help. + * + * Only the configured Ghost host is upgraded: an arbitrary external http image in + * post content may genuinely have no https, and forcing it would break it. + */ +export function secureGhostUrl(url: string | null, ghostHost: string): string | null { + if (!url || !url.startsWith("http://")) return url; + try { + const parsed = new URL(url); + if (parsed.host !== ghostHost) return url; + parsed.protocol = "https:"; + return parsed.toString(); + } catch { + return url; + } +} + +/** Applies {@link secureGhostUrl} to src/srcset/href attributes inside post HTML. */ +function secureHtmlUrls(html: string, ghostHost: string): string { + return html.replace( + /(\s(?:src|href)=")(http:\/\/[^"]+)(")/gi, + (match, pre: string, url: string, post: string) => { + const secure = secureGhostUrl(url, ghostHost); + return secure ? `${pre}${secure}${post}` : match; + } + ); +} + +/** + * 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, + ghostHost: string, + title: string, + excerpt: string +): PostSeo { + const metaTitle = post.meta_title?.trim() || `${title} - LynkedUp Pro Blog`; + const metaDescription = post.meta_description?.trim() || excerpt; + const ogImage = secureGhostUrl(ghostImage(post.og_image || post.feature_image, 1200), ghostHost); + + 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: secureGhostUrl(ghostImage(post.twitter_image, 1200), ghostHost) || 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", +}); + +/** The Ghost host (e.g. "api.lynkedup.dev"), for upgrading its http asset URLs. */ +function ghostHostFrom(config: GhostConfig): string { + try { + return new URL(config.apiUrl).host; + } catch { + return ""; + } +} + +export function normalizePost(post: GhostPost, config: GhostConfig): BlogPost { + const ghostHost = ghostHostFrom(config); + const rawHtml = post.html ?? ""; + const html = secureHtmlUrls(rawHtml, ghostHost); + const plain = toPlainText(rawHtml); + 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: secureGhostUrl(post.feature_image, ghostHost), + 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, ghostHost, 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
@@ -309,14 +300,10 @@
+ +

The construction CRM that does the paperwork.

- 16990 Dallas Pkwy, Suite 206
Dallas, TX 75248
(866) 259-6533
Info@LynkedUpPro.com
+ Dallas, Texas
(866) 259-6533
Info@LynkedUpPro.com
diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6784635 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": false, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "allowJs": false + }, + "include": ["src/ghost/**/*.ts", "vite.config.ts"] +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 0b49e02..0000000 --- a/vite.config.js +++ /dev/null @@ -1,26 +0,0 @@ -import { defineConfig } from "vite"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; - -const root = path.dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - base: "./", - server: { port: 4190 }, - build: { - rollupOptions: { - input: { - main: path.resolve(root, "index.html"), - faq: path.resolve(root, "faq.html"), - blog: path.resolve(root, "blog/index.html"), - blogCostOfPaperwork: path.resolve(root, "blog/cost-of-paperwork/index.html"), - blogStormChecklist: path.resolve(root, "blog/48-hour-storm-checklist/index.html"), - blogClaimsDenied: path.resolve(root, "blog/why-claims-get-denied/index.html"), - terms: path.resolve(root, "terms/index.html"), - }, - output: { - manualChunks: { three: ["three"], gsap: ["gsap"] }, - }, - }, - }, -}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..277807d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,92 @@ +import { defineConfig, loadEnv, type Plugin } from "vite"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; +import { buildGhostBlog } from "./src/ghost/build"; +import { renderEmptyState } from "./src/ghost/render"; + +const root = path.dirname(fileURLToPath(import.meta.url)); + +/** Marker in blog/index.html where Ghost cards are injected. */ +const CARDS_MARKER = ""; + +/** + * Every blog//index.html — the hand-authored posts plus the pages just + * generated from Ghost. Globbed rather than listed so new posts need no config + * change. + */ +function blogPageInputs(): Record { + const blogDir = path.resolve(root, "blog"); + const inputs: Record = {}; + + for (const entry of fs.readdirSync(blogDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const file = path.join(blogDir, entry.name, "index.html"); + if (fs.existsSync(file)) inputs[`blog-${entry.name}`] = file; + } + return inputs; +} + +/** Injects the rendered Ghost cards into the blog listing grid. */ +function ghostListingPlugin(cardsHtml: string): Plugin { + return { + name: "ghost-listing", + transformIndexHtml: { + order: "pre", + handler(html, ctx) { + if (!ctx.filename.replace(/\\/g, "/").endsWith("/blog/index.html")) return html; + if (!html.includes(CARDS_MARKER)) { + console.warn(`[ghost] ${CARDS_MARKER} not found in blog/index.html; cards not injected.`); + return html; + } + // Empty when Ghost has no tagged posts, or (in dev) when it was + // unreachable. The grid has no static cards behind it, so show the + // empty state rather than a blank gap. + return html.replace(CARDS_MARKER, cardsHtml || renderEmptyState()); + }, + }, + }; +} + +export default defineConfig(async ({ mode, command }) => { + // Third arg "" loads unprefixed vars too: these are read here in Node at build + // time and never inlined into the client bundle, so the Ghost key stays server-side. + const env = { ...process.env, ...loadEnv(mode, root, "") }; + + let cardsHtml = ""; + try { + // Always fetch fresh, so a newly published post shows up on the next dev + // restart or deploy. In dev only, fall back to the last cached response if + // Ghost is unreachable. + ({ cardsHtml } = await buildGhostBlog(env, { allowStaleFallback: command === "serve" })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (command === "build") { + // Fail the build rather than publish a blog with posts silently missing. + // On Vercel a failed build leaves the previous deployment serving. + throw new Error(`[ghost] blog generation failed: ${message}`); + } + // In dev, keep the site usable — the hand-authored posts still render. + console.warn(`[ghost] ${message}\n[ghost] Continuing without Ghost posts (dev only).`); + } + + return { + base: "./", + server: { port: 4190 }, + plugins: [ghostListingPlugin(cardsHtml)], + build: { + rollupOptions: { + input: { + main: path.resolve(root, "index.html"), + faq: path.resolve(root, "faq.html"), + blog: path.resolve(root, "blog/index.html"), + terms: path.resolve(root, "terms/index.html"), + ...blogPageInputs(), + }, + output: { + manualChunks: { three: ["three"], gsap: ["gsap"] }, + }, + }, + }, + }; +});