@rimelight/ui 0.0.2 → 0.0.3

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 (103) hide show
  1. package/package.json +5 -1
  2. package/src/components/carousel/carousel-script.ts +188 -188
  3. package/src/components/carousel/index.ts +1 -1
  4. package/src/components/input-otp/InputOtpTypes.ts +6 -6
  5. package/src/components/radio-group/RadioGroupTypes.ts +6 -6
  6. package/src/components/select/SelectTypes.ts +13 -13
  7. package/src/components/switch/SwitchTypes.ts +6 -6
  8. package/src/components/toast/index.ts +1 -1
  9. package/src/components/toast/toast-manager.ts +234 -234
  10. package/src/components/toggle/ToggleTypes.ts +14 -14
  11. package/src/config/cookies.ts +5 -5
  12. package/src/config/index.ts +3 -3
  13. package/src/config/links.ts +4 -4
  14. package/src/config/site.config.ts +45 -45
  15. package/src/domain/cms/index.ts +1 -1
  16. package/src/domain/cms/types.ts +20 -20
  17. package/src/domain/i18n/constants.ts +21 -21
  18. package/src/domain/i18n/cookie-storage.ts +13 -13
  19. package/src/domain/i18n/country-to-locale-map.ts +67 -67
  20. package/src/domain/i18n/fetcher.ts +268 -268
  21. package/src/domain/i18n/flags.ts +16 -16
  22. package/src/domain/i18n/format.ts +90 -90
  23. package/src/domain/i18n/index.ts +10 -10
  24. package/src/domain/i18n/middleware/i18n.ts +160 -160
  25. package/src/domain/i18n/resolve-locale.ts +55 -55
  26. package/src/domain/i18n/schema.ts +29 -29
  27. package/src/domain/index.ts +4 -4
  28. package/src/domain/seo/constants.ts +20 -20
  29. package/src/domain/seo/index.ts +3 -3
  30. package/src/domain/seo/services/index.ts +3 -3
  31. package/src/domain/seo/services/llms.ts +53 -53
  32. package/src/domain/seo/services/robots.ts +12 -12
  33. package/src/domain/seo/services/sitemap.ts +56 -56
  34. package/src/domain/seo/utils.ts +37 -37
  35. package/src/domain/theme/constants.ts +6 -6
  36. package/src/domain/theme/index.ts +2 -2
  37. package/src/domain/theme/schema.ts +10 -10
  38. package/src/env.d.ts +49 -49
  39. package/src/integrations/index.ts +1 -0
  40. package/src/integrations/sri.ts +92 -0
  41. package/src/lib/component-preview.ts +73 -73
  42. package/src/lib/index.ts +6 -6
  43. package/src/lib/remark/remark-modified-time.ts +13 -13
  44. package/src/lib/showcase-preview-elements.ts +408 -408
  45. package/src/lib/showcase-renderer.ts +218 -218
  46. package/src/lib/showcase-utils.ts +189 -189
  47. package/src/lib/turnstile.ts +35 -35
  48. package/src/lib/utils/starwind/positioning.ts +318 -318
  49. package/src/locales/de/blog.json +9 -9
  50. package/src/locales/de/common.json +19 -19
  51. package/src/locales/de/errors.json +6 -6
  52. package/src/locales/de/home.json +20 -20
  53. package/src/locales/de/messages.json +8 -8
  54. package/src/locales/de/nav.json +13 -13
  55. package/src/locales/de/projects.json +9 -9
  56. package/src/locales/de/services.json +43 -43
  57. package/src/locales/en/blog.json +9 -9
  58. package/src/locales/en/chat.json +29 -29
  59. package/src/locales/en/common.json +19 -19
  60. package/src/locales/en/cookie_notice.json +11 -11
  61. package/src/locales/en/editor.json +24 -24
  62. package/src/locales/en/errors.json +6 -6
  63. package/src/locales/en/home.json +20 -20
  64. package/src/locales/en/messages.json +8 -8
  65. package/src/locales/en/nav.json +13 -13
  66. package/src/locales/en/projects.json +9 -9
  67. package/src/locales/en/services.json +43 -43
  68. package/src/locales/es/blog.json +9 -9
  69. package/src/locales/es/common.json +19 -19
  70. package/src/locales/es/errors.json +6 -6
  71. package/src/locales/es/home.json +20 -20
  72. package/src/locales/es/messages.json +8 -8
  73. package/src/locales/es/nav.json +13 -13
  74. package/src/locales/es/projects.json +9 -9
  75. package/src/locales/es/services.json +43 -43
  76. package/src/locales/ja/blog.json +9 -9
  77. package/src/locales/ja/common.json +19 -19
  78. package/src/locales/ja/errors.json +6 -6
  79. package/src/locales/ja/home.json +20 -20
  80. package/src/locales/ja/messages.json +8 -8
  81. package/src/locales/ja/nav.json +13 -13
  82. package/src/locales/ja/projects.json +9 -9
  83. package/src/locales/ja/services.json +43 -43
  84. package/src/locales/pt/blog.json +9 -9
  85. package/src/locales/pt/chat.json +29 -29
  86. package/src/locales/pt/common.json +19 -19
  87. package/src/locales/pt/cookie_notice.json +11 -11
  88. package/src/locales/pt/editor.json +24 -24
  89. package/src/locales/pt/errors.json +6 -6
  90. package/src/locales/pt/home.json +20 -20
  91. package/src/locales/pt/messages.json +8 -8
  92. package/src/locales/pt/nav.json +13 -13
  93. package/src/locales/pt/projects.json +9 -9
  94. package/src/locales/pt/services.json +43 -43
  95. package/src/middleware/index.ts +1 -0
  96. package/src/middleware/sri.ts +76 -0
  97. package/src/utils/index.ts +2 -2
  98. package/src/utils/server/cookies.ts +98 -98
  99. package/src/utils/server/index.ts +1 -1
  100. package/src/utils/shared/deep-merge.ts +36 -36
  101. package/src/utils/shared/embed.ts +105 -105
  102. package/src/utils/shared/index.ts +3 -3
  103. package/src/utils/shared/parse-boolean.ts +14 -14
