@cosmicdrift/kumiko-headless 0.61.0 → 0.64.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.61.0",
3
+ "version": "0.64.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -25,6 +25,10 @@
25
25
  "./dispatcher": {
26
26
  "types": "./src/dispatcher/index.ts",
27
27
  "default": "./src/dispatcher/index.ts"
28
+ },
29
+ "./apex": {
30
+ "types": "./src/apex/index.ts",
31
+ "default": "./src/apex/index.ts"
28
32
  }
29
33
  },
30
34
  "dependencies": {
@@ -0,0 +1,144 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type ApexPage, renderApexPage } from "../index";
3
+
4
+ const brand = { tokensCss: ":root{--primary:#123;--primary-fg:#fff;--bg:#fff;--fg:#000;}" };
5
+
6
+ function page(overrides: Partial<ApexPage> = {}): ApexPage {
7
+ return {
8
+ brand,
9
+ head: { lang: "de", title: "T", description: "D" },
10
+ header: { brand: { href: "/", label: "Acme" } },
11
+ sections: [],
12
+ footer: { brand: { label: "Acme" } },
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe("renderApexPage", () => {
18
+ test("theme dark sets body class, light (default) does not", () => {
19
+ expect(renderApexPage(page({ theme: "dark" }))).toContain('<body class="apex-dark">');
20
+ // light is default — both CSS sets ship, only the body class toggles
21
+ expect(renderApexPage(page())).toContain("<body>");
22
+ expect(renderApexPage(page())).not.toContain('<body class="apex-dark">');
23
+ });
24
+
25
+ test("escapes user-provided text in title and content", () => {
26
+ const html = renderApexPage(
27
+ page({
28
+ head: { lang: "de", title: "<script>x</script>", description: "a & b" },
29
+ sections: [{ kind: "info-grid", items: [{ title: "<b>t</b>", desc: "d" }] }],
30
+ }),
31
+ );
32
+ expect(html).not.toContain("<script>x</script>");
33
+ expect(html).toContain("&lt;script&gt;");
34
+ expect(html).toContain("a &amp; b");
35
+ expect(html).toContain("&lt;b&gt;t&lt;/b&gt;");
36
+ });
37
+
38
+ test("metaHtml and feature icon are passed through unescaped (app-authored)", () => {
39
+ const html = renderApexPage(
40
+ page({
41
+ sections: [
42
+ { kind: "hero", title: "h", tagline: "t", metaHtml: "<strong>ok</strong>" },
43
+ {
44
+ kind: "feature-grid",
45
+ heading: "F",
46
+ items: [{ icon: "<rect/>", title: "x", desc: "d" }],
47
+ },
48
+ ],
49
+ }),
50
+ );
51
+ expect(html).toContain("<strong>ok</strong>");
52
+ expect(html).toContain("<svg"); // icon wrapped in standard svg
53
+ expect(html).toContain("<rect/>");
54
+ });
55
+
56
+ test("pricing: featured card gets badge + featured class, cap line precedes benefits", () => {
57
+ const html = renderApexPage(
58
+ page({
59
+ sections: [
60
+ {
61
+ kind: "pricing-grid",
62
+ heading: "P",
63
+ tiers: [
64
+ {
65
+ name: "Pro",
66
+ amount: "9 €",
67
+ priceSuffix: "/Monat",
68
+ featured: true,
69
+ badge: "Beliebt",
70
+ capLine: "50 X",
71
+ benefits: ["b1"],
72
+ cta: { label: "Go", href: "/s" },
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ }),
78
+ );
79
+ expect(html).toContain("price-card--featured");
80
+ expect(html).toContain('class="price-badge">Beliebt');
81
+ expect(html).toContain("/Monat");
82
+ // cap line must render before the first benefit
83
+ expect(html.indexOf("price-cap")).toBeLessThan(html.indexOf("b1"));
84
+ // featured tier without explicit cta variant → primary button
85
+ expect(html).toContain('class="btn btn-primary" href="/s"');
86
+ });
87
+
88
+ test("cta variant link renders a plain anchor, default renders a button", () => {
89
+ const html = renderApexPage(
90
+ page({
91
+ header: {
92
+ brand: { href: "/", label: "Acme" },
93
+ actions: [
94
+ { label: "Login", href: "/login", variant: "link" },
95
+ { label: "Start", href: "/signup" },
96
+ ],
97
+ },
98
+ }),
99
+ );
100
+ expect(html).toContain('<a href="/login">Login</a>'); // link → no class
101
+ expect(html).toContain('<a class="btn btn-primary" href="/signup">Start</a>');
102
+ });
103
+
104
+ test("footer --footer-cols reflects column count and survives without columns", () => {
105
+ const twoCols = renderApexPage(
106
+ page({
107
+ footer: {
108
+ brand: { label: "Acme" },
109
+ columns: [
110
+ { heading: "A", links: [{ href: "/a", label: "a" }] },
111
+ { heading: "B", links: [{ href: "/b", label: "b" }] },
112
+ ],
113
+ },
114
+ }),
115
+ );
116
+ expect(twoCols).toContain("--footer-cols:2");
117
+ expect(renderApexPage(page())).toContain("--footer-cols:0");
118
+ });
119
+
120
+ test("renders every section kind without throwing or leaking undefined", () => {
121
+ const html = renderApexPage(
122
+ page({
123
+ sections: [
124
+ { kind: "hero", title: "h", tagline: "t" },
125
+ { kind: "feature-grid", heading: "F", items: [{ title: "x", desc: "d" }] },
126
+ {
127
+ kind: "pricing-grid",
128
+ heading: "P",
129
+ tiers: [
130
+ { name: "Free", amount: "0 €", benefits: ["b"], cta: { label: "g", href: "/s" } },
131
+ ],
132
+ },
133
+ { kind: "info-grid", items: [{ title: "t", desc: "d" }] },
134
+ { kind: "final-cta", heading: "c", cta: { label: "g", href: "/s" } },
135
+ { kind: "html", html: "<div id='raw'></div>" },
136
+ ],
137
+ }),
138
+ );
139
+ expect(html).not.toContain("undefined");
140
+ expect(html).not.toContain("[object Object]");
141
+ expect(html).toContain("id='raw'");
142
+ expect(html).toContain("trust-item");
143
+ });
144
+ });
@@ -0,0 +1,201 @@
1
+ // Structural CSS for the Apex marketing surface — shared by every app's static
2
+ // landing. Brand TOKEN VALUES (:root custom properties) and @font-face stay
3
+ // app-side (passed as brand.tokensCss / brand.fontFaceCss); this file owns only
4
+ // the structure: layout, grids, cards, buttons, header/footer, responsive.
5
+ //
6
+ // Two themes ship together; the <body class> selects one (see renderApexPage):
7
+ // - light (default): chrome on light backgrounds — translucent blurred header.
8
+ // - .apex-dark: header/hero/final-cta/footer sit on --primary with --on-dark
9
+ // text + inverted buttons (the "dark sandwich"). Requires the app's tokens
10
+ // to define --on-dark / --on-dark-muted / --on-dark-border.
11
+ //
12
+ // Token divergence is absorbed by CSS fallbacks, not parameters:
13
+ // --font-mono unset → .price-amount stays in body font (var(--font-mono, inherit))
14
+ // --accent-hover unset → eyebrow falls back to --primary
15
+ // --hero-tagline-max unset → hero tagline runs full column (var(--hero-tagline-max, none))
16
+ // icon tint → color-mix derives a faint --primary wash, no hardcoded rgba
17
+
18
+ const BASE_LAYOUT = `
19
+ * { box-sizing: border-box; }
20
+ html, body { margin: 0; padding: 0; }
21
+ body {
22
+ font-family: var(--font-body, "Inter Variable", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
23
+ background: var(--bg); color: var(--fg); line-height: 1.6;
24
+ -webkit-font-smoothing: antialiased;
25
+ min-height: 100vh; display: flex; flex-direction: column;
26
+ }
27
+ main { flex: 1; }
28
+ a { color: var(--primary); text-decoration: none; }
29
+ a:hover { color: var(--primary-hover); }
30
+ h1, h2, h3 { letter-spacing: -0.02em; }
31
+ .container { max-width: 1120px; margin: 0 auto; padding: 0 1.5rem; width: 100%; }
32
+ .container-narrow { max-width: 760px; margin: 0 auto; padding: 0 1.5rem; width: 100%; }
33
+ .btn {
34
+ display: inline-block; padding: 0.6rem 1.15rem; border-radius: 0.5rem;
35
+ font-weight: 600; font-size: 0.9375rem; border: 1px solid transparent;
36
+ cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.05s;
37
+ }
38
+ .btn:active { transform: translateY(1px); }
39
+ .btn-primary { background: var(--primary); color: var(--primary-fg); }
40
+ .btn-primary:hover { background: var(--primary-hover); color: var(--primary-fg); }
41
+ .btn-secondary { background: var(--bg-card); color: var(--fg); border-color: var(--border); }
42
+ .btn-secondary:hover { border-color: var(--fg-muted); color: var(--fg); }
43
+
44
+ section { padding: 5rem 0; }
45
+ .section-head { text-align: center; max-width: 640px; margin: 0 auto 3rem; }
46
+ .section-head h2 { font-size: clamp(1.6rem, 3.5vw, 2.25rem); margin: 0 0 0.75rem; }
47
+ .section-head p { color: var(--fg-muted); margin: 0; font-size: 1.0625rem; }
48
+ .eyebrow { display: inline-block; font-size: 0.8125rem; font-weight: 600; letter-spacing: 0.04em;
49
+ text-transform: uppercase; color: var(--accent-hover, var(--primary)); margin-bottom: 0.75rem; }
50
+ `;
51
+
52
+ const HERO = `
53
+ .hero { padding: 4.5rem 0 4rem; }
54
+ .hero-grid { display: grid; grid-template-columns: 1.05fr 1fr; gap: 3.5rem; align-items: center; }
55
+ .hero-pony { width: 88px; height: 88px; margin: 0 0 1.1rem; display: block; }
56
+ .hero h1 { font-size: clamp(2.1rem, 4.5vw, 3.4rem); line-height: 1.08; margin: 0 0 1.1rem; font-weight: 700; }
57
+ .hero .tagline { font-size: clamp(1.05rem, 1.6vw, 1.25rem); color: var(--fg-muted); margin: 0 0 1.75rem; max-width: var(--hero-tagline-max, none); }
58
+ .hero-cta { display: flex; gap: 0.75rem; flex-wrap: wrap; }
59
+ .hero-meta { margin-top: 1.5rem; font-size: 0.875rem; color: var(--fg-subtle); }
60
+ .hero-meta strong { color: var(--fg-muted); font-weight: 600; }
61
+ .shot-frame { border-radius: 0.75rem; border: 1px solid var(--border); background: var(--bg-card);
62
+ box-shadow: var(--shadow); overflow: hidden; }
63
+ .shot-bar { display: flex; gap: 0.4rem; padding: 0.6rem 0.85rem; border-bottom: 1px solid var(--border); background: var(--bg-muted); }
64
+ .shot-bar span { width: 0.65rem; height: 0.65rem; border-radius: 50%; background: var(--border); }
65
+ .shot-frame img { display: block; width: 100%; height: auto; }
66
+ `;
67
+
68
+ const FEATURES = `
69
+ .features { background: var(--bg-muted); }
70
+ .feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; }
71
+ .feature { background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.75rem; padding: 1.75rem; }
72
+ .feature-icon { width: 2.5rem; height: 2.5rem; border-radius: 0.6rem; display: flex; align-items: center;
73
+ justify-content: center; background: color-mix(in srgb, var(--primary) 9%, transparent); color: var(--primary); margin-bottom: 1rem; }
74
+ .feature-icon svg { width: 1.35rem; height: 1.35rem; }
75
+ .feature h3 { font-size: 1.0625rem; margin: 0 0 0.4rem; }
76
+ .feature p { color: var(--fg-muted); margin: 0; font-size: 0.9375rem; }
77
+ `;
78
+
79
+ const PRICING = `
80
+ .price-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.25rem; align-items: stretch; }
81
+ .price-card { position: relative; background: var(--bg-card); border: 1px solid var(--border);
82
+ border-radius: 0.75rem; padding: 1.75rem 1.5rem; display: flex; flex-direction: column; gap: 0.85rem; }
83
+ .price-card--featured { border-color: var(--primary); box-shadow: var(--shadow); }
84
+ .price-badge { position: absolute; top: -0.7rem; left: 50%; transform: translateX(-50%);
85
+ background: var(--accent, var(--primary)); color: var(--accent-fg, var(--primary-fg)); font-size: 0.75rem; font-weight: 700;
86
+ padding: 0.2rem 0.7rem; border-radius: 999px; white-space: nowrap; }
87
+ .price-card h3 { margin: 0; font-size: 1.25rem; }
88
+ .price-tagline { margin: 0; color: var(--fg-subtle); font-size: 0.875rem; }
89
+ .price-amount { font-size: 2rem; font-weight: 700; font-family: var(--font-mono, inherit); }
90
+ .price-amount span { font-size: 0.9375rem; font-weight: 500; color: var(--fg-subtle); font-family: inherit; }
91
+ .price-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.55rem; flex: 1; }
92
+ .price-list li { position: relative; padding-left: 1.5rem; font-size: 0.9rem; color: var(--fg-muted); }
93
+ .price-list li::before { content: ""; position: absolute; left: 0; top: 0.45rem; width: 0.7rem; height: 0.4rem;
94
+ border-left: 2px solid var(--status-ok); border-bottom: 2px solid var(--status-ok); transform: rotate(-45deg); }
95
+ .price-list li.price-cap { font-weight: 600; color: var(--fg); }
96
+ .price-card .btn { text-align: center; margin-top: 0.5rem; }
97
+ `;
98
+
99
+ const TRUST = `
100
+ .trust-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2rem; }
101
+ .trust-item h3 { font-size: 1.0625rem; margin: 0 0 0.4rem; }
102
+ .trust-item p { color: var(--fg-muted); margin: 0; font-size: 0.9375rem; }
103
+ `;
104
+
105
+ const FINAL_CTA = `
106
+ .final-cta { text-align: center; }
107
+ .final-cta-pony { width: 96px; height: auto; display: block; margin: 0 auto 1rem; }
108
+ .final-cta h2 { font-size: clamp(1.6rem, 3.5vw, 2.25rem); margin: 0 0 0.75rem; }
109
+ .final-cta p { color: var(--fg-muted); margin: 0 0 1.75rem; font-size: 1.0625rem; }
110
+ `;
111
+
112
+ // Light chrome (default). Header floats translucent over the page; footer is a
113
+ // muted band. Dark theme overrides both below.
114
+ const CHROME_LIGHT = `
115
+ header { position: sticky; top: 0; z-index: 10;
116
+ background: color-mix(in srgb, var(--bg) 85%, transparent);
117
+ backdrop-filter: saturate(140%) blur(8px);
118
+ border-bottom: 1px solid var(--border); }
119
+ .nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.85rem 0; }
120
+ .brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; font-size: 1.125rem; color: var(--fg); }
121
+ .brand a { color: var(--fg); display: inline-flex; align-items: center; gap: 0.55rem; }
122
+ .brand a:hover { color: var(--fg); }
123
+ .brand img { width: 1.7rem; height: 1.7rem; border-radius: 0.4rem; }
124
+ .nav-links { display: flex; gap: 1.5rem; align-items: center; font-size: 0.9375rem; }
125
+ .nav-links a { color: var(--fg-muted); }
126
+ .nav-links a:hover { color: var(--fg); }
127
+ .nav-actions { display: flex; gap: 0.75rem; align-items: center; font-size: 0.9375rem; }
128
+ .nav-actions > a:not(.btn) { color: var(--fg-muted); }
129
+ .nav-actions > a:not(.btn):hover { color: var(--fg); }
130
+ @media (max-width: 640px) { .nav-links { display: none; } }
131
+
132
+ footer { border-top: 1px solid var(--border); color: var(--fg-subtle); padding: 3.5rem 0 2.5rem; font-size: 0.9rem; }
133
+ .footer-grid { display: grid; gap: 2rem; grid-template-columns: 1.6fr repeat(var(--footer-cols, 2), 1fr); }
134
+ .footer-brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; color: var(--fg); margin-bottom: 0.75rem; }
135
+ .footer-brand img { width: 1.6rem; height: 1.6rem; border-radius: 0.4rem; }
136
+ .footer-tagline { color: var(--fg-subtle); max-width: 34ch; margin: 0; }
137
+ .footer-col h4 { font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--fg-subtle); margin: 0 0 0.85rem; }
138
+ .footer-col a { display: block; color: var(--fg-muted); margin-bottom: 0.5rem; }
139
+ .footer-col a:hover { color: var(--fg); }
140
+ .footer-bottom { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border);
141
+ display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; }
142
+ `;
143
+
144
+ // Dark sandwich: header/hero/final-cta/footer ride on --primary. Buttons invert
145
+ // (a --primary button on --primary would vanish) → light button, --primary text.
146
+ const CHROME_DARK = `
147
+ .apex-dark header { background: var(--primary); backdrop-filter: none; border-bottom: 1px solid var(--on-dark-border); }
148
+ .apex-dark .brand, .apex-dark .brand a, .apex-dark .brand a:hover { color: var(--on-dark); }
149
+ .apex-dark .nav-links a { color: var(--on-dark-muted); }
150
+ .apex-dark .nav-links a:hover { color: var(--on-dark); }
151
+ .apex-dark .nav-actions > a:not(.btn) { color: var(--on-dark-muted); }
152
+ .apex-dark .nav-actions > a:not(.btn):hover { color: var(--on-dark); }
153
+ .apex-dark header .btn-primary { background: var(--on-dark); color: var(--primary); }
154
+ .apex-dark header .btn-primary:hover { background: rgba(255,255,255,0.85); color: var(--primary-hover); }
155
+
156
+ .apex-dark .hero { background: var(--primary); color: var(--on-dark); }
157
+ .apex-dark .hero h1 { color: var(--on-dark); }
158
+ .apex-dark .hero .tagline { color: var(--on-dark-muted); }
159
+ .apex-dark .hero .btn-primary, .apex-dark .final-cta .btn-primary { background: var(--on-dark); color: var(--primary); }
160
+ .apex-dark .hero .btn-primary:hover, .apex-dark .final-cta .btn-primary:hover { background: rgba(255,255,255,0.85); color: var(--primary-hover); }
161
+ .apex-dark .hero .btn-secondary { background: rgba(255,255,255,0.08); color: var(--on-dark); border-color: rgba(255,255,255,0.30); }
162
+ .apex-dark .hero .btn-secondary:hover { background: rgba(255,255,255,0.16); color: var(--on-dark); border-color: rgba(255,255,255,0.5); }
163
+ .apex-dark .hero-meta { color: rgba(255,255,255,0.55); }
164
+ .apex-dark .hero-meta strong { color: var(--on-dark-muted); }
165
+
166
+ .apex-dark .final-cta { background: var(--primary); color: var(--on-dark); }
167
+ .apex-dark .final-cta h2 { color: var(--on-dark); }
168
+ .apex-dark .final-cta p { color: var(--on-dark-muted); }
169
+
170
+ .apex-dark footer { background: var(--primary); color: var(--on-dark-muted); border-top: none; }
171
+ .apex-dark .footer-brand { color: var(--on-dark); }
172
+ .apex-dark .footer-tagline { color: var(--on-dark-muted); }
173
+ .apex-dark .footer-col h4 { color: var(--on-dark-muted); }
174
+ .apex-dark .footer-col a { color: var(--on-dark-muted); }
175
+ .apex-dark .footer-col a:hover { color: var(--on-dark); }
176
+ .apex-dark .footer-bottom { border-top: 1px solid var(--on-dark-border); }
177
+ `;
178
+
179
+ const RESPONSIVE = `
180
+ @media (max-width: 900px) {
181
+ .hero-grid { grid-template-columns: 1fr; gap: 2.5rem; }
182
+ .feature-grid { grid-template-columns: repeat(2, 1fr); }
183
+ .price-grid { grid-template-columns: repeat(2, 1fr); }
184
+ .footer-grid { grid-template-columns: 1fr 1fr; }
185
+ }
186
+ @media (max-width: 640px) {
187
+ section { padding: 3.5rem 0; }
188
+ .feature-grid, .price-grid, .trust-grid, .footer-grid { grid-template-columns: 1fr; }
189
+ }
190
+ `;
191
+
192
+ export const APEX_STRUCTURAL_CSS =
193
+ BASE_LAYOUT +
194
+ HERO +
195
+ FEATURES +
196
+ PRICING +
197
+ TRUST +
198
+ FINAL_CTA +
199
+ CHROME_LIGHT +
200
+ CHROME_DARK +
201
+ RESPONSIVE;
@@ -0,0 +1,416 @@
1
+ // Apex marketing-surface renderer — turns a typed page description into a
2
+ // complete static HTML string. The shared structure (header/hero/feature-grid/
3
+ // pricing-grid/info-grid/final-cta/footer) lives here; apps pass only their data,
4
+ // brand tokens (brand.tokensCss) and content. Server-side, zero React, one
5
+ // cacheable HTTP response. See APEX_STRUCTURAL_CSS for the CSS contract.
6
+
7
+ import { escapeHtml } from "../format";
8
+ import { APEX_STRUCTURAL_CSS } from "./css";
9
+
10
+ export type ApexTheme = "light" | "dark";
11
+
12
+ /** Brand is raw CSS the app owns: the :root token block and optional @font-face.
13
+ * Token names referenced by the structural CSS: --bg --bg-card --bg-muted
14
+ * --border --fg --fg-muted --fg-subtle --primary --primary-hover --primary-fg
15
+ * --status-ok --shadow. Optional: --accent --accent-fg --font-mono --font-body,
16
+ * and (dark theme) --on-dark --on-dark-muted --on-dark-border. */
17
+ export type ApexBrand = {
18
+ readonly tokensCss: string;
19
+ readonly fontFaceCss?: string;
20
+ };
21
+
22
+ export type ApexCtaVariant = "primary" | "secondary" | "link";
23
+ export type ApexCta = {
24
+ readonly label: string;
25
+ readonly href: string;
26
+ /** "link" renders a plain anchor (no .btn). Default "primary". */
27
+ readonly variant?: ApexCtaVariant;
28
+ };
29
+
30
+ export type ApexLink = { readonly label: string; readonly href: string };
31
+ export type ApexImage = {
32
+ readonly src: string;
33
+ readonly alt: string;
34
+ readonly width?: number;
35
+ readonly height?: number;
36
+ };
37
+
38
+ export type ApexHeader = {
39
+ readonly brand: { readonly href: string; readonly label: string; readonly logoSrc?: string };
40
+ readonly navLinks?: readonly ApexLink[];
41
+ readonly actions?: readonly ApexCta[];
42
+ };
43
+
44
+ export type ApexFooterColumn = { readonly heading: string; readonly links: readonly ApexLink[] };
45
+ export type ApexFooter = {
46
+ readonly brand: { readonly label: string; readonly logoSrc?: string };
47
+ readonly tagline?: string;
48
+ readonly columns?: readonly ApexFooterColumn[];
49
+ /** Plain text — use unicode (© ·), not HTML entities; rendered escaped. */
50
+ readonly bottomLeft?: string;
51
+ readonly bottomRight?: string;
52
+ };
53
+
54
+ export type ApexHeroSection = {
55
+ readonly kind: "hero";
56
+ readonly logo?: ApexImage;
57
+ readonly title: string;
58
+ readonly tagline: string;
59
+ readonly ctas?: readonly ApexCta[];
60
+ /** App-authored trusted HTML (e.g. <strong>…<br/>); rendered verbatim. */
61
+ readonly metaHtml?: string;
62
+ readonly screenshot?: ApexImage;
63
+ };
64
+
65
+ export type ApexFeature = {
66
+ /** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>. Trusted. */
67
+ readonly icon?: string;
68
+ readonly title: string;
69
+ readonly desc: string;
70
+ };
71
+ export type ApexFeatureGridSection = {
72
+ readonly kind: "feature-grid";
73
+ readonly id?: string;
74
+ readonly eyebrow?: string;
75
+ readonly heading: string;
76
+ readonly sub?: string;
77
+ /** Sit on the muted band (.features). Default true. */
78
+ readonly muted?: boolean;
79
+ readonly items: readonly ApexFeature[];
80
+ };
81
+
82
+ export type ApexPricingTier = {
83
+ readonly name: string;
84
+ readonly tagline?: string;
85
+ /** App-formatted ("4,99 €", "0 €", "auf Anfrage"). */
86
+ readonly amount: string;
87
+ /** Localized suffix after the amount ("/Monat", "/month"). */
88
+ readonly priceSuffix?: string;
89
+ readonly featured?: boolean;
90
+ readonly badge?: string;
91
+ readonly capLine?: string;
92
+ readonly benefits: readonly string[];
93
+ readonly cta: ApexCta;
94
+ };
95
+ export type ApexPricingGridSection = {
96
+ readonly kind: "pricing-grid";
97
+ readonly id?: string;
98
+ readonly eyebrow?: string;
99
+ readonly heading: string;
100
+ readonly sub?: string;
101
+ readonly tiers: readonly ApexPricingTier[];
102
+ };
103
+
104
+ export type ApexInfoItem = { readonly title: string; readonly desc: string };
105
+ export type ApexInfoGridSection = {
106
+ readonly kind: "info-grid";
107
+ readonly id?: string;
108
+ readonly eyebrow?: string;
109
+ readonly heading?: string;
110
+ readonly sub?: string;
111
+ readonly muted?: boolean;
112
+ readonly items: readonly ApexInfoItem[];
113
+ };
114
+
115
+ export type ApexFinalCtaSection = {
116
+ readonly kind: "final-cta";
117
+ readonly image?: ApexImage;
118
+ readonly heading: string;
119
+ readonly sub?: string;
120
+ readonly cta: ApexCta;
121
+ };
122
+
123
+ /** Escape hatch for an app-specific section: raw, app-authored HTML. */
124
+ export type ApexHtmlSection = { readonly kind: "html"; readonly html: string };
125
+
126
+ export type ApexSection =
127
+ | ApexHeroSection
128
+ | ApexFeatureGridSection
129
+ | ApexPricingGridSection
130
+ | ApexInfoGridSection
131
+ | ApexFinalCtaSection
132
+ | ApexHtmlSection;
133
+
134
+ export type ApexHead = {
135
+ readonly lang: string;
136
+ readonly title: string;
137
+ readonly description: string;
138
+ readonly canonicalUrl?: string;
139
+ readonly faviconHref?: string;
140
+ readonly ogImage?: string;
141
+ /** hreflang alternates for multilingual SEO (e.g. the other language's URL). */
142
+ readonly alternates?: readonly { readonly hreflang: string; readonly href: string }[];
143
+ };
144
+
145
+ export type ApexPage = {
146
+ readonly theme?: ApexTheme;
147
+ readonly brand: ApexBrand;
148
+ readonly head: ApexHead;
149
+ readonly header: ApexHeader;
150
+ readonly sections: readonly ApexSection[];
151
+ readonly footer: ApexFooter;
152
+ };
153
+
154
+ function dim(img: ApexImage): string {
155
+ return `${img.width !== undefined ? ` width="${img.width}"` : ""}${img.height !== undefined ? ` height="${img.height}"` : ""}`;
156
+ }
157
+
158
+ function svgIcon(inner: string): string {
159
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
160
+ }
161
+
162
+ function renderCta(cta: ApexCta): string {
163
+ const variant = cta.variant ?? "primary";
164
+ const cls = variant === "link" ? "" : ` class="btn btn-${variant}"`;
165
+ return `<a${cls} href="${escapeHtml(cta.href)}">${escapeHtml(cta.label)}</a>`;
166
+ }
167
+
168
+ function renderSectionHead(s: {
169
+ readonly eyebrow?: string;
170
+ readonly heading?: string;
171
+ readonly sub?: string;
172
+ }): string {
173
+ if (s.eyebrow === undefined && s.heading === undefined && s.sub === undefined) return "";
174
+ const eyebrow =
175
+ s.eyebrow !== undefined ? `<span class="eyebrow">${escapeHtml(s.eyebrow)}</span>` : "";
176
+ const heading = s.heading !== undefined ? `<h2>${escapeHtml(s.heading)}</h2>` : "";
177
+ const sub = s.sub !== undefined ? `<p>${escapeHtml(s.sub)}</p>` : "";
178
+ return `<div class="section-head">${eyebrow}${heading}${sub}</div>`;
179
+ }
180
+
181
+ function bandAttrs(muted: boolean | undefined, id: string | undefined): string {
182
+ const cls = muted === false ? "" : ` class="features"`;
183
+ const idAttr = id !== undefined ? ` id="${escapeHtml(id)}"` : "";
184
+ return cls + idAttr;
185
+ }
186
+
187
+ function renderHero(s: ApexHeroSection): string {
188
+ const logo =
189
+ s.logo !== undefined
190
+ ? `<img class="hero-pony" src="${escapeHtml(s.logo.src)}" alt="${escapeHtml(s.logo.alt)}"${dim(s.logo)} />`
191
+ : "";
192
+ const ctas = (s.ctas ?? []).map(renderCta).join("\n ");
193
+ const meta = s.metaHtml !== undefined ? `<p class="hero-meta">${s.metaHtml}</p>` : "";
194
+ const visual =
195
+ s.screenshot !== undefined
196
+ ? `<div class="hero-visual"><div class="shot-frame"><div class="shot-bar"><span></span><span></span><span></span></div><img src="${escapeHtml(s.screenshot.src)}" alt="${escapeHtml(s.screenshot.alt)}"${dim(s.screenshot)} loading="eager" /></div></div>`
197
+ : "";
198
+ return `<section class="hero">
199
+ <div class="container hero-grid">
200
+ <div class="hero-copy">
201
+ ${logo}<h1>${escapeHtml(s.title)}</h1>
202
+ <p class="tagline">${escapeHtml(s.tagline)}</p>
203
+ ${ctas !== "" ? `<div class="hero-cta">${ctas}</div>` : ""}
204
+ ${meta}
205
+ </div>
206
+ ${visual}
207
+ </div>
208
+ </section>`;
209
+ }
210
+
211
+ function renderFeatureGrid(s: ApexFeatureGridSection): string {
212
+ const cards = s.items
213
+ .map(
214
+ (f) => `<article class="feature">
215
+ ${f.icon !== undefined ? `<div class="feature-icon">${svgIcon(f.icon)}</div>` : ""}<h3>${escapeHtml(f.title)}</h3>
216
+ <p>${escapeHtml(f.desc)}</p>
217
+ </article>`,
218
+ )
219
+ .join("\n ");
220
+ return `<section${bandAttrs(s.muted, s.id)}>
221
+ <div class="container">
222
+ ${renderSectionHead(s)}
223
+ <div class="feature-grid">
224
+ ${cards}
225
+ </div>
226
+ </div>
227
+ </section>`;
228
+ }
229
+
230
+ function renderPricingCard(t: ApexPricingTier): string {
231
+ const featured = t.featured === true;
232
+ const badge =
233
+ t.badge !== undefined ? `<span class="price-badge">${escapeHtml(t.badge)}</span>` : "";
234
+ const cap =
235
+ t.capLine !== undefined ? [`<li class="price-cap">${escapeHtml(t.capLine)}</li>`] : [];
236
+ const benefits = t.benefits.map((b) => `<li>${escapeHtml(b)}</li>`);
237
+ const items = [...cap, ...benefits].join("\n ");
238
+ const per = t.priceSuffix !== undefined ? `<span>${escapeHtml(t.priceSuffix)}</span>` : "";
239
+ const cls =
240
+ t.cta.variant !== undefined
241
+ ? `btn btn-${t.cta.variant}`
242
+ : featured
243
+ ? "btn btn-primary"
244
+ : "btn btn-secondary";
245
+ const tagline =
246
+ t.tagline !== undefined ? `<p class="price-tagline">${escapeHtml(t.tagline)}</p>` : "";
247
+ return `<article class="price-card${featured ? " price-card--featured" : ""}">
248
+ ${badge}<h3>${escapeHtml(t.name)}</h3>
249
+ ${tagline}<div class="price-amount">${escapeHtml(t.amount)}${per}</div>
250
+ <ul class="price-list">
251
+ ${items}
252
+ </ul>
253
+ <a class="${cls}" href="${escapeHtml(t.cta.href)}">${escapeHtml(t.cta.label)}</a>
254
+ </article>`;
255
+ }
256
+
257
+ function renderPricingGrid(s: ApexPricingGridSection): string {
258
+ const cards = s.tiers.map(renderPricingCard).join("\n ");
259
+ const idAttr = s.id !== undefined ? ` id="${escapeHtml(s.id)}"` : "";
260
+ return `<section${idAttr}>
261
+ <div class="container">
262
+ ${renderSectionHead(s)}
263
+ <div class="price-grid">
264
+ ${cards}
265
+ </div>
266
+ </div>
267
+ </section>`;
268
+ }
269
+
270
+ function renderInfoGrid(s: ApexInfoGridSection): string {
271
+ const items = s.items
272
+ .map(
273
+ (i) => `<div class="trust-item">
274
+ <h3>${escapeHtml(i.title)}</h3>
275
+ <p>${escapeHtml(i.desc)}</p>
276
+ </div>`,
277
+ )
278
+ .join("\n ");
279
+ return `<section${bandAttrs(s.muted, s.id)}>
280
+ <div class="container">
281
+ ${renderSectionHead(s)}
282
+ <div class="trust-grid">
283
+ ${items}
284
+ </div>
285
+ </div>
286
+ </section>`;
287
+ }
288
+
289
+ function renderFinalCta(s: ApexFinalCtaSection): string {
290
+ const img =
291
+ s.image !== undefined
292
+ ? `<img class="final-cta-pony" src="${escapeHtml(s.image.src)}" alt="${escapeHtml(s.image.alt)}"${dim(s.image)} />`
293
+ : "";
294
+ const sub = s.sub !== undefined ? `<p>${escapeHtml(s.sub)}</p>` : "";
295
+ return `<section class="final-cta">
296
+ <div class="container">
297
+ ${img}<h2>${escapeHtml(s.heading)}</h2>
298
+ ${sub}${renderCta(s.cta)}
299
+ </div>
300
+ </section>`;
301
+ }
302
+
303
+ function renderSection(s: ApexSection): string {
304
+ switch (s.kind) {
305
+ case "hero":
306
+ return renderHero(s);
307
+ case "feature-grid":
308
+ return renderFeatureGrid(s);
309
+ case "pricing-grid":
310
+ return renderPricingGrid(s);
311
+ case "info-grid":
312
+ return renderInfoGrid(s);
313
+ case "final-cta":
314
+ return renderFinalCta(s);
315
+ case "html":
316
+ return s.html;
317
+ }
318
+ }
319
+
320
+ function renderHeader(h: ApexHeader): string {
321
+ const logo =
322
+ h.brand.logoSrc !== undefined ? `<img src="${escapeHtml(h.brand.logoSrc)}" alt="" /> ` : "";
323
+ const navLinks = (h.navLinks ?? [])
324
+ .map((l) => `<a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a>`)
325
+ .join("\n ");
326
+ const actions = (h.actions ?? []).map(renderCta);
327
+ return `<header>
328
+ <div class="container nav">
329
+ <div class="brand"><a href="${escapeHtml(h.brand.href)}">${logo}${escapeHtml(h.brand.label)}</a></div>
330
+ ${navLinks !== "" ? `<nav class="nav-links">${navLinks}</nav>` : ""}
331
+ ${actions.length > 0 ? `<div class="nav-actions">${actions.join("\n ")}</div>` : ""}
332
+ </div>
333
+ </header>`;
334
+ }
335
+
336
+ function renderFooter(f: ApexFooter): string {
337
+ const cols = f.columns ?? [];
338
+ const logo =
339
+ f.brand.logoSrc !== undefined ? `<img src="${escapeHtml(f.brand.logoSrc)}" alt="" /> ` : "";
340
+ const colHtml = cols
341
+ .map(
342
+ (c) => `<div class="footer-col">
343
+ <h4>${escapeHtml(c.heading)}</h4>
344
+ ${c.links.map((l) => `<a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a>`).join("\n ")}
345
+ </div>`,
346
+ )
347
+ .join("\n ");
348
+ const bottom =
349
+ f.bottomLeft !== undefined || f.bottomRight !== undefined
350
+ ? `<div class="footer-bottom">
351
+ ${f.bottomLeft !== undefined ? `<span>${escapeHtml(f.bottomLeft)}</span>` : ""}
352
+ ${f.bottomRight !== undefined ? `<span>${escapeHtml(f.bottomRight)}</span>` : ""}
353
+ </div>`
354
+ : "";
355
+ return `<footer>
356
+ <div class="container">
357
+ <div class="footer-grid" style="--footer-cols:${cols.length}">
358
+ <div>
359
+ <div class="footer-brand">${logo}${escapeHtml(f.brand.label)}</div>
360
+ ${f.tagline !== undefined ? `<p class="footer-tagline">${escapeHtml(f.tagline)}</p>` : ""}
361
+ </div>
362
+ ${colHtml}
363
+ </div>
364
+ ${bottom}
365
+ </div>
366
+ </footer>`;
367
+ }
368
+
369
+ export function renderApexPage(page: ApexPage): string {
370
+ const { head, brand } = page;
371
+ const theme = page.theme ?? "light";
372
+ const css = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
373
+ const sections = page.sections.map(renderSection).join("\n\n ");
374
+ const ogUrl =
375
+ head.canonicalUrl !== undefined
376
+ ? `\n <meta property="og:url" content="${escapeHtml(head.canonicalUrl)}" />`
377
+ : "";
378
+ const ogImage =
379
+ head.ogImage !== undefined
380
+ ? `\n <meta property="og:image" content="${escapeHtml(head.ogImage)}" />`
381
+ : "";
382
+ const favicon =
383
+ head.faviconHref !== undefined
384
+ ? `\n <link rel="icon" href="${escapeHtml(head.faviconHref)}" />`
385
+ : "";
386
+ const canonical =
387
+ head.canonicalUrl !== undefined
388
+ ? `\n <link rel="canonical" href="${escapeHtml(head.canonicalUrl)}" />`
389
+ : "";
390
+ const alternates = (head.alternates ?? [])
391
+ .map(
392
+ (a) =>
393
+ `\n <link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}" />`,
394
+ )
395
+ .join("");
396
+ return `<!doctype html>
397
+ <html lang="${escapeHtml(head.lang)}">
398
+ <head>
399
+ <meta charset="utf-8" />
400
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
401
+ <title>${escapeHtml(head.title)}</title>
402
+ <meta name="description" content="${escapeHtml(head.description)}" />
403
+ <meta property="og:title" content="${escapeHtml(head.title)}" />
404
+ <meta property="og:description" content="${escapeHtml(head.description)}" />
405
+ <meta property="og:type" content="website" />${ogUrl}${ogImage}${favicon}${canonical}${alternates}
406
+ <style>${css}</style>
407
+ </head>
408
+ <body${theme === "dark" ? ` class="apex-dark"` : ""}>
409
+ ${renderHeader(page.header)}
410
+
411
+ ${sections}
412
+
413
+ ${renderFooter(page.footer)}
414
+ </body>
415
+ </html>`;
416
+ }