@teamscala/service-boot 0.23.377 → 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 +1 -1
- package/src/compose-page-title.test.ts +62 -0
- package/src/compose-page-title.ts +26 -6
- package/src/register-registry-page-route/meta-title.ts +47 -68
- package/src/register-registry-page-route/resolve-entity-title.test.ts +54 -0
- package/src/register-registry-page-route/resolve-page-data.ts +46 -44
- package/src/register-registry-page-route.ts +20 -11
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,70 +23,44 @@ export function buildMetaFromContext(
|
|
|
18
23
|
return interpolateMeta(metaContext as Record<string, unknown>, resolved);
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
}
|
|
26
|
+
/**
|
|
27
|
+
* THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
|
|
28
|
+
* browser tab should carry.
|
|
29
|
+
*
|
|
30
|
+
* This existed as THREE hand-written `||` chains, one per render branch in
|
|
31
|
+
* resolve-page-data, and they had already diverged: two ordered form before
|
|
32
|
+
* proposal, the third ordered proposal first. So which resource named the page
|
|
33
|
+
* depended on which branch happened to render it, silently.
|
|
34
|
+
*
|
|
35
|
+
* The divergence is the cheap half of the cost. The expensive half is that a
|
|
36
|
+
* correction has to be applied three times to work, and applying it twice looks
|
|
37
|
+
* exactly like applying it correctly — measured 2026-08-17, when a fix to the
|
|
38
|
+
* quiz subject was shipped, installed and verified present in the bundle while
|
|
39
|
+
* the page kept its old title, because the branch that rendered it held the
|
|
40
|
+
* second copy.
|
|
41
|
+
*
|
|
42
|
+
* One chain, one precedence, one place to change. Adding a resource type is an
|
|
43
|
+
* edit here rather than a fourth branch in three files.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveEntityTitle(resolved: Record<string, unknown>): string | undefined {
|
|
46
|
+
const pick = (key: string, ...fields: string[]): string | undefined => {
|
|
47
|
+
const row = resolved[key] as Record<string, unknown> | undefined;
|
|
48
|
+
if (!row) return undefined;
|
|
49
|
+
for (const field of fields) {
|
|
50
|
+
const value = row[field];
|
|
51
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
83
52
|
}
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
return
|
|
53
|
+
return undefined;
|
|
54
|
+
};
|
|
55
|
+
return (
|
|
56
|
+
pick("form", "name") ||
|
|
57
|
+
pick("proposal", "name") ||
|
|
58
|
+
// A report surface carries its own document name in `report_title`
|
|
59
|
+
// ("Personalised Authority Blueprint"); `title` names the DIAGNOSTIC
|
|
60
|
+
// ("The Launcha Authority Index"), which is the wrong subject for the tab
|
|
61
|
+
// of a document sent to a client. The landing page has no report_title
|
|
62
|
+
// and correctly falls through to the quiz title.
|
|
63
|
+
pick("quiz", "report_title", "title") ||
|
|
64
|
+
pick("agent", "name")
|
|
65
|
+
);
|
|
87
66
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Locks the single subject derivation that replaced three divergent copies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { resolveEntityTitle } from "./meta-title.ts";
|
|
11
|
+
|
|
12
|
+
describe("resolveEntityTitle", () => {
|
|
13
|
+
test("a report is named by its own document name, not the diagnostic", () => {
|
|
14
|
+
// The Launcha Blueprint: `title` names the QUIZ, `report_title` names the
|
|
15
|
+
// DOCUMENT the client receives. The tab must carry the document.
|
|
16
|
+
expect(
|
|
17
|
+
resolveEntityTitle({
|
|
18
|
+
quiz: {
|
|
19
|
+
title: "The Launcha Authority Index",
|
|
20
|
+
report_title: "Personalised Authority Blueprint",
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
).toBe("Personalised Authority Blueprint");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a quiz with no report name falls through to its title", () => {
|
|
27
|
+
expect(resolveEntityTitle({ quiz: { title: "The Launcha Authority Index" } })).toBe(
|
|
28
|
+
"The Launcha Authority Index",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("precedence is single and fixed across every caller", () => {
|
|
33
|
+
// The three former copies disagreed: two put form first, one put proposal
|
|
34
|
+
// first, so the subject depended on which render branch ran. One order now.
|
|
35
|
+
const all = {
|
|
36
|
+
form: { name: "Form" },
|
|
37
|
+
proposal: { name: "Proposal" },
|
|
38
|
+
quiz: { report_title: "Report" },
|
|
39
|
+
agent: { name: "Agent" },
|
|
40
|
+
};
|
|
41
|
+
expect(resolveEntityTitle(all)).toBe("Form");
|
|
42
|
+
expect(resolveEntityTitle({ ...all, form: undefined })).toBe("Proposal");
|
|
43
|
+
expect(resolveEntityTitle({ ...all, form: undefined, proposal: undefined })).toBe("Report");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("blank and whitespace-only names do not win", () => {
|
|
47
|
+
expect(resolveEntityTitle({ form: { name: " " }, agent: { name: "Agent" } })).toBe("Agent");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("undefined when nothing names the page", () => {
|
|
51
|
+
expect(resolveEntityTitle({})).toBeUndefined();
|
|
52
|
+
expect(resolveEntityTitle({ quiz: {} })).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { resolveThemeMode } from "../resolve-theme-mode.ts";
|
|
12
12
|
import { composePageTitle } from "../compose-page-title.ts";
|
|
13
|
+
import { resolveEntityTitle } from "./meta-title.ts";
|
|
13
14
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -90,6 +91,9 @@ export function resolvedToPageData(
|
|
|
90
91
|
// a page framed inside another shell (e.g. a form in a GPT tool panel)
|
|
91
92
|
// matches the host's light/dark scheme so an embedded page doesn't flash.
|
|
92
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,
|
|
93
97
|
): Record<string, unknown> {
|
|
94
98
|
const brand = (resolved.brand as Record<string, unknown>) ?? {};
|
|
95
99
|
const primaryColor = (brand.primaryColor as string) ?? "";
|
|
@@ -124,11 +128,7 @@ export function resolvedToPageData(
|
|
|
124
128
|
// name in <title>/OG, not the generic "Portal". Falls back to the website
|
|
125
129
|
// name, then "Portal". Without this, the region-slots branch returned
|
|
126
130
|
// 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;
|
|
131
|
+
const entityTitle = resolveEntityTitle(resolved);
|
|
132
132
|
|
|
133
133
|
const regions: RegionEntry[] = [];
|
|
134
134
|
for (const slot of regionSlots) {
|
|
@@ -161,11 +161,15 @@ export function resolvedToPageData(
|
|
|
161
161
|
favicon: (brand.favicon as string) ?? null,
|
|
162
162
|
},
|
|
163
163
|
meta: {
|
|
164
|
-
title: composePageTitle(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
),
|
|
169
173
|
},
|
|
170
174
|
};
|
|
171
175
|
}
|
|
@@ -229,12 +233,16 @@ export function resolvedToPageData(
|
|
|
229
233
|
},
|
|
230
234
|
theme,
|
|
231
235
|
meta: {
|
|
232
|
-
title: composePageTitle(
|
|
236
|
+
title: composePageTitle(
|
|
237
|
+
{
|
|
233
238
|
templateTitle: (meta.title as string) || undefined,
|
|
234
239
|
websiteName: (website.name as string) || undefined,
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
240
|
+
organisationName: (brand.companyName as string) || undefined,
|
|
241
|
+
pageName: contentPageName,
|
|
242
|
+
entityTitle: entityTitle || undefined,
|
|
243
|
+
},
|
|
244
|
+
{ orgPrefix },
|
|
245
|
+
),
|
|
238
246
|
},
|
|
239
247
|
};
|
|
240
248
|
}
|
|
@@ -306,11 +314,7 @@ export function resolvedToPageData(
|
|
|
306
314
|
const overrideBody = pickRootProp<string>(regions, "bodyFont");
|
|
307
315
|
|
|
308
316
|
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;
|
|
317
|
+
const entityTitle = resolveEntityTitle(resolved);
|
|
314
318
|
const bodyMetaTitle = regions.find((r) => r.slot === "body")?.metaTitle;
|
|
315
319
|
const pageRootTitle = pickRootProp<string>(regions, "title");
|
|
316
320
|
const pageDescription =
|
|
@@ -366,13 +370,17 @@ export function resolvedToPageData(
|
|
|
366
370
|
},
|
|
367
371
|
theme,
|
|
368
372
|
meta: {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
),
|
|
376
384
|
...(pageDescription ? { description: pageDescription } : {}),
|
|
377
385
|
},
|
|
378
386
|
};
|
|
@@ -438,17 +446,7 @@ export function resolvedToPageData(
|
|
|
438
446
|
const screenMode = pageRoot?.screenMode as string | undefined;
|
|
439
447
|
|
|
440
448
|
const pageName = (page?.name as string) || null;
|
|
441
|
-
const entityTitle =
|
|
442
|
-
(resolved.form as { name?: string })?.name ||
|
|
443
|
-
// A report surface carries its own document name in `report_title`
|
|
444
|
-
// ("Personalised Authority Blueprint"); the quiz `title` names the
|
|
445
|
-
// DIAGNOSTIC ("The Launcha Authority Index"), which is the wrong subject
|
|
446
|
-
// for the tab of a document sent to a client. Prefer the document's name
|
|
447
|
-
// and fall back to the quiz title for the landing page, which has none.
|
|
448
|
-
(resolved.quiz as { report_title?: string })?.report_title ||
|
|
449
|
-
(resolved.quiz as { title?: string })?.title ||
|
|
450
|
-
(resolved.proposal as { name?: string })?.name ||
|
|
451
|
-
(resolved.agent as { name?: string })?.name;
|
|
449
|
+
const entityTitle = resolveEntityTitle(resolved);
|
|
452
450
|
const entityDescription =
|
|
453
451
|
((resolved.form as Record<string, unknown>)?.description as string) ||
|
|
454
452
|
((resolved.quiz as Record<string, unknown>)?.description as string) ||
|
|
@@ -479,13 +477,17 @@ export function resolvedToPageData(
|
|
|
479
477
|
...(pageDescription ? { description: pageDescription } : {}),
|
|
480
478
|
...meta,
|
|
481
479
|
};
|
|
482
|
-
mergedMeta.title = composePageTitle(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
+
);
|
|
489
491
|
|
|
490
492
|
return {
|
|
491
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) {
|