@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/MIGRATION.md +24 -0
- package/README.md +45 -1
- package/dist/index.cjs +1065 -52
- package/dist/index.js +1065 -52
- package/dist/schema.cjs +728 -0
- package/dist/schema.js +676 -0
- package/dist/server.cjs +33 -17
- package/dist/server.js +32 -17
- package/index.d.ts +29 -0
- package/package.json +11 -3
- package/schema.d.ts +96 -0
- package/server.d.ts +9 -1
- package/styles.css +122 -0
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/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.
|
|
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:
|
|
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; }
|