@cosmicdrift/kumiko-headless 0.146.3 → 0.147.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 +2 -2
- package/src/apex/__tests__/lightbox.test.ts +1 -1
- package/src/apex/__tests__/render.test.ts +30 -0
- package/src/apex/index.ts +22 -12
- package/src/apex/lightbox.ts +2 -8
- package/src/locale-routing/__tests__/locale-routing.test.ts +24 -0
- package/src/locale-routing/index.ts +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.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>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-framework": "0.147.0",
|
|
40
40
|
"zod": "^4.4.3"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { APEX_LIGHTBOX_SCRIPT, APEX_LIGHTBOX_SCRIPT_CSP_HASH } from "../
|
|
3
|
+
import { APEX_LIGHTBOX_SCRIPT, APEX_LIGHTBOX_SCRIPT_CSP_HASH } from "../index";
|
|
4
4
|
|
|
5
5
|
function scriptBody(html: string): string {
|
|
6
6
|
const match = html.match(/^<script>(?<body>[\s\S]*)<\/script>$/);
|
|
@@ -122,6 +122,8 @@ describe("renderApexPage", () => {
|
|
|
122
122
|
expect(html.indexOf("price-cap")).toBeLessThan(html.indexOf("b1"));
|
|
123
123
|
// featured tier without explicit cta variant → primary button
|
|
124
124
|
expect(html).toContain('class="btn btn-primary" href="/s"');
|
|
125
|
+
|
|
126
|
+
expect(html).not.toContain("btn-link");
|
|
125
127
|
});
|
|
126
128
|
|
|
127
129
|
test("cta variant link renders a plain anchor, default renders a button", () => {
|
|
@@ -140,6 +142,29 @@ describe("renderApexPage", () => {
|
|
|
140
142
|
expect(html).toContain('<a class="btn btn-primary" href="/signup">Start</a>');
|
|
141
143
|
});
|
|
142
144
|
|
|
145
|
+
test("pricing-tier cta variant link renders a plain anchor via renderCta, not a btn-link class", () => {
|
|
146
|
+
const html = renderApexPage(
|
|
147
|
+
page({
|
|
148
|
+
sections: [
|
|
149
|
+
{
|
|
150
|
+
kind: "pricing-grid",
|
|
151
|
+
heading: "Preise",
|
|
152
|
+
tiers: [
|
|
153
|
+
{
|
|
154
|
+
name: "Free",
|
|
155
|
+
amount: "0 €",
|
|
156
|
+
benefits: ["b1"],
|
|
157
|
+
cta: { label: "Los", href: "/free", variant: "link" },
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
expect(html).toContain('<a href="/free">Los</a>');
|
|
165
|
+
expect(html).not.toContain("btn-link");
|
|
166
|
+
});
|
|
167
|
+
|
|
143
168
|
test("footer --footer-cols reflects column count and survives without columns", () => {
|
|
144
169
|
const twoCols = renderApexPage(
|
|
145
170
|
page({
|
|
@@ -193,6 +218,11 @@ describe("renderApexPage", () => {
|
|
|
193
218
|
);
|
|
194
219
|
expect(withRobots).toContain('<meta name="robots" content="noindex, nofollow" />');
|
|
195
220
|
});
|
|
221
|
+
test("omits both description meta tags when description is empty", () => {
|
|
222
|
+
const html = renderApexPage(page({ head: { lang: "de", title: "T", description: "" } }));
|
|
223
|
+
expect(html).not.toContain('<meta name="description"');
|
|
224
|
+
expect(html).not.toContain('<meta property="og:description"');
|
|
225
|
+
});
|
|
196
226
|
|
|
197
227
|
test("renders og:site_name and og:locale", () => {
|
|
198
228
|
const html = renderApexPage(
|
package/src/apex/index.ts
CHANGED
|
@@ -9,7 +9,11 @@ import { APEX_STRUCTURAL_CSS } from "./css";
|
|
|
9
9
|
import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
|
|
10
10
|
|
|
11
11
|
export { APEX_NAV_MENU_CSS, APEX_STRUCTURAL_CSS } from "./css";
|
|
12
|
-
export {
|
|
12
|
+
export {
|
|
13
|
+
APEX_LIGHTBOX_HTML,
|
|
14
|
+
APEX_LIGHTBOX_SCRIPT,
|
|
15
|
+
APEX_LIGHTBOX_SCRIPT_CSP_HASH,
|
|
16
|
+
} from "./lightbox";
|
|
13
17
|
|
|
14
18
|
export type ApexTheme = "light" | "dark";
|
|
15
19
|
|
|
@@ -296,21 +300,23 @@ function renderPricingCard(t: ApexPricingTier): string {
|
|
|
296
300
|
const benefits = t.benefits.map((b) => `<li>${escapeHtml(b)}</li>`);
|
|
297
301
|
const items = [...cap, ...benefits].join("\n ");
|
|
298
302
|
const per = t.priceSuffix !== undefined ? `<span>${escapeHtml(t.priceSuffix)}</span>` : "";
|
|
299
|
-
const cls =
|
|
300
|
-
t.cta.variant !== undefined
|
|
301
|
-
? `btn btn-${t.cta.variant}`
|
|
302
|
-
: featured
|
|
303
|
-
? "btn btn-primary"
|
|
304
|
-
: "btn btn-secondary";
|
|
305
303
|
const tagline =
|
|
306
304
|
t.tagline !== undefined ? `<p class="price-tagline">${escapeHtml(t.tagline)}</p>` : "";
|
|
305
|
+
// Delegates to renderCta so a `variant: "link"` tier CTA gets the same
|
|
306
|
+
// class-free anchor as everywhere else instead of re-deriving the class
|
|
307
|
+
// string here (a prior inline duplicate had no "link" case, always
|
|
308
|
+
// emitting `.btn-link`, which the structural CSS never defines).
|
|
309
|
+
const cta =
|
|
310
|
+
t.cta.variant === undefined
|
|
311
|
+
? renderCta({ ...t.cta, variant: featured ? "primary" : "secondary" })
|
|
312
|
+
: renderCta(t.cta);
|
|
307
313
|
return `<article class="price-card${featured ? " price-card--featured" : ""}">
|
|
308
314
|
${badge}<h3>${escapeHtml(t.name)}</h3>
|
|
309
315
|
${tagline}<div class="price-amount">${escapeHtml(t.amount)}${per}</div>
|
|
310
316
|
<ul class="price-list">
|
|
311
317
|
${items}
|
|
312
318
|
</ul>
|
|
313
|
-
|
|
319
|
+
${cta}
|
|
314
320
|
</article>`;
|
|
315
321
|
}
|
|
316
322
|
|
|
@@ -505,10 +511,14 @@ export function renderApexHeadTags(head: ApexHead): string {
|
|
|
505
511
|
head.schemaJson !== undefined
|
|
506
512
|
? `\n <script type="application/ld+json">${scriptSafeJsonHtml(head.schemaJson)}</script>`
|
|
507
513
|
: "";
|
|
508
|
-
|
|
509
|
-
<meta name="description" content="${escapeHtml(head.description)}"
|
|
510
|
-
|
|
511
|
-
|
|
514
|
+
const metaDescription = head.description
|
|
515
|
+
? `\n <meta name="description" content="${escapeHtml(head.description)}" />`
|
|
516
|
+
: "";
|
|
517
|
+
const ogDescription = head.description
|
|
518
|
+
? `\n <meta property="og:description" content="${escapeHtml(head.description)}" />`
|
|
519
|
+
: "";
|
|
520
|
+
return `<title>${escapeHtml(head.title)}</title>${metaDescription}
|
|
521
|
+
<meta property="og:title" content="${escapeHtml(head.title)}" />${ogDescription}
|
|
512
522
|
<meta property="og:type" content="website" />${ogUrl}${ogImage}${siteName}${locale}${twitterCard}${twitterSite}${favicon}${canonical}${alternates}${robots}${preconnects}${schema}`;
|
|
513
523
|
}
|
|
514
524
|
|
package/src/apex/lightbox.ts
CHANGED
|
@@ -45,12 +45,6 @@ const APEX_LIGHTBOX_SCRIPT_BODY = `
|
|
|
45
45
|
/** ponytail: one delegated listener; no-op when no .shot-frame on the page. */
|
|
46
46
|
export const APEX_LIGHTBOX_SCRIPT = `<script>${APEX_LIGHTBOX_SCRIPT_BODY}</script>`;
|
|
47
47
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// pre-rendered to static HTML at build time, where a nonce can't work
|
|
51
|
-
// anyway) need a hash-source instead. Add this to your CSP's script-src,
|
|
52
|
-
// e.g. `script-src 'self' 'sha256-...'` (value = this constant).
|
|
53
|
-
// lightbox.test.ts asserts this matches APEX_LIGHTBOX_SCRIPT_BODY byte-for-
|
|
54
|
-
// byte, so an edit to the script that doesn't update the hash fails CI
|
|
55
|
-
// instead of silently breaking every strict-CSP consumer's lightbox.
|
|
48
|
+
// CSP hash-source for the inline script above; apex output is often
|
|
49
|
+
// pre-rendered, so a per-request nonce can't work. Guarded by lightbox.test.ts.
|
|
56
50
|
export const APEX_LIGHTBOX_SCRIPT_CSP_HASH = "sha256-f+hHLpDuQsjmtFZCjdM13D9NaMTCyOKaawAhfLf/X9o=";
|
|
@@ -92,3 +92,27 @@ describe("createLocaleRouter inverted default (website-style)", () => {
|
|
|
92
92
|
expect(router.altLocalePath("/de")).toBe("/");
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
|
+
|
|
96
|
+
describe("createLocaleRouter homePage validation", () => {
|
|
97
|
+
test("throws at construction when homePage has no routes entry", () => {
|
|
98
|
+
expect(() =>
|
|
99
|
+
createLocaleRouter({
|
|
100
|
+
defaultLocale: "de",
|
|
101
|
+
prefixedLocales: ["en"],
|
|
102
|
+
routes: { features: { de: "/funktionen", en: "/en/features" } },
|
|
103
|
+
}),
|
|
104
|
+
).toThrow(/homePage/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("publicPath throws instead of crashing when page is missing from routes", () => {
|
|
108
|
+
const router = createLocaleRouter<"home" | "features">({
|
|
109
|
+
defaultLocale: "de",
|
|
110
|
+
prefixedLocales: ["en"],
|
|
111
|
+
routes: { home: { de: "/", en: "/en" } } as unknown as Record<
|
|
112
|
+
"home" | "features",
|
|
113
|
+
Record<string, string>
|
|
114
|
+
>,
|
|
115
|
+
});
|
|
116
|
+
expect(() => router.publicPath("features", "de")).toThrow(/no path for page/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -53,6 +53,11 @@ export function createLocaleRouter<TPage extends string>(
|
|
|
53
53
|
homePage = "home" as TPage,
|
|
54
54
|
} = config;
|
|
55
55
|
|
|
56
|
+
if (routes[homePage] === undefined) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`locale-routing: homePage "${String(homePage)}" has no entry in routes — altLocalePath's fallback would throw at request time`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
56
61
|
const pathIndex = new Map<string, { page: TPage; locale: string }>();
|
|
57
62
|
|
|
58
63
|
for (const [page, localePaths] of Object.entries(routes) as [TPage, Record<string, string>][]) {
|
|
@@ -102,13 +107,18 @@ export function createLocaleRouter<TPage extends string>(
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
function publicPath(page: TPage, locale: string): string {
|
|
105
|
-
const
|
|
110
|
+
const localePaths = routes[page];
|
|
111
|
+
const routePath = localePaths?.[locale];
|
|
106
112
|
if (routePath === undefined) {
|
|
107
113
|
throw new Error(`locale-routing: no path for page "${String(page)}" locale "${locale}"`);
|
|
108
114
|
}
|
|
109
115
|
return routePath;
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
// Binary toggle (defaultLocale <-> prefixedLocales[0]) — altLocalePath has
|
|
119
|
+
// no way to target a specific alt locale beyond the first prefixed one.
|
|
120
|
+
// Fine for the current bilingual (de/en) setup; a third prefixed locale
|
|
121
|
+
// needs altLocalePath to take an explicit targetLocale param instead.
|
|
112
122
|
function otherLocale(currentLocale: string): string {
|
|
113
123
|
if (currentLocale === defaultLocale) {
|
|
114
124
|
return prefixedLocales[0] ?? defaultLocale;
|