@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.146.3",
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.146.3",
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 "../lightbox";
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 { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
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
- <a class="${cls}" href="${escapeHtml(t.cta.href)}">${escapeHtml(t.cta.label)}</a>
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
- return `<title>${escapeHtml(head.title)}</title>
509
- <meta name="description" content="${escapeHtml(head.description)}" />
510
- <meta property="og:title" content="${escapeHtml(head.title)}" />
511
- <meta property="og:description" content="${escapeHtml(head.description)}" />
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
 
@@ -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
- // Apps that enforce `script-src 'self'` without `'unsafe-inline'`/nonce (the
49
- // apex renderer has no per-request nonce hook its output is often
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 routePath = routes[page][locale];
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;