@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 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
+ [![npm version](https://badge.fury.io/js/@dreamtree-org%2Flanding-kit.svg)](https://www.npmjs.com/package/@dreamtree-org/landing-kit)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![Website](https://img.shields.io/badge/by-Dreamtree--Org-0ea5e9)](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` &nbsp;•&nbsp; **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
+ [![Pay via UPI](https://img.shields.io/badge/Pay-via%20UPI-22c55e?style=for-the-badge&logo=googlepay&logoColor=white)](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
- ## 60-second start
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 sendPage = (req, res, file) => {
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(read(file), origin(req), { html: true, env }));
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 body = await isPublic(req) ? read(route.slice(1)) : null;
497
- if (body == null) {
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 esc = escapeHtml;
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 sendPage = (req, res, file) => {
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(read(file), origin(req), { html: true, env }));
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 body = await isPublic(req) ? read(route.slice(1)) : null;
441
- if (body == null) {
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 esc = escapeHtml;
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.6.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: Array<{ path: string; html: string; title?: string; description?: string; jsonLd?: unknown; head?: string }>;
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 };