@cosmicdrift/kumiko-headless 0.288.0 → 0.290.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__/page-head.test.ts +63 -0
- package/src/apex/index.ts +115 -0
- package/src/changes.json +8 -0
- package/src/view-model/edit.ts +2 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.290.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.290.0",
|
|
40
40
|
"temporal-polyfill": "^0.3.2",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { injectPageHead, resolveAndInjectPageHead } from "../index";
|
|
3
|
+
|
|
4
|
+
describe("injectPageHead", () => {
|
|
5
|
+
const TAGS = '<title>New Title</title>\n<meta name="description" content="d">';
|
|
6
|
+
|
|
7
|
+
test("replaces an existing <title> instead of duplicating it", () => {
|
|
8
|
+
const html = "<html><head><title>Old</title></head><body></body></html>";
|
|
9
|
+
const out = injectPageHead(html, TAGS);
|
|
10
|
+
expect(out).not.toContain("<title>Old</title>");
|
|
11
|
+
expect((out.match(/<title>/g) ?? []).length).toBe(1);
|
|
12
|
+
expect(out).toContain("<title>New Title</title>");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("applying twice does not duplicate the head-tags block", () => {
|
|
16
|
+
const html = "<html><head><title>Old</title></head><body></body></html>";
|
|
17
|
+
const once = injectPageHead(html, TAGS);
|
|
18
|
+
const twice = injectPageHead(once, TAGS);
|
|
19
|
+
expect(twice).toBe(once);
|
|
20
|
+
expect((twice.match(/kumiko-page-head/g) ?? []).length).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("HTML without </head> is returned unchanged", () => {
|
|
24
|
+
const html = "<html><body><title>Old</title></body></html>";
|
|
25
|
+
expect(injectPageHead(html, TAGS)).toBe(html);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("resolveAndInjectPageHead", () => {
|
|
30
|
+
const HTML = "<!doctype html><html><head><title>Shell</title></head><body>shell</body></html>";
|
|
31
|
+
const input = { path: "/", host: "t", systemQuery: async () => ({}) };
|
|
32
|
+
|
|
33
|
+
test("resolved meta is injected as head tags", async () => {
|
|
34
|
+
const out = await resolveAndInjectPageHead(
|
|
35
|
+
HTML,
|
|
36
|
+
async () => ({ title: "Vehicle X", description: "A car", ogImage: "https://x/i.png" }),
|
|
37
|
+
input,
|
|
38
|
+
);
|
|
39
|
+
expect(out).toContain("<title>Vehicle X</title>");
|
|
40
|
+
expect(out).toContain('<meta property="og:image" content="https://x/i.png" />');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("resolver throws → unchanged html, not a rejection", async () => {
|
|
44
|
+
const out = await resolveAndInjectPageHead(
|
|
45
|
+
HTML,
|
|
46
|
+
async () => {
|
|
47
|
+
throw new Error("boom");
|
|
48
|
+
},
|
|
49
|
+
input,
|
|
50
|
+
);
|
|
51
|
+
expect(out).toBe(HTML);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("resolver returns null → unchanged html", async () => {
|
|
55
|
+
const out = await resolveAndInjectPageHead(HTML, async () => null, input);
|
|
56
|
+
expect(out).toBe(HTML);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("resolver never resolves → unchanged html after the shared timeout", async () => {
|
|
60
|
+
const out = await resolveAndInjectPageHead(HTML, () => new Promise(() => {}), input);
|
|
61
|
+
expect(out).toBe(HTML);
|
|
62
|
+
}, 2000);
|
|
63
|
+
});
|
package/src/apex/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// brand tokens (brand.tokensCss) and content. Server-side, zero React, one
|
|
5
5
|
// cacheable HTTP response. See APEX_STRUCTURAL_CSS for the CSS contract.
|
|
6
6
|
|
|
7
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { escapeHtml } from "../format";
|
|
8
9
|
import { APEX_STRUCTURAL_CSS } from "./css";
|
|
9
10
|
import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
|
|
@@ -550,3 +551,117 @@ export function renderApexPage(page: ApexPage): string {
|
|
|
550
551
|
</body>
|
|
551
552
|
</html>`;
|
|
552
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
|
+
}
|
package/src/changes.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.289.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "headless gains resolveAndInjectPageHead/injectPageHead for per-request head injection",
|
|
6
|
+
"detail": "New apex exports: PageHeadMeta/PageHeadResolver/PageHeadSystemQuery types, injectPageHead (idempotent marker-based </head> splice), and resolveAndInjectPageHead (resolves a PageHeadResolver with a shared 300ms timeout, converts to ApexHead, renders via renderApexHeadTags, injects). Moved out of @cosmicdrift/kumiko-server-runtime (kumiko-framework#3026) so runDevApp and runProdApp share one resolve+inject call instead of two copies of the timeout/fallback logic; server-runtime re-exports the three types unchanged for existing consumers."
|
|
7
|
+
}
|
|
8
|
+
]
|
package/src/view-model/edit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isWriteFormEditSection,
|
|
13
13
|
normalizeEditField,
|
|
14
14
|
parseRefTarget,
|
|
15
|
+
sectionFieldSpecs,
|
|
15
16
|
WRITE_FORM_SECTION_ENTITY,
|
|
16
17
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
18
|
import {
|
|
@@ -167,11 +168,7 @@ export function computeEditViewModel<
|
|
|
167
168
|
}
|
|
168
169
|
// `groups` flattens into the same per-field pipeline as plain `fields`;
|
|
169
170
|
// the group structure below just re-groups the computed views by name.
|
|
170
|
-
const
|
|
171
|
-
sectionSpec.groups !== undefined
|
|
172
|
-
? sectionSpec.groups.flatMap((group) => group.fields)
|
|
173
|
-
: sectionSpec.fields;
|
|
174
|
-
const fields: EditFieldViewModel[] = flatFieldSpecs.map((fieldSpec) => {
|
|
171
|
+
const fields: EditFieldViewModel[] = sectionFieldSpecs(sectionSpec).map((fieldSpec) => {
|
|
175
172
|
const normalized = normalizeEditField(fieldSpec);
|
|
176
173
|
const fieldDef = entity.fields[normalized.field];
|
|
177
174
|
if (!fieldDef) {
|