@teamscala/service-boot 0.23.378 → 0.23.379
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
|
@@ -86,3 +86,65 @@ describe("composePageTitle", () => {
|
|
|
86
86
|
expect(composePageTitle({ websiteName: "" })).toBe("Untitled");
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
describe("composePageTitle with orgPrefix (feature flag page-title-org-prefix)", () => {
|
|
91
|
+
test("prefix is the owning org's name, never the internal website label", () => {
|
|
92
|
+
// The defect: client-facing document tabs read "Public Pages: <subject>".
|
|
93
|
+
expect(
|
|
94
|
+
composePageTitle(
|
|
95
|
+
{
|
|
96
|
+
websiteName: "Public Pages",
|
|
97
|
+
organisationName: "GBG Indo",
|
|
98
|
+
entityTitle: "Launcha Blueprint",
|
|
99
|
+
},
|
|
100
|
+
{ orgPrefix: true },
|
|
101
|
+
),
|
|
102
|
+
).toBe("GBG Indo: Launcha Blueprint");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("org with no display name gets NO prefix — never the website name", () => {
|
|
106
|
+
expect(
|
|
107
|
+
composePageTitle(
|
|
108
|
+
{ websiteName: "Public Pages", organisationName: "", entityTitle: "Launcha Blueprint" },
|
|
109
|
+
{ orgPrefix: true },
|
|
110
|
+
),
|
|
111
|
+
).toBe("Launcha Blueprint");
|
|
112
|
+
expect(
|
|
113
|
+
composePageTitle({ websiteName: "Public Pages", entityTitle: "Launcha Blueprint" }, { orgPrefix: true }),
|
|
114
|
+
).toBe("Launcha Blueprint");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("per-page override and curated template still win over the org prefix", () => {
|
|
118
|
+
expect(
|
|
119
|
+
composePageTitle(
|
|
120
|
+
{ pageMetaTitle: "Override", organisationName: "GBG Indo", pageName: "P" },
|
|
121
|
+
{ orgPrefix: true },
|
|
122
|
+
),
|
|
123
|
+
).toBe("Override");
|
|
124
|
+
expect(
|
|
125
|
+
composePageTitle(
|
|
126
|
+
{ templateTitle: "T", organisationName: "GBG Indo", pageName: "P" },
|
|
127
|
+
{ orgPrefix: true },
|
|
128
|
+
),
|
|
129
|
+
).toBe("T");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("no subject renders the org name alone; nothing at all is 'Untitled'", () => {
|
|
133
|
+
expect(composePageTitle({ organisationName: "GBG Indo" }, { orgPrefix: true })).toBe(
|
|
134
|
+
"GBG Indo",
|
|
135
|
+
);
|
|
136
|
+
expect(composePageTitle({}, { orgPrefix: true })).toBe("Untitled");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("flag off keeps the legacy website-name derivation", () => {
|
|
140
|
+
expect(
|
|
141
|
+
composePageTitle(
|
|
142
|
+
{ websiteName: "Public Pages", organisationName: "GBG Indo", pageName: "Dashboard" },
|
|
143
|
+
{ orgPrefix: false },
|
|
144
|
+
),
|
|
145
|
+
).toBe("Public Pages: Dashboard");
|
|
146
|
+
expect(
|
|
147
|
+
composePageTitle({ websiteName: "Public Pages", organisationName: "GBG Indo", pageName: "Dashboard" }),
|
|
148
|
+
).toBe("Public Pages: Dashboard");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -32,8 +32,13 @@ export interface TitleContext {
|
|
|
32
32
|
pageMetaTitle?: string | null;
|
|
33
33
|
/** Resolved resource-template string (forms/quiz/gpt). Curated per-surface shape. */
|
|
34
34
|
templateTitle?: string | null;
|
|
35
|
-
/** builda_websites.name — the surface, the
|
|
35
|
+
/** builda_websites.name — the surface, the legacy site context (orgPrefix off). */
|
|
36
36
|
websiteName?: string | null;
|
|
37
|
+
/** The OWNING ORG's display name (brand.companyName, resolved from the
|
|
38
|
+
* org the page's brand_org_path names). The title prefix when orgPrefix is
|
|
39
|
+
* on — operator ruling 2026-08-17: a client-facing document's tab carries
|
|
40
|
+
* the org's name, never the internal website label ("Public Pages"). */
|
|
41
|
+
organisationName?: string | null;
|
|
37
42
|
/** builda_pages.name — the page (e.g. "Dashboard"). */
|
|
38
43
|
pageName?: string | null;
|
|
39
44
|
/** Resource entity title (proposal/form/quiz/agent name) — preferred subject. */
|
|
@@ -46,20 +51,35 @@ function trimToDefined(value: unknown): string | undefined {
|
|
|
46
51
|
return trimmed ? trimmed : undefined;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
export function composePageTitle(
|
|
54
|
+
export function composePageTitle(
|
|
55
|
+
ctx: TitleContext,
|
|
56
|
+
opts?: { orgPrefix?: boolean },
|
|
57
|
+
): string {
|
|
50
58
|
// 1. Explicit per-page override wins outright — the operator's escape
|
|
51
|
-
//
|
|
59
|
+
// hatch for a custom SEO/social title. It is NEVER the default source.
|
|
52
60
|
const override = trimToDefined(ctx.pageMetaTitle);
|
|
53
61
|
if (override) return override;
|
|
54
62
|
|
|
55
63
|
// 2. Curated resource template (forms/quiz/gpt). Already contextual
|
|
56
|
-
//
|
|
64
|
+
// (entity + org), so it beats the generic derivation for those surfaces.
|
|
57
65
|
const template = trimToDefined(ctx.templateTitle);
|
|
58
66
|
if (template) return template;
|
|
59
67
|
|
|
60
|
-
// 3. Default derivation
|
|
61
|
-
//
|
|
68
|
+
// 3. Default derivation. Site-first so the stable surface precedes the
|
|
69
|
+
// varying page — how a tab is scanned. With orgPrefix on (feature flag
|
|
70
|
+
// page-title-org-prefix), the prefix is the OWNING ORG's name, never the
|
|
71
|
+
// website label: an internal site name ("Public Pages") on a
|
|
72
|
+
// client-facing document is the defect the flag exists to remove. An org
|
|
73
|
+
// with no display name gets NO prefix — falling back to the website name
|
|
74
|
+
// would reintroduce the defect the mode exists to fix.
|
|
62
75
|
const subject = trimToDefined(ctx.entityTitle) ?? trimToDefined(ctx.pageName);
|
|
76
|
+
if (opts?.orgPrefix) {
|
|
77
|
+
const orgName = trimToDefined(ctx.organisationName);
|
|
78
|
+
if (orgName && subject) return `${orgName}: ${subject}`;
|
|
79
|
+
if (orgName) return orgName;
|
|
80
|
+
if (subject) return subject;
|
|
81
|
+
return "Untitled";
|
|
82
|
+
}
|
|
63
83
|
const websiteName = trimToDefined(ctx.websiteName);
|
|
64
84
|
if (websiteName && subject) return `${websiteName}: ${subject}`;
|
|
65
85
|
if (websiteName) return websiteName;
|
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* Meta/title helpers for the route registrar: interpolate the route's
|
|
7
|
-
* meta_context against the resolved graph
|
|
8
|
-
*
|
|
7
|
+
* meta_context against the resolved graph. Extracted from
|
|
8
|
+
* register-registry-page-route.ts.
|
|
9
|
+
*
|
|
10
|
+
* resolveTemplateTitle (the page_meta_template title reader) was DELETED
|
|
11
|
+
* 2026-08-17: it only ever applied when NO resource entity resolved, and on a
|
|
12
|
+
* resource page an entity ALWAYS resolves — the curated template was discarded
|
|
13
|
+
* by construction, dead on exactly the pages the type exists for (Valencia's
|
|
14
|
+
* finding). composePageTitle (+ the org-prefix flag) owns titles end to end.
|
|
9
15
|
*/
|
|
10
16
|
import { interpolateMeta } from "../resolve-from-loader-config.ts";
|
|
11
|
-
import { getEntriesByType } from "@teamscala/db/registry/accessors";
|
|
12
17
|
|
|
13
18
|
export function buildMetaFromContext(
|
|
14
19
|
metaContext: Record<string, Record<string, string>> | undefined,
|
|
@@ -18,74 +23,6 @@ export function buildMetaFromContext(
|
|
|
18
23
|
return interpolateMeta(metaContext as Record<string, unknown>, resolved);
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
export function resolveTemplateTitle(
|
|
22
|
-
service: string,
|
|
23
|
-
resolved: Record<string, unknown>,
|
|
24
|
-
): string | undefined {
|
|
25
|
-
const rows = getEntriesByType("page_meta_template") as Array<{
|
|
26
|
-
slug: string;
|
|
27
|
-
config: Record<string, unknown>;
|
|
28
|
-
}>;
|
|
29
|
-
const bySlug = new Map<string, Record<string, unknown>>();
|
|
30
|
-
for (const row of rows) {
|
|
31
|
-
bySlug.set(row.slug, row.config);
|
|
32
|
-
}
|
|
33
|
-
const merged = (bySlug.get("default") ?? null) as Record<string, unknown> | null;
|
|
34
|
-
const serviceTmpl = (bySlug.get(service) ?? null) as Record<string, unknown> | null;
|
|
35
|
-
const tmpl = serviceTmpl ?? merged;
|
|
36
|
-
if (!tmpl) return undefined;
|
|
37
|
-
const titleTemplate = tmpl.titleTemplate as string | undefined;
|
|
38
|
-
if (!titleTemplate) return undefined;
|
|
39
|
-
const brand = (resolved.brand as Record<string, unknown>) ?? {};
|
|
40
|
-
const website = (resolved.website as Record<string, unknown>) ?? {};
|
|
41
|
-
const page = (resolved.page as Record<string, unknown>) ?? {};
|
|
42
|
-
// The full placeholder set the curated resource templates use:
|
|
43
|
-
// {org.name}, {website.name}, {page.name}, {form.name}, {quiz.name},
|
|
44
|
-
// {proposal.name}, {agent.name}. Previously only {org.name} resolved, so
|
|
45
|
-
// every resource template ("{agent.name} — {org.name}", "{form.name} by
|
|
46
|
-
// {org.name}") shipped with a blank subject. Resolving the graph here is
|
|
47
|
-
// the executor half of composePageTitle (business-logic-is-rows-plus-
|
|
48
|
-
// executors): the template ROWS carry the shape, this carries the values.
|
|
49
|
-
const context: Record<string, unknown> = {
|
|
50
|
-
org: { name: (brand.companyName as string) || undefined },
|
|
51
|
-
website: { name: (website.name as string) || undefined },
|
|
52
|
-
page: { name: (page.name as string) || undefined },
|
|
53
|
-
form: { name: ((resolved.form as Record<string, unknown>)?.name as string) || undefined },
|
|
54
|
-
// An assessment has no `name` column — it carries `title`, and a report
|
|
55
|
-
// surface carries `report_title` ("Personalised Authority Blueprint")
|
|
56
|
-
// which is the document's own name and the right subject for its tab.
|
|
57
|
-
// Reading `.name` here resolved to "", so the template produced " by
|
|
58
|
-
// <org>", was discarded as empty, and EVERY quiz page silently fell back
|
|
59
|
-
// to the "<website>: <subject>" derivation — surfacing the internal site
|
|
60
|
-
// name ("Public Pages") on a client-facing document. The failure was
|
|
61
|
-
// invisible because the fallback is itself a plausible title.
|
|
62
|
-
quiz: {
|
|
63
|
-
name:
|
|
64
|
-
((resolved.quiz as Record<string, unknown>)?.report_title as string) ||
|
|
65
|
-
((resolved.quiz as Record<string, unknown>)?.title as string) ||
|
|
66
|
-
((resolved.quiz as Record<string, unknown>)?.name as string) ||
|
|
67
|
-
undefined,
|
|
68
|
-
},
|
|
69
|
-
proposal: {
|
|
70
|
-
name: ((resolved.proposal as Record<string, unknown>)?.name as string) || undefined,
|
|
71
|
-
},
|
|
72
|
-
agent: { name: ((resolved.agent as Record<string, unknown>)?.name as string) || undefined },
|
|
73
|
-
};
|
|
74
|
-
const result = titleTemplate.replace(/\{([^}]+)\}/g, (_m, path: string) => {
|
|
75
|
-
const keys = path.trim().split(".");
|
|
76
|
-
let cur: unknown = context;
|
|
77
|
-
for (const k of keys) {
|
|
78
|
-
if (cur && typeof cur === "object") {
|
|
79
|
-
cur = (cur as Record<string, unknown>)[k];
|
|
80
|
-
} else {
|
|
81
|
-
return "";
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return cur === null || cur === undefined ? "" : String(cur);
|
|
85
|
-
});
|
|
86
|
-
return result || undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
26
|
/**
|
|
90
27
|
* THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
|
|
91
28
|
* browser tab should carry.
|
|
@@ -91,6 +91,9 @@ export function resolvedToPageData(
|
|
|
91
91
|
// a page framed inside another shell (e.g. a form in a GPT tool panel)
|
|
92
92
|
// matches the host's light/dark scheme so an embedded page doesn't flash.
|
|
93
93
|
themeOverride?: "light" | "dark",
|
|
94
|
+
// feature flag page-title-org-prefix (read live by the caller): title
|
|
95
|
+
// prefixes carry the OWNING ORG's name instead of the website label.
|
|
96
|
+
orgPrefix?: boolean,
|
|
94
97
|
): Record<string, unknown> {
|
|
95
98
|
const brand = (resolved.brand as Record<string, unknown>) ?? {};
|
|
96
99
|
const primaryColor = (brand.primaryColor as string) ?? "";
|
|
@@ -158,11 +161,15 @@ export function resolvedToPageData(
|
|
|
158
161
|
favicon: (brand.favicon as string) ?? null,
|
|
159
162
|
},
|
|
160
163
|
meta: {
|
|
161
|
-
title: composePageTitle(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
title: composePageTitle(
|
|
165
|
+
{
|
|
166
|
+
templateTitle: (meta.title as string) || undefined,
|
|
167
|
+
websiteName: (website.name as string) || undefined,
|
|
168
|
+
organisationName: (brand.companyName as string) || undefined,
|
|
169
|
+
entityTitle: entityTitle || undefined,
|
|
170
|
+
},
|
|
171
|
+
{ orgPrefix },
|
|
172
|
+
),
|
|
166
173
|
},
|
|
167
174
|
};
|
|
168
175
|
}
|
|
@@ -226,12 +233,16 @@ export function resolvedToPageData(
|
|
|
226
233
|
},
|
|
227
234
|
theme,
|
|
228
235
|
meta: {
|
|
229
|
-
title: composePageTitle(
|
|
236
|
+
title: composePageTitle(
|
|
237
|
+
{
|
|
230
238
|
templateTitle: (meta.title as string) || undefined,
|
|
231
239
|
websiteName: (website.name as string) || undefined,
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
240
|
+
organisationName: (brand.companyName as string) || undefined,
|
|
241
|
+
pageName: contentPageName,
|
|
242
|
+
entityTitle: entityTitle || undefined,
|
|
243
|
+
},
|
|
244
|
+
{ orgPrefix },
|
|
245
|
+
),
|
|
235
246
|
},
|
|
236
247
|
};
|
|
237
248
|
}
|
|
@@ -359,13 +370,17 @@ export function resolvedToPageData(
|
|
|
359
370
|
},
|
|
360
371
|
theme,
|
|
361
372
|
meta: {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
373
|
+
title: composePageTitle(
|
|
374
|
+
{
|
|
375
|
+
pageMetaTitle: bodyMetaTitle,
|
|
376
|
+
templateTitle: (meta.title as string) || undefined,
|
|
377
|
+
websiteName: (website.name as string) || undefined,
|
|
378
|
+
organisationName: (brand.companyName as string) || undefined,
|
|
379
|
+
pageName: pageName ?? pageRootTitle,
|
|
380
|
+
entityTitle: entityTitle || undefined,
|
|
381
|
+
},
|
|
382
|
+
{ orgPrefix },
|
|
383
|
+
),
|
|
369
384
|
...(pageDescription ? { description: pageDescription } : {}),
|
|
370
385
|
},
|
|
371
386
|
};
|
|
@@ -462,13 +477,17 @@ export function resolvedToPageData(
|
|
|
462
477
|
...(pageDescription ? { description: pageDescription } : {}),
|
|
463
478
|
...meta,
|
|
464
479
|
};
|
|
465
|
-
mergedMeta.title = composePageTitle(
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
480
|
+
mergedMeta.title = composePageTitle(
|
|
481
|
+
{
|
|
482
|
+
pageMetaTitle,
|
|
483
|
+
templateTitle: (meta.title as string) || undefined,
|
|
484
|
+
websiteName: (website.name as string) || undefined,
|
|
485
|
+
organisationName: (brand.companyName as string) || undefined,
|
|
486
|
+
pageName: pageName ?? ((pageRoot?.title as string) || undefined),
|
|
487
|
+
entityTitle: entityTitle || undefined,
|
|
488
|
+
},
|
|
489
|
+
{ orgPrefix },
|
|
490
|
+
);
|
|
472
491
|
|
|
473
492
|
return {
|
|
474
493
|
puckData,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { RenderModeLiteral } from "./page-route-shell-types.ts";
|
|
16
16
|
import { PageRouteConfigSchema } from "@teamscala/db-validation/registry-schemas/page-route";
|
|
17
17
|
import { ResourceLoaderConfigSchema } from "@teamscala/db-validation/registry-schemas/resource-loader";
|
|
18
|
+
import { FeatureFlagConfigSchema } from "@teamscala/db-validation/registry-schemas/feature-flag";
|
|
18
19
|
import {
|
|
19
20
|
loadRegistryConfig,
|
|
20
21
|
loadRegistryConfigsByType,
|
|
@@ -43,7 +44,6 @@ import {
|
|
|
43
44
|
import { resolvedToPageData } from "./register-registry-page-route/resolve-page-data.ts";
|
|
44
45
|
import {
|
|
45
46
|
buildMetaFromContext,
|
|
46
|
-
resolveTemplateTitle,
|
|
47
47
|
} from "./register-registry-page-route/meta-title.ts";
|
|
48
48
|
import { prefetchAiChatSession } from "./register-registry-page-route/ai-chat-prefetch.ts";
|
|
49
49
|
import {
|
|
@@ -479,16 +479,24 @@ export async function registerRegistryPageRoute(
|
|
|
479
479
|
) {
|
|
480
480
|
meta.title = pageMetaTitle.trim();
|
|
481
481
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
482
|
+
// Org-prefixed page titles (feature flag page-title-org-prefix, read
|
|
483
|
+
// LIVE per request — feature flags are never cached). The title prefix
|
|
484
|
+
// is the OWNING ORG's name, never the internal website label
|
|
485
|
+
// ("Public Pages: <subject>" on a client-facing document was the
|
|
486
|
+
// defect; operator ruling 2026-08-17). Absent or unreadable flag ⇒
|
|
487
|
+
// legacy <website.name>: <subject> derivation — the switch fails toward
|
|
488
|
+
// the pre-flag behaviour, and flipping it back is one registry edit.
|
|
489
|
+
let orgTitlePrefix = false;
|
|
490
|
+
try {
|
|
491
|
+
orgTitlePrefix = (
|
|
492
|
+
await loadRegistryConfig(
|
|
493
|
+
"feature_flag",
|
|
494
|
+
"page-title-org-prefix",
|
|
495
|
+
FeatureFlagConfigSchema,
|
|
496
|
+
)
|
|
497
|
+
).enabled;
|
|
498
|
+
} catch {
|
|
499
|
+
// Flag row absent — legacy derivation.
|
|
492
500
|
}
|
|
493
501
|
const regionSlots = (
|
|
494
502
|
loaderConfig as v.InferOutput<typeof ResourceLoaderConfigSchema> & {
|
|
@@ -515,6 +523,7 @@ export async function registerRegistryPageRoute(
|
|
|
515
523
|
routeProviders !== baseProviders ? routeProviders : undefined,
|
|
516
524
|
regionSlots,
|
|
517
525
|
themeOverride,
|
|
526
|
+
orgTitlePrefix,
|
|
518
527
|
);
|
|
519
528
|
const panelSlug = new URL(request.url).searchParams.get("panel");
|
|
520
529
|
if (panelSlug) {
|