@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Converts the resolved loader-graph into the pageData shape the renderer
|
|
7
|
+
* consumes — three branches: arbitrary region-slots (portal/utility), the
|
|
8
|
+
* header/body/footer public shell, and the single-page fallback. Extracted
|
|
9
|
+
* from register-registry-page-route.ts (single-purpose-file split).
|
|
10
|
+
*/
|
|
11
|
+
import { resolveThemeMode } from "../resolve-theme-mode.ts";
|
|
12
|
+
import { composePageTitle } from "../compose-page-title.ts";
|
|
13
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Local structural type for Puck page data. Avoids a type-only import of
|
|
17
|
+
* `@puckeditor/core`, which is NOT a runtime dependency — `deps-derived-from-source`
|
|
18
|
+
* excludes whole-statement type-only imports, so the bare specifier has no
|
|
19
|
+
* installed package to resolve against at publish typecheck time. The shape used
|
|
20
|
+
* here (root/content/zones) is all this module reads or constructs.
|
|
21
|
+
*/
|
|
22
|
+
type PuckData = {
|
|
23
|
+
root: { props?: Record<string, unknown> };
|
|
24
|
+
content: unknown[];
|
|
25
|
+
zones?: Record<string, unknown>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const EMPTY_PUCK_DATA: PuckData = { root: { props: {} }, content: [] };
|
|
29
|
+
|
|
30
|
+
// Returns null on malformed JSON (observed via warn log) instead of a silent
|
|
31
|
+
// masking empty fallback — protective-layers-fail-explicitly. Callers default
|
|
32
|
+
// to EMPTY_PUCK_DATA where an empty render is the intended degradation.
|
|
33
|
+
function extractPuckData(source: Record<string, unknown>): PuckData | null {
|
|
34
|
+
const raw = source.puck_data ?? source.puckData;
|
|
35
|
+
if (!raw) return EMPTY_PUCK_DATA;
|
|
36
|
+
if (typeof raw === "string") {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw) as PuckData;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
getAppLogger().warn(
|
|
41
|
+
"[extractPuckData] malformed puck_data JSON — rendering empty",
|
|
42
|
+
{ err },
|
|
43
|
+
);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return raw as PuckData;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applyLayoutDefaults(
|
|
51
|
+
puckData: PuckData,
|
|
52
|
+
defaults: Record<string, string>,
|
|
53
|
+
): void {
|
|
54
|
+
if (!defaults || Object.keys(defaults).length === 0) return;
|
|
55
|
+
const data = puckData as { root?: { props?: Record<string, unknown> } };
|
|
56
|
+
if (!data.root) data.root = { props: {} };
|
|
57
|
+
if (!data.root.props) data.root.props = {};
|
|
58
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
59
|
+
if (data.root.props[key] == null) data.root.props[key] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pickRootProp<T>(regions: RegionEntry[], key: string): T | undefined {
|
|
64
|
+
for (const region of regions) {
|
|
65
|
+
const props = (
|
|
66
|
+
region.puckData as { root?: { props?: Record<string, unknown> } } | null
|
|
67
|
+
)?.root?.props;
|
|
68
|
+
const value = props?.[key];
|
|
69
|
+
if (value != null && value !== "") return value as T;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RegionEntry {
|
|
75
|
+
slot: string;
|
|
76
|
+
puckData: PuckData;
|
|
77
|
+
pageId: string;
|
|
78
|
+
website?: { slug: string; name: string; organisation_id?: string | null };
|
|
79
|
+
metaTitle?: string | null;
|
|
80
|
+
metaDescription?: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function resolvedToPageData(
|
|
84
|
+
resolved: Record<string, unknown>,
|
|
85
|
+
meta: Record<string, unknown>,
|
|
86
|
+
request: Request,
|
|
87
|
+
providers: string[] | undefined,
|
|
88
|
+
regionSlots?: string[],
|
|
89
|
+
// ?theme=light|dark from the URL — wins over per-page/brand/website theme so
|
|
90
|
+
// a page framed inside another shell (e.g. a form in a GPT tool panel)
|
|
91
|
+
// matches the host's light/dark scheme so an embedded page doesn't flash.
|
|
92
|
+
themeOverride?: "light" | "dark",
|
|
93
|
+
): Record<string, unknown> {
|
|
94
|
+
const brand = (resolved.brand as Record<string, unknown>) ?? {};
|
|
95
|
+
const primaryColor = (brand.primaryColor as string) ?? "";
|
|
96
|
+
const headingFont = (brand.headingFont as string) ?? null;
|
|
97
|
+
const bodyFont = (brand.bodyFont as string) ?? null;
|
|
98
|
+
const logo = (brand.logo as {
|
|
99
|
+
light: string | null;
|
|
100
|
+
dark: string | null;
|
|
101
|
+
brand: string | null;
|
|
102
|
+
}) ?? { light: null, dark: null, brand: null };
|
|
103
|
+
|
|
104
|
+
const brandTheme = typeof brand.theme === "string" ? brand.theme : undefined;
|
|
105
|
+
|
|
106
|
+
// Branch 1: arbitrary regions (portal/agents/docs) — region_slots from loader config
|
|
107
|
+
if (regionSlots && regionSlots.length > 0) {
|
|
108
|
+
const website = (resolved.website as Record<string, unknown>) ?? {};
|
|
109
|
+
const layoutDefaults =
|
|
110
|
+
(website.layout_defaults as Record<string, string>) ??
|
|
111
|
+
(website.layout_settings as Record<string, string>) ??
|
|
112
|
+
{};
|
|
113
|
+
const layoutSettings = website.layout_settings as
|
|
114
|
+
| { theme?: unknown }
|
|
115
|
+
| null
|
|
116
|
+
| undefined;
|
|
117
|
+
const websiteTheme =
|
|
118
|
+
layoutSettings && typeof layoutSettings.theme === "string"
|
|
119
|
+
? layoutSettings.theme
|
|
120
|
+
: undefined;
|
|
121
|
+
|
|
122
|
+
// Title derives from the primary resolved entity (e.g. the GPT agent),
|
|
123
|
+
// matching the flat-page branch — so a utility-shell GPT shows the agent
|
|
124
|
+
// name in <title>/OG, not the generic "Portal". Falls back to the website
|
|
125
|
+
// name, then "Portal". Without this, the region-slots branch returned
|
|
126
|
+
// early with a hardcoded "Portal" title (gpt-test: SSR/DOM/OG title FAIL).
|
|
127
|
+
const entityTitle =
|
|
128
|
+
(resolved.form as { name?: string })?.name ||
|
|
129
|
+
(resolved.quiz as { title?: string })?.title ||
|
|
130
|
+
(resolved.proposal as { name?: string })?.name ||
|
|
131
|
+
(resolved.agent as { name?: string })?.name;
|
|
132
|
+
|
|
133
|
+
const regions: RegionEntry[] = [];
|
|
134
|
+
for (const slot of regionSlots) {
|
|
135
|
+
const source = resolved[slot] as Record<string, unknown> | undefined;
|
|
136
|
+
if (!source) continue;
|
|
137
|
+
const puckData = extractPuckData(source) ?? EMPTY_PUCK_DATA;
|
|
138
|
+
applyLayoutDefaults(puckData, layoutDefaults);
|
|
139
|
+
regions.push({
|
|
140
|
+
slot,
|
|
141
|
+
puckData,
|
|
142
|
+
pageId: (source.id as string) ?? "",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (regions.length === 0) {
|
|
147
|
+
return {
|
|
148
|
+
puckData: { root: { props: {} }, content: [] } as PuckData,
|
|
149
|
+
theme: "dark" as const,
|
|
150
|
+
brand: {
|
|
151
|
+
primaryColor,
|
|
152
|
+
backgroundColor: (brand.backgroundColor as string | null) ?? null,
|
|
153
|
+
lightSurface: (brand.lightSurface as string | null) ?? null,
|
|
154
|
+
darkSurface: (brand.darkSurface as string | null) ?? null,
|
|
155
|
+
headingFont,
|
|
156
|
+
bodyFont,
|
|
157
|
+
logo,
|
|
158
|
+
companyName: (brand.companyName as string) ?? null,
|
|
159
|
+
companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
|
|
160
|
+
contactEmail: (brand.contactEmail as string) ?? null,
|
|
161
|
+
favicon: (brand.favicon as string) ?? null,
|
|
162
|
+
},
|
|
163
|
+
meta: {
|
|
164
|
+
title: composePageTitle({
|
|
165
|
+
templateTitle: (meta.title as string) || undefined,
|
|
166
|
+
websiteName: (website.name as string) || undefined,
|
|
167
|
+
entityTitle: entityTitle || undefined,
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const rootTheme = pickRootProp<string>(regions, "theme");
|
|
174
|
+
const { theme } = resolveThemeMode({
|
|
175
|
+
override: themeOverride,
|
|
176
|
+
candidates: [rootTheme, brandTheme, websiteTheme],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const overrideColor = pickRootProp<string>(regions, "primaryColor");
|
|
180
|
+
const overrideHeading = pickRootProp<string>(regions, "headingFont");
|
|
181
|
+
const overrideBody = pickRootProp<string>(regions, "bodyFont");
|
|
182
|
+
// The page the user is viewing is the main content region (center for
|
|
183
|
+
// portal, body for utility). Its name ("Dashboard") is the title subject.
|
|
184
|
+
const contentSlot = regionSlots.find((s) => s === "center" || s === "body");
|
|
185
|
+
const contentPageName = contentSlot
|
|
186
|
+
? ((resolved[contentSlot] as Record<string, unknown> | undefined)?.name as
|
|
187
|
+
| string
|
|
188
|
+
| undefined)
|
|
189
|
+
: undefined;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
puckData: { root: { props: {} }, content: [] } as PuckData,
|
|
193
|
+
regions,
|
|
194
|
+
// Region-shell blocks (e.g. the GPT chat in the center region) read
|
|
195
|
+
// their resolved entities from pageData.loaderData — same surface the
|
|
196
|
+
// flat single-page branch exposes. Without it the chat block has no
|
|
197
|
+
// agent and renders "Custom GPT not found".
|
|
198
|
+
loaderData: resolved,
|
|
199
|
+
website: {
|
|
200
|
+
slug: website.slug as string,
|
|
201
|
+
name: website.name as string,
|
|
202
|
+
organisation_id: website.organisation_id as string | null,
|
|
203
|
+
// header_settings carries the DB-driven header config (e.g.
|
|
204
|
+
// backdropBlur) the public shell applies — PublicTemplate reads
|
|
205
|
+
// pageData.website.header_settings.backdropBlur. Dropping it here
|
|
206
|
+
// silently kills the header blur even though the DB switch is set
|
|
207
|
+
// (the 2026-06-17 regression). Pass it through verbatim.
|
|
208
|
+
header_settings:
|
|
209
|
+
(website.header_settings as Record<string, unknown> | null) ?? null,
|
|
210
|
+
},
|
|
211
|
+
orgId:
|
|
212
|
+
((resolved.center as Record<string, unknown> | undefined)
|
|
213
|
+
?.organisation_id as string | null) ??
|
|
214
|
+
((resolved.agent as Record<string, unknown> | undefined)
|
|
215
|
+
?.organisation_id as string | null) ??
|
|
216
|
+
null,
|
|
217
|
+
brand: {
|
|
218
|
+
primaryColor: overrideColor ?? primaryColor,
|
|
219
|
+
backgroundColor: (brand.backgroundColor as string | null) ?? null,
|
|
220
|
+
lightSurface: (brand.lightSurface as string | null) ?? null,
|
|
221
|
+
darkSurface: (brand.darkSurface as string | null) ?? null,
|
|
222
|
+
headingFont: overrideHeading ?? headingFont,
|
|
223
|
+
bodyFont: overrideBody ?? bodyFont,
|
|
224
|
+
logo,
|
|
225
|
+
companyName: (brand.companyName as string) ?? null,
|
|
226
|
+
companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
|
|
227
|
+
contactEmail: (brand.contactEmail as string) ?? null,
|
|
228
|
+
favicon: (brand.favicon as string) ?? null,
|
|
229
|
+
},
|
|
230
|
+
theme,
|
|
231
|
+
meta: {
|
|
232
|
+
title: composePageTitle({
|
|
233
|
+
templateTitle: (meta.title as string) || undefined,
|
|
234
|
+
websiteName: (website.name as string) || undefined,
|
|
235
|
+
pageName: contentPageName,
|
|
236
|
+
entityTitle: entityTitle || undefined,
|
|
237
|
+
}),
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Branch 2: public shell (header/body/footer regions from header/footer sources)
|
|
243
|
+
const headerSource = resolved.header as Record<string, unknown> | undefined;
|
|
244
|
+
const footerSource = resolved.footer as Record<string, unknown> | undefined;
|
|
245
|
+
|
|
246
|
+
if (headerSource || footerSource) {
|
|
247
|
+
const page = (resolved.page as Record<string, unknown>) ?? {};
|
|
248
|
+
const website =
|
|
249
|
+
(resolved.website_by_host as Record<string, unknown>) ??
|
|
250
|
+
(resolved.website as Record<string, unknown>) ??
|
|
251
|
+
{};
|
|
252
|
+
const layoutDefaults =
|
|
253
|
+
(website.layout_defaults as Record<string, string>) ??
|
|
254
|
+
(website.layout_settings as Record<string, string>) ??
|
|
255
|
+
{};
|
|
256
|
+
const layoutSettings = website.layout_settings as
|
|
257
|
+
| { theme?: unknown }
|
|
258
|
+
| null
|
|
259
|
+
| undefined;
|
|
260
|
+
const websiteTheme =
|
|
261
|
+
layoutSettings && typeof layoutSettings.theme === "string"
|
|
262
|
+
? layoutSettings.theme
|
|
263
|
+
: undefined;
|
|
264
|
+
|
|
265
|
+
const regions: RegionEntry[] = [];
|
|
266
|
+
|
|
267
|
+
if (headerSource) {
|
|
268
|
+
const headerPuck = extractPuckData(headerSource) ?? EMPTY_PUCK_DATA;
|
|
269
|
+
applyLayoutDefaults(headerPuck, layoutDefaults);
|
|
270
|
+
regions.push({
|
|
271
|
+
slot: "header",
|
|
272
|
+
puckData: headerPuck,
|
|
273
|
+
pageId: (headerSource.id as string) ?? "",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const bodyPuck = extractPuckData(page) ?? EMPTY_PUCK_DATA;
|
|
278
|
+
applyLayoutDefaults(bodyPuck, layoutDefaults);
|
|
279
|
+
regions.push({
|
|
280
|
+
slot: "body",
|
|
281
|
+
puckData: bodyPuck,
|
|
282
|
+
pageId: (page.id as string) ?? "",
|
|
283
|
+
metaTitle: (page.meta_title as string) ?? null,
|
|
284
|
+
metaDescription: (page.meta_description as string) ?? null,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
if (footerSource) {
|
|
288
|
+
const footerPuck = extractPuckData(footerSource) ?? EMPTY_PUCK_DATA;
|
|
289
|
+
applyLayoutDefaults(footerPuck, layoutDefaults);
|
|
290
|
+
regions.push({
|
|
291
|
+
slot: "footer",
|
|
292
|
+
puckData: footerPuck,
|
|
293
|
+
pageId: (footerSource.id as string) ?? "",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const rootTheme = pickRootProp<string>(regions, "theme");
|
|
298
|
+
const { theme } = resolveThemeMode({
|
|
299
|
+
override: themeOverride,
|
|
300
|
+
candidates: [rootTheme, brandTheme, websiteTheme],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const overrideColor = pickRootProp<string>(regions, "primaryColor");
|
|
304
|
+
const overrideBg = pickRootProp<string>(regions, "backgroundColor");
|
|
305
|
+
const overrideHeading = pickRootProp<string>(regions, "headingFont");
|
|
306
|
+
const overrideBody = pickRootProp<string>(regions, "bodyFont");
|
|
307
|
+
|
|
308
|
+
const pageName = (page.name as string) || null;
|
|
309
|
+
const entityTitle =
|
|
310
|
+
(resolved.proposal as { name?: string })?.name ||
|
|
311
|
+
(resolved.form as { name?: string })?.name ||
|
|
312
|
+
(resolved.quiz as { title?: string })?.title ||
|
|
313
|
+
(resolved.agent as { name?: string })?.name;
|
|
314
|
+
const bodyMetaTitle = regions.find((r) => r.slot === "body")?.metaTitle;
|
|
315
|
+
const pageRootTitle = pickRootProp<string>(regions, "title");
|
|
316
|
+
const pageDescription =
|
|
317
|
+
regions.find((r) => r.slot === "body")?.metaDescription ??
|
|
318
|
+
pickRootProp<string>(regions, "description") ??
|
|
319
|
+
null;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
puckData: { root: { props: {} }, content: [] } as PuckData,
|
|
323
|
+
regions,
|
|
324
|
+
// Region-shell blocks (e.g. the header Navigation deriving the body's
|
|
325
|
+
// document sections) read the resolved entity graph from
|
|
326
|
+
// pageData.loaderData — the same surface Branch 1 exposes. Without it a
|
|
327
|
+
// header/footer block serverLoader gets ctx.loaderData=undefined and
|
|
328
|
+
// cannot see the body page, so a server-derived nav produced no items
|
|
329
|
+
// and the menu was client-only (vanished on stale tabs / narrow views).
|
|
330
|
+
loaderData: resolved,
|
|
331
|
+
website: {
|
|
332
|
+
slug: website.slug as string,
|
|
333
|
+
name: website.name as string,
|
|
334
|
+
organisation_id: website.organisation_id as string | null,
|
|
335
|
+
// header_settings carries the DB-driven header config (e.g.
|
|
336
|
+
// backdropBlur) the public shell applies — PublicTemplate reads
|
|
337
|
+
// pageData.website.header_settings.backdropBlur. Dropping it here
|
|
338
|
+
// silently kills the header blur even though the DB switch is set
|
|
339
|
+
// (the 2026-06-17 regression). Pass it through verbatim.
|
|
340
|
+
header_settings:
|
|
341
|
+
(website.header_settings as Record<string, unknown> | null) ?? null,
|
|
342
|
+
},
|
|
343
|
+
orgId:
|
|
344
|
+
(page.organisation_id as string) ??
|
|
345
|
+
null,
|
|
346
|
+
brand: {
|
|
347
|
+
primaryColor: overrideColor ?? primaryColor,
|
|
348
|
+
// Thread the org brand's dark surface (brand_background_color /
|
|
349
|
+
// brand_dark_color) when the page sets no explicit override — so
|
|
350
|
+
// dark-mode pages use the brand's real dark colour, not a
|
|
351
|
+
// primary-derived tint. Uses the org brand's real dark colour.
|
|
352
|
+
backgroundColor:
|
|
353
|
+
overrideBg ?? (brand.backgroundColor as string | null) ?? null,
|
|
354
|
+
lightSurface: (brand.lightSurface as string | null) ?? null,
|
|
355
|
+
darkSurface: (brand.darkSurface as string | null) ?? null,
|
|
356
|
+
accentColor: (brand.accentColor as string | null) ?? null,
|
|
357
|
+
secondaryColor: (brand.secondaryColor as string | null) ?? null,
|
|
358
|
+
mutedColor: (brand.mutedColor as string | null) ?? null,
|
|
359
|
+
headingFont: overrideHeading ?? headingFont,
|
|
360
|
+
bodyFont: overrideBody ?? bodyFont,
|
|
361
|
+
logo,
|
|
362
|
+
companyName: (brand.companyName as string) ?? null,
|
|
363
|
+
companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
|
|
364
|
+
contactEmail: (brand.contactEmail as string) ?? null,
|
|
365
|
+
favicon: (brand.favicon as string) ?? null,
|
|
366
|
+
},
|
|
367
|
+
theme,
|
|
368
|
+
meta: {
|
|
369
|
+
title: composePageTitle({
|
|
370
|
+
pageMetaTitle: bodyMetaTitle,
|
|
371
|
+
templateTitle: (meta.title as string) || undefined,
|
|
372
|
+
websiteName: (website.name as string) || undefined,
|
|
373
|
+
pageName: pageName ?? pageRootTitle,
|
|
374
|
+
entityTitle: entityTitle || undefined,
|
|
375
|
+
}),
|
|
376
|
+
...(pageDescription ? { description: pageDescription } : {}),
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const page = (resolved.page as Record<string, unknown>) ?? null;
|
|
382
|
+
const puckData = page
|
|
383
|
+
? extractPuckData(page) ?? EMPTY_PUCK_DATA
|
|
384
|
+
: ({ root: { props: {} }, content: [] } as PuckData);
|
|
385
|
+
|
|
386
|
+
const website = (resolved.website as Record<string, unknown>) ?? {};
|
|
387
|
+
const layoutSettings = (website.layout_settings ??
|
|
388
|
+
resolved.websiteDefaults) as { theme?: unknown } | null | undefined;
|
|
389
|
+
const websiteTheme =
|
|
390
|
+
layoutSettings && typeof layoutSettings.theme === "string"
|
|
391
|
+
? layoutSettings.theme
|
|
392
|
+
: undefined;
|
|
393
|
+
|
|
394
|
+
const pageRoot = (
|
|
395
|
+
puckData as { root?: { props?: Record<string, unknown> } } | null
|
|
396
|
+
)?.root?.props;
|
|
397
|
+
const rootTheme =
|
|
398
|
+
pageRoot && typeof pageRoot.theme === "string" ? pageRoot.theme : undefined;
|
|
399
|
+
const agent = (resolved.agent as Record<string, unknown>) ?? null;
|
|
400
|
+
// A GPT page's mode is the agent's dark_theme flag (the DB switch). It's the
|
|
401
|
+
// top candidate so the SSR :root matches the GPT shell's own scope (both
|
|
402
|
+
// derive from the same ai_agents.dark_theme) — flip the flag, the whole GPT
|
|
403
|
+
// + everything it frames flips. themeOverride (?theme=) still wins for an
|
|
404
|
+
// embedded render.
|
|
405
|
+
const agentTheme =
|
|
406
|
+
typeof agent?.dark_theme === "boolean"
|
|
407
|
+
? agent.dark_theme
|
|
408
|
+
? "dark"
|
|
409
|
+
: "light"
|
|
410
|
+
: undefined;
|
|
411
|
+
// themeOverride (?theme=) wins so a form/quiz framed in a GPT panel matches
|
|
412
|
+
// the host shell's light/dark scheme — this single-page branch serves
|
|
413
|
+
// form/quiz/proposal/gpt pages.
|
|
414
|
+
const { theme } = resolveThemeMode({
|
|
415
|
+
override: themeOverride,
|
|
416
|
+
candidates: [agentTheme, rootTheme, brandTheme, websiteTheme],
|
|
417
|
+
});
|
|
418
|
+
const orgId =
|
|
419
|
+
(page?.organisation_id as string) ??
|
|
420
|
+
(agent?.organisation_id as string) ??
|
|
421
|
+
null;
|
|
422
|
+
|
|
423
|
+
const overrideColor =
|
|
424
|
+
pageRoot &&
|
|
425
|
+
typeof pageRoot.primaryColor === "string" &&
|
|
426
|
+
pageRoot.primaryColor
|
|
427
|
+
? pageRoot.primaryColor
|
|
428
|
+
: null;
|
|
429
|
+
const overrideHeading =
|
|
430
|
+
pageRoot && typeof pageRoot.headingFont === "string" && pageRoot.headingFont
|
|
431
|
+
? pageRoot.headingFont
|
|
432
|
+
: null;
|
|
433
|
+
const overrideBody =
|
|
434
|
+
pageRoot && typeof pageRoot.bodyFont === "string" && pageRoot.bodyFont
|
|
435
|
+
? pageRoot.bodyFont
|
|
436
|
+
: null;
|
|
437
|
+
|
|
438
|
+
const screenMode = pageRoot?.screenMode as string | undefined;
|
|
439
|
+
|
|
440
|
+
const pageName = (page?.name as string) || null;
|
|
441
|
+
const entityTitle =
|
|
442
|
+
(resolved.form as { name?: string })?.name ||
|
|
443
|
+
(resolved.quiz as { title?: string })?.title ||
|
|
444
|
+
(resolved.proposal as { name?: string })?.name ||
|
|
445
|
+
(resolved.agent as { name?: string })?.name;
|
|
446
|
+
const entityDescription =
|
|
447
|
+
((resolved.form as Record<string, unknown>)?.description as string) ||
|
|
448
|
+
((resolved.quiz as Record<string, unknown>)?.description as string) ||
|
|
449
|
+
((resolved.proposal as Record<string, unknown>)?.description as string) ||
|
|
450
|
+
((resolved.agent as Record<string, unknown>)?.description as string);
|
|
451
|
+
const pageDescription =
|
|
452
|
+
(page?.meta_description as string) ||
|
|
453
|
+
entityDescription ||
|
|
454
|
+
(pageRoot?.description as string) ||
|
|
455
|
+
null;
|
|
456
|
+
const loaderDataPageMetaTitle = (
|
|
457
|
+
resolved.loaderData as { page?: { meta_title?: unknown } } | undefined
|
|
458
|
+
)?.page?.meta_title;
|
|
459
|
+
const pageMetaTitle =
|
|
460
|
+
(typeof loaderDataPageMetaTitle === "string" &&
|
|
461
|
+
loaderDataPageMetaTitle.trim()
|
|
462
|
+
? loaderDataPageMetaTitle.trim()
|
|
463
|
+
: typeof page?.meta_title === "string" && page.meta_title.trim()
|
|
464
|
+
? page.meta_title.trim()
|
|
465
|
+
: null) || null;
|
|
466
|
+
|
|
467
|
+
// Title is DERIVED via the single composer: meta_title is an explicit
|
|
468
|
+
// per-page OVERRIDE (priority 1), a curated resource template wins for
|
|
469
|
+
// resource surfaces (priority 2), otherwise <website>: <subject>. The
|
|
470
|
+
// /sign-in special-case is gone — pageMetaTitle now wins uniformly, so a
|
|
471
|
+
// sign-in page with a meta_title is honoured on every path, not just /sign-in.
|
|
472
|
+
const mergedMeta: Record<string, unknown> = {
|
|
473
|
+
...(pageDescription ? { description: pageDescription } : {}),
|
|
474
|
+
...meta,
|
|
475
|
+
};
|
|
476
|
+
mergedMeta.title = composePageTitle({
|
|
477
|
+
pageMetaTitle,
|
|
478
|
+
templateTitle: (meta.title as string) || undefined,
|
|
479
|
+
websiteName: (website.name as string) || undefined,
|
|
480
|
+
pageName: pageName ?? ((pageRoot?.title as string) || undefined),
|
|
481
|
+
entityTitle: entityTitle || undefined,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
puckData,
|
|
486
|
+
website: {
|
|
487
|
+
slug: website.slug as string,
|
|
488
|
+
name: website.name as string,
|
|
489
|
+
organisation_id: website.organisation_id as string | null,
|
|
490
|
+
// Pass header_settings through (DB-driven header blur etc.) — see the
|
|
491
|
+
// other two website assignments above. Dropping it kills the blur.
|
|
492
|
+
header_settings:
|
|
493
|
+
(website.header_settings as Record<string, unknown> | null) ?? null,
|
|
494
|
+
},
|
|
495
|
+
brand: {
|
|
496
|
+
primaryColor: overrideColor ?? primaryColor,
|
|
497
|
+
backgroundColor: (brand.backgroundColor as string | null) ?? null,
|
|
498
|
+
lightSurface: (brand.lightSurface as string | null) ?? null,
|
|
499
|
+
darkSurface: (brand.darkSurface as string | null) ?? null,
|
|
500
|
+
headingFont: overrideHeading ?? headingFont,
|
|
501
|
+
bodyFont: overrideBody ?? bodyFont,
|
|
502
|
+
logo,
|
|
503
|
+
companyName: (brand.companyName as string) ?? null,
|
|
504
|
+
companyFriendlyName: (brand.companyFriendlyName as string) ?? null,
|
|
505
|
+
contactEmail: (brand.contactEmail as string) ?? null,
|
|
506
|
+
favicon: (brand.favicon as string) ?? null,
|
|
507
|
+
},
|
|
508
|
+
theme,
|
|
509
|
+
orgId,
|
|
510
|
+
loaderData: resolved,
|
|
511
|
+
...(screenMode ? { fullScreen: screenMode === "full-screen" } : {}),
|
|
512
|
+
...(pageRoot?.standalone != null
|
|
513
|
+
? { standalone: pageRoot.standalone === false ? false : undefined }
|
|
514
|
+
: {}),
|
|
515
|
+
...(providers ? { providerKeys: providers } : {}),
|
|
516
|
+
meta: mergedMeta,
|
|
517
|
+
};
|
|
518
|
+
}
|