@caelo-cms/shared 0.10.23 → 0.10.24

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.
Files changed (59) hide show
  1. package/dist/ai-tools.d.ts +0 -60
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +1 -59
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/base-css.d.ts +11 -0
  6. package/dist/base-css.d.ts.map +1 -1
  7. package/dist/base-css.js +13 -1
  8. package/dist/base-css.js.map +1 -1
  9. package/dist/design-draft-shell.d.ts +21 -0
  10. package/dist/design-draft-shell.d.ts.map +1 -0
  11. package/dist/design-draft-shell.js +81 -0
  12. package/dist/design-draft-shell.js.map +1 -0
  13. package/dist/genesis.d.ts +43 -3
  14. package/dist/genesis.d.ts.map +1 -1
  15. package/dist/genesis.js +72 -5
  16. package/dist/genesis.js.map +1 -1
  17. package/dist/i18n.d.ts +0 -50
  18. package/dist/i18n.d.ts.map +1 -1
  19. package/dist/i18n.js +8 -149
  20. package/dist/i18n.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/preview-compose.d.ts +9 -15
  26. package/dist/preview-compose.d.ts.map +1 -1
  27. package/dist/preview-compose.js +3 -43
  28. package/dist/preview-compose.js.map +1 -1
  29. package/dist/seo.d.ts +8 -21
  30. package/dist/seo.d.ts.map +1 -1
  31. package/dist/seo.js +4 -22
  32. package/dist/seo.js.map +1 -1
  33. package/dist/structured-sets.d.ts +0 -36
  34. package/dist/structured-sets.d.ts.map +1 -1
  35. package/dist/structured-sets.js +1 -52
  36. package/dist/structured-sets.js.map +1 -1
  37. package/dist/version.d.ts +2 -2
  38. package/dist/version.js +1 -1
  39. package/package.json +2 -2
  40. package/src/ai-tools.ts +1 -75
  41. package/src/base-css.ts +13 -1
  42. package/src/design-draft-shell.test.ts +85 -0
  43. package/src/design-draft-shell.ts +109 -0
  44. package/src/genesis.ts +76 -5
  45. package/src/i18n.test.ts +1 -217
  46. package/src/i18n.ts +8 -186
  47. package/src/index.ts +1 -1
  48. package/src/preview-compose.test.ts +35 -0
  49. package/src/preview-compose.ts +3 -56
  50. package/src/seo.test.ts +0 -40
  51. package/src/seo.ts +8 -36
  52. package/src/structured-sets.ts +1 -67
  53. package/src/version.ts +1 -1
  54. package/dist/translation.d.ts +0 -127
  55. package/dist/translation.d.ts.map +0 -1
  56. package/dist/translation.js +0 -208
  57. package/dist/translation.js.map +0 -1
  58. package/src/translation.test.ts +0 -160
  59. package/src/translation.ts +0 -295
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #375 — theme shell for growth-time design-draft previews.
5
+ *
6
+ * Page/module-scope drafts are stored as FRAGMENTS bound to the site's
7
+ * `var(--…)` tokens; this composer wraps one in the site's real
8
+ * compiled theme at view time: web fonts (#150), theme CSS variables,
9
+ * and the invisible technical baseline (#151) — in the exact head
10
+ * order `composePagePreview` uses, so a draft previews in the same
11
+ * cascade it will materialise into. Pure / sync / no IO: the caller
12
+ * (the `genesis.render_draft` op) loads the theme row and resolves
13
+ * fonts, mirroring `pages.render_preview`.
14
+ */
15
+
16
+ import { BASE_TECHNICAL_CSS } from "./base-css.js";
17
+ import { type ComposeFonts, type ComposeTheme, fontsHeadFragment } from "./preview-compose.js";
18
+ import { caeloMissingComment } from "./template-engine.js";
19
+ import { renderThemeCss } from "./theme-render.js";
20
+
21
+ /** Same four placeholders the template engine resolves (v0.11.1, #76). */
22
+ const THEME_ASSET_KEY_TO_SLOT = {
23
+ theme_logo_url: "logo",
24
+ theme_logo_dark_url: "logoDark",
25
+ theme_favicon_url: "favicon",
26
+ theme_social_share_url: "socialShare",
27
+ } as const;
28
+
29
+ export interface DesignDraftShellInput {
30
+ /** The stored fragment (already script-stripped at the boundary). */
31
+ readonly fragmentHtml: string;
32
+ /** Active theme; undefined renders the shell without theme vars
33
+ * (loud: the draft's `var(--…)` references fall to browser initial
34
+ * values — visibly broken, per CLAUDE.md §2 no-fallbacks). */
35
+ readonly theme?: ComposeTheme;
36
+ /** Resolved web fonts; undefined = system stacks only. */
37
+ readonly fonts?: ComposeFonts;
38
+ /** Document title shown in the iframe (accessibility). */
39
+ readonly title: string;
40
+ }
41
+
42
+ export interface DesignDraftShellOutput {
43
+ readonly html: string;
44
+ /** `theme-asset-unbound:<slot>` markers, mirroring the template
45
+ * engine's failure-marker contract. */
46
+ readonly missingSlots: readonly string[];
47
+ }
48
+
49
+ /**
50
+ * Resolve `{{theme_*_url}}` placeholders in a draft fragment. Bound
51
+ * slots substitute their URL; a placeholder on an unbound slot stays
52
+ * loud-raw IN PLACE (placeholders usually sit inside attribute values,
53
+ * where an appended comment would corrupt the URL string — PR-378
54
+ * review) and lands in `missingSlots`; the caller appends the real
55
+ * `caelo:missing` comment nodes at the end of the body so the marker
56
+ * tooling still sees them. Other `{{…}}` sequences are left untouched:
57
+ * a draft is not a fielded module.
58
+ */
59
+ function substituteThemeAssetUrls(
60
+ html: string,
61
+ theme: ComposeTheme | undefined,
62
+ missing: string[],
63
+ ): string {
64
+ return html.replace(/\{\{\s*(theme_[a-z_]+)\s*\}\}/g, (match, key: string) => {
65
+ const slot = THEME_ASSET_KEY_TO_SLOT[key as keyof typeof THEME_ASSET_KEY_TO_SLOT];
66
+ if (slot === undefined) return match;
67
+ const url = theme?.assets[slot]?.url;
68
+ if (url === undefined) {
69
+ missing.push(`theme-asset-unbound:${slot}`);
70
+ return match;
71
+ }
72
+ return url;
73
+ });
74
+ }
75
+
76
+ export function composeDesignDraftShell(input: DesignDraftShellInput): DesignDraftShellOutput {
77
+ const missing: string[] = [];
78
+ const substituted = substituteThemeAssetUrls(input.fragmentHtml, input.theme, missing);
79
+ // Real comment NODES, after the fragment — never inside an attribute.
80
+ const markers = [...new Set(missing)].map((m) => caeloMissingComment(m)).join("");
81
+ const body = substituted + markers;
82
+
83
+ // Head order mirrors composePagePreview: fonts first (URL discovery),
84
+ // then theme vars (so fragment CSS can `var(--color-primary)`), then
85
+ // the technical baseline.
86
+ const head: string[] = [
87
+ `<meta charset="utf-8">`,
88
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`,
89
+ `<title>${escapeHtml(input.title)}</title>`,
90
+ ];
91
+ const fonts = fontsHeadFragment(input.fonts);
92
+ if (fonts !== null) head.push(fonts);
93
+ const themeCss = input.theme !== undefined ? renderThemeCss(input.theme.tokens) : null;
94
+ if (themeCss !== null) head.push(`<style data-source="theme">${themeCss}</style>`);
95
+ head.push(`<style data-source="base">${BASE_TECHNICAL_CSS}</style>`);
96
+
97
+ return {
98
+ html: `<!doctype html>\n<html><head>${head.join("")}</head><body>${body}</body></html>`,
99
+ missingSlots: missing,
100
+ };
101
+ }
102
+
103
+ function escapeHtml(s: string): string {
104
+ return s
105
+ .replaceAll("&", "&amp;")
106
+ .replaceAll("<", "&lt;")
107
+ .replaceAll(">", "&gt;")
108
+ .replaceAll('"', "&quot;");
109
+ }
package/src/genesis.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * issue #163 — Site Genesis shared shapes.
4
+ * issue #163 — Site Genesis shared shapes; issue #375 generalises the
5
+ * same draft loop to growth-time.
5
6
  *
6
7
  * Design-time is divergent: the AI drafts complete freeform HTML pages
7
8
  * (one per design direction) and the operator picks one; the CMS
8
- * structure is derived FROM that choice (#164's compiler). These are
9
- * the boundary schemas for the brief + the draft rows.
9
+ * structure is derived FROM that choice (#164's compiler). Growth-time
10
+ * reuses the identical draft → present → select → materialise loop at
11
+ * smaller scope: `page` and `module` drafts are FRAGMENTS bound to the
12
+ * site's `var(--…)` theme tokens, composed into the real theme shell
13
+ * only at view time (never an AI approximation of the theme — #375).
14
+ * These are the boundary schemas for the brief + the draft rows.
10
15
  */
11
16
 
12
17
  import { z } from "zod";
@@ -41,14 +46,50 @@ export type GenesisDraftStatus = z.infer<typeof genesisDraftStatus>;
41
46
  export const genesisDraftSourceKind = z.enum(["genesis", "byod_image", "byod_html"]);
42
47
  export type GenesisDraftSourceKind = z.infer<typeof genesisDraftSourceKind>;
43
48
 
44
- export const genesisAddDraftInput = z
49
+ /**
50
+ * issue #375 — what a draft covers. `site` is the Genesis case (a whole
51
+ * new design); `page` / `module` are growth-time variants of something
52
+ * that already exists.
53
+ */
54
+ export const designDraftScope = z.enum(["site", "page", "module"]);
55
+ export type DesignDraftScope = z.infer<typeof designDraftScope>;
56
+
57
+ /**
58
+ * issue #375 — how a draft's HTML is stored. `site` drafts are complete
59
+ * standalone documents (Genesis, unchanged); `page`/`module` drafts are
60
+ * fragments that only become a renderable document when composed with
61
+ * the site's real theme shell at view time — so a stored variant never
62
+ * freezes a stale copy of the theme.
63
+ */
64
+ export const designDraftFormat = z.enum(["document", "fragment"]);
65
+ export type DesignDraftFormat = z.infer<typeof designDraftFormat>;
66
+
67
+ /** The stored format is fully determined by the scope (see the paired
68
+ * DB CHECK constraint): `site` → standalone document, else fragment. */
69
+ export function draftFormatForScope(scope: DesignDraftScope): DesignDraftFormat {
70
+ return scope === "site" ? "document" : "fragment";
71
+ }
72
+
73
+ const addDraftBase = z
45
74
  .object({
46
75
  /** Human-readable design direction ("bold editorial"). */
47
76
  direction: z.string().min(3).max(120),
48
77
  /** Why this direction fits the brief — shown beside the preview. */
49
78
  rationale: z.string().max(1000).default(""),
50
- /** Complete self-contained single-file HTML. */
79
+ /** Site scope: complete self-contained single-file HTML. Page/module
80
+ * scope: an HTML fragment bound to the site's `var(--…)` tokens. */
51
81
  html: z.string().min(200).max(GENESIS_DRAFT_MAX_HTML_BYTES),
82
+ /** issue #375 — what this draft covers; `site` is the Genesis case. */
83
+ scope: designDraftScope.default("site"),
84
+ /** issue #375 — page scope: the page this variant redesigns. Module
85
+ * scope: optional page context for "this module as used on …". */
86
+ targetPageId: z.string().uuid().optional(),
87
+ /** issue #375 — module scope: the module this variant restyles. */
88
+ targetModuleId: z.string().uuid().optional(),
89
+ /** issue #375 — groups one request's variants into one comparison.
90
+ * Omit on the first save (the op mints and returns it); pass it on
91
+ * every sibling save of the same round. */
92
+ variantSetId: z.string().uuid().optional(),
52
93
  /** issue #199 — 'byod_image' (AI reproduction of an operator
53
94
  * mockup) / 'byod_html' (operator HTML, sanitised at the op). */
54
95
  sourceKind: genesisDraftSourceKind.default("genesis"),
@@ -57,6 +98,36 @@ export const genesisAddDraftInput = z
57
98
  referenceAssetId: z.string().uuid().optional(),
58
99
  })
59
100
  .strict();
101
+
102
+ /** Scope/target pairing rules shared by the op and the AI tool. */
103
+ export const genesisAddDraftInput = addDraftBase.superRefine((v, ctx) => {
104
+ if (v.scope === "site") {
105
+ if (v.targetPageId !== undefined || v.targetModuleId !== undefined) {
106
+ ctx.addIssue({
107
+ code: z.ZodIssueCode.custom,
108
+ message: "site-scope drafts take no target — drop targetPageId/targetModuleId",
109
+ });
110
+ }
111
+ } else if (v.sourceKind !== "genesis") {
112
+ ctx.addIssue({
113
+ code: z.ZodIssueCode.custom,
114
+ message:
115
+ "byod drafts are site-scope only (issue #199); growth-time variants use sourceKind 'genesis'",
116
+ });
117
+ } else if (v.scope === "page") {
118
+ if (v.targetPageId === undefined || v.targetModuleId !== undefined) {
119
+ ctx.addIssue({
120
+ code: z.ZodIssueCode.custom,
121
+ message: "page-scope drafts need targetPageId (and no targetModuleId)",
122
+ });
123
+ }
124
+ } else if (v.targetModuleId === undefined) {
125
+ ctx.addIssue({
126
+ code: z.ZodIssueCode.custom,
127
+ message: "module-scope drafts need targetModuleId (targetPageId is optional page context)",
128
+ });
129
+ }
130
+ });
60
131
  export type GenesisAddDraftInput = z.infer<typeof genesisAddDraftInput>;
61
132
 
62
133
  /**
package/src/i18n.test.ts CHANGED
@@ -1,116 +1,7 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import {
5
- buildHreflangLinks,
6
- computeContentHash,
7
- isHomeSlug,
8
- type LocaleConfig,
9
- lintLocaleConfig,
10
- pageIsLocaleHome,
11
- resolveLocaleUrl,
12
- } from "./i18n.js";
13
-
14
- const ENG: LocaleConfig = {
15
- code: "en",
16
- displayName: "English",
17
- urlStrategy: "none",
18
- urlHost: null,
19
- isDefault: true,
20
- };
21
-
22
- const DE_SUBDIR: LocaleConfig = {
23
- code: "de",
24
- displayName: "Deutsch",
25
- urlStrategy: "subdirectory",
26
- urlHost: null,
27
- isDefault: false,
28
- };
29
-
30
- const DE_SUBDOMAIN: LocaleConfig = {
31
- code: "de",
32
- displayName: "Deutsch",
33
- urlStrategy: "subdomain",
34
- urlHost: "de.example.com",
35
- isDefault: false,
36
- };
37
-
38
- const DE_DOMAIN: LocaleConfig = {
39
- code: "de",
40
- displayName: "Deutsch",
41
- urlStrategy: "domain",
42
- urlHost: "example.de",
43
- isDefault: false,
44
- };
45
-
46
- describe("resolveLocaleUrl", () => {
47
- it("strategy=none yields bare slug under siteBaseUrl with trailing slash", () => {
48
- expect(resolveLocaleUrl(ENG, "about", "https://example.com")).toBe(
49
- "https://example.com/about/",
50
- );
51
- });
52
-
53
- it("strategy=subdirectory prefixes the locale code", () => {
54
- expect(resolveLocaleUrl(DE_SUBDIR, "about", "https://example.com")).toBe(
55
- "https://example.com/de/about/",
56
- );
57
- });
58
-
59
- it("strategy=subdomain uses urlHost", () => {
60
- expect(resolveLocaleUrl(DE_SUBDOMAIN, "about", "https://example.com")).toBe(
61
- "https://de.example.com/about/",
62
- );
63
- });
64
-
65
- it("strategy=domain uses urlHost", () => {
66
- expect(resolveLocaleUrl(DE_DOMAIN, "about", "https://example.com")).toBe(
67
- "https://example.de/about/",
68
- );
69
- });
70
-
71
- it("strategy=subdomain throws when urlHost missing", () => {
72
- expect(() =>
73
- resolveLocaleUrl({ ...DE_SUBDOMAIN, urlHost: null }, "about", "https://example.com"),
74
- ).toThrow(/url_host/);
75
- });
76
-
77
- it("trailing slashes on baseUrl are normalised", () => {
78
- expect(resolveLocaleUrl(ENG, "about", "https://example.com//")).toBe(
79
- "https://example.com/about/",
80
- );
81
- });
82
-
83
- it("leading slashes on slug are stripped", () => {
84
- expect(resolveLocaleUrl(ENG, "/about", "https://example.com")).toBe(
85
- "https://example.com/about/",
86
- );
87
- });
88
-
89
- it("home slug renders as the locale root (no extra path segment)", () => {
90
- expect(resolveLocaleUrl(ENG, "home", "https://example.com")).toBe("https://example.com/");
91
- expect(resolveLocaleUrl(DE_SUBDIR, "home", "https://example.com")).toBe(
92
- "https://example.com/de/",
93
- );
94
- expect(resolveLocaleUrl(DE_SUBDOMAIN, "home", "https://example.com")).toBe(
95
- "https://de.example.com/",
96
- );
97
- });
98
-
99
- it("explicit isHomePage collapses a NON-magic slug to the locale root (0184)", () => {
100
- // slug "en" is a normal nested page without the designation...
101
- expect(resolveLocaleUrl(ENG, "en", "https://example.com")).toBe("https://example.com/en/");
102
- // ...and the locale root once designated, per url strategy.
103
- expect(resolveLocaleUrl(ENG, "en", "https://example.com", "directory", true)).toBe(
104
- "https://example.com/",
105
- );
106
- expect(resolveLocaleUrl(DE_SUBDIR, "en", "https://example.com", "directory", true)).toBe(
107
- "https://example.com/de/",
108
- );
109
- expect(resolveLocaleUrl(DE_SUBDOMAIN, "en", "https://example.com", "directory", true)).toBe(
110
- "https://de.example.com/",
111
- );
112
- });
113
- });
4
+ import { computeContentHash, isHomeSlug, pageIsLocaleHome } from "./i18n.js";
114
5
 
115
6
  describe("isHomeSlug", () => {
116
7
  it("recognises the magic-slug sentinels regardless of surrounding slashes", () => {
@@ -145,90 +36,6 @@ describe("pageIsLocaleHome (0184 shared predicate)", () => {
145
36
  });
146
37
  });
147
38
 
148
- // renderLanguageSelector lives in structured-sets.ts but exercises the
149
- // same i18n primitives (resolveLocaleUrl produces hrefs the test feeds
150
- // in pre-resolved). Cover the three paths: vanilla render, override
151
- // relabel, hidden locale.
152
- import { renderLanguageSelector } from "./structured-sets.js";
153
-
154
- describe("renderLanguageSelector", () => {
155
- const ENGLISH = {
156
- code: "en",
157
- displayName: "English",
158
- href: "https://example.com/about/",
159
- isCurrent: true,
160
- };
161
- const GERMAN = {
162
- code: "de",
163
- displayName: "Deutsch",
164
- href: "https://example.com/de/about/",
165
- isCurrent: false,
166
- };
167
-
168
- it("emits one anchor per locale + aria-current on the current page", () => {
169
- const html = renderLanguageSelector({ availableLocales: [ENGLISH, GERMAN] });
170
- expect(html).toContain('hreflang="en"');
171
- expect(html).toContain('hreflang="de"');
172
- expect(html).toContain('aria-current="true"');
173
- expect(html).toContain("English");
174
- expect(html).toContain("Deutsch");
175
- });
176
-
177
- it("override relabels a locale", () => {
178
- const html = renderLanguageSelector({
179
- availableLocales: [ENGLISH, GERMAN],
180
- overrides: [{ locale: "de", label: "DE 🇩🇪" }],
181
- });
182
- expect(html).toContain("DE 🇩🇪");
183
- expect(html).not.toContain("Deutsch");
184
- });
185
-
186
- it("hidden override drops the locale entirely", () => {
187
- const html = renderLanguageSelector({
188
- availableLocales: [ENGLISH, GERMAN],
189
- overrides: [{ locale: "de", hidden: true }],
190
- });
191
- expect(html).toContain('hreflang="en"');
192
- expect(html).not.toContain('hreflang="de"');
193
- });
194
-
195
- it("empty available list returns empty string", () => {
196
- expect(renderLanguageSelector({ availableLocales: [] })).toBe("");
197
- });
198
-
199
- it("escapes attribute special chars", () => {
200
- const html = renderLanguageSelector({
201
- availableLocales: [{ code: "en", displayName: "EN&Co", href: "/?a=1&b=2", isCurrent: false }],
202
- });
203
- expect(html).toContain("a=1&amp;b=2");
204
- expect(html).toContain("EN&amp;Co");
205
- });
206
- });
207
-
208
- describe("buildHreflangLinks", () => {
209
- it("emits one link per locale + x-default for the default", () => {
210
- const html = buildHreflangLinks([
211
- { localeCode: "en", url: "https://example.com/about", isDefault: true },
212
- { localeCode: "de", url: "https://example.com/de/about", isDefault: false },
213
- ]);
214
- expect(html).toContain('hreflang="en"');
215
- expect(html).toContain('hreflang="de"');
216
- expect(html).toContain('hreflang="x-default"');
217
- expect(html).toContain('href="https://example.com/about"');
218
- });
219
-
220
- it("returns empty string when no entries", () => {
221
- expect(buildHreflangLinks([])).toBe("");
222
- });
223
-
224
- it("escapes attribute special characters", () => {
225
- const html = buildHreflangLinks([
226
- { localeCode: "en", url: "https://example.com/?a=1&b=2", isDefault: true },
227
- ]);
228
- expect(html).toContain("a=1&amp;b=2");
229
- });
230
- });
231
-
232
39
  describe("computeContentHash", () => {
233
40
  it("is stable across runs for the same input", async () => {
234
41
  const a = await computeContentHash({ x: 1, y: [1, 2] });
@@ -249,26 +56,3 @@ describe("computeContentHash", () => {
249
56
  expect(a).not.toBe(b);
250
57
  });
251
58
  });
252
-
253
- describe("lintLocaleConfig", () => {
254
- it("warns when subdomain/domain used while advanced toggle is off", () => {
255
- const warnings = lintLocaleConfig([ENG, DE_SUBDOMAIN], false);
256
- expect(warnings.find((w) => w.code === "advanced-routing-disabled")).toBeTruthy();
257
- });
258
-
259
- it("does not warn when toggle matches usage", () => {
260
- const warnings = lintLocaleConfig([ENG, DE_SUBDOMAIN], true);
261
- expect(warnings.find((w) => w.code === "advanced-routing-disabled")).toBeFalsy();
262
- });
263
-
264
- it("warns when subdomain locale lacks urlHost", () => {
265
- const broken: LocaleConfig = { ...DE_SUBDOMAIN, urlHost: null };
266
- const warnings = lintLocaleConfig([ENG, broken], true);
267
- expect(warnings.find((w) => w.code === "missing-url-host")).toBeTruthy();
268
- });
269
-
270
- it("warns about mixed default-none + subdir-sibling configs", () => {
271
- const warnings = lintLocaleConfig([ENG, DE_SUBDIR], true);
272
- expect(warnings.find((w) => w.code === "mixed-default-none-subdir")).toBeTruthy();
273
- });
274
- });
package/src/i18n.ts CHANGED
@@ -1,28 +1,15 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  /**
4
- * P9 — i18n primitives shared between the static generator, the
5
- * preview op, and the admin UI:
4
+ * Slug/URL primitives shared between the static generator, the preview
5
+ * op, and the admin UI. Epic #380 Phase A (#383): locale URL shaping
6
+ * (resolveLocaleUrl, buildHreflangLinks) is deleted — URL shape beyond
7
+ * base + slug becomes a plugin contribution on the URL composition
8
+ * point (#390). What survives until the page-identity cut (#384):
6
9
  *
7
- * computeContentHash(page, modules) — sha256 of canonical-JSON
8
- * content. Drives `pages.content_hash` + the translation_status
9
- * recompute path.
10
- *
11
- * resolveLocaleUrl(locale, slug, siteBaseUrl, pageUrlStyle?, isHomePage?) —
12
- * builds the public URL for a (locale, slug) tuple given the
13
- * locale's url_strategy + url_host. `isHomePage` forces the locale
14
- * root (0184 explicit designation). Used by:
15
- * - hreflang emitter
16
- * - sitemap.xml emitter (when extended for i18n)
17
- * - language-selector module
18
- *
19
- * buildHreflangLinks(currentLocale, perLocaleUrls, defaultLocaleCode) —
20
- * emits the `<link rel="alternate" hreflang=...>` markup.
21
- *
22
- * lintLocaleConfig(locales, advancedUrlRouting) — surfaces config
23
- * warnings (mixed strategies, missing url_host for subdomain/
24
- * domain, default locale using `none` alongside subdirectory
25
- * siblings, advanced strategy chosen while toggle is off).
10
+ * isHomeSlug / pageIsLocaleHome — the home-decision predicate.
11
+ * computeContentHash — drives `pages.content_hash` (removed in #384).
12
+ * trimSlashes / trimTrailingSlashes — linear-scan slug hygiene.
26
13
  */
27
14
 
28
15
  const TEXT_ENCODER = new TextEncoder();
@@ -55,14 +42,6 @@ export function trimTrailingSlashes(s: string): string {
55
42
  return s.slice(0, end);
56
43
  }
57
44
 
58
- export interface LocaleConfig {
59
- code: string;
60
- displayName: string;
61
- urlStrategy: "none" | "subdirectory" | "subdomain" | "domain";
62
- urlHost: string | null;
63
- isDefault: boolean;
64
- }
65
-
66
45
  /**
67
46
  * The "magic slug" sentinel test: a page whose slug is empty, `home`,
68
47
  * or `index` is the locale root by convention even without an explicit
@@ -93,111 +72,6 @@ export function pageIsLocaleHome(
93
72
  return (localeHomePageId != null && pageId === localeHomePageId) || isHomeSlug(slug);
94
73
  }
95
74
 
96
- /**
97
- * Build a public-facing URL for a (locale, slug) tuple. Pure function —
98
- * no DB access — so the static generator can call it for every page
99
- * without round-trips.
100
- *
101
- * @param locale The target locale's full config row.
102
- * @param slug Path component (e.g. "about", "blog/post-1"). No leading slash.
103
- * @param siteBaseUrl Default base URL when the strategy is `none` or
104
- * `subdirectory` (e.g. "https://example.com").
105
- * @param pageUrlStyle Page emission style (see below).
106
- * @param isHomePage Explicit homepage designation (0184 —
107
- * `locales.home_page_id`). When true this page IS the
108
- * locale root and resolves to `<base>/` regardless of
109
- * its own slug. The explicit flag WINS over the slug
110
- * sentinel below; the sentinel stays as a back-compat
111
- * fallback for callers that can't cheaply surface the
112
- * designation.
113
- * @returns Absolute URL, including scheme + host.
114
- */
115
- export function resolveLocaleUrl(
116
- locale: LocaleConfig,
117
- slug: string,
118
- siteBaseUrl: string,
119
- // v0.2.85 — page emission style. 'directory' (default) builds
120
- // URLs ending in `/<slug>/`; 'no-extension' builds URLs ending
121
- // in `/<slug>` (no trailing slash) to match what the bucket
122
- // actually serves when pages are emitted as bare slugs. Home
123
- // page is always `<base>/` regardless of style.
124
- pageUrlStyle: "directory" | "no-extension" = "directory",
125
- isHomePage?: boolean,
126
- ): string {
127
- const stripped = trimSlashes(slug);
128
- // 0184 — the explicit designation wins; the slug sentinel
129
- // (""/`home`/`index`) survives as a back-compat fallback so a page the
130
- // AI never ran set_home_page on still resolves to the root. Shared
131
- // `isHomeSlug` keeps this test identical across every home-decision site.
132
- const isHome = isHomePage === true || isHomeSlug(stripped);
133
- // tail: the path component appended after `<base>/` or `<base>/<locale>/`.
134
- // 'directory' style: trailing slash for non-home so the URL points at
135
- // the directory the bucket serves index.html from.
136
- // 'no-extension' style: no trailing slash, no extension — the URL
137
- // points at the bare-slug object the bucket serves directly.
138
- const tail = isHome ? "" : pageUrlStyle === "no-extension" ? stripped : `${stripped}/`;
139
- const base = trimTrailingSlashes(siteBaseUrl);
140
- switch (locale.urlStrategy) {
141
- case "none":
142
- return tail ? `${base}/${tail}` : `${base}/`;
143
- case "subdirectory":
144
- // Default locale with strategy `subdirectory` still gets the prefix
145
- // unless the migration set strategy=none for it. The decision is
146
- // explicit per locale row, not implicit on isDefault.
147
- return tail ? `${base}/${locale.code}/${tail}` : `${base}/${locale.code}/`;
148
- case "subdomain": {
149
- if (!locale.urlHost) {
150
- throw new Error(
151
- `locale '${locale.code}' uses url_strategy='subdomain' without url_host — config invalid`,
152
- );
153
- }
154
- const protocol = base.startsWith("http://") ? "http://" : "https://";
155
- return tail ? `${protocol}${locale.urlHost}/${tail}` : `${protocol}${locale.urlHost}/`;
156
- }
157
- case "domain": {
158
- if (!locale.urlHost) {
159
- throw new Error(
160
- `locale '${locale.code}' uses url_strategy='domain' without url_host — config invalid`,
161
- );
162
- }
163
- const protocol = base.startsWith("http://") ? "http://" : "https://";
164
- return tail ? `${protocol}${locale.urlHost}/${tail}` : `${protocol}${locale.urlHost}/`;
165
- }
166
- }
167
- }
168
-
169
- /**
170
- * Emit `<link rel="alternate" hreflang="…">` tags for every locale
171
- * that has a published variant of this page. The default locale also
172
- * gets an `x-default` entry per Google's i18n guidance.
173
- *
174
- * Returned as a single string suitable for splicing into <head>.
175
- */
176
- export function buildHreflangLinks(
177
- perLocaleUrls: ReadonlyArray<{ localeCode: string; url: string; isDefault: boolean }>,
178
- ): string {
179
- if (perLocaleUrls.length === 0) return "";
180
- const lines: string[] = [];
181
- for (const v of perLocaleUrls) {
182
- lines.push(
183
- `<link rel="alternate" hreflang="${escapeAttr(v.localeCode)}" href="${escapeAttr(v.url)}" />`,
184
- );
185
- }
186
- const def = perLocaleUrls.find((v) => v.isDefault);
187
- if (def) {
188
- lines.push(`<link rel="alternate" hreflang="x-default" href="${escapeAttr(def.url)}" />`);
189
- }
190
- return lines.join("\n");
191
- }
192
-
193
- function escapeAttr(s: string): string {
194
- return s
195
- .replace(/&/g, "&amp;")
196
- .replace(/"/g, "&quot;")
197
- .replace(/</g, "&lt;")
198
- .replace(/>/g, "&gt;");
199
- }
200
-
201
75
  /**
202
76
  * Canonical-JSON serializer + sha256 → hex. Stable across runs because
203
77
  * keys are sorted. The output is used for `pages.content_hash` so that
@@ -215,55 +89,3 @@ export async function computeContentHash(value: unknown): Promise<string> {
215
89
  const digest = await crypto.subtle.digest("SHA-256", TEXT_ENCODER.encode(canonical));
216
90
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
217
91
  }
218
-
219
- export interface LocaleLintWarning {
220
- code: string;
221
- message: string;
222
- }
223
-
224
- /**
225
- * Cross-row config sanity checks. Run at:
226
- * - propose-time (warnings stored on the proposal preview)
227
- * - render-time (no fail-loudly per CLAUDE.md §2 since the renderer
228
- * can still emit; surfaced in admin UI as banners)
229
- */
230
- export function lintLocaleConfig(
231
- locales: ReadonlyArray<LocaleConfig>,
232
- advancedUrlRouting: boolean,
233
- ): LocaleLintWarning[] {
234
- const warnings: LocaleLintWarning[] = [];
235
- const usingAdvanced = locales.some(
236
- (l) => l.urlStrategy === "subdomain" || l.urlStrategy === "domain",
237
- );
238
- if (usingAdvanced && !advancedUrlRouting) {
239
- warnings.push({
240
- code: "advanced-routing-disabled",
241
- message:
242
- "one or more locales use 'subdomain' or 'domain' strategy but Advanced URL Routing is disabled — enable it under /security/locales",
243
- });
244
- }
245
- for (const l of locales) {
246
- if ((l.urlStrategy === "subdomain" || l.urlStrategy === "domain") && !l.urlHost) {
247
- warnings.push({
248
- code: "missing-url-host",
249
- message: `locale '${l.code}' uses url_strategy='${l.urlStrategy}' without url_host`,
250
- });
251
- }
252
- }
253
- // Default-locale 'none' alongside subdirectory siblings is a common
254
- // mixed config; surface it so users know the default's URL stays bare
255
- // while siblings get prefixed.
256
- const def = locales.find((l) => l.isDefault);
257
- const subdirSiblings = locales.filter((l) => !l.isDefault && l.urlStrategy === "subdirectory");
258
- if (def && def.urlStrategy === "none" && subdirSiblings.length > 0) {
259
- warnings.push({
260
- code: "mixed-default-none-subdir",
261
- message: `default locale '${def.code}' uses 'none' while ${subdirSiblings
262
- .map((l) => l.code)
263
- .join(
264
- ", ",
265
- )} use 'subdirectory' — this is valid but unusual; verify hreflang renders correctly`,
266
- });
267
- }
268
- return warnings;
269
- }