@caelo-cms/shared 0.10.22 → 0.10.23

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 (241) hide show
  1. package/dist/ai-tools.d.ts +290 -214
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +341 -264
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +24 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +28 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-manifest.d.ts +36 -0
  29. package/dist/design-manifest.d.ts.map +1 -0
  30. package/dist/design-manifest.js +90 -0
  31. package/dist/design-manifest.js.map +1 -0
  32. package/dist/fonts.d.ts +89 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +241 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/genesis-inventory.d.ts +32 -0
  37. package/dist/genesis-inventory.d.ts.map +1 -0
  38. package/dist/genesis-inventory.js +186 -0
  39. package/dist/genesis-inventory.js.map +1 -0
  40. package/dist/genesis.d.ts +62 -0
  41. package/dist/genesis.d.ts.map +1 -0
  42. package/dist/genesis.js +78 -0
  43. package/dist/genesis.js.map +1 -0
  44. package/dist/i18n.d.ts +44 -1
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +72 -6
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +27 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +27 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/interactions.d.ts +23 -0
  53. package/dist/interactions.d.ts.map +1 -0
  54. package/dist/interactions.js +44 -0
  55. package/dist/interactions.js.map +1 -0
  56. package/dist/media.d.ts +101 -16
  57. package/dist/media.d.ts.map +1 -1
  58. package/dist/media.js +126 -15
  59. package/dist/media.js.map +1 -1
  60. package/dist/page-log.d.ts +94 -0
  61. package/dist/page-log.d.ts.map +1 -0
  62. package/dist/page-log.js +111 -0
  63. package/dist/page-log.js.map +1 -0
  64. package/dist/preview-compose.d.ts +79 -0
  65. package/dist/preview-compose.d.ts.map +1 -1
  66. package/dist/preview-compose.js +155 -25
  67. package/dist/preview-compose.js.map +1 -1
  68. package/dist/proposal-status.d.ts +40 -0
  69. package/dist/proposal-status.d.ts.map +1 -0
  70. package/dist/proposal-status.js +34 -0
  71. package/dist/proposal-status.js.map +1 -0
  72. package/dist/responsive-images.d.ts +64 -0
  73. package/dist/responsive-images.d.ts.map +1 -0
  74. package/dist/responsive-images.js +98 -0
  75. package/dist/responsive-images.js.map +1 -0
  76. package/dist/safe-keys.d.ts +9 -0
  77. package/dist/safe-keys.d.ts.map +1 -0
  78. package/dist/safe-keys.js +20 -0
  79. package/dist/safe-keys.js.map +1 -0
  80. package/dist/seo.d.ts +8 -0
  81. package/dist/seo.d.ts.map +1 -1
  82. package/dist/seo.js +3 -1
  83. package/dist/seo.js.map +1 -1
  84. package/dist/skills.d.ts +14 -68
  85. package/dist/skills.d.ts.map +1 -1
  86. package/dist/skills.js +19 -113
  87. package/dist/skills.js.map +1 -1
  88. package/dist/strip-cdata.d.ts +7 -0
  89. package/dist/strip-cdata.d.ts.map +1 -0
  90. package/dist/strip-cdata.js +48 -0
  91. package/dist/strip-cdata.js.map +1 -0
  92. package/dist/structured-sets.d.ts +6 -16
  93. package/dist/structured-sets.d.ts.map +1 -1
  94. package/dist/structured-sets.js +5 -16
  95. package/dist/structured-sets.js.map +1 -1
  96. package/dist/subagents.d.ts +105 -3
  97. package/dist/subagents.d.ts.map +1 -1
  98. package/dist/subagents.js +224 -41
  99. package/dist/subagents.js.map +1 -1
  100. package/dist/template-engine.d.ts +85 -0
  101. package/dist/template-engine.d.ts.map +1 -0
  102. package/dist/template-engine.js +403 -0
  103. package/dist/template-engine.js.map +1 -0
  104. package/dist/theme-importers/auto-detect.d.ts +26 -0
  105. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  106. package/dist/theme-importers/auto-detect.js +42 -0
  107. package/dist/theme-importers/auto-detect.js.map +1 -0
  108. package/dist/theme-importers/css-comments.d.ts +12 -0
  109. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  110. package/dist/theme-importers/css-comments.js +15 -0
  111. package/dist/theme-importers/css-comments.js.map +1 -0
  112. package/dist/theme-importers/dtcg.d.ts +46 -0
  113. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  114. package/dist/theme-importers/dtcg.js +111 -0
  115. package/dist/theme-importers/dtcg.js.map +1 -0
  116. package/dist/theme-importers/loose.d.ts +3 -0
  117. package/dist/theme-importers/loose.d.ts.map +1 -0
  118. package/dist/theme-importers/loose.js +76 -0
  119. package/dist/theme-importers/loose.js.map +1 -0
  120. package/dist/theme-importers/shadcn.d.ts +24 -0
  121. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  122. package/dist/theme-importers/shadcn.js +135 -0
  123. package/dist/theme-importers/shadcn.js.map +1 -0
  124. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  125. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  126. package/dist/theme-importers/style-dictionary.js +125 -0
  127. package/dist/theme-importers/style-dictionary.js.map +1 -0
  128. package/dist/theme-importers/tailwind.d.ts +3 -0
  129. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  130. package/dist/theme-importers/tailwind.js +218 -0
  131. package/dist/theme-importers/tailwind.js.map +1 -0
  132. package/dist/theme-literal-binding.d.ts +37 -0
  133. package/dist/theme-literal-binding.d.ts.map +1 -0
  134. package/dist/theme-literal-binding.js +138 -0
  135. package/dist/theme-literal-binding.js.map +1 -0
  136. package/dist/theme-normalize.d.ts +31 -0
  137. package/dist/theme-normalize.d.ts.map +1 -0
  138. package/dist/theme-normalize.js +587 -0
  139. package/dist/theme-normalize.js.map +1 -0
  140. package/dist/theme-ramp.d.ts +55 -0
  141. package/dist/theme-ramp.d.ts.map +1 -0
  142. package/dist/theme-ramp.js +149 -0
  143. package/dist/theme-ramp.js.map +1 -0
  144. package/dist/theme-render.d.ts +105 -0
  145. package/dist/theme-render.d.ts.map +1 -0
  146. package/dist/theme-render.js +441 -0
  147. package/dist/theme-render.js.map +1 -0
  148. package/dist/themes-errors.d.ts +109 -0
  149. package/dist/themes-errors.d.ts.map +1 -0
  150. package/dist/themes-errors.js +170 -0
  151. package/dist/themes-errors.js.map +1 -0
  152. package/dist/themes.d.ts +343 -0
  153. package/dist/themes.d.ts.map +1 -0
  154. package/dist/themes.js +697 -0
  155. package/dist/themes.js.map +1 -0
  156. package/dist/version.d.ts +7 -4
  157. package/dist/version.d.ts.map +1 -1
  158. package/dist/version.js +6 -3
  159. package/dist/version.js.map +1 -1
  160. package/package.json +10 -2
  161. package/src/__tests__/redos-hardening.test.ts +160 -0
  162. package/src/ai-tools-add-module-modes.test.ts +106 -0
  163. package/src/ai-tools-position.test.ts +134 -0
  164. package/src/ai-tools.test.ts +81 -0
  165. package/src/ai-tools.ts +1179 -0
  166. package/src/auth-forms.ts +36 -0
  167. package/src/base-css.ts +30 -0
  168. package/src/build-page.test.ts +228 -0
  169. package/src/build-page.ts +319 -0
  170. package/src/cap-failures.ts +67 -0
  171. package/src/content.test.ts +170 -0
  172. package/src/content.ts +620 -0
  173. package/src/context.ts +43 -0
  174. package/src/css-gradient-scan.ts +88 -0
  175. package/src/css-var-scan.test.ts +96 -0
  176. package/src/css-var-scan.ts +144 -0
  177. package/src/derive-module-type.test.ts +80 -0
  178. package/src/design-manifest.ts +93 -0
  179. package/src/fonts.test.ts +157 -0
  180. package/src/fonts.ts +296 -0
  181. package/src/genesis-inventory.test.ts +86 -0
  182. package/src/genesis-inventory.ts +215 -0
  183. package/src/genesis-sanitize.test.ts +35 -0
  184. package/src/genesis.ts +87 -0
  185. package/src/i18n.test.ts +274 -0
  186. package/src/i18n.ts +269 -0
  187. package/src/index.test.ts +10 -0
  188. package/src/index.ts +59 -0
  189. package/src/interactions.ts +48 -0
  190. package/src/logger.ts +147 -0
  191. package/src/media.test.ts +160 -0
  192. package/src/media.ts +355 -0
  193. package/src/page-log.test.ts +163 -0
  194. package/src/page-log.ts +124 -0
  195. package/src/preview-compose.test.ts +602 -0
  196. package/src/preview-compose.ts +709 -0
  197. package/src/preview-scanner.test.ts +96 -0
  198. package/src/preview-scanner.ts +214 -0
  199. package/src/proposal-status.test.ts +69 -0
  200. package/src/proposal-status.ts +40 -0
  201. package/src/responsive-images.test.ts +104 -0
  202. package/src/responsive-images.ts +151 -0
  203. package/src/result.ts +29 -0
  204. package/src/safe-keys.ts +21 -0
  205. package/src/seo.test.ts +234 -0
  206. package/src/seo.ts +261 -0
  207. package/src/skills.ts +48 -0
  208. package/src/snapshots.test.ts +80 -0
  209. package/src/snapshots.ts +81 -0
  210. package/src/strip-cdata.test.ts +41 -0
  211. package/src/strip-cdata.ts +50 -0
  212. package/src/structured-sets.ts +180 -0
  213. package/src/subagents.test.ts +262 -0
  214. package/src/subagents.ts +432 -0
  215. package/src/template-engine.test.ts +379 -0
  216. package/src/template-engine.ts +520 -0
  217. package/src/theme-gradient.test.ts +92 -0
  218. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  219. package/src/theme-importers/auto-detect.ts +84 -0
  220. package/src/theme-importers/css-comments.ts +15 -0
  221. package/src/theme-importers/dtcg.ts +106 -0
  222. package/src/theme-importers/loose.ts +76 -0
  223. package/src/theme-importers/shadcn.ts +133 -0
  224. package/src/theme-importers/style-dictionary.ts +125 -0
  225. package/src/theme-importers/tailwind.ts +217 -0
  226. package/src/theme-literal-binding.test.ts +71 -0
  227. package/src/theme-literal-binding.ts +159 -0
  228. package/src/theme-motion.test.ts +115 -0
  229. package/src/theme-normalize-envelope.test.ts +43 -0
  230. package/src/theme-normalize-gradient.test.ts +135 -0
  231. package/src/theme-normalize.ts +661 -0
  232. package/src/theme-ramp.ts +187 -0
  233. package/src/theme-render-sanitize.test.ts +45 -0
  234. package/src/theme-render.test.ts +119 -0
  235. package/src/theme-render.ts +487 -0
  236. package/src/theme-shadow.test.ts +56 -0
  237. package/src/themes-errors.ts +199 -0
  238. package/src/themes.ts +842 -0
  239. package/src/translation.test.ts +160 -0
  240. package/src/translation.ts +295 -0
  241. package/src/version.ts +66 -0
