@dreamtree-org/landing-kit 0.5.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/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/index.d.ts CHANGED
@@ -295,3 +295,32 @@ export declare const LandingProvider: import('react').Provider<{ brand: Brand |
295
295
  export declare function useLanding(): { brand: Brand | null; linkComponent: ComponentType<any> | null; onBookDemo: ((t: HTMLElement) => void) | null; home: boolean };
296
296
  /** The "Book a demo" island for an un-hydrated page: React mounts on first open. */
297
297
  export declare function createDemoIsland(props: Omit<DemoDialogProps, 'onClose'> | (() => Omit<DemoDialogProps, 'onClose'>)): { openDemo(trigger?: HTMLElement | null): void; close(): void };
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Tenant pages: a store's own landing page as data. Validate and build pages
301
+ // with `@dreamtree-org/landing-kit/schema`; render them with TenantPage.
302
+ import type { PageDoc, Theme, MediaRef, SectionType, ValidationError, Palette, FontPair } from './schema';
303
+
304
+ export interface TenantPageProps {
305
+ /** A page document (validated again here; invalid sections are dropped). */
306
+ page: PageDoc | unknown;
307
+ theme?: Partial<Theme> | null;
308
+ /** App-rendered content for slot sections: a node or a function of the section's props. */
309
+ slots?: Partial<Record<'featured' | 'categories', ReactNode | ((props: Record<string, unknown>) => ReactNode)>>;
310
+ /** Uploaded image → URL ('wide' | 'narrow' | 'logo' | 'gallery'). */
311
+ resolveMedia?: (ref: MediaRef, variant: string) => string | null;
312
+ linkComponent?: ComponentType<any> | null;
313
+ /** Newsletter sign-up handler; reject with an Error to show its message. */
314
+ onSubscribe?: ((data: { email: string; consent: boolean; section: string }) => Promise<unknown>) | null;
315
+ storeName?: string;
316
+ className?: string;
317
+ /** 1 (default): the first section's heading is the page's h1. */
318
+ headingStart?: 1 | 2;
319
+ }
320
+ export function TenantPage(props: TenantPageProps): JSX.Element;
321
+ export const TENANT_BLOCKS: Record<SectionType, (props: any) => JSX.Element | null>;
322
+ export function HeroSlider(props: Record<string, unknown>): JSX.Element | null;
323
+ export function NamedIcon(props: { name: string; size?: number; className?: string }): JSX.Element | null;
324
+ export function validatePage(doc: unknown): { ok: boolean; errors: ValidationError[]; page: PageDoc };
325
+ export function validateTheme(theme: unknown): { ok: boolean; errors: ValidationError[]; warnings: ValidationError[]; theme: Theme };
326
+ export function themeVars(theme: unknown): { theme: Theme; font: FontPair; palette: { primary: Palette; accent: Palette }; style: Record<string, string> };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreamtree-org/landing-kit",
3
- "version": "0.5.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",
@@ -36,6 +36,11 @@
36
36
  "import": "./dist/seo.js",
37
37
  "require": "./dist/seo.cjs"
38
38
  },
39
+ "./schema": {
40
+ "types": "./schema.d.ts",
41
+ "import": "./dist/schema.js",
42
+ "require": "./dist/schema.cjs"
43
+ },
39
44
  "./demo-server": {
40
45
  "types": "./demo-server.d.ts",
41
46
  "import": "./dist/demo-server.js",
@@ -60,6 +65,7 @@
60
65
  "auth.d.ts",
61
66
  "server.d.ts",
62
67
  "seo.d.ts",
68
+ "schema.d.ts",
63
69
  "demo-server.d.ts",
64
70
  "enhance.d.ts",
65
71
  "README.md",
@@ -70,7 +76,8 @@
70
76
  "build": "node build.mjs",
71
77
  "test": "node build.mjs && node --test test/*.test.mjs",
72
78
  "prepublishOnly": "npm test",
73
- "test:viewports": "node build.mjs && node test/viewports.mjs"
79
+ "test:viewports": "node build.mjs && node test/viewports.mjs",
80
+ "test:tenant-viewports": "node build.mjs && node test/tenant-viewports.mjs"
74
81
  },
