@cosmicdrift/kumiko-headless 0.146.4 → 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 +17 -11
- 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
|
@@ -300,21 +300,23 @@ function renderPricingCard(t: ApexPricingTier): string {
|
|
|
300
300
|
const benefits = t.benefits.map((b) => `<li>${escapeHtml(b)}</li>`);
|
|
301
301
|
const items = [...cap, ...benefits].join("\n ");
|
|
302
302
|
const per = t.priceSuffix !== undefined ? `<span>${escapeHtml(t.priceSuffix)}</span>` : "";
|
|
303
|
-
const cls =
|
|
304
|
-
t.cta.variant !== undefined
|
|
305
|
-
? `btn btn-${t.cta.variant}`
|
|
306
|
-
: featured
|
|
307
|
-
? "btn btn-primary"
|
|
308
|
-
: "btn btn-secondary";
|
|
309
303
|
const tagline =
|
|
310
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);
|
|
311
313
|
return `<article class="price-card${featured ? " price-card--featured" : ""}">
|
|
312
314
|
${badge}<h3>${escapeHtml(t.name)}</h3>
|
|
313
315
|
${tagline}<div class="price-amount">${escapeHtml(t.amount)}${per}</div>
|
|
314
316
|
<ul class="price-list">
|
|
315
317
|
${items}
|
|
316
318
|
</ul>
|
|
317
|
-
|
|
319
|
+
${cta}
|
|
318
320
|
</article>`;
|
|
319
321
|
}
|
|
320
322
|
|
|
@@ -509,10 +511,14 @@ export function renderApexHeadTags(head: ApexHead): string {
|
|
|
509
511
|
head.schemaJson !== undefined
|
|
510
512
|
? `\n <script type="application/ld+json">${scriptSafeJsonHtml(head.schemaJson)}</script>`
|
|
511
513
|
: "";
|
|
512
|
-
|
|
513
|
-
<meta name="description" content="${escapeHtml(head.description)}"
|
|
514
|
-
|
|
515
|
-
|
|
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}
|
|
516
522
|
<meta property="og:type" content="website" />${ogUrl}${ogImage}${siteName}${locale}${twitterCard}${twitterSite}${favicon}${canonical}${alternates}${robots}${preconnects}${schema}`;
|
|
517
523
|
}
|
|
518
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;
|