@dreamtree-org/landing-kit 0.6.0 → 0.7.0

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
@@ -266,7 +266,8 @@ to emit a graph without URLs. No ratings, reviews or prices are ever generated;
266
266
 
267
267
  | Function | Meaning |
268
268
  |---|---|
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'`) |
269
+ | `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 |
270
+ | `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
271
  | `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
272
  | `sessionCheck({ cookie: 'JWT_TOKEN', bearer, verify })` | `hasSession(req)` from your own JWT check |
272
273
  | `siteUrl(req)` · `verificationTags(env)` · `readCookie` · `fillSite` | helpers |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamtree-org/landing-kit",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
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
5
  "type": "module",
6
6
  "license": "MIT",
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 };