package/src/genesis.ts ADDED
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * issue #163 — Site Genesis shared shapes.
5
+ *
6
+ * Design-time is divergent: the AI drafts complete freeform HTML pages
7
+ * (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.
10
+ */
11
+
12
+ import { z } from "zod";
13
+
14
+ /**
15
+ * The structured Design Brief the discovery dialog produces. Every
16
+ * field optional — the AI fills what the conversation answered; the
17
+ * draft prompts degrade gracefully on gaps. Stored on
18
+ * `site_defaults.design_brief`.
19
+ */
20
+ export const designBriefSchema = z
21
+ .object({
22
+ audience: z.string().min(1).max(500).optional(),
23
+ /** 3–5 adjectives the operator wants the design to feel like. */
24
+ moodWords: z.array(z.string().min(1).max(40)).max(12).optional(),
25
+ tone: z.string().min(1).max(300).optional(),
26
+ industry: z.string().min(1).max(200).optional(),
27
+ differentiators: z.string().min(1).max(1000).optional(),
28
+ imageryDirection: z.string().min(1).max(500).optional(),
29
+ avoid: z.string().min(1).max(500).optional(),
30
+ })
31
+ .strict();
32
+ export type DesignBrief = z.infer<typeof designBriefSchema>;
33
+
34
+ /** Complete single-file drafts stay well under this (inline CSS only). */
35
+ export const GENESIS_DRAFT_MAX_HTML_BYTES = 300_000;
36
+
37
+ export const genesisDraftStatus = z.enum(["candidate", "selected", "discarded"]);
38
+ export type GenesisDraftStatus = z.infer<typeof genesisDraftStatus>;
39
+
40
+ /** issue #199 — where a draft came from. */
41
+ export const genesisDraftSourceKind = z.enum(["genesis", "byod_image", "byod_html"]);
42
+ export type GenesisDraftSourceKind = z.infer<typeof genesisDraftSourceKind>;
43
+
44
+ export const genesisAddDraftInput = z
45
+ .object({
46
+ /** Human-readable design direction ("bold editorial"). */
47
+ direction: z.string().min(3).max(120),
48
+ /** Why this direction fits the brief — shown beside the preview. */
49
+ rationale: z.string().max(1000).default(""),
50
+ /** Complete self-contained single-file HTML. */
51
+ html: z.string().min(200).max(GENESIS_DRAFT_MAX_HTML_BYTES),
52
+ /** issue #199 — 'byod_image' (AI reproduction of an operator
53
+ * mockup) / 'byod_html' (operator HTML, sanitised at the op). */
54
+ sourceKind: genesisDraftSourceKind.default("genesis"),
55
+ /** issue #199 — byod_image only: the uploaded mockup's media
56
+ * asset. The parity gate uses THIS image as the reference. */
57
+ referenceAssetId: z.string().uuid().optional(),
58
+ })
59
+ .strict();
60
+ export type GenesisAddDraftInput = z.infer<typeof genesisAddDraftInput>;
61
+
62
+ /**
63
+ * issue #199 — strip <script> blocks from operator-provided draft
64
+ * HTML. Defense in depth: drafts render inside sandbox="" iframes
65
+ * (scripts never execute there), but stored HTML flows onward into
66
+ * materialisation, so the boundary removes them outright. Linear
67
+ * scan per the #113 discipline; case-insensitive; unterminated
68
+ * script blocks drop everything to EOF (never leak half a script).
69
+ */
70
+ export function sanitizeDraftHtml(html: string): string {
71
+ const lower = html.toLowerCase();
72
+ let out = "";
73
+ let from = 0;
74
+ while (true) {
75
+ const open = lower.indexOf("<script", from);
76
+ if (open === -1) {
77
+ out += html.slice(from);
78
+ return out;
79
+ }
80
+ out += html.slice(from, open);
81
+ const close = lower.indexOf("</script", open);
82
+ if (close === -1) return out;
83
+ const closeEnd = lower.indexOf(">", close);
84
+ if (closeEnd === -1) return out;
85
+ from = closeEnd + 1;
86
+ }
87
+ }
@@ -0,0 +1,274 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
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
+ });
114
+
115
+ describe("isHomeSlug", () => {
116
+ it("recognises the magic-slug sentinels regardless of surrounding slashes", () => {
117
+ for (const s of ["", "home", "index", "/", "/home/", "/index"]) {
118
+ expect(isHomeSlug(s)).toBe(true);
119
+ }
120
+ });
121
+
122
+ it("treats any other slug as non-home", () => {
123
+ for (const s of ["about", "en", "blog/post", "homepage"]) {
124
+ expect(isHomeSlug(s)).toBe(false);
125
+ }
126
+ });
127
+ });
128
+
129
+ describe("pageIsLocaleHome (0184 shared predicate)", () => {
130
+ const PAGE = "11111111-1111-4111-8111-111111111111";
131
+ const OTHER = "22222222-2222-4222-8222-222222222222";
132
+
133
+ it("is true when the page IS the locale's designated home_page_id", () => {
134
+ expect(pageIsLocaleHome(PAGE, "en", PAGE)).toBe(true);
135
+ });
136
+
137
+ it("is true for a magic slug even with no designation", () => {
138
+ expect(pageIsLocaleHome(PAGE, "home", null)).toBe(true);
139
+ expect(pageIsLocaleHome(PAGE, "index", undefined)).toBe(true);
140
+ });
141
+
142
+ it("is false for a non-magic slug that isn't the designated page", () => {
143
+ expect(pageIsLocaleHome(PAGE, "en", OTHER)).toBe(false);
144
+ expect(pageIsLocaleHome(PAGE, "about", null)).toBe(false);
145
+ });
146
+ });
147
+
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
+ describe("computeContentHash", () => {
233
+ it("is stable across runs for the same input", async () => {
234
+ const a = await computeContentHash({ x: 1, y: [1, 2] });
235
+ const b = await computeContentHash({ x: 1, y: [1, 2] });
236
+ expect(a).toBe(b);
237
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
238
+ });
239
+
240
+ it("is order-independent for object keys", async () => {
241
+ const a = await computeContentHash({ x: 1, y: 2 });
242
+ const b = await computeContentHash({ y: 2, x: 1 });
243
+ expect(a).toBe(b);
244
+ });
245
+
246
+ it("is order-dependent for arrays", async () => {
247
+ const a = await computeContentHash({ x: [1, 2] });
248
+ const b = await computeContentHash({ x: [2, 1] });
249
+ expect(a).not.toBe(b);
250
+ });
251
+ });
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 ADDED
@@ -0,0 +1,269 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P9 — i18n primitives shared between the static generator, the
5
+ * preview op, and the admin UI:
6
+ *
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).
26
+ */
27
+
28
+ const TEXT_ENCODER = new TextEncoder();
29
+
30
+ /**
31
+ * Strip leading and trailing `/` characters with a single linear scan.
32
+ *
33
+ * Replaces the regex `/^\/+|\/+$/g`, whose trailing-slash branch
34
+ * backtracks O(n²) on a long run of slashes that is not at end-of-string
35
+ * (CodeQL js/polynomial-redos). This char-index walk is unconditionally
36
+ * linear and produces byte-identical output for every input.
37
+ */
38
+ export function trimSlashes(s: string): string {
39
+ let start = 0;
40
+ let end = s.length;
41
+ while (start < end && s.charCodeAt(start) === 47 /* '/' */) start += 1;
42
+ while (end > start && s.charCodeAt(end - 1) === 47 /* '/' */) end -= 1;
43
+ return s.slice(start, end);
44
+ }
45
+
46
+ /**
47
+ * Strip only trailing `/` characters with a single linear scan — the
48
+ * non-backtracking replacement for `/\/+$/` (same ReDoS class as above).
49
+ * Used for base URLs, which carry a scheme and must keep their leading
50
+ * characters.
51
+ */
52
+ export function trimTrailingSlashes(s: string): string {
53
+ let end = s.length;
54
+ while (end > 0 && s.charCodeAt(end - 1) === 47 /* '/' */) end -= 1;
55
+ return s.slice(0, end);
56
+ }
57
+
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
+ /**
67
+ * The "magic slug" sentinel test: a page whose slug is empty, `home`,
68
+ * or `index` is the locale root by convention even without an explicit
69
+ * `locales.home_page_id` designation. Leading/trailing slashes are
70
+ * ignored so `/home/` reads the same as `home`.
71
+ */
72
+ export function isHomeSlug(slug: string): boolean {
73
+ const s = trimSlashes(slug);
74
+ return s === "" || s === "home" || s === "index";
75
+ }
76
+
77
+ /**
78
+ * The single home-decision predicate every URL/output-path site must
79
+ * use so canonical, hreflang, redirects, and the emitted file agree
80
+ * (0184 explicit-homepage feature). A page is the locale root when it
81
+ * IS the locale's designated `home_page_id` OR carries a magic slug.
82
+ *
83
+ * @param pageId The page being resolved.
84
+ * @param slug That page's slug.
85
+ * @param localeHomePageId `locales.home_page_id` for the page's locale
86
+ * (null/undefined when no page is designated).
87
+ */
88
+ export function pageIsLocaleHome(
89
+ pageId: string,
90
+ slug: string,
91
+ localeHomePageId: string | null | undefined,
92
+ ): boolean {
93
+ return (localeHomePageId != null && pageId === localeHomePageId) || isHomeSlug(slug);
94
+ }
95
+
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
+ /**
202
+ * Canonical-JSON serializer + sha256 → hex. Stable across runs because
203
+ * keys are sorted. The output is used for `pages.content_hash` so that
204
+ * a Mode-2 translation can detect whether its source has changed.
205
+ */
206
+ export async function computeContentHash(value: unknown): Promise<string> {
207
+ const canonical = JSON.stringify(value, (_key, val) => {
208
+ if (val && typeof val === "object" && !Array.isArray(val)) {
209
+ const sorted: Record<string, unknown> = {};
210
+ for (const k of Object.keys(val).sort()) sorted[k] = (val as Record<string, unknown>)[k];
211
+ return sorted;
212
+ }
213
+ return val;
214
+ });
215
+ const digest = await crypto.subtle.digest("SHA-256", TEXT_ENCODER.encode(canonical));
216
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
217
+ }
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
+ }
@@ -0,0 +1,10 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { PROJECT_NAME } from "./index.js";
5
+
6
+ describe("shared scaffold", () => {
7
+ it("exports the project name", () => {
8
+ expect(PROJECT_NAME).toBe("caelo-cms");
9
+ });
10
+ });