75
82
  "peerDependencies": {
76
83
  "react": ">=18",
@@ -94,7 +101,8 @@
94
101
  "llms.txt",
95
102
  "express",
96
103
  "prerender",
97
- "dreamtree"
104
+ "dreamtree",
105
+ "multi-tenant"
98
106
  ],
99
107
  "publishConfig": {
100
108
  "access": "public"
package/schema.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ // @dreamtree-org/landing-kit/schema — tenant landing pages as data (no React, no DOM).
2
+
3
+ export type FieldKind = 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'select' | 'bool' | 'list' | 'richtext';
4
+ export interface FieldOption { value: string; label: string }
5
+ export interface FieldDef {
6
+ key: string;
7
+ kind: FieldKind;
8
+ label: string;
9
+ /** Characters for text kinds; items for lists. */
10
+ max?: number;
11
+ /** Minimum items (lists). */
12
+ min?: number;
13
+ required?: boolean;
14
+ options?: FieldOption[];
15
+ /** Fields of each list item. */
16
+ of?: FieldDef[];
17
+ /** Which item field names a list row in a builder. */
18
+ itemLabel?: string;
19
+ default?: unknown;
20
+ }
21
+ export interface SectionTypeDef {
22
+ label: string;
23
+ description: string;
24
+ /** Rendered by the app through TenantPage `slots[type]` (featured, categories). */
25
+ slot?: boolean;
26
+ fields: FieldDef[];
27
+ }
28
+ export type SectionType =
29
+ | 'hero_slider' | 'featured' | 'categories' | 'promo_banner' | 'trust_badges' | 'cta_cards' | 'testimonials'
30
+ | 'faq' | 'rich_text' | 'usp_strip' | 'brand_strip' | 'newsletter' | 'gallery';
31
+ export type IconName =
32
+ | 'shield-check' | 'badge-check' | 'truck' | 'wrench' | 'refresh' | 'battery' | 'cpu' | 'laptop' | 'star' | 'headset'
33
+ | 'rupee' | 'award' | 'clock' | 'map-pin' | 'phone' | 'check' | 'sparkles' | 'tag' | 'gift' | 'heart';
34
+ /** An uploaded image: the app resolves media_id to a URL. */
35
+ export interface MediaRef { media_id: string | number; alt?: string }
36
+
37
+ export interface PageSection { id: string; type: SectionType; enabled: boolean; props: Record<string, unknown> }
38
+ export interface PageDoc { version: 1; sections: PageSection[] }
39
+ export interface ValidationError { path: string; message: string }
40
+
41
+ export const SECTION_TYPES: Record<SectionType, SectionTypeDef>;
42
+ export const SECTION_TYPE_KEYS: SectionType[];
43
+ export const ICON_NAMES: IconName[];
44
+ export const PAGE_VERSION: 1;
45
+ export const MAX_SECTIONS: number;
46
+ export function sectionDefaults(type: SectionType): Record<string, unknown>;
47
+ export function newSectionId(type: SectionType): string;
48
+ export function newSection(type: SectionType): PageSection;
49
+
50
+ /** Normalise a page (defaults filled, unknown props dropped, rich text sanitised) and report every problem by path. */
51
+ export function validatePage(doc: unknown): { ok: boolean; errors: ValidationError[]; page: PageDoc };
52
+ /** Hero with the store's name and tagline, then the featured and category slots. */
53
+ export function defaultPage(store?: { name?: string; tagline?: string }): PageDoc;
54
+
55
+ export type FontPairKey = 'system' | 'inter' | 'poppins' | 'dmsans' | 'nunito' | 'serif';
56
+ export type RadiusKey = 'none' | 'sm' | 'md' | 'lg' | 'full';
57
+ export type ThemeMode = 'light' | 'dark' | 'auto';
58
+ export type PresetKey = 'classic' | 'forest' | 'sunset' | 'midnight' | 'slate' | 'berry';
59
+ export interface Theme {
60
+ preset: PresetKey | 'custom';
61
+ primary: string;
62
+ accent: string;
63
+ font: FontPairKey;
64
+ radius: RadiusKey;
65
+ mode: ThemeMode;
66
+ logo: MediaRef | null;
67
+ favicon: MediaRef | null;
68
+ }
69
+ export interface FontPair { label: string; heading: string; body: string; /** Stylesheet to load, or null for system fonts. */ href: string | null }
70
+ export type Palette = Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>;
71
+
72
+ export const THEME_PRESETS: Record<PresetKey, { label: string; primary: string; accent: string; font: FontPairKey; radius: RadiusKey; mode: ThemeMode }>;
73
+ export const FONT_PAIRS: Record<FontPairKey, FontPair>;
74
+ export const RADII: Record<RadiusKey, [string, string]>;
75
+ export const MODES: ThemeMode[];
76
+ export const DEFAULT_THEME: Theme;
77
+ /** Errors for malformed values; warnings for readability (contrast) the builder should show. */
78
+ export function validateTheme(theme: unknown): { ok: boolean; errors: ValidationError[]; warnings: ValidationError[]; theme: Theme };
79
+ /** CSS variables for a .dt-landing root plus the generated palettes (to map onto an app's own tokens). */
80
+ export function themeVars(theme: unknown): { theme: Theme; font: FontPair; palette: { primary: Palette; accent: Palette }; style: Record<string, string> };
81
+
82
+ /** Allowlist sanitiser (p, br, h2–h4, strong, b, em, i, u, ul, ol, li, a[href], blockquote). */
83
+ export function sanitizeRichText(html: unknown, opts?: { maxLength?: number }): string;
84
+ /** http(s), mailto:, tel:, /path or #fragment — otherwise null. */
85
+ export function safeHref(href: unknown): string | null;
86
+
87
+ /** 50…950 from one colour; the colour itself is step 600. */
88
+ export function brandPalette(hex: string): Palette;
89
+ export function contrastRatio(a: string, b: string): number;
90
+ /** '#ffffff' or '#101828', whichever reads better on `hex`. */
91
+ export function readableOn(hex: string): string;
92
+ export function luminance(hex: string): number;
93
+ export function hexToOklch(hex: string): { l: number; c: number; h: number };
94
+ export function oklchToHex(c: { l: number; c: number; h: number }): string;
95
+ export function isHex(v: unknown): boolean;
96
+ export const PALETTE_STEPS: number[];
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 };
package/styles.css CHANGED
@@ -876,3 +876,125 @@ html:has(.dt-landing) { scroll-behavior: smooth; scroll-padding-top: calc(var(--
876
876
  /* Shared helpers for form content inside the card */
877
877
  .dt-auth-link { color: var(--dt-brand); font-weight: 500; font-size: 0.875rem; text-decoration: none; }
878
878
  .dt-auth-link:hover { text-decoration: underline; }
879
+
880
+ /* ================================================================== *
881
+ * Tenant pages (TenantPage): a store's own landing page from data.
882
+ * Theme variables come from themeVars(): --dt-brand, --dt-brand-accent,
883
+ * --dt-brand-contrast, --dt-lp-font, --dt-lp-font-heading, --dt-lp-radius.
884
+ * ================================================================== */
885
+ .dt-lp-tenant { min-height: 0; --dt-brand-contrast: #fff; --dt-brand-accent-contrast: #fff; --dt-lp-font-heading: var(--dt-lp-font); }
886
+ .dt-lp-tenant :where(h1, h2, h3, .dt-lp-h2, .dt-lp-h3) { font-family: var(--dt-lp-font-heading); }
887
+ .dt-lp-tenant .dt-lp-btn--primary { color: var(--dt-brand-contrast); }
888
+ .dt-lp-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
889
+
890
+ /* Hero slider */
891
+ .dt-lp-hero-slider { position: relative; overflow: hidden; background: var(--dt-brand-panel); color: #fff; }
892
+ .dt-lp-slides { position: relative; display: grid; }
893
+ .dt-lp-slide { grid-area: 1 / 1; position: relative; display: flex; align-items: center; min-height: 22rem; opacity: 0; visibility: hidden; transition: opacity 0.5s ease, visibility 0s linear 0.5s; background: linear-gradient(120deg, var(--dt-brand-panel), var(--dt-brand)); }
894
+ .dt-lp-slide[data-active] { opacity: 1; visibility: visible; transition: opacity 0.5s ease; }
895
+ .dt-lp-hero-slider--compact .dt-lp-slide { min-height: 16rem; }
896
+ .dt-lp-hero-slider--tall .dt-lp-slide { min-height: 30rem; }
897
+ @media (min-width: 768px) {
898
+ .dt-lp-slide { min-height: 26rem; }
899
+ .dt-lp-hero-slider--compact .dt-lp-slide { min-height: 18rem; }
900
+ .dt-lp-hero-slider--tall .dt-lp-slide { min-height: 36rem; }
901
+ }
902
+ .dt-lp-slide-media, .dt-lp-slide-media img { position: absolute; inset: 0; width: 100%; height: 100%; }
903
+ .dt-lp-slide-media img { object-fit: cover; max-width: none; }
904
+ .dt-lp-slide--image::after { content: ''; position: absolute; inset: 0; pointer-events: none; }
905
+ .dt-lp-slide--overlay-light::after { background: linear-gradient(90deg, rgb(0 0 0 / 0.55), rgb(0 0 0 / 0.15)); }
906
+ .dt-lp-slide--overlay-strong::after { background: rgb(0 0 0 / 0.6); }
907
+ .dt-lp-slide--center.dt-lp-slide--overlay-light::after { background: rgb(0 0 0 / 0.4); }
908
+ .dt-lp-slide-body { position: relative; z-index: 1; width: 100%; padding-top: 3rem; padding-bottom: 4.5rem; }
909
+ .dt-lp-slide-copy { max-width: 38rem; display: grid; gap: 1rem; justify-items: start; }
910
+ .dt-lp-slide--center .dt-lp-slide-copy { margin: 0 auto; text-align: center; justify-items: center; }
911
+ .dt-lp-slide-eyebrow { color: rgb(255 255 255 / 0.85); }
912
+ .dt-lp-slide-heading { font-size: clamp(1.75rem, 4.5vw, 3rem); line-height: 1.12; font-weight: 700; letter-spacing: -0.02em; color: #fff; text-wrap: balance; }
913
+ .dt-lp-slide-text { font-size: 1.0625rem; color: rgb(255 255 255 / 0.9); max-width: 34rem; }
914
+ .dt-lp-slide-cta { margin-top: 0.5rem; }
915
+ .dt-lp-slider-controls { position: absolute; left: 0; right: 0; bottom: 1rem; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
916
+ .dt-lp-slider-btn { display: inline-flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 999px; border: 1px solid rgb(255 255 255 / 0.5); background: rgb(0 0 0 / 0.35); color: #fff; cursor: pointer; }
917
+ .dt-lp-slider-btn:hover { background: rgb(0 0 0 / 0.55); }
918
+ .dt-lp-slider-btn:focus-visible, .dt-lp-slider-dot:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
919
+ .dt-lp-slider-dots { display: flex; gap: 0.25rem; }
920
+ .dt-lp-slider-dot { width: 1.75rem; height: 2.75rem; border: 0; background: transparent; cursor: pointer; position: relative; padding: 0; }
921
+ .dt-lp-slider-dot::before { content: ''; position: absolute; left: 50%; top: 50%; width: 0.625rem; height: 0.625rem; border-radius: 999px; transform: translate(-50%, -50%); background: rgb(255 255 255 / 0.5); }
922
+ .dt-lp-slider-dot[aria-current='true']::before { background: #fff; width: 1.25rem; }
923
+ @media (prefers-reduced-motion: reduce) {
924
+ .dt-lp-slide, .dt-lp-slide[data-active] { transition: none; }
925
+ }
926
+
927
+ /* Grids and cards for tenant sections */
928
+ .dt-lp-tgrid { display: grid; gap: 1rem; margin-top: 2rem; grid-template-columns: 1fr; }
929
+ .dt-lp-intro + .dt-lp-tgrid { margin-top: 2rem; }
930
+ .dt-lp-tgrid:first-child { margin-top: 0; }
931
+ @media (min-width: 640px) { .dt-lp-tgrid--2, .dt-lp-tgrid--3, .dt-lp-tgrid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
932
+ @media (min-width: 1024px) { .dt-lp-tgrid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .dt-lp-tgrid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
933
+ .dt-lp-tcard { height: 100%; }
934
+ .dt-lp-ctacard { display: flex; gap: 1rem; align-items: flex-start; height: 100%; transition: border-color 0.15s, box-shadow 0.15s; }
935
+ .dt-lp-ctacard:hover { border-color: var(--dt-brand-line); box-shadow: 0 4px 16px rgb(16 24 40 / 0.08); }
936
+ .dt-lp-ctacard .dt-lp-icon-tile + .dt-lp-card-text { margin-top: 0; }
937
+ .dt-lp-ctacard .dt-lp-textlink { margin-top: 0.75rem; }
938
+ .dt-lp-quote { display: flex; flex-direction: column; gap: 0.75rem; }
939
+ .dt-lp-quote blockquote p { color: var(--dt-lp-text); font-size: 0.9375rem; }
940
+ .dt-lp-quote-by { font-size: 0.875rem; color: var(--dt-lp-muted); margin-top: auto; }
941
+ .dt-lp-stars { display: flex; gap: 0.125rem; font-size: 1rem; line-height: 1; }
942
+ .dt-lp-star { color: var(--dt-lp-border); }
943
+ .dt-lp-star--on { color: var(--dt-lp-warn); }
944
+
945
+ /* Promo banner */
946
+ .dt-lp-promo-wrap { padding: 2rem 0; }
947
+ .dt-lp-promo { position: relative; display: grid; gap: 1.5rem; align-items: center; padding: 2rem; border-radius: var(--dt-lp-radius-lg); overflow: hidden; color: #fff; }
948
+ .dt-lp-promo--brand { background: linear-gradient(120deg, var(--dt-brand-panel), var(--dt-brand)); }
949
+ .dt-lp-promo--accent { background: var(--dt-brand-accent); color: var(--dt-brand-accent-contrast); }
950
+ .dt-lp-promo--dark { background: var(--dt-lp-dark-bg); }
951
+ .dt-lp-promo-copy { display: grid; gap: 0.75rem; justify-items: start; }
952
+ .dt-lp-promo .dt-lp-promo-heading { color: inherit; }
953
+ .dt-lp-promo-text { opacity: 0.9; max-width: 36rem; }
954
+ .dt-lp-promo-img { width: 100%; max-height: 16rem; object-fit: cover; border-radius: var(--dt-lp-radius); }
955
+ @media (min-width: 768px) { .dt-lp-promo { padding: 2.5rem 3rem; } .dt-lp-promo--image { grid-template-columns: 3fr 2fr; } }
956
+
957
+ /* Rich text */
958
+ .dt-lp-prose { margin-top: 1rem; color: var(--dt-lp-body); }
959
+ .dt-lp-prose > * + * { margin-top: 0.875rem; }
960
+ .dt-lp-prose h2, .dt-lp-prose h3, .dt-lp-prose h4 { color: var(--dt-lp-text); font-weight: 600; line-height: 1.3; }
961
+ .dt-lp-prose h2 { font-size: 1.375rem; margin-top: 1.5rem; }
962
+ .dt-lp-prose h3 { font-size: 1.125rem; margin-top: 1.25rem; }
963
+ .dt-lp-prose ul, .dt-lp-prose ol { padding-left: 1.25rem; }
964
+ .dt-lp-prose ul { list-style: disc; }
965
+ .dt-lp-prose ol { list-style: decimal; }
966
+ .dt-lp-prose a { color: var(--dt-brand-ink); text-decoration: underline; }
967
+ .dt-lp-prose blockquote { border-left: 3px solid var(--dt-brand-line); padding-left: 1rem; color: var(--dt-lp-text); }
968
+
969
+ /* Highlights strip */
970
+ .dt-lp-usp { padding: 1rem 0; }
971
+ .dt-lp-usp-list { display: grid; gap: 0.75rem 1.5rem; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); }
972
+ .dt-lp-usp-item { display: flex; align-items: flex-start; gap: 0.625rem; font-size: 0.9375rem; color: var(--dt-lp-text); }
973
+ .dt-lp-usp-icon { color: var(--dt-brand-ink); margin-top: 0.125rem; }
974
+ .dt-lp-usp-text { display: block; font-size: 0.8125rem; color: var(--dt-lp-muted); }
975
+
976
+ /* Brands */
977
+ .dt-lp-brand-list { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem 2.5rem; margin-top: 1.5rem; }
978
+ .dt-lp-brand-item { display: flex; align-items: center; min-height: 2.5rem; font-weight: 700; font-size: 1.125rem; color: var(--dt-lp-muted); letter-spacing: 0.02em; }
979
+ .dt-lp-brand-item img { max-height: 2.5rem; width: auto; filter: grayscale(1); opacity: 0.8; }
980
+
981
+ /* Newsletter */
982
+ .dt-lp-news { margin-top: 1.5rem; display: grid; gap: 0.75rem; }
983
+ .dt-lp-news-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
984
+ .dt-lp-input { flex: 1 1 14rem; min-height: 2.75rem; padding: 0.5rem 0.875rem; border: 1px solid var(--dt-lp-border); border-radius: var(--dt-lp-radius); background: var(--dt-lp-surface); color: var(--dt-lp-text); font: inherit; }
985
+ .dt-lp-input:focus-visible { outline: 2px solid var(--dt-brand); outline-offset: 1px; }
986
+ .dt-lp-news-consent { display: flex; gap: 0.5rem; align-items: flex-start; font-size: 0.8125rem; color: var(--dt-lp-body); text-align: left; }
987
+ .dt-lp-news-consent input { margin-top: 0.2rem; accent-color: var(--dt-brand); }
988
+ .dt-lp-news-done { margin-top: 1.5rem; text-align: center; font-weight: 600; color: var(--dt-brand-ink); }
989
+ .dt-lp-news-error { font-size: 0.875rem; color: #b42318; }
990
+
991
+ /* Gallery */
992
+ .dt-lp-gallery { display: grid; gap: 0.75rem; margin-top: 1.5rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
993
+ @media (min-width: 768px) { .dt-lp-gallery--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } .dt-lp-gallery--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
994
+ .dt-lp-gallery figure { position: relative; border-radius: var(--dt-lp-radius); overflow: hidden; background: var(--dt-lp-muted-bg); aspect-ratio: 4 / 3; }
995
+ .dt-lp-gallery img { width: 100%; height: 100%; object-fit: cover; }
996
+ .dt-lp-gallery figcaption { position: absolute; left: 0; right: 0; bottom: 0; padding: 0.5rem 0.75rem; font-size: 0.8125rem; color: #fff; background: linear-gradient(transparent, rgb(0 0 0 / 0.6)); }
997
+
998
+ /* App-filled slots (featured, categories) */
999
+ .dt-lp-slot-body { margin-top: 1.5rem; }
1000
+ .dt-lp-intro:empty + .dt-lp-slot-body, .dt-lp-slot-body:first-child { margin-top: 0; }