@dreamtree-org/landing-kit 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +12 -0
- package/README.md +73 -3
- package/dist/server.cjs +33 -17
- package/dist/server.js +32 -17
- package/package.json +6 -2
- package/server.d.ts +9 -1
package/MIGRATION.md
CHANGED
|
@@ -106,3 +106,15 @@ Nothing changes for product landing pages. New, opt-in:
|
|
|
106
106
|
- Apps that stored per-tenant brand colours elsewhere can move them into a theme with
|
|
107
107
|
`validateTheme({ preset: 'custom', primary, accent })`.
|
|
108
108
|
|
|
109
|
+
|
|
110
|
+
## 0.7.0 — serve-time page transforms (additive)
|
|
111
|
+
|
|
112
|
+
Nothing changes unless you opt in:
|
|
113
|
+
|
|
114
|
+
- `landingRoutes(app, { transformPage })`: `(body, { req, route, file, kind }) => string | null`
|
|
115
|
+
(may be async) runs on every prerendered page (`kind: 'page'`) and crawler file (`'crawler'`)
|
|
116
|
+
before the origin is filled in. Return the rewritten body, or null to serve the prerendered one;
|
|
117
|
+
a throw is logged and also serves the prerendered body. It runs per request, so cache inside it.
|
|
118
|
+
- `fillPage(template, page)` from `/server`: the template fill `prerenderPages` does, as a string.
|
|
119
|
+
Keep a copy of the template (prerenderPages overwrites `landing.html`) and an SSR bundle to
|
|
120
|
+
re-render pages with live data, e.g. CMS-driven pricing.
|
package/README.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
The shared marketing landing pages **and** authentication frame (`<AuthShell>`, `brands`, `getBrand`) for every Dreamtree product: replaces the landing setup copied across billpro, taskpro and venturepro (page chrome, sections, "Book a demo" dialog, SEO files, Express routes and prerender) and the login/signup/reset/platform-admin frame in one package.
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/@dreamtree-org/landing-kit)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.dreamtreeglobal.com)
|
|
8
|
+
|
|
9
|
+
## About the Company
|
|
10
|
+
|
|
11
|
+
Landing Kit is built and maintained under **[Dreamtree-Org](https://www.dreamtreeglobal.com)** — a team crafting thoughtful, developer-first software for the modern web. We believe great products start with a great foundation, and that's exactly what this kit aims to be: a polished, open, and dependable landing and auth frame for teams everywhere.
|
|
12
|
+
|
|
13
|
+
🌐 **Website:** [www.dreamtreeglobal.com](https://www.dreamtreeglobal.com)
|
|
14
|
+
|
|
15
|
+
## Author & Maintainer
|
|
16
|
+
|
|
17
|
+
**Partha Preetham Krishna** — [Dreamtree-Org](https://www.dreamtreeglobal.com)
|
|
18
|
+
📧 [preetham.krishna.dev@gmail.com](mailto:preetham.krishna.dev@gmail.com)
|
|
19
|
+
|
|
20
|
+
Have an idea, a bug, or just want to say hi? Reach out anytime — feedback from developers like you is what keeps this kit growing.
|
|
21
|
+
|
|
22
|
+
## 💚 Sponsor & Support
|
|
23
|
+
|
|
24
|
+
Landing Kit is **free and open source**, built in the open and powered entirely by passion and late nights. Every feature you rely on — every responsive section, prerendered page, and auth screen — represents hours of careful craft so that _your_ product can launch a fast, crawlable landing page in minutes.
|
|
25
|
+
|
|
26
|
+
If this kit has saved you time, sparked an idea, or spared you another hand-built marketing page, please consider supporting its continued development. **Your sponsorship keeps the updates coming, the bugs squashed, and the docs sharp — for the whole community.** Even the smallest contribution is a huge encouragement. 🙏
|
|
27
|
+
|
|
28
|
+
### ☕ Make a one-tap donation via UPI
|
|
29
|
+
|
|
30
|
+
**📷 Scan the QR with any UPI app** (Google Pay, PhonePe, Paytm, BHIM, …) to pay instantly:
|
|
31
|
+
|
|
32
|
+
<p>
|
|
33
|
+
<img src="https://unpkg.com/@dreamtree-org/twreact-ui@latest/doc/assets/upi-qr.png" alt="Scan to pay Dreamtree-Org via UPI" width="220" height="220" />
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
> **UPI ID:** `dhrugantha.llp@kotak` • **Payee:** Dreamtree-Org
|
|
37
|
+
>
|
|
38
|
+
> 📱 On mobile, you can also tap the button below — it opens your UPI app directly. On desktop, scan the QR above or copy the UPI ID into your payment app.
|
|
39
|
+
|
|
40
|
+
[](upi://pay?pa=dhrugantha.llp@kotak&pn=Dreamtree-Org&cu=INR&tn=Support%20Landing%20Kit)
|
|
41
|
+
|
|
42
|
+
### 🤝 Become a Sponsor
|
|
43
|
+
|
|
44
|
+
Want to back the project long-term or as a company? You can sponsor the package directly:
|
|
45
|
+
|
|
46
|
+
- 📦 **npm:** [`@dreamtree-org/landing-kit`](https://www.npmjs.com/package/@dreamtree-org/landing-kit) — star, share, and sponsor the package
|
|
47
|
+
- ✉️ **Corporate sponsorships & partnerships:** [preetham.krishna.dev@gmail.com](mailto:preetham.krishna.dev@gmail.com)
|
|
48
|
+
|
|
49
|
+
Every star ⭐, share, and contribution helps more than you know. Thank you for being part of the journey!
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
5
53
|
- Two facades, both taking the product's brand from **its own repo** (`dreamtree.brand.json`, twreact-ui's brand format) through `toLandingBrand()`:
|
|
6
54
|
- `<LandingPage brand={brand} content={content} />` — a product's landing page is a content file.
|
|
7
55
|
- `<AuthShell brand={brand} title="Welcome back">…</AuthShell>` (also available at `@dreamtree-org/landing-kit/auth` + `@dreamtree-org/landing-kit/auth.css`).
|
|
@@ -10,12 +58,14 @@ The shared marketing landing pages **and** authentication frame (`<AuthShell>`,
|
|
|
10
58
|
- Built for prerendered, **un-hydrated** pages (fast and crawlable): `enhanceLanding()` wires the menu and the demo button in a few hundred bytes. It also works hydrated in an SPA or Next.js (`'use client'`, `onBookDemo`, `linkComponent`).
|
|
11
59
|
- Responsive from 320px up. The nav folds into a menu below 1024px. Checked with Playwright at 320/375/768/1024/1440, light and dark.
|
|
12
60
|
|
|
13
|
-
##
|
|
61
|
+
## Installation
|
|
14
62
|
|
|
15
63
|
```bash
|
|
16
64
|
npm install @dreamtree-org/landing-kit
|
|
17
65
|
```
|
|
18
66
|
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
19
69
|
```jsx
|
|
20
70
|
// Landing.jsx
|
|
21
71
|
import { LandingPage, toLandingBrand } from '@dreamtree-org/landing-kit';
|
|
@@ -266,7 +316,8 @@ to emit a graph without URLs. No ratings, reviews or prices are ever generated;
|
|
|
266
316
|
|
|
267
317
|
| Function | Meaning |
|
|
268
318
|
|---|---|
|
|
269
|
-
| `landingRoutes(app, { dist, isPublicHost, hasSession, redirects, onSession, onPrivateHost, trailingSlash, rawFiles, manifest, cacheControl })` | serves `/` (visitors only, `Vary: Cookie`), every page in the manifest (default `landing-pages.json`; its paths are relative to its own directory), robots/sitemap/llms with the origin filled in, verification meta tags from env; 404s the raw files (`rawFiles: 'redirect'` 301s them to their page instead); `/x/` → 301 `/x` (`trailingSlash: 'serve'` answers both); `isPublicHost` may be async; a non-public host gets `Disallow: /` and, at `/`, `next()` or the app shell (`onPrivateHost: 'app-shell'`) |
|
|
319
|
+
| `landingRoutes(app, { dist, isPublicHost, hasSession, redirects, onSession, onPrivateHost, trailingSlash, rawFiles, manifest, cacheControl, transformPage })` | serves `/` (visitors only, `Vary: Cookie`), every page in the manifest (default `landing-pages.json`; its paths are relative to its own directory), robots/sitemap/llms with the origin filled in, verification meta tags from env; 404s the raw files (`rawFiles: 'redirect'` 301s them to their page instead); `/x/` → 301 `/x` (`trailingSlash: 'serve'` answers both); `isPublicHost` may be async; a non-public host gets `Disallow: /` and, at `/`, `next()` or the app shell (`onPrivateHost: 'app-shell'`); `transformPage(body, { req, route, file, kind })` may rewrite a page or crawler file per request (e.g. live prices) and falls back to the prerendered body on null or a throw |
|
|
320
|
+
| `fillPage(template, page)` | one page poured into the template, as a string: what `prerenderPages` writes. Pair it with `transformPage` to re-render a page with live data |
|
|
270
321
|
| `prerenderPages({ dist, pages, files, homeFile, pagesDir, manifest })` | build step: fills `%TITLE% %DESCRIPTION% %PATH% %JSONLD% %HEAD% <!--landing-->` per page, writes `landing.html`, `landing-pages/…`, the manifest and crawler files; throws on an unfilled slot before writing anything |
|
|
271
322
|
| `sessionCheck({ cookie: 'JWT_TOKEN', bearer, verify })` | `hasSession(req)` from your own JWT check |
|
|
272
323
|
| `siteUrl(req)` · `verificationTags(env)` · `readCookie` · `fillSite` | helpers |
|
|
@@ -338,6 +389,25 @@ and in `app/robots.ts` / `app/sitemap.ts` use `buildRobots` / `buildSitemap` wit
|
|
|
338
389
|
- No Vite/Next build plugin. You keep your own landing.html, SSR entry and build; the package fills and writes the files.
|
|
339
390
|
- No icon library. Pass lucide (or any) components.
|
|
340
391
|
|
|
392
|
+
## Contributing
|
|
393
|
+
|
|
394
|
+
1. Fork the repository
|
|
395
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
396
|
+
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
397
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
398
|
+
5. Open a Pull Request
|
|
399
|
+
|
|
341
400
|
## License
|
|
342
401
|
|
|
343
|
-
MIT
|
|
402
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
403
|
+
|
|
404
|
+
## Support
|
|
405
|
+
|
|
406
|
+
- 🐛 [Issue Tracker](https://github.com/dreamtree-org/dreamtree-org.landing-kit/issues)
|
|
407
|
+
- 📦 [npm package](https://www.npmjs.com/package/@dreamtree-org/landing-kit)
|
|
408
|
+
- 🌐 [Dreamtree-Org](https://www.dreamtreeglobal.com)
|
|
409
|
+
- 📧 [preetham.krishna.dev@gmail.com](mailto:preetham.krishna.dev@gmail.com)
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
**Made with ❤️ by [Partha Preetham Krishna](https://www.linkedin.com/in/partha-preetham-krishna-68ba00197/)** under [Dreamtree-Org](https://www.dreamtreeglobal.com) · [www.dreamtreeglobal.com](https://www.dreamtreeglobal.com)
|
package/dist/server.cjs
CHANGED
|
@@ -43,6 +43,7 @@ __export(server_exports, {
|
|
|
43
43
|
buildSitemap: () => buildSitemap,
|
|
44
44
|
contentFacts: () => contentFacts,
|
|
45
45
|
escapeHtml: () => escapeHtml,
|
|
46
|
+
fillPage: () => fillPage,
|
|
46
47
|
fillSite: () => fillSite,
|
|
47
48
|
jsonLdScript: () => jsonLdScript,
|
|
48
49
|
landingRoutes: () => landingRoutes,
|
|
@@ -420,7 +421,8 @@ function landingRoutes(app, options = {}) {
|
|
|
420
421
|
crawlerCache = "public, max-age=3600",
|
|
421
422
|
privateRobots = PRIVATE_ROBOTS,
|
|
422
423
|
appUrl,
|
|
423
|
-
env = process.env
|
|
424
|
+
env = process.env,
|
|
425
|
+
transformPage
|
|
424
426
|
} = opts;
|
|
425
427
|
if (!app || typeof app.get !== "function") throw new Error("landing-kit landingRoutes: pass the Express app as the first argument.");
|
|
426
428
|
if (!dist) throw new Error('landing-kit landingRoutes: "dist" is missing \u2014 pass { dist: path.join(__dirname, "client/dist") }.');
|
|
@@ -452,9 +454,20 @@ function landingRoutes(app, options = {}) {
|
|
|
452
454
|
res.set("Cache-Control", "no-cache");
|
|
453
455
|
return res.sendFile(import_node_path.default.join(dist, "index.html"), (err) => err && next(err));
|
|
454
456
|
};
|
|
455
|
-
const
|
|
457
|
+
const bodyFor = async (req, body, meta) => {
|
|
458
|
+
if (!transformPage) return body;
|
|
459
|
+
try {
|
|
460
|
+
const out = await transformPage(body, { req, ...meta });
|
|
461
|
+
return typeof out === "string" ? out : body;
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error(`landing-kit transformPage failed on ${meta.route}:`, err?.message || err);
|
|
464
|
+
return body;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const sendPage = async (req, res, file, route) => {
|
|
468
|
+
const body = await bodyFor(req, read(file), { route, file, kind: "page" });
|
|
456
469
|
res.set("Cache-Control", cacheControl);
|
|
457
|
-
res.type("html").send(fillSite(
|
|
470
|
+
res.type("html").send(fillSite(body, origin(req), { html: true, env }));
|
|
458
471
|
};
|
|
459
472
|
app.get("/", guard(async (req, res, next) => {
|
|
460
473
|
const file = routes["/"];
|
|
@@ -462,7 +475,7 @@ function landingRoutes(app, options = {}) {
|
|
|
462
475
|
if (!await isPublic(req)) return onPrivateHost === "app-shell" ? appShell(res, next) : next();
|
|
463
476
|
res.set("Vary", "Cookie");
|
|
464
477
|
if (hasSession(req)) return onSession === "app-shell" ? appShell(res, next) : next();
|
|
465
|
-
sendPage(req, res, file);
|
|
478
|
+
await sendPage(req, res, file, "/");
|
|
466
479
|
}));
|
|
467
480
|
const byFile = Object.fromEntries(Object.entries(routes).map(([route, f]) => [`/${f}`, route]));
|
|
468
481
|
const rawSet = /* @__PURE__ */ new Set([`/${manifestFile.split(import_node_path.default.sep).join("/")}`, ...Object.keys(byFile)]);
|
|
@@ -487,17 +500,18 @@ function landingRoutes(app, options = {}) {
|
|
|
487
500
|
const file = routes[trimmed];
|
|
488
501
|
if (!file || read(file) == null) return next();
|
|
489
502
|
if (trimmed !== req.path && trailingSlash !== "serve") return res.redirect(301, trimmed + req.originalUrl.slice(req.path.length));
|
|
490
|
-
sendPage(req, res, file);
|
|
503
|
+
await sendPage(req, res, file, trimmed);
|
|
491
504
|
})
|
|
492
505
|
);
|
|
493
506
|
}
|
|
494
507
|
for (const [route, type] of Object.entries(CRAWLER_FILES)) {
|
|
495
508
|
app.get(route, guard(async (req, res, next) => {
|
|
496
|
-
const
|
|
497
|
-
if (
|
|
509
|
+
const raw = await isPublic(req) ? read(route.slice(1)) : null;
|
|
510
|
+
if (raw == null) {
|
|
498
511
|
if (route !== "/robots.txt") return next();
|
|
499
512
|
return res.type("text/plain").send(privateRobots);
|
|
500
513
|
}
|
|
514
|
+
const body = await bodyFor(req, raw, { route, file: route.slice(1), kind: "crawler" });
|
|
501
515
|
res.set("Cache-Control", crawlerCache);
|
|
502
516
|
res.type(type).send(fillSite(body, origin(req)));
|
|
503
517
|
}));
|
|
@@ -507,21 +521,22 @@ function landingRoutes(app, options = {}) {
|
|
|
507
521
|
function pageFile(p, { homeFile = "landing.html", pagesDir = "landing-pages" } = {}) {
|
|
508
522
|
return p === "/" ? homeFile : import_node_path.default.posix.join(pagesDir, `${p.replace(/^\/+|\/+$/g, "")}.html`);
|
|
509
523
|
}
|
|
524
|
+
function fillPage(template, p, { slot = "<!--landing-->" } = {}) {
|
|
525
|
+
if (!template || !template.includes(slot)) throw new Error(`landing-kit prerenderPages: the template has no ${slot} slot for the page body.`);
|
|
526
|
+
for (const k of ["path", "html"]) if (p?.[k] == null) throw new Error(`landing-kit prerenderPages: page "${p?.path || "?"}" has no "${k}".`);
|
|
527
|
+
const esc = escapeHtml;
|
|
528
|
+
const jsonLd = p.jsonLd == null ? "" : typeof p.jsonLd === "string" ? p.jsonLd : JSON.stringify(p.jsonLd).replace(/</g, "\\u003c");
|
|
529
|
+
const out = template.replace(/%TITLE%/g, () => esc(p.title || "")).replace(/%DESCRIPTION%/g, () => esc(p.description || "")).replace(/%PATH%/g, () => p.path).replace("%JSONLD%", () => jsonLd).replace("%HEAD%", () => p.head || "").replace(slot, () => p.html);
|
|
530
|
+
const left = /%(TITLE|DESCRIPTION|PATH|JSONLD|HEAD)%/.exec(out);
|
|
531
|
+
if (left) throw new Error(`landing-kit prerenderPages: unfilled ${left[0]} on ${p.path}`);
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
510
534
|
function prerenderPages({ dist, template = "landing.html", pages, files = {}, homeFile = "landing.html", pagesDir = "landing-pages", manifest = "landing-pages.json", slot = "<!--landing-->" }) {
|
|
511
535
|
if (!dist) throw new Error('landing-kit prerenderPages: "dist" is missing.');
|
|
512
536
|
if (!Array.isArray(pages) || !pages.length) throw new Error('landing-kit prerenderPages: "pages" is missing \u2014 pass [{ path, html, title, description }].');
|
|
513
537
|
const tpl = template.includes("<") ? template : import_node_fs.default.readFileSync(import_node_path.default.join(dist, template), "utf8");
|
|
514
538
|
if (!tpl.includes(slot)) throw new Error(`landing-kit prerenderPages: the template has no ${slot} slot for the page body.`);
|
|
515
|
-
const
|
|
516
|
-
const outputs = [];
|
|
517
|
-
for (const p of pages) {
|
|
518
|
-
for (const k of ["path", "html"]) if (p[k] == null) throw new Error(`landing-kit prerenderPages: page "${p.path || "?"}" has no "${k}".`);
|
|
519
|
-
const jsonLd = p.jsonLd == null ? "" : typeof p.jsonLd === "string" ? p.jsonLd : JSON.stringify(p.jsonLd).replace(/</g, "\\u003c");
|
|
520
|
-
const out = tpl.replace(/%TITLE%/g, () => esc(p.title || "")).replace(/%DESCRIPTION%/g, () => esc(p.description || "")).replace(/%PATH%/g, () => p.path).replace("%JSONLD%", () => jsonLd).replace("%HEAD%", () => p.head || "").replace(slot, () => p.html);
|
|
521
|
-
const left = /%(TITLE|DESCRIPTION|PATH|JSONLD|HEAD)%/.exec(out);
|
|
522
|
-
if (left) throw new Error(`landing-kit prerenderPages: unfilled ${left[0]} on ${p.path}`);
|
|
523
|
-
outputs.push([p.path, pageFile(p.path, { homeFile, pagesDir }), out]);
|
|
524
|
-
}
|
|
539
|
+
const outputs = pages.map((p) => [p.path, pageFile(p.path, { homeFile, pagesDir }), fillPage(tpl, p, { slot })]);
|
|
525
540
|
import_node_fs.default.rmSync(import_node_path.default.join(dist, pagesDir), { recursive: true, force: true });
|
|
526
541
|
const map = {};
|
|
527
542
|
let bytes = 0;
|
|
@@ -556,6 +571,7 @@ function prerenderPages({ dist, template = "landing.html", pages, files = {}, ho
|
|
|
556
571
|
buildSitemap,
|
|
557
572
|
contentFacts,
|
|
558
573
|
escapeHtml,
|
|
574
|
+
fillPage,
|
|
559
575
|
fillSite,
|
|
560
576
|
jsonLdScript,
|
|
561
577
|
landingRoutes,
|
package/dist/server.js
CHANGED
|
@@ -364,7 +364,8 @@ function landingRoutes(app, options = {}) {
|
|
|
364
364
|
crawlerCache = "public, max-age=3600",
|
|
365
365
|
privateRobots = PRIVATE_ROBOTS,
|
|
366
366
|
appUrl,
|
|
367
|
-
env = process.env
|
|
367
|
+
env = process.env,
|
|
368
|
+
transformPage
|
|
368
369
|
} = opts;
|
|
369
370
|
if (!app || typeof app.get !== "function") throw new Error("landing-kit landingRoutes: pass the Express app as the first argument.");
|
|
370
371
|
if (!dist) throw new Error('landing-kit landingRoutes: "dist" is missing \u2014 pass { dist: path.join(__dirname, "client/dist") }.');
|
|
@@ -396,9 +397,20 @@ function landingRoutes(app, options = {}) {
|
|
|
396
397
|
res.set("Cache-Control", "no-cache");
|
|
397
398
|
return res.sendFile(path.join(dist, "index.html"), (err) => err && next(err));
|
|
398
399
|
};
|
|
399
|
-
const
|
|
400
|
+
const bodyFor = async (req, body, meta) => {
|
|
401
|
+
if (!transformPage) return body;
|
|
402
|
+
try {
|
|
403
|
+
const out = await transformPage(body, { req, ...meta });
|
|
404
|
+
return typeof out === "string" ? out : body;
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error(`landing-kit transformPage failed on ${meta.route}:`, err?.message || err);
|
|
407
|
+
return body;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const sendPage = async (req, res, file, route) => {
|
|
411
|
+
const body = await bodyFor(req, read(file), { route, file, kind: "page" });
|
|
400
412
|
res.set("Cache-Control", cacheControl);
|
|
401
|
-
res.type("html").send(fillSite(
|
|
413
|
+
res.type("html").send(fillSite(body, origin(req), { html: true, env }));
|
|
402
414
|
};
|
|
403
415
|
app.get("/", guard(async (req, res, next) => {
|
|
404
416
|
const file = routes["/"];
|
|
@@ -406,7 +418,7 @@ function landingRoutes(app, options = {}) {
|
|
|
406
418
|
if (!await isPublic(req)) return onPrivateHost === "app-shell" ? appShell(res, next) : next();
|
|
407
419
|
res.set("Vary", "Cookie");
|
|
408
420
|
if (hasSession(req)) return onSession === "app-shell" ? appShell(res, next) : next();
|
|
409
|
-
sendPage(req, res, file);
|
|
421
|
+
await sendPage(req, res, file, "/");
|
|
410
422
|
}));
|
|
411
423
|
const byFile = Object.fromEntries(Object.entries(routes).map(([route, f]) => [`/${f}`, route]));
|
|
412
424
|
const rawSet = /* @__PURE__ */ new Set([`/${manifestFile.split(path.sep).join("/")}`, ...Object.keys(byFile)]);
|
|
@@ -431,17 +443,18 @@ function landingRoutes(app, options = {}) {
|
|
|
431
443
|
const file = routes[trimmed];
|
|
432
444
|
if (!file || read(file) == null) return next();
|
|
433
445
|
if (trimmed !== req.path && trailingSlash !== "serve") return res.redirect(301, trimmed + req.originalUrl.slice(req.path.length));
|
|
434
|
-
sendPage(req, res, file);
|
|
446
|
+
await sendPage(req, res, file, trimmed);
|
|
435
447
|
})
|
|
436
448
|
);
|
|
437
449
|
}
|
|
438
450
|
for (const [route, type] of Object.entries(CRAWLER_FILES)) {
|
|
439
451
|
app.get(route, guard(async (req, res, next) => {
|
|
440
|
-
const
|
|
441
|
-
if (
|
|
452
|
+
const raw = await isPublic(req) ? read(route.slice(1)) : null;
|
|
453
|
+
if (raw == null) {
|
|
442
454
|
if (route !== "/robots.txt") return next();
|
|
443
455
|
return res.type("text/plain").send(privateRobots);
|
|
444
456
|
}
|
|
457
|
+
const body = await bodyFor(req, raw, { route, file: route.slice(1), kind: "crawler" });
|
|
445
458
|
res.set("Cache-Control", crawlerCache);
|
|
446
459
|
res.type(type).send(fillSite(body, origin(req)));
|
|
447
460
|
}));
|
|
@@ -451,21 +464,22 @@ function landingRoutes(app, options = {}) {
|
|
|
451
464
|
function pageFile(p, { homeFile = "landing.html", pagesDir = "landing-pages" } = {}) {
|
|
452
465
|
return p === "/" ? homeFile : path.posix.join(pagesDir, `${p.replace(/^\/+|\/+$/g, "")}.html`);
|
|
453
466
|
}
|
|
467
|
+
function fillPage(template, p, { slot = "<!--landing-->" } = {}) {
|
|
468
|
+
if (!template || !template.includes(slot)) throw new Error(`landing-kit prerenderPages: the template has no ${slot} slot for the page body.`);
|
|
469
|
+
for (const k of ["path", "html"]) if (p?.[k] == null) throw new Error(`landing-kit prerenderPages: page "${p?.path || "?"}" has no "${k}".`);
|
|
470
|
+
const esc = escapeHtml;
|
|
471
|
+
const jsonLd = p.jsonLd == null ? "" : typeof p.jsonLd === "string" ? p.jsonLd : JSON.stringify(p.jsonLd).replace(/</g, "\\u003c");
|
|
472
|
+
const out = template.replace(/%TITLE%/g, () => esc(p.title || "")).replace(/%DESCRIPTION%/g, () => esc(p.description || "")).replace(/%PATH%/g, () => p.path).replace("%JSONLD%", () => jsonLd).replace("%HEAD%", () => p.head || "").replace(slot, () => p.html);
|
|
473
|
+
const left = /%(TITLE|DESCRIPTION|PATH|JSONLD|HEAD)%/.exec(out);
|
|
474
|
+
if (left) throw new Error(`landing-kit prerenderPages: unfilled ${left[0]} on ${p.path}`);
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
454
477
|
function prerenderPages({ dist, template = "landing.html", pages, files = {}, homeFile = "landing.html", pagesDir = "landing-pages", manifest = "landing-pages.json", slot = "<!--landing-->" }) {
|
|
455
478
|
if (!dist) throw new Error('landing-kit prerenderPages: "dist" is missing.');
|
|
456
479
|
if (!Array.isArray(pages) || !pages.length) throw new Error('landing-kit prerenderPages: "pages" is missing \u2014 pass [{ path, html, title, description }].');
|
|
457
480
|
const tpl = template.includes("<") ? template : fs.readFileSync(path.join(dist, template), "utf8");
|
|
458
481
|
if (!tpl.includes(slot)) throw new Error(`landing-kit prerenderPages: the template has no ${slot} slot for the page body.`);
|
|
459
|
-
const
|
|
460
|
-
const outputs = [];
|
|
461
|
-
for (const p of pages) {
|
|
462
|
-
for (const k of ["path", "html"]) if (p[k] == null) throw new Error(`landing-kit prerenderPages: page "${p.path || "?"}" has no "${k}".`);
|
|
463
|
-
const jsonLd = p.jsonLd == null ? "" : typeof p.jsonLd === "string" ? p.jsonLd : JSON.stringify(p.jsonLd).replace(/</g, "\\u003c");
|
|
464
|
-
const out = tpl.replace(/%TITLE%/g, () => esc(p.title || "")).replace(/%DESCRIPTION%/g, () => esc(p.description || "")).replace(/%PATH%/g, () => p.path).replace("%JSONLD%", () => jsonLd).replace("%HEAD%", () => p.head || "").replace(slot, () => p.html);
|
|
465
|
-
const left = /%(TITLE|DESCRIPTION|PATH|JSONLD|HEAD)%/.exec(out);
|
|
466
|
-
if (left) throw new Error(`landing-kit prerenderPages: unfilled ${left[0]} on ${p.path}`);
|
|
467
|
-
outputs.push([p.path, pageFile(p.path, { homeFile, pagesDir }), out]);
|
|
468
|
-
}
|
|
482
|
+
const outputs = pages.map((p) => [p.path, pageFile(p.path, { homeFile, pagesDir }), fillPage(tpl, p, { slot })]);
|
|
469
483
|
fs.rmSync(path.join(dist, pagesDir), { recursive: true, force: true });
|
|
470
484
|
const map = {};
|
|
471
485
|
let bytes = 0;
|
|
@@ -499,6 +513,7 @@ export {
|
|
|
499
513
|
buildSitemap,
|
|
500
514
|
contentFacts,
|
|
501
515
|
escapeHtml,
|
|
516
|
+
fillPage,
|
|
502
517
|
fillSite,
|
|
503
518
|
jsonLdScript,
|
|
504
519
|
landingRoutes,
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreamtree-org/landing-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "The shared marketing site and auth frame (AuthShell + brands) for every Dreamtree product: content-driven landing pages, login/signup shell, Express routes, prerender and SEO files.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Partha Preetham Krishna",
|
|
7
|
+
"email": "preetham.krishna.dev@gmail.com",
|
|
8
|
+
"url": "https://www.dreamtreeglobal.com"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"license": "MIT",
|
|
7
12
|
"repository": {
|
|
@@ -75,7 +80,6 @@
|
|
|
75
80
|
"scripts": {
|
|
76
81
|
"build": "node build.mjs",
|
|
77
82
|
"test": "node build.mjs && node --test test/*.test.mjs",
|
|
78
|
-
"prepublishOnly": "npm test",
|
|
79
83
|
"test:viewports": "node build.mjs && node test/viewports.mjs",
|
|
80
84
|
"test:tenant-viewports": "node build.mjs && node test/tenant-viewports.mjs"
|
|
81
85
|
},
|
package/server.d.ts
CHANGED
|
@@ -31,13 +31,21 @@ export interface LandingRoutesOptions {
|
|
|
31
31
|
privateRobots?: string;
|
|
32
32
|
appUrl?: string;
|
|
33
33
|
env?: Record<string, string | undefined>;
|
|
34
|
+
/**
|
|
35
|
+
* Runs on every page ('page') and crawler file ('crawler') before the origin is filled in,
|
|
36
|
+
* e.g. to re-render live prices. null/undefined or a throw serves the prerendered body.
|
|
37
|
+
*/
|
|
38
|
+
transformPage?: (body: string, meta: { req: any; route: string; file: string; kind: 'page' | 'crawler' }) => string | null | undefined | Promise<string | null | undefined>;
|
|
34
39
|
}
|
|
40
|
+
export interface PrerenderPage { path: string; html: string; title?: string; description?: string; jsonLd?: unknown; head?: string }
|
|
41
|
+
/** One page poured into the template: what prerenderPages writes, as a string. */
|
|
42
|
+
export declare function fillPage(template: string, page: PrerenderPage, opts?: { slot?: string }): string;
|
|
35
43
|
export declare function landingRoutes(app: any, options: LandingRoutesOptions | string): string[];
|
|
36
44
|
export declare function pageFile(path: string, opts?: { homeFile?: string; pagesDir?: string }): string;
|
|
37
45
|
export declare function prerenderPages(args: {
|
|
38
46
|
dist: string;
|
|
39
47
|
template?: string;
|
|
40
|
-
pages:
|
|
48
|
+
pages: PrerenderPage[];
|
|
41
49
|
files?: Record<string, string | null | undefined>;
|
|
42
50
|
homeFile?: string; pagesDir?: string; manifest?: string; slot?: string;
|
|
43
51
|
}): { manifest: Record<string, string>; bytes: number };
|