@teamscala/service-boot 0.23.447 → 0.23.448
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
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
|
|
54
54
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
55
55
|
import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
|
|
56
|
+
import { themeFontsLink } from "./theme-fonts-link.ts";
|
|
56
57
|
import { join } from "node:path";
|
|
57
58
|
|
|
58
59
|
interface AssetManifest {
|
|
@@ -67,7 +68,7 @@ const PAGE_DOCUMENT = `<!doctype html>
|
|
|
67
68
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
68
69
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
69
70
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
70
|
-
|
|
71
|
+
<!--theme-fonts-->
|
|
71
72
|
<link rel="stylesheet" href="{{stylesPath}}" />
|
|
72
73
|
<!--head-outlet-->
|
|
73
74
|
</head>
|
|
@@ -140,7 +141,11 @@ export async function readHtmlTemplate(): Promise<string> {
|
|
|
140
141
|
}
|
|
141
142
|
let html = PAGE_DOCUMENT
|
|
142
143
|
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
143
|
-
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
|
|
144
|
+
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
|
|
145
|
+
// The head's font families derive from the css_theme row (one source);
|
|
146
|
+
// captured at cache build — boot-time semantics, a row edit reaches the
|
|
147
|
+
// envelope on the next service restart.
|
|
148
|
+
.replace("<!--theme-fonts-->", () => themeFontsLink());
|
|
144
149
|
// Tombstone stamp — surfaces a failed client build at the served layer.
|
|
145
150
|
// `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
|
|
146
151
|
// JS dependency, visible via view-source / devtools / a future status
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The served <head> carries every font family the css_theme row declares —
|
|
7
|
+
* derived from the row (one source), never a hard-coded family list. The old
|
|
8
|
+
* envelope loaded only Inter, so the proposal heading variant's manuscript
|
|
9
|
+
* serif never loaded and fell back to Times (2026-09-02).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
type ConfigRow = { fonts?: Record<string, string> } | undefined;
|
|
15
|
+
let injectedConfig: ConfigRow;
|
|
16
|
+
|
|
17
|
+
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
18
|
+
getEntryConfig: (_type: string, _slug: string) => injectedConfig,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const { readHtmlTemplate } = await import("./read-html-template.ts");
|
|
22
|
+
const { primaryFontFamily, themeFontFamilies, themeFontsLink } = await import(
|
|
23
|
+
"./theme-fonts-link.ts"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const { mkdir, mkdtemp, rm } = await import("node:fs/promises");
|
|
27
|
+
const { tmpdir } = await import("node:os");
|
|
28
|
+
const { join } = await import("node:path");
|
|
29
|
+
const { beforeEach, afterEach } = await import("bun:test");
|
|
30
|
+
|
|
31
|
+
let dir: string;
|
|
32
|
+
let origCwd: string;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
origCwd = process.cwd();
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), "theme-fonts-link-"));
|
|
37
|
+
await mkdir(join(dir, "dist", "client"), { recursive: true });
|
|
38
|
+
process.chdir(dir);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
process.chdir(origCwd);
|
|
43
|
+
await rm(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function writeManifest(): Promise<void> {
|
|
47
|
+
await Bun.file(
|
|
48
|
+
join(dir, "dist", "client", "asset-manifest.json"),
|
|
49
|
+
).write(
|
|
50
|
+
`${JSON.stringify({ stylesPath: "/assets/styles.css", hydratePath: "/assets/hydrate.js" }, null, 2)}\n`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function servedHead(): Promise<string> {
|
|
55
|
+
await writeManifest();
|
|
56
|
+
const html = await readHtmlTemplate();
|
|
57
|
+
return html.slice(0, html.indexOf("</head>"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("theme-fonts-link", () => {
|
|
61
|
+
test("primaryFontFamily takes the first family of a stack", () => {
|
|
62
|
+
expect(primaryFontFamily("Inter, sans-serif")).toBe("Inter");
|
|
63
|
+
expect(primaryFontFamily("'Playfair Display'")).toBe("Playfair Display");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("families derive from the css_theme row, deduplicated", () => {
|
|
67
|
+
injectedConfig = {
|
|
68
|
+
fonts: {
|
|
69
|
+
body: "Inter, sans-serif",
|
|
70
|
+
heading: "Inter, sans-serif",
|
|
71
|
+
manuscript: "Playfair Display",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
expect(themeFontFamilies()).toEqual(["Inter", "Playfair Display"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the served head carries every family the row declares", async () => {
|
|
78
|
+
injectedConfig = {
|
|
79
|
+
fonts: {
|
|
80
|
+
body: "Inter, sans-serif",
|
|
81
|
+
heading: "Inter, sans-serif",
|
|
82
|
+
manuscript: "Playfair Display",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const head = await servedHead();
|
|
86
|
+
expect(head).toContain("family=Inter");
|
|
87
|
+
expect(head).toContain(encodeURIComponent("Playfair Display"));
|
|
88
|
+
expect(head).not.toContain("<!--theme-fonts-->");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("an absent row degrades to no fonts link — never throws", async () => {
|
|
92
|
+
injectedConfig = undefined;
|
|
93
|
+
expect(themeFontsLink()).toBe("");
|
|
94
|
+
const head = await servedHead();
|
|
95
|
+
expect(head).not.toContain("fonts.googleapis.com/css2");
|
|
96
|
+
expect(head).toContain("<!--head-outlet-->");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Derives the document head's Google Fonts link from the `css_theme` row's
|
|
7
|
+
* `fonts` map — the ONE source for the families a page's variants reference
|
|
8
|
+
* (`the-theme-decides-colour-not-the-component` extends to type). The old
|
|
9
|
+
* envelope hard-coded Inter, so a proposal heading naming a manuscript serif
|
|
10
|
+
* ("Playfair Display") never had its family loaded and fell back to Times
|
|
11
|
+
* (2026-09-02). Now every family the row declares is loaded; a family a
|
|
12
|
+
* variant names that the row does not declare is a row defect, caught at the
|
|
13
|
+
* row's schema boundary.
|
|
14
|
+
*
|
|
15
|
+
* Read from the boot registry cache (captured at service boot, the same
|
|
16
|
+
* semantics as the resource_loader capture) and guarded: a missing or
|
|
17
|
+
* unreadable row degrades to NO fonts link — system fonts render, the page
|
|
18
|
+
* still works. This module must never throw into the document builder.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getEntryConfig } from "@teamscala/db/registry/accessors";
|
|
22
|
+
|
|
23
|
+
interface ThemeFontsConfig {
|
|
24
|
+
fonts?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The primary family name of a CSS font stack: "Inter, sans-serif" →
|
|
28
|
+
* "Inter"; "Playfair Display" → "Playfair Display". */
|
|
29
|
+
export function primaryFontFamily(stack: string): string {
|
|
30
|
+
const first = stack.split(",")[0] ?? "";
|
|
31
|
+
return first.trim().replace(/^['"]|['"]$/g, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The declared theme families, deduplicated by primary family, in row
|
|
35
|
+
* order (body, heading, manuscript, mono…). Empty when the row is absent. */
|
|
36
|
+
export function themeFontFamilies(): string[] {
|
|
37
|
+
try {
|
|
38
|
+
const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
|
|
39
|
+
const fonts = config?.fonts ?? {};
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
const families: string[] = [];
|
|
42
|
+
for (const value of Object.values(fonts)) {
|
|
43
|
+
if (typeof value !== "string" || value.trim() === "") continue;
|
|
44
|
+
const primary = primaryFontFamily(value);
|
|
45
|
+
if (primary === "" || seen.has(primary)) continue;
|
|
46
|
+
seen.add(primary);
|
|
47
|
+
families.push(primary);
|
|
48
|
+
}
|
|
49
|
+
return families;
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The <link> tag loading every declared theme family, or "" when none. */
|
|
56
|
+
export function themeFontsLink(): string {
|
|
57
|
+
const families = themeFontFamilies();
|
|
58
|
+
if (families.length === 0) return "";
|
|
59
|
+
const WEIGHTS = "400;500;600;700";
|
|
60
|
+
const params = families
|
|
61
|
+
.map((family) => `family=${encodeURIComponent(family)}:wght@${WEIGHTS}`)
|
|
62
|
+
.join("&");
|
|
63
|
+
return `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${params}&display=optional" />`;
|
|
64
|
+
}
|