Skip to content

Commit fcbb342

Browse files
committed
Add Cloudflare AI runtime page translations
1 parent efc061c commit fcbb342

8 files changed

Lines changed: 599 additions & 47 deletions

File tree

astro.config.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ const CPU_COUNT = Number.isFinite(BUILD_CONCURRENCY) && BUILD_CONCURRENCY > 0 ?
2424
const SRC_DIR = `${fileURLToPath(new URL('./src/', import.meta.url))
2525
.replace(/\\/g, '/')
2626
.replace(/\/$/, '')}/`
27-
const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url)).replace(/\\/g, '/').replace(/\/$/, '')
27+
const PUBLIC_DIR = fileURLToPath(new URL('./public/', import.meta.url))
28+
.replace(/\\/g, '/')
29+
.replace(/\/$/, '')
2830

2931
// Build a map of page paths to their lastmod dates for sitemap
3032
function getPageLastModDates() {
@@ -81,7 +83,9 @@ export default defineConfig({
8183
trailingSlash: 'always',
8284
site: `https://${config.base_domain.prod}`,
8385
output: 'server',
84-
adapter: cloudflare(),
86+
adapter: cloudflare({
87+
remoteBindings: false,
88+
}),
8589
session: {
8690
driver: sessionDrivers.null(),
8791
},

src/components/Footer.astro

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,14 @@
11
---
22
import * as m from '@/paraglide/messages'
3-
import { locales } from '@/services/locale'
43
import { getRelativeLocaleUrl } from 'astro:i18n'
4+
import { prefixRuntimeLocale, runtimeLocaleFlags, runtimeTranslationLocales } from '@/services/runtimeTranslation'
55
66
const year = new Date().getFullYear()
7-
const showLanguageSelector = locales.length > 1
8-
9-
// Map locale codes to flag emojis
10-
const localeFlags: Record<string, string> = {
11-
de: '🇩🇪',
12-
en: '🇺🇸',
13-
es: '🇪🇸',
14-
fr: '🇫🇷',
15-
id: '🇮🇩',
16-
it: '🇮🇹',
17-
ja: '🇯🇵',
18-
ko: '🇰🇷',
19-
zh: '🇨🇳',
20-
}
21-
22-
const currentFlag = localeFlags[Astro.locals.locale] || '🇺🇸'
7+
const translationState = Astro.locals.translation
8+
const currentPageLocale = translationState?.requestedLocale || Astro.locals.locale
9+
const currentFlag = runtimeLocaleFlags[currentPageLocale] || runtimeLocaleFlags.en
10+
const selectorPath = translationState?.sourcePath || Astro.url.pathname
11+
const showLanguageSelector = Boolean(translationState?.showSelector)
2312
2413
interface NavigationItem {
2514
name: string | (() => string)
@@ -327,18 +316,19 @@ const navigation: Record<string, NavigationItem[]> = {
327316
class="flex items-center text-sm text-zinc-400 hover:text-white focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900"
328317
>
329318
<span class="mr-2">{currentFlag}</span>
330-
{Astro.locals.locale.toUpperCase()}
319+
{currentPageLocale.toUpperCase()}
331320
<span class="ml-1">▼</span>
332321
</button>
333322
<div
334323
id="language-dropdown"
335324
class="ring-opacity-5 absolute bottom-full left-0 z-50 mb-2 hidden w-32 rounded-md bg-[#1c1c1f] shadow-lg ring-1 ring-black focus:outline-none"
336325
>
337326
<div class="py-1">
338-
{locales.map((item) => (
327+
{runtimeTranslationLocales.map((item) => (
339328
<a
340-
href={getRelativeLocaleUrl(item.toLowerCase(), Astro.url.pathname.replace(`/${Astro.locals.locale}/`, '/'))}
329+
href={prefixRuntimeLocale(selectorPath, item)}
341330
class="block px-4 py-2 text-sm text-zinc-400 hover:bg-zinc-800 hover:text-white"
331+
aria-current={currentPageLocale === item ? 'true' : undefined}
342332
>
343333
{item.toUpperCase()}
344334
</a>

src/components/SEO.astro

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,40 @@ import { AstroFont } from 'astro-font'
55
import { join } from 'node:path'
66
import * as m from '@/paraglide/messages'
77
import { generateAlternateVersions } from '@/lib/alternateVersions'
8+
import { prefixRuntimeLocale } from '@/services/runtimeTranslation'
89
9-
const rawLocale = (Astro.locals.locale || 'en').toString().toLowerCase()
10+
const translationState = Astro.locals.translation
11+
const isRuntimeTranslated = Boolean(translationState?.isTranslated)
12+
const baseUrl = Astro.locals.runtimeConfig?.public?.baseUrl || Astro.url.origin
13+
const pageLocale = (translationState?.requestedLocale || Astro.locals.locale || 'en').toString().toLowerCase()
14+
const defaultCanonical = isRuntimeTranslated ? new URL(translationState?.sourcePath || Astro.url.pathname, baseUrl) : Astro.url
15+
const defaultUrl = isRuntimeTranslated ? new URL(prefixRuntimeLocale(translationState?.sourcePath || Astro.url.pathname, translationState!.requestedLocale), baseUrl) : Astro.url
16+
const defaultAlternateVersions = isRuntimeTranslated ? [] : generateAlternateVersions(Astro)
17+
const defaultRobots = isRuntimeTranslated ? 'noindex, follow' : 'index, follow'
18+
19+
const rawLocale = pageLocale
1020
// Paraglide message functions use a narrower locale union than the app router.
1121
// Cast once here to avoid repeating casts and keep runtime behavior unchanged.
1222
const messageLocale = Astro.locals.locale as any
1323
14-
1524
const {
1625
ldJSON = structuredData,
1726
title = m.website_title({}, { locale: messageLocale }),
1827
description = m.website_description({}, { locale: messageLocale }),
19-
url = Astro.url,
20-
image = `${Astro.locals.runtimeConfig?.public?.baseUrl || Astro.url.origin}/capgo_social.png`,
21-
canonical = Astro.url,
28+
url = defaultUrl,
29+
image = `${baseUrl}/capgo_social.png`,
30+
canonical = defaultCanonical,
2231
author = `Martin DONADIEU`,
2332
keywords = m.website_keywords({}, { locale: messageLocale }),
2433
audio = null,
25-
alternateVersions = generateAlternateVersions(Astro),
34+
alternateVersions = defaultAlternateVersions,
2635
// Article-specific props for blog posts
2736
ogType = 'website',
2837
articlePublishedTime = null,
2938
articleModifiedTime = null,
3039
articleSection = null,
3140
articleTags = null,
32-
robots = 'index, follow',
41+
robots = defaultRobots,
3342
} = Astro.props
3443
3544
const localeName_to_OGP = {
@@ -56,13 +65,11 @@ const toStringUrl = (value: unknown): string | null => {
5665
}
5766
5867
const titleString = typeof title === 'string' && title.trim().length > 0 ? title : m.website_title({}, { locale: messageLocale })
59-
const descriptionString =
60-
typeof description === 'string' && description.trim().length > 0 ? description : m.website_description({}, { locale: messageLocale })
68+
const descriptionString = typeof description === 'string' && description.trim().length > 0 ? description : m.website_description({}, { locale: messageLocale })
6169
6270
const urlString = toStringUrl(url) || Astro.url.toString()
6371
const canonicalString = toStringUrl(canonical) || urlString
6472
65-
const baseUrl = Astro.locals.runtimeConfig?.public?.baseUrl || Astro.url.origin
6673
const rawImage = typeof image === 'string' ? image : toStringUrl(image)
6774
const imageUrl = rawImage ? new URL(rawImage, baseUrl).toString() : new URL('/capgo_social.png', baseUrl).toString()
6875
@@ -87,7 +94,6 @@ if (displayDescription.length > descSize) displayDescription = `${displayDescrip
8794
const titleFix = titleString || (imageUrl.split('/').pop() || '.').split('.')[0]
8895
8996
const makeFontPath = (name: string) => join(process.cwd(), 'public', 'fonts', name)
90-
9197
---
9298

9399
<AstroFont

src/env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ declare namespace App {
55
interface Locals {
66
locale: import('./services/locale').Locales
77
runtimeConfig: import('./config/app').RuntimeConfig
8+
translation?: import('./services/runtimeTranslation').RuntimeTranslationState
89
translations: typeof import('./services/translations').default
910
}
1011
}

src/layouts/Layout.astro

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,24 @@ import globalStylesHref from '../css/global.css?url'
88
const content = Astro.props.content ?? {}
99
1010
const isLocalhost = Astro.url.origin.includes('localhost:')
11+
const pageLocale = Astro.locals.translation?.requestedLocale || Astro.locals.locale
1112
---
1213

1314
<!doctype html>
14-
<html lang={Astro.locals.locale.toLowerCase()}>
15+
<html lang={pageLocale.toLowerCase()}>
1516
<head>
1617
<link rel="stylesheet" href={globalStylesHref} />
1718
<SEO {...content} />
1819
<PostHog />
1920
</head>
2021
<body>
21-
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400">
22+
<a
23+
href="#main-content"
24+
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded-md focus:bg-blue-600 focus:px-4 focus:py-2 focus:text-white focus:ring-2 focus:ring-blue-400 focus:outline-none"
25+
>
2226
Skip to main content
2327
</a>
24-
<div class="overflow-x-hidden text-white bg-gray-900">
28+
<div class="overflow-x-hidden bg-gray-900 text-white">
2529
<Header />
2630
<main id="main-content">
2731
<slot />

src/middleware.ts

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,102 @@ import { defineMiddleware } from 'astro:middleware'
22
import { useRuntimeConfig } from './config/app'
33
import { paraglideMiddleware } from './paraglide/server.js'
44
import { defaultLocale, type Locales } from './services/locale'
5+
import {
6+
isRuntimeTranslationEligiblePath,
7+
runtimeTranslationLocales,
8+
stripRuntimeLocalePrefix,
9+
translateHtmlResponseWithCloudflareAI,
10+
type RuntimeTranslationLocale,
11+
} from './services/runtimeTranslation'
12+
13+
interface RuntimeEnv {
14+
AI?: {
15+
run(model: string, payload: Record<string, unknown>): Promise<unknown>
16+
}
17+
}
18+
19+
const buildTranslationState = (
20+
locale: RuntimeTranslationLocale,
21+
options: {
22+
enabled: boolean
23+
isTranslated: boolean
24+
showSelector: boolean
25+
sourcePath: string
26+
},
27+
) => ({
28+
availableLocales: runtimeTranslationLocales,
29+
enabled: options.enabled,
30+
isTranslated: options.isTranslated,
31+
requestedLocale: locale,
32+
showSelector: options.showSelector,
33+
sourcePath: options.sourcePath,
34+
})
35+
36+
export const onRequest = defineMiddleware(async (context, next) => {
37+
const runtimeConfig = useRuntimeConfig()
38+
const { locale: requestedRuntimeLocale, pathname: sourcePath } = stripRuntimeLocalePrefix(context.url.pathname)
39+
const runtimeEnv = ((context.locals as { runtime?: { env?: RuntimeEnv } }).runtime?.env || {}) as RuntimeEnv
40+
const isLocalRequest = ['127.0.0.1', 'localhost'].includes(context.url.hostname)
41+
const translationEnabled = Boolean(runtimeEnv.AI) && !isLocalRequest
42+
const eligiblePath = isRuntimeTranslationEligiblePath(sourcePath)
43+
const showSelector = translationEnabled && eligiblePath
44+
const shouldTranslate = Boolean(requestedRuntimeLocale) && requestedRuntimeLocale !== defaultLocale && translationEnabled && eligiblePath
45+
const localeForUi: RuntimeTranslationLocale = shouldTranslate && requestedRuntimeLocale ? requestedRuntimeLocale : defaultLocale
46+
const search = context.url.search || ''
547

6-
export const onRequest = defineMiddleware((context, next) => {
748
// When Astro pre-renders during `astro build`, there is no real request.
849
// Skip the Paraglide middleware so we don't touch unavailable request headers.
950
// Use context.isPrerendered which is the reliable way to detect prerendering
1051
if (context.isPrerendered) {
11-
context.locals.locale = (context.currentLocale || defaultLocale) as Locales
12-
context.locals.runtimeConfig = useRuntimeConfig()
52+
context.locals.locale = defaultLocale as Locales
53+
context.locals.runtimeConfig = runtimeConfig
54+
context.locals.translation = buildTranslationState(defaultLocale, {
55+
enabled: false,
56+
isTranslated: false,
57+
showSelector: false,
58+
sourcePath,
59+
})
1360
return next()
1461
}
1562

16-
return paraglideMiddleware(context.request, async () => {
17-
context.locals.locale = (context.currentLocale || defaultLocale) as Locales
18-
context.locals.runtimeConfig = useRuntimeConfig()
19-
return await next()
63+
if (requestedRuntimeLocale === defaultLocale) {
64+
return Response.redirect(new URL(`${sourcePath}${search}`, context.url), 302)
65+
}
66+
67+
if (requestedRuntimeLocale && (!translationEnabled || !eligiblePath)) {
68+
return Response.redirect(new URL(`${sourcePath}${search}`, context.url), 302)
69+
}
70+
71+
const requestForRender = shouldTranslate ? new Request(new URL(`${sourcePath}${search}`, context.url), context.request) : context.request
72+
73+
return paraglideMiddleware(requestForRender, async () => {
74+
context.locals.locale = defaultLocale as Locales
75+
context.locals.runtimeConfig = runtimeConfig
76+
context.locals.translation = buildTranslationState(localeForUi, {
77+
enabled: translationEnabled,
78+
isTranslated: shouldTranslate,
79+
showSelector,
80+
sourcePath,
81+
})
82+
83+
const renderedResponse = shouldTranslate ? await next(requestForRender) : await next()
84+
if (!shouldTranslate || !runtimeEnv.AI) {
85+
return renderedResponse
86+
}
87+
88+
const fallbackResponse = renderedResponse.clone()
89+
90+
try {
91+
return await translateHtmlResponseWithCloudflareAI({
92+
ai: runtimeEnv.AI,
93+
locale: requestedRuntimeLocale as RuntimeTranslationLocale,
94+
requestUrl: context.url,
95+
response: renderedResponse,
96+
sourcePath,
97+
})
98+
} catch (error) {
99+
console.error(`Runtime translation failed for ${context.request.url}`, error)
100+
return fallbackResponse
101+
}
20102
})
21103
})

0 commit comments

Comments
 (0)