@@ -1,90 +1,90 @@
1
- // Sanitizer: Turns <script> into &lt;script&gt;
2
- function escapeHtml(unsafe: string | number): string {
3
- return String(unsafe)
4
- .replace(/&/g, "&amp;")
5
- .replace(/</g, "&lt;")
6
- .replace(/>/g, "&gt;")
7
- .replace(/"/g, "&quot;")
8
- .replace(/'/g, "&#039;")
9
- }
10
-
11
- type TemplateValues = Record<string, string | number>
12
-
13
- /**
14
- * Interpolates {placeholders} in a template string and escapes values. - If `template` is
15
- * undefined/null, returns an empty string (and logs in dev). - If `values` is undefined, returns
16
- * the template unchanged.
17
- */
18
- export function fmt(template: string | null | undefined, values?: TemplateValues): string {
19
- if (template == null) {
20
- if (!import.meta.env.PROD) {
21
- console.warn("[i18n] fmt() called with null/undefined template")
22
- }
23
- return ""
24
- }
25
-
26
- if (!values) return template
27
-
28
- return template.replace(/\{([^}]+)\}/g, (match, key) => {
29
- const replacement = values[key]
30
-
31
- if (replacement === undefined || replacement === null) {
32
- return match
33
- }
34
-
35
- return escapeHtml(replacement)
36
- })
37
- }
38
-
39
- /**
40
- * Simple two-form plural helper. Good for English-like languages where "1" vs "other" is enough.
41
- */
42
- export function plural(count: number, singular: string, pluralForm: string): string {
43
- return count === 1 ? singular : fmt(pluralForm, { count })
44
- }
45
-
46
- /**
47
- * ICU-style plural categories used by Intl.PluralRules.
48
- */
49
- export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
50
-
51
- export type PluralPatterns = Partial<Record<PluralCategory, string>>
52
-
53
- /**
54
- * Advanced plural helper using Intl.PluralRules + fmt().
55
- *
56
- * - `locale` controls plural categories (en, de, es, ja, ...).
57
- * - `patterns` comes from KV (e.g. { zero, one, other }).
58
- * - Missing categories fall back to `other`.
59
- *
60
- * Example: pluralIcu(5, 'en', { zero: 'No items', one: '{count} item', other: '{count} items' })
61
- */
62
- export function pluralIcu(count: number, locale: string, patterns: PluralPatterns): string {
63
- const rules = new Intl.PluralRules(locale)
64
- const cat = rules.select(count) as PluralCategory
65
-
66
- const template =
67
- patterns[cat] ??
68
- patterns.other ??
69
- (import.meta.env.PROD ? "" : (console.warn('[i18n] pluralIcu(): missing "other" pattern'), ""))
70
-
71
- return fmt(template, { count })
72
- }
73
-
74
- // Used in the LanguageSwitcher and SeoHreflangs components
75
- export function replaceLocaleInPath(
76
- currentPath: string,
77
- currentLocale: string,
78
- newLocale: string
79
- ): string {
80
- const prefixRegex = new RegExp(`^/${currentLocale}/`)
81
- return currentPath.replace(prefixRegex, `/${newLocale}/`)
82
- }
83
-
84
- // Used in the LanguageSwitcher component
85
- export function getLangFromUrl(pathname: string) {
86
- // Remove leading/trailing slashes: '/en/about/' -> 'en/about'
87
- const cleaned = pathname.replace(/^\/+|\/+$/g, "")
88
- const [lang] = cleaned.split("/")
89
- return lang
90
- }
1
+ // Sanitizer: Turns <script> into &lt;script&gt;
2
+ function escapeHtml(unsafe: string | number): string {
3
+ return String(unsafe)
4
+ .replace(/&/g, "&amp;")
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;")
7
+ .replace(/"/g, "&quot;")
8
+ .replace(/'/g, "&#039;")
9
+ }
10
+
11
+ type TemplateValues = Record<string, string | number>
12
+
13
+ /**
14
+ * Interpolates {placeholders} in a template string and escapes values. - If `template` is
15
+ * undefined/null, returns an empty string (and logs in dev). - If `values` is undefined, returns
16
+ * the template unchanged.
17
+ */
18
+ export function fmt(template: string | null | undefined, values?: TemplateValues): string {
19
+ if (template == null) {
20
+ if (!import.meta.env.PROD) {
21
+ console.warn("[i18n] fmt() called with null/undefined template")
22
+ }
23
+ return ""
24
+ }
25
+
26
+ if (!values) return template
27
+
28
+ return template.replace(/\{([^}]+)\}/g, (match, key) => {
29
+ const replacement = values[key]
30
+
31
+ if (replacement === undefined || replacement === null) {
32
+ return match
33
+ }
34
+
35
+ return escapeHtml(replacement)
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Simple two-form plural helper. Good for English-like languages where "1" vs "other" is enough.
41
+ */
42
+ export function plural(count: number, singular: string, pluralForm: string): string {
43
+ return count === 1 ? singular : fmt(pluralForm, { count })
44
+ }
45
+
46
+ /**
47
+ * ICU-style plural categories used by Intl.PluralRules.
48
+ */
49
+ export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other"
50
+
51
+ export type PluralPatterns = Partial<Record<PluralCategory, string>>
52
+
53
+ /**
54
+ * Advanced plural helper using Intl.PluralRules + fmt().
55
+ *
56
+ * - `locale` controls plural categories (en, de, es, ja, ...).
57
+ * - `patterns` comes from KV (e.g. { zero, one, other }).
58
+ * - Missing categories fall back to `other`.
59
+ *
60
+ * Example: pluralIcu(5, 'en', { zero: 'No items', one: '{count} item', other: '{count} items' })
61
+ */
62
+ export function pluralIcu(count: number, locale: string, patterns: PluralPatterns): string {
63
+ const rules = new Intl.PluralRules(locale)
64
+ const cat = rules.select(count) as PluralCategory
65
+
66
+ const template =
67
+ patterns[cat] ??
68
+ patterns.other ??
69
+ (import.meta.env.PROD ? "" : (console.warn('[i18n] pluralIcu(): missing "other" pattern'), ""))
70
+
71
+ return fmt(template, { count })
72
+ }
73
+
74
+ // Used in the LanguageSwitcher and SeoHreflangs components
75
+ export function replaceLocaleInPath(
76
+ currentPath: string,
77
+ currentLocale: string,
78
+ newLocale: string
79
+ ): string {
80
+ const prefixRegex = new RegExp(`^/${currentLocale}/`)
81
+ return currentPath.replace(prefixRegex, `/${newLocale}/`)
82
+ }
83
+
84
+ // Used in the LanguageSwitcher component
85
+ export function getLangFromUrl(pathname: string) {
86
+ // Remove leading/trailing slashes: '/en/about/' -> 'en/about'
87
+ const cleaned = pathname.replace(/^\/+|\/+$/g, "")
88
+ const [lang] = cleaned.split("/")
89
+ return lang
90
+ }
@@ -1,10 +1,10 @@
1
- export * from "./constants"
2
- export * from "./runtime-constants"
3
- export * from "./schema"
4
- export * from "./resolve-locale"
5
- export * from "./flags"
6
- export * from "./format"
7
- export * from "./cookie-storage"
8
- export * from "./country-to-locale-map"
9
- export * from "./middleware/i18n"
10
- export * from "./fetcher"
1
+ export * from "./constants"
2
+ export * from "./runtime-constants"
3
+ export * from "./schema"
4
+ export * from "./resolve-locale"
5
+ export * from "./flags"
6
+ export * from "./format"
7
+ export * from "./cookie-storage"
8
+ export * from "./country-to-locale-map"
9
+ export * from "./middleware/i18n"
10
+ export * from "./fetcher"
@@ -1,160 +1,160 @@
1
- import type { MiddlewareHandler } from "astro"
2
- import { LocaleSchema, type Locale } from "../schema"
3
- import { DEFAULT_LOCALE } from "../constants"
4
- import { getCookieLang, setCookieLang } from "../cookie-storage"
5
- import { mapCountryToLocale } from "../country-to-locale-map"
6
- import { resolveLocaleForTranslations } from "../resolve-locale"
7
-
8
- // Regex to identify public static assets (images, fonts, scripts, etc.)
9
- // These files should bypass i18n routing and locale detection.
10
- const PUBLIC_FILE_REGEX =
11
- /\.(?:ico|png|jpg|jpeg|svg|webp|gif|css|js|map|txt|xml|json|woff2?|avif)$/i
12
-
13
- // List of URL prefixes that should be ignored by the i18n middleware.
14
- // Includes API endpoints, internal Astro paths, and standard static routes.
15
- const IGNORED_PREFIXES = ["/api", "/assets", "/_astro", "/_image", "/_actions", "/favicon"]
16
-
17
- // Extracts the context type from Astro's MiddlewareHandler for cleaner function signatures.
18
- type I18nMiddlewareContext = Parameters<MiddlewareHandler>[0]
19
-
20
- // Determines if the current request path matches static assets or ignored prefixes.
21
- // Returns true to skip i18n processing for this request.
22
- function shouldBypassI18n(pathname: string): boolean {
23
- if (PUBLIC_FILE_REGEX.test(pathname)) return true
24
- return IGNORED_PREFIXES.some((p) => pathname.startsWith(p))
25
- }
26
-
27
- // Applies strict Content Security Policy (CSP) and other security headers to the response.
28
- // Wrap outgoing responses with this function to ensure consistent security posture.
29
- function applySecurityHeaders(response: Response): Response {
30
- // Tip: You can apply your security headers (CSP, HSTS, X-Frame-Options, etc.) here.
31
- // Check the GitHub repository for the example of implementation.
32
- return response
33
- }
34
-
35
- // Constructs a standardized localized URL path ensuring a trailing slash.
36
- // Example: buildLocalizedPath('en', ['about', 'team']) -> '/en/about/team/'
37
- function buildLocalizedPath(locale: Locale, rest: string[]): string {
38
- const suffix = rest.join("/")
39
- return suffix ? `/${locale}/${suffix}/` : `/${locale}/`
40
- }
41
-
42
- // Resolves the most appropriate locale for the user based on a fallback cascade:
43
- // 1. User's saved cookie preference.
44
- // 2. Browser's Accept-Language header (matching 2-char code).
45
- // 3. Cloudflare Geo-IP country mapping.
46
- // 4. Finally, falls back to the application's DEFAULT_LOCALE.
47
- function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
48
- const cookieLocale = getCookieLang(context.cookies)
49
- if (cookieLocale) return cookieLocale
50
-
51
- const browserRaw = context.preferredLocale
52
- if (browserRaw) {
53
- // Currently we support 2-chars locales only.
54
- let parsed = LocaleSchema.safeParse(browserRaw)
55
- if (parsed.success) return parsed.data
56
-
57
- // Fallback: language part only, e.g. 'pt-br' -> 'pt'
58
- const short = browserRaw.split("-")[0]
59
- parsed = LocaleSchema.safeParse(short)
60
- if (parsed.success) return parsed.data
61
- } else {
62
- // Cloudflare Geo-IP Strategy
63
- const country = context.request.cf?.country
64
- let parsed = LocaleSchema.safeParse(mapCountryToLocale(country ?? ""))
65
- if (parsed.success) return parsed.data
66
- }
67
-
68
- return DEFAULT_LOCALE
69
- }
70
-
71
- // Main i18n middleware handler. Intercepts incoming requests, manages routing,
72
- // sets locale context, and handles soft 404 protections for edge environments.
73
- export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
74
- const url = new URL(context.request.url)
75
- const pathname = url.pathname
76
-
77
- // 1. Split the URL early to access its segments
78
- const segments = pathname.split("/").filter(Boolean) // "/es/about/" -> ["es", "about"]
79
- const firstSegment = segments[0] ?? null
80
-
81
- // 2. SAFETY NET (For static file 404 pages)
82
- // Attempt to infer the locale from the URL, or fallback to the default.
83
- let safeLocale = DEFAULT_LOCALE
84
- if (firstSegment) {
85
- const parsed = LocaleSchema.safeParse(firstSegment)
86
- if (parsed.success) {
87
- safeLocale = parsed.data
88
- }
89
- }
90
-
91
- // Inject the locale into the context BEFORE the bypass check!
92
- context.locals.uiLocale = safeLocale
93
- context.locals.translationLocale = resolveLocaleForTranslations(safeLocale)
94
-
95
- // 3. Bypass static files and system paths (with Soft 404 protection)
96
- if (shouldBypassI18n(pathname)) {
97
- const response = await next()
98
-
99
- const contentType = response.headers.get("content-type") || ""
100
- const isHtml = contentType.includes("text/html")
101
- const isRedirect = response.status >= 300 && response.status < 400
102
-
103
- // If Astro attempts to return HTML for a static/API request (and it's not a redirect),
104
- // it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
105
- // We strictly intercept this and return a lightweight text-based 404 instead.
106
- // Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
107
- if (isHtml && !isRedirect) {
108
- // Aggressively save Worker CPU cycles for missing static files
109
- return new Response("Not found", {
110
- status: 404,
111
- headers: { "Content-Type": "text/plain; charset=utf-8" }
112
- })
113
- }
114
-
115
- return response
116
- }
117
-
118
- const fallbackLocale = resolveFallbackLocale(context)
119
-
120
- // 1. No locale in URL — redirect to fallback locale root
121
- if (!firstSegment) {
122
- const target = buildLocalizedPath(fallbackLocale, [])
123
- if (pathname !== target) {
124
- // Apply headers to the redirect response
125
- return applySecurityHeaders(context.redirect(target, 302))
126
- }
127
- return applySecurityHeaders(await next())
128
- }
129
-
130
- // 2. URL has first segment — validate as locale
131
- const parsed = LocaleSchema.safeParse(firstSegment)
132
- const urlLocale: Locale | null = parsed.success ? parsed.data : null
133
-
134
- if (urlLocale) {
135
- // Keep cookie in sync with URL locale
136
- setCookieLang(context.cookies, urlLocale)
137
- // Set uiLocale to Astro locals
138
- context.locals.uiLocale = urlLocale
139
- // Define and set translationLocale to Astro locals
140
- const translationLocale = resolveLocaleForTranslations(urlLocale)
141
- context.locals.translationLocale = translationLocale
142
-
143
- // Normalize trailing slash and structure
144
- const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
145
- if (pathname !== normalized) {
146
- return applySecurityHeaders(context.redirect(normalized, 302))
147
- }
148
-
149
- return applySecurityHeaders(await next())
150
- }
151
-
152
- // 3. Invalid locale in URL -> treat the whole path as content missing the locale prefix.
153
- const target = buildLocalizedPath(fallbackLocale, segments)
154
-
155
- if (pathname !== target) {
156
- return applySecurityHeaders(context.redirect(target, 302))
157
- }
158
-
159
- return applySecurityHeaders(await next())
160
- }
1
+ import type { MiddlewareHandler } from "astro"
2
+ import { LocaleSchema, type Locale } from "../schema"
3
+ import { DEFAULT_LOCALE } from "../constants"
4
+ import { getCookieLang, setCookieLang } from "../cookie-storage"
5
+ import { mapCountryToLocale } from "../country-to-locale-map"
6
+ import { resolveLocaleForTranslations } from "../resolve-locale"
7
+
8
+ // Regex to identify public static assets (images, fonts, scripts, etc.)
9
+ // These files should bypass i18n routing and locale detection.
10
+ const PUBLIC_FILE_REGEX =
11
+ /\.(?:ico|png|jpg|jpeg|svg|webp|gif|css|js|map|txt|xml|json|woff2?|avif)$/i
12
+
13
+ // List of URL prefixes that should be ignored by the i18n middleware.
14
+ // Includes API endpoints, internal Astro paths, and standard static routes.
15
+ const IGNORED_PREFIXES = ["/api", "/assets", "/_astro", "/_image", "/_actions", "/favicon"]
16
+
17
+ // Extracts the context type from Astro's MiddlewareHandler for cleaner function signatures.
18
+ type I18nMiddlewareContext = Parameters<MiddlewareHandler>[0]
19
+
20
+ // Determines if the current request path matches static assets or ignored prefixes.
21
+ // Returns true to skip i18n processing for this request.
22
+ function shouldBypassI18n(pathname: string): boolean {
23
+ if (PUBLIC_FILE_REGEX.test(pathname)) return true
24
+ return IGNORED_PREFIXES.some((p) => pathname.startsWith(p))
25
+ }
26
+
27
+ // Applies strict Content Security Policy (CSP) and other security headers to the response.
28
+ // Wrap outgoing responses with this function to ensure consistent security posture.
29
+ function applySecurityHeaders(response: Response): Response {
30
+ // Tip: You can apply your security headers (CSP, HSTS, X-Frame-Options, etc.) here.
31
+ // Check the GitHub repository for the example of implementation.
32
+ return response
33
+ }
34
+
35
+ // Constructs a standardized localized URL path ensuring a trailing slash.
36
+ // Example: buildLocalizedPath('en', ['about', 'team']) -> '/en/about/team/'
37
+ function buildLocalizedPath(locale: Locale, rest: string[]): string {
38
+ const suffix = rest.join("/")
39
+ return suffix ? `/${locale}/${suffix}/` : `/${locale}/`
40
+ }
41
+
42
+ // Resolves the most appropriate locale for the user based on a fallback cascade:
43
+ // 1. User's saved cookie preference.
44
+ // 2. Browser's Accept-Language header (matching 2-char code).
45
+ // 3. Cloudflare Geo-IP country mapping.
46
+ // 4. Finally, falls back to the application's DEFAULT_LOCALE.
47
+ function resolveFallbackLocale(context: I18nMiddlewareContext): Locale {
48
+ const cookieLocale = getCookieLang(context.cookies)
49
+ if (cookieLocale) return cookieLocale
50
+
51
+ const browserRaw = context.preferredLocale
52
+ if (browserRaw) {
53
+ // Currently we support 2-chars locales only.
54
+ let parsed = LocaleSchema.safeParse(browserRaw)
55
+ if (parsed.success) return parsed.data
56
+
57
+ // Fallback: language part only, e.g. 'pt-br' -> 'pt'
58
+ const short = browserRaw.split("-")[0]
59
+ parsed = LocaleSchema.safeParse(short)
60
+ if (parsed.success) return parsed.data
61
+ } else {
62
+ // Cloudflare Geo-IP Strategy
63
+ const country = context.request.cf?.country
64
+ let parsed = LocaleSchema.safeParse(mapCountryToLocale(country ?? ""))
65
+ if (parsed.success) return parsed.data
66
+ }
67
+
68
+ return DEFAULT_LOCALE
69
+ }
70
+
71
+ // Main i18n middleware handler. Intercepts incoming requests, manages routing,
72
+ // sets locale context, and handles soft 404 protections for edge environments.
73
+ export const i18nMiddleware: MiddlewareHandler = async (context, next) => {
74
+ const url = new URL(context.request.url)
75
+ const pathname = url.pathname
76
+
77
+ // 1. Split the URL early to access its segments
78
+ const segments = pathname.split("/").filter(Boolean) // "/es/about/" -> ["es", "about"]
79
+ const firstSegment = segments[0] ?? null
80
+
81
+ // 2. SAFETY NET (For static file 404 pages)
82
+ // Attempt to infer the locale from the URL, or fallback to the default.
83
+ let safeLocale = DEFAULT_LOCALE
84
+ if (firstSegment) {
85
+ const parsed = LocaleSchema.safeParse(firstSegment)
86
+ if (parsed.success) {
87
+ safeLocale = parsed.data
88
+ }
89
+ }
90
+
91
+ // Inject the locale into the context BEFORE the bypass check!
92
+ context.locals.uiLocale = safeLocale
93
+ context.locals.translationLocale = resolveLocaleForTranslations(safeLocale)
94
+
95
+ // 3. Bypass static files and system paths (with Soft 404 protection)
96
+ if (shouldBypassI18n(pathname)) {
97
+ const response = await next()
98
+
99
+ const contentType = response.headers.get("content-type") || ""
100
+ const isHtml = contentType.includes("text/html")
101
+ const isRedirect = response.status >= 300 && response.status < 400
102
+
103
+ // If Astro attempts to return HTML for a static/API request (and it's not a redirect),
104
+ // it means the file wasn't found and Astro rendered a fallback page (404.astro or catch-all).
105
+ // We strictly intercept this and return a lightweight text-based 404 instead.
106
+ // Why? To mitigate Directory Bruteforcing / Fuzzing attacks and save resources.
107
+ if (isHtml && !isRedirect) {
108
+ // Aggressively save Worker CPU cycles for missing static files
109
+ return new Response("Not found", {
110
+ status: 404,
111
+ headers: { "Content-Type": "text/plain; charset=utf-8" }
112
+ })
113
+ }
114
+
115
+ return response
116
+ }
117
+
118
+ const fallbackLocale = resolveFallbackLocale(context)
119
+
120
+ // 1. No locale in URL — redirect to fallback locale root
121
+ if (!firstSegment) {
122
+ const target = buildLocalizedPath(fallbackLocale, [])
123
+ if (pathname !== target) {
124
+ // Apply headers to the redirect response
125
+ return applySecurityHeaders(context.redirect(target, 302))
126
+ }
127
+ return applySecurityHeaders(await next())
128
+ }
129
+
130
+ // 2. URL has first segment — validate as locale
131
+ const parsed = LocaleSchema.safeParse(firstSegment)
132
+ const urlLocale: Locale | null = parsed.success ? parsed.data : null
133
+
134
+ if (urlLocale) {
135
+ // Keep cookie in sync with URL locale
136
+ setCookieLang(context.cookies, urlLocale)
137
+ // Set uiLocale to Astro locals
138
+ context.locals.uiLocale = urlLocale
139
+ // Define and set translationLocale to Astro locals
140
+ const translationLocale = resolveLocaleForTranslations(urlLocale)
141
+ context.locals.translationLocale = translationLocale
142
+
143
+ // Normalize trailing slash and structure
144
+ const normalized = buildLocalizedPath(urlLocale, segments.slice(1))
145
+ if (pathname !== normalized) {
146
+ return applySecurityHeaders(context.redirect(normalized, 302))
147
+ }
148
+
149
+ return applySecurityHeaders(await next())
150
+ }
151
+
152
+ // 3. Invalid locale in URL -> treat the whole path as content missing the locale prefix.
153
+ const target = buildLocalizedPath(fallbackLocale, segments)
154
+
155
+ if (pathname !== target) {
156
+ return applySecurityHeaders(context.redirect(target, 302))
157
+ }
158
+
159
+ return applySecurityHeaders(await next())
160
+ }
@@ -1,55 +1,55 @@
1
- import type { AstroGlobal } from "astro"
2
-
3
- import { LocaleSchema, type Locale } from "./schema"
4
- import { DEFAULT_LOCALE, ENABLE_MISSING_TRANSLATION_BANNER } from "./constants"
5
- import { LOCALES_WITH_TRANSLATIONS, FULLY_TRANSLATED_LOCALES } from "./runtime-constants"
6
-
7
- // Checking for the presence of at least one file with translations
8
- function hasTranslations(locale: Locale): boolean {
9
- return (LOCALES_WITH_TRANSLATIONS as readonly string[]).includes(locale)
10
- }
11
-
12
- // Checking the completeness of translations
13
- function isFullyTranslated(locale: Locale): boolean {
14
- return (FULLY_TRANSLATED_LOCALES as readonly string[]).includes(locale)
15
- }
16
-
17
- /**
18
- * Optional: return a “missing translation” banner message when uiLocale has no translations or
19
- * (blog) content is missing in required language.
20
- *
21
- * Returns: - MissingTranslationType: type of message to show ('ui' or 'content') - null: if banner
22
- * should not be shown
23
- */
24
- type MissingTranslationType = "ui" | "content" | null
25
-
26
- export function checkMissingTranslation(
27
- uiLocale: Locale,
28
- isMissingContent: boolean | undefined
29
- ): MissingTranslationType {
30
- if (!ENABLE_MISSING_TRANSLATION_BANNER) return null
31
-
32
- if (isFullyTranslated(uiLocale) && !isMissingContent) return null
33
-
34
- return isMissingContent ? "content" : "ui"
35
- }
36
-
37
- /**
38
- * UI / routing locale:
39
- *
40
- * - What user selected
41
- * - What is used for <html lang>, URL, etc.
42
- */
43
- export function resolveLocale(Astro: AstroGlobal): Locale {
44
- const raw = Astro.currentLocale ?? DEFAULT_LOCALE
45
- const parsed = LocaleSchema.safeParse(raw)
46
- return parsed.success ? parsed.data : DEFAULT_LOCALE
47
- }
48
-
49
- /**
50
- * Translation locale: - What we actually load from KV - Falls back if there is no bundle for the UI
51
- * locale
52
- */
53
- export function resolveLocaleForTranslations(locale: Locale): Locale {
54
- return hasTranslations(locale) ? locale : DEFAULT_LOCALE
55
- }
1
+ import type { AstroGlobal } from "astro"
2
+
3
+ import { LocaleSchema, type Locale } from "./schema"
4
+ import { DEFAULT_LOCALE, ENABLE_MISSING_TRANSLATION_BANNER } from "./constants"
5
+ import { LOCALES_WITH_TRANSLATIONS, FULLY_TRANSLATED_LOCALES } from "./runtime-constants"
6
+
7
+ // Checking for the presence of at least one file with translations
8
+ function hasTranslations(locale: Locale): boolean {
9
+ return (LOCALES_WITH_TRANSLATIONS as readonly string[]).includes(locale)
10
+ }
11
+
12
+ // Checking the completeness of translations
13
+ function isFullyTranslated(locale: Locale): boolean {
14
+ return (FULLY_TRANSLATED_LOCALES as readonly string[]).includes(locale)
15
+ }
16
+
17
+ /**
18
+ * Optional: return a “missing translation” banner message when uiLocale has no translations or
19
+ * (blog) content is missing in required language.
20
+ *
21
+ * Returns: - MissingTranslationType: type of message to show ('ui' or 'content') - null: if banner
22
+ * should not be shown
23
+ */
24
+ type MissingTranslationType = "ui" | "content" | null
25
+
26
+ export function checkMissingTranslation(
27
+ uiLocale: Locale,
28
+ isMissingContent: boolean | undefined
29
+ ): MissingTranslationType {
30
+ if (!ENABLE_MISSING_TRANSLATION_BANNER) return null
31
+
32
+ if (isFullyTranslated(uiLocale) && !isMissingContent) return null
33
+
34
+ return isMissingContent ? "content" : "ui"
35
+ }
36
+
37
+ /**
38
+ * UI / routing locale:
39
+ *
40
+ * - What user selected
41
+ * - What is used for <html lang>, URL, etc.
42
+ */
43
+ export function resolveLocale(Astro: AstroGlobal): Locale {
44
+ const raw = Astro.currentLocale ?? DEFAULT_LOCALE
45
+ const parsed = LocaleSchema.safeParse(raw)
46
+ return parsed.success ? parsed.data : DEFAULT_LOCALE
47
+ }
48
+
49
+ /**
50
+ * Translation locale: - What we actually load from KV - Falls back if there is no bundle for the UI
51
+ * locale
52
+ */
53
+ export function resolveLocaleForTranslations(locale: Locale): Locale {
54
+ return hasTranslations(locale) ? locale : DEFAULT_LOCALE
55
+ }