@cosmicdrift/kumiko-headless 0.327.0 → 0.328.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apex/css.d.ts +3 -0
- package/{src/apex/css.ts → dist/apex/css.js} +11 -24
- package/dist/apex/index.d.ts +229 -0
- package/dist/apex/index.js +360 -0
- package/dist/apex/lightbox.d.ts +4 -0
- package/{src/apex/lightbox.ts → dist/apex/lightbox.js} +0 -4
- package/dist/contracts/asset.d.ts +15 -0
- package/dist/contracts/asset.js +17 -0
- package/dist/contracts/index.d.ts +3 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/locale.d.ts +7 -0
- package/dist/contracts/locale.js +11 -0
- package/dist/contracts/primitives.d.ts +93 -0
- package/dist/contracts/primitives.js +21 -0
- package/dist/dispatcher/index.d.ts +2 -0
- package/dist/dispatcher/index.js +1 -0
- package/dist/dispatcher/types.d.ts +86 -0
- package/dist/dispatcher/types.js +34 -0
- package/dist/form/form-controller.d.ts +2 -0
- package/dist/form/form-controller.js +404 -0
- package/dist/form/index.d.ts +3 -0
- package/dist/form/index.js +2 -0
- package/dist/form/types.d.ts +71 -0
- package/dist/form/types.js +1 -0
- package/dist/form/zod-bridge.d.ts +5 -0
- package/{src/form/zod-bridge.ts → dist/form/zod-bridge.js} +52 -58
- package/dist/format/escape.d.ts +5 -0
- package/{src/format/escape.ts → dist/format/escape.js} +43 -47
- package/dist/format/html-template.d.ts +9 -0
- package/dist/format/html-template.js +39 -0
- package/dist/format/index.d.ts +10 -0
- package/dist/format/index.js +192 -0
- package/dist/format/money.d.ts +1 -0
- package/{src/format/money.ts → dist/format/money.js} +6 -5
- package/dist/index.d.ts +14 -0
- package/dist/index.js +9 -0
- package/dist/locale-routing/index.d.ts +39 -0
- package/dist/locale-routing/index.js +143 -0
- package/dist/nav/index.d.ts +2 -0
- package/dist/nav/index.js +1 -0
- package/dist/nav/resolve.d.ts +2 -0
- package/dist/nav/resolve.js +58 -0
- package/dist/nav/types.d.ts +26 -0
- package/dist/nav/types.js +1 -0
- package/dist/store/create-store.d.ts +2 -0
- package/{src/store/create-store.ts → dist/store/create-store.js} +21 -23
- package/dist/store/equality.d.ts +1 -0
- package/{src/store/equality.ts → dist/store/equality.js} +19 -17
- package/dist/store/index.d.ts +3 -0
- package/dist/store/index.js +2 -0
- package/dist/store/types.d.ts +7 -0
- package/dist/store/types.js +10 -0
- package/dist/view-model/edit.d.ts +10 -0
- package/dist/view-model/edit.js +392 -0
- package/dist/view-model/embedded-list.d.ts +27 -0
- package/dist/view-model/embedded-list.js +63 -0
- package/dist/view-model/index.d.ts +7 -0
- package/dist/view-model/index.js +3 -0
- package/dist/view-model/list.d.ts +16 -0
- package/dist/view-model/list.js +201 -0
- package/dist/view-model/types.d.ts +300 -0
- package/dist/view-model/types.js +1 -0
- package/package.json +31 -12
- package/src/changes.json +7 -0
- package/src/apex/__tests__/lightbox.test.ts +0 -20
- package/src/apex/__tests__/page-head.test.ts +0 -63
- package/src/apex/__tests__/render.test.ts +0 -401
- package/src/apex/index.ts +0 -667
- package/src/contracts/__tests__/contracts.test.ts +0 -123
- package/src/contracts/__tests__/field-issue-compat.test.ts +0 -19
- package/src/contracts/asset.ts +0 -48
- package/src/contracts/index.ts +0 -23
- package/src/contracts/locale.ts +0 -33
- package/src/contracts/primitives.ts +0 -158
- package/src/dispatcher/__tests__/contract.test.ts +0 -163
- package/src/dispatcher/index.ts +0 -16
- package/src/dispatcher/types.ts +0 -217
- package/src/form/__tests__/conditional-fields.test.ts +0 -177
- package/src/form/__tests__/form-controller.test.ts +0 -231
- package/src/form/__tests__/submit.test.ts +0 -575
- package/src/form/__tests__/validation.test.ts +0 -274
- package/src/form/form-controller.ts +0 -458
- package/src/form/index.ts +0 -15
- package/src/form/types.ts +0 -297
- package/src/format/__tests__/escape.test.ts +0 -79
- package/src/format/__tests__/format.test.ts +0 -281
- package/src/format/__tests__/html-template.test.ts +0 -47
- package/src/format/html-template.ts +0 -49
- package/src/format/index.ts +0 -219
- package/src/index.ts +0 -118
- package/src/locale-routing/__tests__/locale-routing.test.ts +0 -276
- package/src/locale-routing/index.ts +0 -218
- package/src/nav/__tests__/resolve.test.ts +0 -279
- package/src/nav/index.ts +0 -8
- package/src/nav/resolve.ts +0 -60
- package/src/nav/types.ts +0 -70
- package/src/store/__tests__/create-store.test.ts +0 -139
- package/src/store/__tests__/equality.test.ts +0 -66
- package/src/store/index.ts +0 -3
- package/src/store/types.ts +0 -27
- package/src/view-model/__tests__/edit.test.ts +0 -1072
- package/src/view-model/__tests__/embedded-list.test.ts +0 -96
- package/src/view-model/__tests__/field-labels.test.ts +0 -62
- package/src/view-model/__tests__/list.test.ts +0 -428
- package/src/view-model/edit.ts +0 -641
- package/src/view-model/embedded-list.ts +0 -89
- package/src/view-model/index.ts +0 -43
- package/src/view-model/list.ts +0 -277
- package/src/view-model/types.ts +0 -392
package/src/apex/index.ts
DELETED
|
@@ -1,667 +0,0 @@
|
|
|
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 type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { escapeHtml } from "../format";
|
|
9
|
-
import { APEX_STRUCTURAL_CSS } from "./css";
|
|
10
|
-
import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
|
|
11
|
-
|
|
12
|
-
export { APEX_NAV_MENU_CSS, APEX_NAV_TOGGLE_RESPONSIVE_CSS, APEX_STRUCTURAL_CSS } from "./css";
|
|
13
|
-
export {
|
|
14
|
-
APEX_LIGHTBOX_HTML,
|
|
15
|
-
APEX_LIGHTBOX_SCRIPT,
|
|
16
|
-
APEX_LIGHTBOX_SCRIPT_CSP_HASH,
|
|
17
|
-
} from "./lightbox";
|
|
18
|
-
|
|
19
|
-
export type ApexTheme = "light" | "dark";
|
|
20
|
-
|
|
21
|
-
/** Brand is raw CSS the app owns: the :root token block and optional @font-face.
|
|
22
|
-
* Token names referenced by the structural CSS: --bg --bg-card --bg-muted
|
|
23
|
-
* --border --fg --fg-muted --fg-subtle --primary --primary-hover --primary-fg
|
|
24
|
-
* --status-ok --shadow. Optional: --accent --accent-fg --font-mono --font-body,
|
|
25
|
-
* and (dark theme) --on-dark --on-dark-muted --on-dark-border. */
|
|
26
|
-
export type ApexBrand = {
|
|
27
|
-
readonly tokensCss: string;
|
|
28
|
-
readonly fontFaceCss?: string;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export type ApexCtaVariant = "primary" | "secondary" | "link";
|
|
32
|
-
export type ApexCta = {
|
|
33
|
-
readonly label: string;
|
|
34
|
-
/** App-authored only, no user input — escapeHtml encodes entities but does
|
|
35
|
-
* NOT sanitize the URL scheme (a `javascript:`/`data:` URI survives it
|
|
36
|
-
* unchanged). Trust boundary is the deploy-time apex config, not runtime. */
|
|
37
|
-
readonly href: string;
|
|
38
|
-
/** "link" renders a plain anchor (no .btn). Default "primary". */
|
|
39
|
-
readonly variant?: ApexCtaVariant;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// `kind` is optional here (footer links / ApexNavMenu.footer don't need it) —
|
|
43
|
-
// it exists so ApexNavEntry (ApexLink | ApexNavMenu) is a real discriminated
|
|
44
|
-
// union: TS narrows on `entry.kind === "menu"` without a structural
|
|
45
|
-
// `"items" in entry` check or an `as` cast.
|
|
46
|
-
export type ApexLink = { readonly kind?: "link"; readonly label: string; readonly href: string };
|
|
47
|
-
|
|
48
|
-
/** One entry inside a dropdown nav menu: icon + title + optional description. */
|
|
49
|
-
export type ApexNavMenuItem = {
|
|
50
|
-
/** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
|
|
51
|
-
* ponytail: rendered verbatim, no sanitizer — trusted because it's
|
|
52
|
-
* app-authored at deploy time, same trust boundary as ApexCta.href. If an
|
|
53
|
-
* app ever sources this from tenant/user content, sanitize at that
|
|
54
|
-
* construction boundary before it reaches here. */
|
|
55
|
-
readonly icon?: string;
|
|
56
|
-
readonly title: string;
|
|
57
|
-
readonly desc?: string;
|
|
58
|
-
readonly href: string;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
/** A header nav entry that opens a dropdown (icon/title/desc rows) instead of
|
|
62
|
-
* navigating. CSS-only: reveals on hover + keyboard focus. */
|
|
63
|
-
export type ApexNavMenu = {
|
|
64
|
-
readonly kind: "menu";
|
|
65
|
-
readonly label: string;
|
|
66
|
-
readonly items: readonly ApexNavMenuItem[];
|
|
67
|
-
/** Optional link under a divider at the foot of the panel (e.g. "See all →"). */
|
|
68
|
-
readonly footer?: ApexLink;
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
/** A header nav entry: a plain link, or a dropdown menu. */
|
|
72
|
-
export type ApexNavEntry = ApexLink | ApexNavMenu;
|
|
73
|
-
export type ApexImage = {
|
|
74
|
-
readonly src: string;
|
|
75
|
-
readonly alt: string;
|
|
76
|
-
readonly width?: number;
|
|
77
|
-
readonly height?: number;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
export type ApexHeader = {
|
|
81
|
-
readonly brand: { readonly href: string; readonly label: string; readonly logoSrc?: string };
|
|
82
|
-
/** Plain links and/or dropdown menus, in order. */
|
|
83
|
-
readonly navLinks?: readonly ApexNavEntry[];
|
|
84
|
-
readonly actions?: readonly ApexCta[];
|
|
85
|
-
/** aria-label for the mobile hamburger trigger (< 640px). Default "Menu". */
|
|
86
|
-
readonly menuLabel?: string;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
export type ApexFooterColumn = { readonly heading: string; readonly links: readonly ApexLink[] };
|
|
90
|
-
export type ApexFooter = {
|
|
91
|
-
readonly brand: { readonly label: string; readonly logoSrc?: string };
|
|
92
|
-
readonly tagline?: string;
|
|
93
|
-
readonly columns?: readonly ApexFooterColumn[];
|
|
94
|
-
/** Plain text — use unicode (© ·), not HTML entities; rendered escaped. */
|
|
95
|
-
readonly bottomLeft?: string;
|
|
96
|
-
readonly bottomRight?: string;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
export type ApexHeroSection = {
|
|
100
|
-
readonly kind: "hero";
|
|
101
|
-
readonly logo?: ApexImage;
|
|
102
|
-
readonly title: string;
|
|
103
|
-
readonly tagline: string;
|
|
104
|
-
readonly ctas?: readonly ApexCta[];
|
|
105
|
-
/** App-authored trusted HTML (e.g. <strong>…<br/>); rendered verbatim. */
|
|
106
|
-
readonly metaHtml?: string;
|
|
107
|
-
readonly screenshot?: ApexImage;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
export type ApexFeature = {
|
|
111
|
-
/** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>.
|
|
112
|
-
* ponytail: rendered verbatim, no sanitizer — trusted because it's
|
|
113
|
-
* app-authored at deploy time, same trust boundary as ApexCta.href. If an
|
|
114
|
-
* app ever sources this from tenant/user content, sanitize at that
|
|
115
|
-
* construction boundary before it reaches here. */
|
|
116
|
-
readonly icon?: string;
|
|
117
|
-
readonly title: string;
|
|
118
|
-
readonly desc: string;
|
|
119
|
-
};
|
|
120
|
-
export type ApexFeatureGridSection = {
|
|
121
|
-
readonly kind: "feature-grid";
|
|
122
|
-
readonly id?: string;
|
|
123
|
-
readonly eyebrow?: string;
|
|
124
|
-
readonly heading: string;
|
|
125
|
-
readonly sub?: string;
|
|
126
|
-
/** Sit on the muted band (.features). Default true. */
|
|
127
|
-
readonly muted?: boolean;
|
|
128
|
-
readonly items: readonly ApexFeature[];
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
export type ApexPricingTier = {
|
|
132
|
-
readonly name: string;
|
|
133
|
-
readonly tagline?: string;
|
|
134
|
-
/** App-formatted ("4,99 €", "0 €", "auf Anfrage"). */
|
|
135
|
-
readonly amount: string;
|
|
136
|
-
/** Localized suffix after the amount ("/Monat", "/month"). */
|
|
137
|
-
readonly priceSuffix?: string;
|
|
138
|
-
readonly featured?: boolean;
|
|
139
|
-
readonly badge?: string;
|
|
140
|
-
readonly capLine?: string;
|
|
141
|
-
readonly benefits: readonly string[];
|
|
142
|
-
readonly cta: ApexCta;
|
|
143
|
-
};
|
|
144
|
-
export type ApexPricingGridSection = {
|
|
145
|
-
readonly kind: "pricing-grid";
|
|
146
|
-
readonly id?: string;
|
|
147
|
-
readonly eyebrow?: string;
|
|
148
|
-
readonly heading: string;
|
|
149
|
-
readonly sub?: string;
|
|
150
|
-
readonly tiers: readonly ApexPricingTier[];
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
export type ApexInfoItem = { readonly title: string; readonly desc: string };
|
|
154
|
-
export type ApexInfoGridSection = {
|
|
155
|
-
readonly kind: "info-grid";
|
|
156
|
-
readonly id?: string;
|
|
157
|
-
readonly eyebrow?: string;
|
|
158
|
-
readonly heading?: string;
|
|
159
|
-
readonly sub?: string;
|
|
160
|
-
readonly muted?: boolean;
|
|
161
|
-
readonly items: readonly ApexInfoItem[];
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
export type ApexFinalCtaSection = {
|
|
165
|
-
readonly kind: "final-cta";
|
|
166
|
-
readonly image?: ApexImage;
|
|
167
|
-
readonly heading: string;
|
|
168
|
-
readonly sub?: string;
|
|
169
|
-
readonly cta: ApexCta;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
/** Escape hatch for an app-specific section: raw, app-authored HTML. */
|
|
173
|
-
export type ApexHtmlSection = { readonly kind: "html"; readonly html: string };
|
|
174
|
-
|
|
175
|
-
export type ApexSection =
|
|
176
|
-
| ApexHeroSection
|
|
177
|
-
| ApexFeatureGridSection
|
|
178
|
-
| ApexPricingGridSection
|
|
179
|
-
| ApexInfoGridSection
|
|
180
|
-
| ApexFinalCtaSection
|
|
181
|
-
| ApexHtmlSection;
|
|
182
|
-
|
|
183
|
-
export type ApexHead = {
|
|
184
|
-
readonly lang: string;
|
|
185
|
-
readonly title: string;
|
|
186
|
-
readonly description: string;
|
|
187
|
-
readonly canonicalUrl?: string;
|
|
188
|
-
readonly faviconHref?: string;
|
|
189
|
-
readonly ogImage?: string;
|
|
190
|
-
/** hreflang alternates for multilingual SEO (e.g. the other language's URL). */
|
|
191
|
-
readonly alternates?: readonly { readonly hreflang: string; readonly href: string }[];
|
|
192
|
-
/** Robots meta content (e.g. "index, follow", "noindex, nofollow"). Omit = no tag. */
|
|
193
|
-
readonly robots?: string;
|
|
194
|
-
/** og:site_name — brand name for social shares. */
|
|
195
|
-
readonly siteName?: string;
|
|
196
|
-
/** og:locale — e.g. "de_DE", "en_US". */
|
|
197
|
-
readonly locale?: string;
|
|
198
|
-
/** twitter:site — @handle for X/Twitter card. */
|
|
199
|
-
readonly twitterSite?: string;
|
|
200
|
-
/** URLs for <link rel="preconnect"> hints (Core Web Vitals). */
|
|
201
|
-
readonly preconnects?: readonly string[];
|
|
202
|
-
/** Arbitrary JSON-LD structured data (Schema.org). Rendered as-is into <script type="application/ld+json">. */
|
|
203
|
-
readonly schemaJson?: Record<string, unknown>;
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
export type ApexPage = {
|
|
207
|
-
readonly theme?: ApexTheme;
|
|
208
|
-
readonly brand: ApexBrand;
|
|
209
|
-
readonly head: ApexHead;
|
|
210
|
-
readonly header: ApexHeader;
|
|
211
|
-
readonly sections: readonly ApexSection[];
|
|
212
|
-
readonly footer: ApexFooter;
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
// JSON in <script>-Kontext: `<` als < serialisieren, damit weder
|
|
216
|
-
// `</script>` noch `<!--` aus dem Block ausbrechen kann (JSON bleibt valide).
|
|
217
|
-
function scriptSafeJsonHtml(value: unknown): string {
|
|
218
|
-
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function dim(img: ApexImage): string {
|
|
222
|
-
return `${img.width !== undefined ? ` width="${img.width}"` : ""}${img.height !== undefined ? ` height="${img.height}"` : ""}`;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function svgIcon(innerHtml: string): string {
|
|
226
|
-
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${innerHtml}</svg>`;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function renderCta(cta: ApexCta): string {
|
|
230
|
-
const variant = cta.variant ?? "primary";
|
|
231
|
-
const cls = variant === "link" ? "" : ` class="btn btn-${variant}"`;
|
|
232
|
-
return `<a${cls} href="${escapeHtml(cta.href)}">${escapeHtml(cta.label)}</a>`;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function renderSectionHead(s: {
|
|
236
|
-
readonly eyebrow?: string;
|
|
237
|
-
readonly heading?: string;
|
|
238
|
-
readonly sub?: string;
|
|
239
|
-
}): string {
|
|
240
|
-
if (s.eyebrow === undefined && s.heading === undefined && s.sub === undefined) return "";
|
|
241
|
-
const eyebrow =
|
|
242
|
-
s.eyebrow !== undefined ? `<span class="eyebrow">${escapeHtml(s.eyebrow)}</span>` : "";
|
|
243
|
-
const heading = s.heading !== undefined ? `<h2>${escapeHtml(s.heading)}</h2>` : "";
|
|
244
|
-
const sub = s.sub !== undefined ? `<p>${escapeHtml(s.sub)}</p>` : "";
|
|
245
|
-
return `<div class="section-head">${eyebrow}${heading}${sub}</div>`;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function bandAttrs(muted: boolean | undefined, id: string | undefined): string {
|
|
249
|
-
const cls = muted === false ? "" : ` class="features"`;
|
|
250
|
-
const idAttr = id !== undefined ? ` id="${escapeHtml(id)}"` : "";
|
|
251
|
-
return cls + idAttr;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function renderHero(s: ApexHeroSection): string {
|
|
255
|
-
const logo =
|
|
256
|
-
s.logo !== undefined
|
|
257
|
-
? `<img class="hero-pony" src="${escapeHtml(s.logo.src)}" alt="${escapeHtml(s.logo.alt)}"${dim(s.logo)} />`
|
|
258
|
-
: "";
|
|
259
|
-
const ctasHtml = (s.ctas ?? []).map(renderCta).join("\n ");
|
|
260
|
-
const meta = s.metaHtml !== undefined ? `<p class="hero-meta">${s.metaHtml}</p>` : "";
|
|
261
|
-
const visual =
|
|
262
|
-
s.screenshot !== undefined
|
|
263
|
-
? `<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>`
|
|
264
|
-
: "";
|
|
265
|
-
return `<section class="hero">
|
|
266
|
-
<div class="container hero-grid">
|
|
267
|
-
<div class="hero-copy">
|
|
268
|
-
${logo}<h1>${escapeHtml(s.title)}</h1>
|
|
269
|
-
<p class="tagline">${escapeHtml(s.tagline)}</p>
|
|
270
|
-
${ctasHtml !== "" ? `<div class="hero-cta">${ctasHtml}</div>` : ""}
|
|
271
|
-
${meta}
|
|
272
|
-
</div>
|
|
273
|
-
${visual}
|
|
274
|
-
</div>
|
|
275
|
-
</section>`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function renderFeatureGrid(s: ApexFeatureGridSection): string {
|
|
279
|
-
const cardsHtml = s.items
|
|
280
|
-
.map(
|
|
281
|
-
(f) => `<article class="feature">
|
|
282
|
-
${f.icon !== undefined ? `<div class="feature-icon">${svgIcon(f.icon)}</div>` : ""}<h3>${escapeHtml(f.title)}</h3>
|
|
283
|
-
<p>${escapeHtml(f.desc)}</p>
|
|
284
|
-
</article>`,
|
|
285
|
-
)
|
|
286
|
-
.join("\n ");
|
|
287
|
-
return `<section${bandAttrs(s.muted, s.id)}>
|
|
288
|
-
<div class="container">
|
|
289
|
-
${renderSectionHead(s)}
|
|
290
|
-
<div class="feature-grid">
|
|
291
|
-
${cardsHtml}
|
|
292
|
-
</div>
|
|
293
|
-
</div>
|
|
294
|
-
</section>`;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function renderPricingCard(t: ApexPricingTier): string {
|
|
298
|
-
const featured = t.featured === true;
|
|
299
|
-
const badge =
|
|
300
|
-
t.badge !== undefined ? `<span class="price-badge">${escapeHtml(t.badge)}</span>` : "";
|
|
301
|
-
const cap =
|
|
302
|
-
t.capLine !== undefined ? [`<li class="price-cap">${escapeHtml(t.capLine)}</li>`] : [];
|
|
303
|
-
const benefits = t.benefits.map((b) => `<li>${escapeHtml(b)}</li>`);
|
|
304
|
-
const itemsHtml = [...cap, ...benefits].join("\n ");
|
|
305
|
-
const per = t.priceSuffix !== undefined ? `<span>${escapeHtml(t.priceSuffix)}</span>` : "";
|
|
306
|
-
const tagline =
|
|
307
|
-
t.tagline !== undefined ? `<p class="price-tagline">${escapeHtml(t.tagline)}</p>` : "";
|
|
308
|
-
// Delegates to renderCta so a `variant: "link"` tier CTA gets the same
|
|
309
|
-
// class-free anchor as everywhere else instead of re-deriving the class
|
|
310
|
-
// string here (a prior inline duplicate had no "link" case, always
|
|
311
|
-
// emitting `.btn-link`, which the structural CSS never defines).
|
|
312
|
-
const cta =
|
|
313
|
-
t.cta.variant === undefined
|
|
314
|
-
? renderCta({ ...t.cta, variant: featured ? "primary" : "secondary" })
|
|
315
|
-
: renderCta(t.cta);
|
|
316
|
-
return `<article class="price-card${featured ? " price-card--featured" : ""}">
|
|
317
|
-
${badge}<h3>${escapeHtml(t.name)}</h3>
|
|
318
|
-
${tagline}<div class="price-amount">${escapeHtml(t.amount)}${per}</div>
|
|
319
|
-
<ul class="price-list">
|
|
320
|
-
${itemsHtml}
|
|
321
|
-
</ul>
|
|
322
|
-
${cta}
|
|
323
|
-
</article>`;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function renderPricingGrid(s: ApexPricingGridSection): string {
|
|
327
|
-
const cardsHtml = s.tiers.map(renderPricingCard).join("\n ");
|
|
328
|
-
const idAttr = s.id !== undefined ? ` id="${escapeHtml(s.id)}"` : "";
|
|
329
|
-
return `<section${idAttr}>
|
|
330
|
-
<div class="container">
|
|
331
|
-
${renderSectionHead(s)}
|
|
332
|
-
<div class="price-grid">
|
|
333
|
-
${cardsHtml}
|
|
334
|
-
</div>
|
|
335
|
-
</div>
|
|
336
|
-
</section>`;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function renderInfoGrid(s: ApexInfoGridSection): string {
|
|
340
|
-
const itemsHtml = s.items
|
|
341
|
-
.map(
|
|
342
|
-
(i) => `<div class="trust-item">
|
|
343
|
-
<h3>${escapeHtml(i.title)}</h3>
|
|
344
|
-
<p>${escapeHtml(i.desc)}</p>
|
|
345
|
-
</div>`,
|
|
346
|
-
)
|
|
347
|
-
.join("\n ");
|
|
348
|
-
return `<section${bandAttrs(s.muted, s.id)}>
|
|
349
|
-
<div class="container">
|
|
350
|
-
${renderSectionHead(s)}
|
|
351
|
-
<div class="trust-grid">
|
|
352
|
-
${itemsHtml}
|
|
353
|
-
</div>
|
|
354
|
-
</div>
|
|
355
|
-
</section>`;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function renderFinalCta(s: ApexFinalCtaSection): string {
|
|
359
|
-
const img =
|
|
360
|
-
s.image !== undefined
|
|
361
|
-
? `<img class="final-cta-pony" src="${escapeHtml(s.image.src)}" alt="${escapeHtml(s.image.alt)}"${dim(s.image)} />`
|
|
362
|
-
: "";
|
|
363
|
-
const sub = s.sub !== undefined ? `<p>${escapeHtml(s.sub)}</p>` : "";
|
|
364
|
-
return `<section class="final-cta">
|
|
365
|
-
<div class="container">
|
|
366
|
-
${img}<h2>${escapeHtml(s.heading)}</h2>
|
|
367
|
-
${sub}${renderCta(s.cta)}
|
|
368
|
-
</div>
|
|
369
|
-
</section>`;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function renderSection(s: ApexSection): string {
|
|
373
|
-
switch (s.kind) {
|
|
374
|
-
case "hero":
|
|
375
|
-
return renderHero(s);
|
|
376
|
-
case "feature-grid":
|
|
377
|
-
return renderFeatureGrid(s);
|
|
378
|
-
case "pricing-grid":
|
|
379
|
-
return renderPricingGrid(s);
|
|
380
|
-
case "info-grid":
|
|
381
|
-
return renderInfoGrid(s);
|
|
382
|
-
case "final-cta":
|
|
383
|
-
return renderFinalCta(s);
|
|
384
|
-
case "html":
|
|
385
|
-
return s.html;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function renderNavMenu(m: ApexNavMenu): string {
|
|
390
|
-
const itemsHtml = m.items
|
|
391
|
-
.map(
|
|
392
|
-
(it) =>
|
|
393
|
-
`<a class="nav-menu__item" href="${escapeHtml(it.href)}">${
|
|
394
|
-
it.icon !== undefined ? `<span class="nav-menu__icon">${svgIcon(it.icon)}</span>` : ""
|
|
395
|
-
}<span class="nav-menu__text"><span class="nav-menu__title">${escapeHtml(it.title)}</span>${
|
|
396
|
-
it.desc !== undefined ? `<span class="nav-menu__desc">${escapeHtml(it.desc)}</span>` : ""
|
|
397
|
-
}</span></a>`,
|
|
398
|
-
)
|
|
399
|
-
.join("");
|
|
400
|
-
const footer =
|
|
401
|
-
m.footer !== undefined
|
|
402
|
-
? `<div class="nav-menu__sep"></div><a class="nav-menu__more" href="${escapeHtml(m.footer.href)}">${escapeHtml(m.footer.label)}</a>`
|
|
403
|
-
: "";
|
|
404
|
-
return `<details class="nav-menu"><summary class="nav-menu__trigger">${escapeHtml(m.label)}<span class="nav-menu__chev">${svgIcon('<path d="m6 9 6 6 6-6"/>')}</span></summary><div class="nav-menu__panel">${itemsHtml}${footer}</div></details>`;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function renderNavEntry(entry: ApexNavEntry): string {
|
|
408
|
-
return entry.kind === "menu"
|
|
409
|
-
? renderNavMenu(entry)
|
|
410
|
-
: `<a href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</a>`;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** Render just the apex header chrome (brand + nav + actions). Exported so a
|
|
414
|
-
* consumer that composes its own page shell (not a full apex page) can reuse
|
|
415
|
-
* the identical header — markup stays single-source. */
|
|
416
|
-
export function renderApexHeader(h: ApexHeader): string {
|
|
417
|
-
const logo =
|
|
418
|
-
h.brand.logoSrc !== undefined ? `<img src="${escapeHtml(h.brand.logoSrc)}" alt="" /> ` : "";
|
|
419
|
-
const navLinksHtml = (h.navLinks ?? []).map(renderNavEntry).join("\n ");
|
|
420
|
-
const actionsHtml = (h.actions ?? []).map(renderCta).join("\n ");
|
|
421
|
-
return `<header>
|
|
422
|
-
<div class="container nav">
|
|
423
|
-
<div class="brand"><a href="${escapeHtml(h.brand.href)}">${logo}${escapeHtml(h.brand.label)}</a></div>
|
|
424
|
-
${navLinksHtml !== "" ? `<nav class="nav-links">${navLinksHtml}</nav><details class="nav-toggle"><summary class="nav-toggle__trigger" aria-label="${escapeHtml(h.menuLabel ?? "Menu")}">${svgIcon('<path d="M4 6h16M4 12h16M4 18h16"/>')}</summary><nav class="nav-links">${navLinksHtml}</nav></details>` : ""}
|
|
425
|
-
${actionsHtml !== "" ? `<div class="nav-actions">${actionsHtml}</div>` : ""}
|
|
426
|
-
</div>
|
|
427
|
-
</header>`;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function renderFooter(f: ApexFooter): string {
|
|
431
|
-
const cols = f.columns ?? [];
|
|
432
|
-
const logo =
|
|
433
|
-
f.brand.logoSrc !== undefined ? `<img src="${escapeHtml(f.brand.logoSrc)}" alt="" /> ` : "";
|
|
434
|
-
const colHtml = cols
|
|
435
|
-
.map(
|
|
436
|
-
(c) => `<div class="footer-col">
|
|
437
|
-
<h4>${escapeHtml(c.heading)}</h4>
|
|
438
|
-
${c.links.map((l) => `<a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a>`).join("\n ")}
|
|
439
|
-
</div>`,
|
|
440
|
-
)
|
|
441
|
-
.join("\n ");
|
|
442
|
-
const bottom =
|
|
443
|
-
f.bottomLeft !== undefined || f.bottomRight !== undefined
|
|
444
|
-
? `<div class="footer-bottom">
|
|
445
|
-
${f.bottomLeft !== undefined ? `<span>${escapeHtml(f.bottomLeft)}</span>` : ""}
|
|
446
|
-
${f.bottomRight !== undefined ? `<span>${escapeHtml(f.bottomRight)}</span>` : ""}
|
|
447
|
-
</div>`
|
|
448
|
-
: "";
|
|
449
|
-
return `<footer>
|
|
450
|
-
<div class="container">
|
|
451
|
-
<div class="footer-grid" style="--footer-cols:${Math.max(1, cols.length)}">
|
|
452
|
-
<div>
|
|
453
|
-
<div class="footer-brand">${logo}${escapeHtml(f.brand.label)}</div>
|
|
454
|
-
${f.tagline !== undefined ? `<p class="footer-tagline">${escapeHtml(f.tagline)}</p>` : ""}
|
|
455
|
-
</div>
|
|
456
|
-
${colHtml}
|
|
457
|
-
</div>
|
|
458
|
-
${bottom}
|
|
459
|
-
</div>
|
|
460
|
-
</footer>`;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// All <title>/meta/link/script tags for an ApexHead — the single source both
|
|
464
|
-
// renderApexPage and any other head (e.g. page-render's wrapInLayout) splice
|
|
465
|
-
// into their own <head>. Extracted verbatim from renderApexPage so existing
|
|
466
|
-
// output stays byte-identical (see render.test.ts regression coverage).
|
|
467
|
-
export function renderApexHeadTags(head: ApexHead): string {
|
|
468
|
-
const ogUrl =
|
|
469
|
-
head.canonicalUrl !== undefined
|
|
470
|
-
? `\n <meta property="og:url" content="${escapeHtml(head.canonicalUrl)}" />`
|
|
471
|
-
: "";
|
|
472
|
-
const ogImage =
|
|
473
|
-
head.ogImage !== undefined
|
|
474
|
-
? `\n <meta property="og:image" content="${escapeHtml(head.ogImage)}" />`
|
|
475
|
-
: "";
|
|
476
|
-
const favicon =
|
|
477
|
-
head.faviconHref !== undefined
|
|
478
|
-
? `\n <link rel="icon" href="${escapeHtml(head.faviconHref)}" />`
|
|
479
|
-
: "";
|
|
480
|
-
const canonical =
|
|
481
|
-
head.canonicalUrl !== undefined
|
|
482
|
-
? `\n <link rel="canonical" href="${escapeHtml(head.canonicalUrl)}" />`
|
|
483
|
-
: "";
|
|
484
|
-
const alternates = (head.alternates ?? [])
|
|
485
|
-
.map(
|
|
486
|
-
(a) =>
|
|
487
|
-
`\n <link rel="alternate" hreflang="${escapeHtml(a.hreflang)}" href="${escapeHtml(a.href)}" />`,
|
|
488
|
-
)
|
|
489
|
-
.join("");
|
|
490
|
-
const robots =
|
|
491
|
-
head.robots !== undefined
|
|
492
|
-
? `\n <meta name="robots" content="${escapeHtml(head.robots)}" />`
|
|
493
|
-
: "";
|
|
494
|
-
const siteName =
|
|
495
|
-
head.siteName !== undefined
|
|
496
|
-
? `\n <meta property="og:site_name" content="${escapeHtml(head.siteName)}" />`
|
|
497
|
-
: "";
|
|
498
|
-
const locale =
|
|
499
|
-
head.locale !== undefined
|
|
500
|
-
? `\n <meta property="og:locale" content="${escapeHtml(head.locale)}" />`
|
|
501
|
-
: "";
|
|
502
|
-
const twitterCard =
|
|
503
|
-
head.ogImage !== undefined
|
|
504
|
-
? `\n <meta name="twitter:card" content="summary_large_image" />`
|
|
505
|
-
: "";
|
|
506
|
-
const twitterSite =
|
|
507
|
-
head.twitterSite !== undefined
|
|
508
|
-
? `\n <meta name="twitter:site" content="${escapeHtml(head.twitterSite)}" />`
|
|
509
|
-
: "";
|
|
510
|
-
const preconnects = (head.preconnects ?? [])
|
|
511
|
-
.map((url) => `\n <link rel="preconnect" href="${escapeHtml(url)}" />`)
|
|
512
|
-
.join("");
|
|
513
|
-
const schema =
|
|
514
|
-
head.schemaJson !== undefined
|
|
515
|
-
? `\n <script type="application/ld+json">${scriptSafeJsonHtml(head.schemaJson)}</script>`
|
|
516
|
-
: "";
|
|
517
|
-
const metaDescription = head.description
|
|
518
|
-
? `\n <meta name="description" content="${escapeHtml(head.description)}" />`
|
|
519
|
-
: "";
|
|
520
|
-
const ogDescription = head.description
|
|
521
|
-
? `\n <meta property="og:description" content="${escapeHtml(head.description)}" />`
|
|
522
|
-
: "";
|
|
523
|
-
return `<title>${escapeHtml(head.title)}</title>${metaDescription}
|
|
524
|
-
<meta property="og:title" content="${escapeHtml(head.title)}" />${ogDescription}
|
|
525
|
-
<meta property="og:type" content="website" />${ogUrl}${ogImage}${siteName}${locale}${twitterCard}${twitterSite}${favicon}${canonical}${alternates}${robots}${preconnects}${schema}`;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
export function renderApexPage(page: ApexPage): string {
|
|
529
|
-
const { head, brand } = page;
|
|
530
|
-
const theme = page.theme ?? "light";
|
|
531
|
-
// Brand-CSS ist app-authored (Trust-Boundary siehe Datei-Header), kein Tenant-Input.
|
|
532
|
-
const cssHtml = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
|
|
533
|
-
const sectionsHtml = page.sections.map(renderSection).join("\n\n ");
|
|
534
|
-
return `<!doctype html>
|
|
535
|
-
<html lang="${escapeHtml(head.lang)}">
|
|
536
|
-
<head>
|
|
537
|
-
<meta charset="utf-8" />
|
|
538
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,interactive-widget=resizes-content" />
|
|
539
|
-
${renderApexHeadTags(head)}
|
|
540
|
-
<style>${cssHtml}</style>
|
|
541
|
-
</head>
|
|
542
|
-
<body${theme === "dark" ? ` class="apex-dark"` : ""}>
|
|
543
|
-
${renderApexHeader(page.header)}
|
|
544
|
-
|
|
545
|
-
${sectionsHtml}
|
|
546
|
-
|
|
547
|
-
${renderFooter(page.footer)}
|
|
548
|
-
|
|
549
|
-
${APEX_LIGHTBOX_HTML}
|
|
550
|
-
${APEX_LIGHTBOX_SCRIPT}
|
|
551
|
-
</body>
|
|
552
|
-
</html>`;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/** Per-request head metadata (Open-Graph/title/description) for a
|
|
556
|
-
* server-rendered HTML shell. Only `title` is required; everything else
|
|
557
|
-
* falls back to no tag rather than a placeholder. */
|
|
558
|
-
export type PageHeadMeta = {
|
|
559
|
-
readonly title: string;
|
|
560
|
-
readonly description?: string;
|
|
561
|
-
readonly ogImage?: string;
|
|
562
|
-
readonly canonicalUrl?: string;
|
|
563
|
-
readonly siteName?: string;
|
|
564
|
-
readonly locale?: string;
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
// Matches bundled-features' shared/system-query.ts SystemQueryFn shape
|
|
568
|
-
// (non-generic, Promise<unknown>) rather than a generic <T> signature —
|
|
569
|
-
// that's the convention every other systemQuery caller in the framework
|
|
570
|
-
// already follows (r.httpRoute handlers, seo/managed-pages features).
|
|
571
|
-
export type PageHeadSystemQuery = (
|
|
572
|
-
type: string,
|
|
573
|
-
payload: unknown,
|
|
574
|
-
tenantId: TenantId,
|
|
575
|
-
) => Promise<unknown>;
|
|
576
|
-
|
|
577
|
-
export type PageHeadResolver = (input: {
|
|
578
|
-
readonly path: string;
|
|
579
|
-
readonly host: string;
|
|
580
|
-
readonly systemQuery: PageHeadSystemQuery;
|
|
581
|
-
}) => Promise<PageHeadMeta | null>;
|
|
582
|
-
|
|
583
|
-
const HEAD_TAGS_MARKER = "<!-- kumiko-page-head -->";
|
|
584
|
-
const TITLE_TAG_RE = /<title\b[^>]*>[\s\S]*?<\/title>/i;
|
|
585
|
-
|
|
586
|
-
// Idempotent by marker (repeated calls, e.g. hostDispatch + default path
|
|
587
|
-
// both hitting the same request, never double-inject) and safe on a
|
|
588
|
-
// head-less document (nothing to splice into). `tagsHtml` is pre-rendered
|
|
589
|
-
// HTML from a caller (renderApexHeadTags) — this function only owns
|
|
590
|
-
// placement + the original <title> removal, not escaping.
|
|
591
|
-
export function injectPageHead(html: string, tagsHtml: string): string {
|
|
592
|
-
if (html.includes(HEAD_TAGS_MARKER)) return html;
|
|
593
|
-
if (!html.includes("</head>")) return html;
|
|
594
|
-
const withoutTitle = html.replace(TITLE_TAG_RE, "");
|
|
595
|
-
return withoutTitle.replace("</head>", () => `${HEAD_TAGS_MARKER}\n${tagsHtml}\n</head>`);
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Resolver runs alongside the request, never gates it: a slow, throwing, or
|
|
599
|
-
// null-returning resolver must never turn a 200 shell into a 500 or a
|
|
600
|
-
// stall. One constant shared by runProdApp and runDevApp so both time out
|
|
601
|
-
// on the same value (kumiko-framework#3026).
|
|
602
|
-
const PAGE_HEAD_TIMEOUT_MS = 300;
|
|
603
|
-
|
|
604
|
-
async function resolvePageHeadWithTimeout(
|
|
605
|
-
resolvePageHead: PageHeadResolver,
|
|
606
|
-
input: {
|
|
607
|
-
readonly path: string;
|
|
608
|
-
readonly host: string;
|
|
609
|
-
readonly systemQuery: PageHeadSystemQuery;
|
|
610
|
-
},
|
|
611
|
-
): Promise<PageHeadMeta | null> {
|
|
612
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
613
|
-
try {
|
|
614
|
-
const timedOut = new Promise<null>((resolve) => {
|
|
615
|
-
timer = setTimeout(() => resolve(null), PAGE_HEAD_TIMEOUT_MS);
|
|
616
|
-
});
|
|
617
|
-
// .catch on the resolver's own promise (not just the outer try/catch)
|
|
618
|
-
// so a rejection arriving AFTER the timeout already won the race
|
|
619
|
-
// doesn't surface as an unhandled rejection.
|
|
620
|
-
const resolved = resolvePageHead(input).catch(() => null);
|
|
621
|
-
return await Promise.race([resolved, timedOut]);
|
|
622
|
-
} catch {
|
|
623
|
-
return null;
|
|
624
|
-
} finally {
|
|
625
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// ApexHead.lang is required (html lang="..."); PageHeadMeta only carries
|
|
630
|
-
// og:locale-shaped strings ("de_DE", "en-US") since that's all og:locale
|
|
631
|
-
// needs. Take the language subtag before the region separator; default to
|
|
632
|
-
// "en" when there's no locale to derive one from.
|
|
633
|
-
function apexLangFromLocale(locale: string | undefined): string {
|
|
634
|
-
return locale?.split(/[_-]/)[0]?.toLowerCase() || "en";
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function toApexHead(meta: PageHeadMeta): ApexHead {
|
|
638
|
-
return {
|
|
639
|
-
lang: apexLangFromLocale(meta.locale),
|
|
640
|
-
title: meta.title,
|
|
641
|
-
description: meta.description ?? "",
|
|
642
|
-
...(meta.canonicalUrl !== undefined ? { canonicalUrl: meta.canonicalUrl } : {}),
|
|
643
|
-
...(meta.ogImage !== undefined ? { ogImage: meta.ogImage } : {}),
|
|
644
|
-
...(meta.siteName !== undefined ? { siteName: meta.siteName } : {}),
|
|
645
|
-
...(meta.locale !== undefined ? { locale: meta.locale } : {}),
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/** Resolves per-request head metadata and splices it into `html`, capped at
|
|
650
|
-
* the shared timeout. Never throws and never changes `html` on a resolver
|
|
651
|
-
* error, a `null` resolve, or a timeout — the caller's shell always ships
|
|
652
|
-
* unchanged. runProdApp and runDevApp both call this single function, so a
|
|
653
|
-
* future change to timeout or fallback semantics has one place to land
|
|
654
|
-
* (kumiko-framework#3026). */
|
|
655
|
-
export async function resolveAndInjectPageHead(
|
|
656
|
-
html: string,
|
|
657
|
-
resolvePageHead: PageHeadResolver,
|
|
658
|
-
input: {
|
|
659
|
-
readonly path: string;
|
|
660
|
-
readonly host: string;
|
|
661
|
-
readonly systemQuery: PageHeadSystemQuery;
|
|
662
|
-
},
|
|
663
|
-
): Promise<string> {
|
|
664
|
-
const meta = await resolvePageHeadWithTimeout(resolvePageHead, input);
|
|
665
|
-
if (!meta) return html;
|
|
666
|
-
return injectPageHead(html, renderApexHeadTags(toApexHead(meta)));
|
|
667
|
-
}
|