diff --git a/cms/package.json b/cms/package.json
index 86aebf40..eb61ad62 100644
--- a/cms/package.json
+++ b/cms/package.json
@@ -7,7 +7,9 @@
"develop": "strapi develop",
"start": "strapi start",
"build": "strapi build",
- "strapi": "strapi"
+ "strapi": "strapi",
+ "translations:export": "tsx scripts/export-translations.ts",
+ "translations:import": "tsx scripts/import-translations.ts"
},
"strapi": {
"uuid": "d5c8f4e2-9a1b-4c3d-8e7f-6a5b4c3d2e1f"
@@ -17,14 +19,19 @@
"@ckeditor/strapi-plugin-ckeditor": "^1.1.1",
"@strapi/strapi": "5.31.3",
"better-sqlite3": "11.5.0",
+ "dotenv": "^16.4.5",
"esbuild": "0.25.11",
+ "node-html-markdown": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2",
"styled-components": "^6.1.19"
},
"devDependencies": {
+ "@types/dotenv": "^6.1.1",
+ "@types/markdown-it": "^14.1.2",
"@types/node": "^22.10.1",
+ "tsx": "^4.21.0",
"typescript": "^5.7.2"
},
"engines": {
diff --git a/cms/scripts/README.md b/cms/scripts/README.md
new file mode 100644
index 00000000..b0e0f2dd
--- /dev/null
+++ b/cms/scripts/README.md
@@ -0,0 +1,101 @@
+# Translation Scripts
+
+This directory contains TypeScript scripts for managing blog post translations through a file-based workflow.
+
+**Note:** The `exports/translations/` folder is `.gitignore`'d because it contains temporary work files for translation agencies. The final generated MDX files in `src/content/blog/` ARE committed.
+
+## Testing Translation Flow
+
+### Prerequisites
+
+1. **Start Strapi Server**:
+
+ ```bash
+ cd cms
+ npm run develop
+ ```
+
+2. **Generate an API Token**:
+ - Go to http://localhost:1337/admin/settings/api-tokens
+ - Click "Create new API Token"
+ - Name: "Translation Scripts"
+ - Token type: Full access (or configure specific permissions for blog-post)
+ - Copy the generated token
+ - Add to `cms/.env`:
+ ```
+ STRAPI_API_TOKEN=your_token_here
+ STRAPI_URL=http://localhost:1337
+ STRAPI_UPLOADS_BASE_URL=http://localhost:1337
+ ```
+
+### Test Flow
+
+#### Step 1: Export a Blog Post
+
+```bash
+cd cms
+npm run translations:export
+```
+
+This creates MDX files in `cms/exports/translations/` with the format:
+
+- `YYYY-MM-DD-slug.mdx` (English posts)
+
+**Note**: The export script automatically converts all image paths (both featured and body images) from `/uploads/xxx.jpg` to full URLs like `http://localhost:1337/uploads/xxx.jpg`. This ensures images work correctly when importing back.
+
+#### Step 2: Create a Translated Version
+
+Translate the content (title, description, body) while preserving the image URLs.
+
+#### Step 3: Import the Translation
+
+```bash
+npm run translations:import
+```
+
+The script will:
+
+- Create a new blog post entry in Strapi
+- Set the `lang` field to "es"
+- Use image URLs as-is
+- Generate a unique slug by appending language code (e.g., `my-post-es`)
+- Publish the post
+
+After the import completes, the lifecycle hook will automatically generate the final MDX file in `src/content/blog/`:
+
+- `YYYY-MM-DD-slug-es.es.mdx` (Spanish version)
+
+#### Step 4: Verify the Generated File
+
+Check that the translated MDX file was created with both featured image and body images:
+
+```bash
+cat src/content/blog/2025-01-22-my-post-es.es.mdx
+```
+
+**What to check in Strapi Admin:**
+
+- Go to **Blog Posts** → Click on your imported post
+- The featured image should appear in the **Featured Image** field
+- Scroll to the **Content** editor - body images should render correctly
+- If images don't appear, try clicking the "Refresh" button or re-saving the post
+
+## How Images Work
+
+### Featured Image
+
+- **Export**: Converts `/uploads/image.jpg` to `http://localhost:1337/uploads/image.jpg`
+- **Import**: Uses the full URL directly (no re-upload needed)
+- **Result**: Featured image shows in Strapi admin and in the blog post
+
+## Scripts
+
+- `export-translations.ts` - Export all published blog posts to MDX with full image URLs
+- `import-translations.ts` - Import translated MDX files to Strapi
+
+## Filename Format
+
+- **Original**: `YYYY-MM-DD-slug.mdx`
+- **Translated**: `YYYY-MM-DD-slug.{lang}.mdx` (e.g., `post.es.mdx`, `post.fr.mdx`)
+
+**Note**: The import script appends language code to slug to ensure uniqueness (e.g., `slug-es`), so the generated MDX filename will be `YYYY-MM-DD-slug-es.{lang}.mdx`. This is intentional to maintain slug uniqueness in Strapi.
\ No newline at end of file
diff --git a/cms/scripts/export-translations.ts b/cms/scripts/export-translations.ts
new file mode 100644
index 00000000..9d2286e0
--- /dev/null
+++ b/cms/scripts/export-translations.ts
@@ -0,0 +1,369 @@
+import fs from 'fs'
+import path from 'path'
+import dotenv from 'dotenv'
+import { htmlToMarkdown } from '../src/utils/contentUtils.js'
+
+dotenv.config({ path: path.join(__dirname, '../.env') })
+
+const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'
+const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN
+
+if (!STRAPI_API_TOKEN) {
+ console.error('❌ STRAPI_API_TOKEN is required. Set it in .env')
+ process.exit(1)
+}
+
+const EXPORTS_DIR = path.join(__dirname, '../exports/translations')
+
+// CLI Argument Parsing
+const args = process.argv.slice(2)
+const options = {
+ limit: parseInt(getArgValue('--limit') || '0'),
+ since: getArgValue('--since'),
+ ids: getArgValue('--ids')?.split(',').map(Number),
+ slugs: getArgValue('--slugs')?.split(','),
+ force: args.includes('--force'),
+ help: args.includes('--help') || args.includes('-h')
+}
+
+function getArgValue(name: string): string | undefined {
+ const index = args.indexOf(name)
+ if (index !== -1 && index + 1 < args.length) {
+ return args[index + 1]
+ }
+ return undefined
+}
+
+if (options.help) {
+ console.log(`
+Usage: tsx scripts/export-translations.ts [options]
+
+Options:
+ --limit Process only the first N posts
+ --since Process posts published after this date
+ --ids Process only specific post IDs
+ --slugs Process only specific slugs
+ --force Export all locales even if translation exists (uses translated content)
+ -h, --help Show this help message
+ `)
+ process.exit(0)
+}
+
+function escapeQuotes(value: string): string {
+ return value.replace(/"/g, '\\"')
+}
+
+function formatDate(dateString: string): string {
+ if (!dateString) return ''
+ const date = new Date(dateString)
+ if (isNaN(date.getTime())) return dateString
+ return date.toISOString().split('T')[0]
+}
+
+interface BlogPost {
+ id: number
+ title: string
+ description: string
+ slug: string
+ date: string
+ content?: string
+ featuredImage?: MediaFile
+ ogImageUrl?: string
+ lang?: string
+ linked_translations?: BlogPost[]
+}
+
+interface MediaFile {
+ id: number
+ url: string
+ alternativeText?: string
+ name?: string
+}
+
+interface StrapiResponse {
+ data?: BlogPost[]
+}
+
+interface Locale {
+ id: number
+ name: string
+ code: string
+ isDefault: boolean
+}
+
+interface LocalesResponse {
+ data?: Locale[]
+}
+
+interface Translations {
+ [key: string]: string | boolean
+}
+
+function generateMDX(post: BlogPost, locale?: string, translations?: Translations, isTranslated?: boolean): string {
+ const imageUrl = post.featuredImage?.url
+
+ const frontmatterLines = [
+ `title: "${escapeQuotes(post.title)}"`,
+ `description: "${escapeQuotes(post.description)}"`,
+ post.ogImageUrl
+ ? `ogImageUrl: "${escapeQuotes(post.ogImageUrl)}"`
+ : undefined,
+ `date: ${formatDate(post.date)}`,
+ `slug: ${post.slug}`,
+ imageUrl ? `image: "${escapeQuotes(imageUrl)}"` : undefined,
+ locale ? `lang: "${escapeQuotes(locale)}"` : undefined,
+ locale
+ ? `uniqueSlug: ${locale !== 'en' ? `${locale}-${post.slug}` : post.slug}`
+ : undefined,
+ isTranslated !== undefined ? `isTranslated: ${isTranslated}` : undefined
+ ].filter(Boolean) as string[]
+
+ if (translations) {
+ frontmatterLines.push('translations:')
+ Object.entries(translations).forEach(([key, value]) => {
+ if (typeof value === 'boolean') {
+ frontmatterLines.push(` ${key}: ${value}`)
+ } else {
+ frontmatterLines.push(` ${key}: "${value}"`)
+ }
+ })
+ }
+
+ const frontmatter = frontmatterLines.join('\n')
+ const content = post.content || ''
+
+ return `---\n${frontmatter}\n---\n\n${content}\n`
+}
+
+function generateFilename(post: BlogPost, locale?: string): string {
+ const date = formatDate(post.date)
+ const prefix = date ? `${date}-` : ''
+ const langSuffix = locale ? `.${locale}` : ''
+ return `${prefix}${post.slug}${langSuffix}.mdx`
+}
+
+async function fetchLocales(): Promise {
+ try {
+ const response = await fetch(`${STRAPI_URL}/api/i18n/locales`, {
+ headers: {
+ Authorization: `Bearer ${STRAPI_API_TOKEN}`
+ }
+ })
+
+ if (!response.ok) {
+ console.warn('⚠️ Could not fetch locales from Strapi, using default set')
+ return ['es', 'zh', 'de', 'fr']
+ }
+
+ const data = await response.json()
+ console.log('Locales API response:', data)
+ let localeCodes: string[] = []
+ if (Array.isArray(data)) {
+ localeCodes = data
+ .map((locale: Locale) => locale.code)
+ .filter((code) => code !== 'en')
+ } else {
+ const responseData = data as LocalesResponse
+ localeCodes =
+ responseData.data
+ ?.map((locale) => locale.code)
+ .filter((code) => code !== 'en') || []
+ }
+ return localeCodes.length > 0 ? localeCodes : ['es', 'zh', 'de', 'fr']
+ } catch (error) {
+ console.warn(
+ '⚠️ Error fetching locales, using default set:',
+ (error as Error).message
+ )
+ return ['es', 'zh', 'de', 'fr']
+ }
+}
+
+async function fetchBlogPosts(filters: string[] = []): Promise {
+ try {
+ const queryParams = [
+ 'populate[0]=featuredImage',
+ 'populate[1]=linked_translations',
+ 'populate[2]=linked_translations.featuredImage',
+ 'filters[publishedAt][$notNull]=true',
+ // Only export English posts as source
+ 'filters[$or][0][lang][$eq]=en',
+ 'filters[$or][1][lang][$null]=true',
+ ...filters
+ ]
+
+ const response = await fetch(
+ `${STRAPI_URL}/api/blog-posts?${queryParams.join('&')}`,
+ {
+ headers: {
+ Authorization: `Bearer ${STRAPI_API_TOKEN}`
+ }
+ }
+ )
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch blog posts: ${response.statusText}`)
+ }
+
+ const data = (await response.json()) as StrapiResponse
+ return data.data || []
+ } catch (error) {
+ console.error('❌ Error fetching blog posts:', (error as Error).message)
+ return []
+ }
+}
+
+async function exportTranslations(): Promise {
+ console.log('🚀 Starting translation export...')
+
+ const locales = await fetchLocales()
+ console.log(
+ `📍 Found ${locales.length} locales for translation: ${locales.join(', ')}`
+ )
+
+ const apiFilters: string[] = []
+ if (options.since) {
+ apiFilters.push(`filters[publishedAt][$gte]=${options.since}`)
+ }
+ if (options.ids) {
+ options.ids.forEach((id, index) => {
+ apiFilters.push(`filters[id][$in][${index}]=${id}`)
+ })
+ }
+ if (options.slugs) {
+ options.slugs.forEach((slug, index) => {
+ apiFilters.push(`filters[slug][$in][${index}]=${slug}`)
+ })
+ }
+
+ let posts = await fetchBlogPosts(apiFilters)
+
+ if (options.limit > 0) {
+ posts = posts.slice(0, options.limit)
+ console.log(`🔢 Limited to ${options.limit} posts`)
+ }
+
+ if (posts.length === 0) {
+ console.log('ℹ️ No published blog posts found to export')
+ return
+ }
+
+ if (!fs.existsSync(EXPORTS_DIR)) {
+ fs.mkdirSync(EXPORTS_DIR, { recursive: true })
+ }
+
+ const failedExports: string[] = []
+ let totalExported = 0
+ let totalSkipped = 0
+
+ for (const post of posts) {
+ try {
+ // Build translations map
+ const translations: Translations = {
+ en: post.slug
+ }
+
+ // Check which locales already have translations in Strapi
+ const existingLocales = (post.linked_translations || [])
+ .map((t) => t.lang)
+ .filter(Boolean) as string[]
+
+ let localesToExport: string[]
+ if (options.force) {
+ localesToExport = locales
+ } else {
+ localesToExport = locales.filter((l) => !existingLocales.includes(l))
+ }
+
+ if (localesToExport.length === 0) {
+ console.log(
+ `⏭️ Skipping "${post.title}": Translations already exist for all locales (use --force to override)`
+ )
+ totalSkipped++
+ continue
+ }
+
+ locales.forEach((locale) => {
+ // e.g., 'es-my-post-title'
+ translations[locale] = `${locale}-${post.slug}`
+ })
+
+ // Export English version as reference
+ const filename = generateFilename(post)
+ const filepath = path.join(EXPORTS_DIR, filename)
+ const mdxContent = generateMDX(post, undefined, translations)
+
+ fs.writeFileSync(filepath, mdxContent, 'utf-8')
+ console.log(`✅ Exported reference (EN): ${filename}`)
+ totalExported++
+
+ // Export missing or forced localized versions for translation
+ for (const locale of localesToExport) {
+ const existingTranslation = (post.linked_translations || []).find(
+ (t) => t.lang === locale
+ )
+
+ const localizedFilename = generateFilename(post, locale)
+ const localizedFilepath = path.join(EXPORTS_DIR, localizedFilename)
+
+ let localizedMdxContent: string
+ if (existingTranslation && options.force) {
+ // Use the actual translation data from Strapi
+ const translationData: BlogPost = {
+ ...existingTranslation,
+ date: existingTranslation.date || post.date // Fallback to parent date
+ }
+ // Use isTranslated: true because this reflects existing state
+ localizedMdxContent = generateMDX(
+ translationData,
+ locale,
+ translations,
+ true
+ )
+ console.log(
+ `✅ Exported existing translation (${locale}): ${localizedFilename}`
+ )
+ } else {
+ // Use English source as template
+ localizedMdxContent = generateMDX(
+ post,
+ locale,
+ translations,
+ false
+ )
+ console.log(
+ `✅ Exported for translation (${locale}): ${localizedFilename}`
+ )
+ }
+
+ fs.writeFileSync(localizedFilepath, localizedMdxContent, 'utf-8')
+ totalExported++
+ }
+
+ if (!options.force && localesToExport.length < locales.length) {
+ const skipped = locales.filter((l) => !localesToExport.includes(l))
+ console.log(` (Skipped ${skipped.join(', ')} - already exists)`)
+ }
+ } catch (error) {
+ console.error(
+ `❌ Failed to export post "${post.title}":`,
+ (error as Error).message
+ )
+ failedExports.push(post.title)
+ }
+ }
+
+ console.log(
+ `\n✨ Export complete! ${totalExported} files generated. ${totalSkipped} posts fully skipped.`
+ )
+
+ if (failedExports.length > 0) {
+ console.log(`\n⚠️ Failed exports (${failedExports.length}):`)
+ failedExports.forEach((title) => console.log(` - ${title}`))
+ }
+}
+
+exportTranslations().catch((error) => {
+ console.error('❌ Unhandled error during export:', error)
+ process.exit(1)
+})
diff --git a/cms/scripts/import-translations.ts b/cms/scripts/import-translations.ts
new file mode 100644
index 00000000..1b3ebc07
--- /dev/null
+++ b/cms/scripts/import-translations.ts
@@ -0,0 +1,480 @@
+import fs from 'fs'
+import path from 'path'
+import dotenv from 'dotenv'
+import { markdownToHtml } from '../src/utils/contentUtils.js'
+
+dotenv.config({ path: path.join(__dirname, '../.env') })
+
+const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'
+const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN
+
+if (!STRAPI_API_TOKEN) {
+ console.error('❌ STRAPI_API_TOKEN is required. Set it in .env')
+ process.exit(1)
+}
+
+const IMPORTS_DIR = path.join(__dirname, '../exports/translations')
+
+// CLI Argument Parsing
+const args = process.argv.slice(2)
+const options = {
+ force: args.includes('--force'),
+ help: args.includes('--help') || args.includes('-h')
+}
+
+if (options.help) {
+ console.log(`
+Usage: tsx scripts/import-translations.ts [options]
+
+Options:
+ --force Overwrite existing entries in Strapi
+ -h, --help Show this help message
+ `)
+ process.exit(0)
+}
+
+interface Frontmatter {
+ title?: string
+ description?: string
+ date?: string
+ slug?: string
+ image?: string
+ ogImageUrl?: string
+ [key: string]: string | undefined
+}
+
+interface ParsedMDX {
+ frontmatter: Frontmatter
+ body: string
+}
+
+interface StrapiBlogPost {
+ title: string
+ description: string
+ slug: string
+ date: string
+ content: string
+ lang: string
+ publishedAt: string
+ ogImageUrl?: string
+ featuredImage?: { id: number }
+ linked_translations?: any
+}
+
+interface FullPostAttributes {
+ title?: string
+ description?: string
+ date?: string
+ slug?: string
+ image?: string
+ ogImageUrl?: string
+ lang?: string
+ featuredImage?: {
+ data?: {
+ id: number
+ }
+ }
+}
+
+interface StrapiResponse {
+ data?: Array<{
+ id: number
+ documentId: string
+ attributes: Frontmatter
+ }>
+}
+
+interface FullStrapiResponse {
+ data?: Array<{
+ id: number
+ documentId: string
+ slug: string
+ lang?: string
+ ogImageUrl?: string
+ featuredImage?: {
+ id: number
+ url: string
+ }
+ }>
+}
+
+interface ImportError {
+ file: string
+ error: string
+}
+
+interface SkipInfo {
+ file: string
+ reason: string
+}
+
+function parseFrontmatter(content: string): ParsedMDX | null {
+ const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/
+ const match = content.match(frontmatterRegex)
+
+ if (!match) {
+ return null
+ }
+
+ const [, frontmatterStr, body] = match
+ const frontmatter: Frontmatter = {}
+
+ frontmatterStr.split('\n').forEach((line) => {
+ const [key, ...valueParts] = line.split(':')
+ if (key && valueParts.length > 0) {
+ let value = valueParts.join(':').trim()
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1)
+ }
+ frontmatter[key.trim()] = value
+ }
+ })
+
+ return {
+ frontmatter,
+ body
+ }
+}
+
+function extractLangFromFilename(filename: string): string | null {
+ const match = filename.match(/\.([a-z]{2}(-[A-Z]{2})?)\.mdx$/)
+ return match ? match[1] : null
+}
+
+function extractSlugAndDate(filename: string): {
+ date: string | null
+ slug: string
+} {
+ const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})-(.*)/)
+ if (dateMatch) {
+ return {
+ date: dateMatch[1],
+ slug: dateMatch[2]
+ .replace(/\.([a-z]{2}(-[A-Z]{2})?)\.mdx$/, '')
+ .replace(/\.mdx$/, '')
+ }
+ }
+ const slugMatch = filename.match(/^(.*)/)
+ return {
+ date: null,
+ slug: slugMatch
+ ? slugMatch[1]
+ .replace(/\.([a-z]{2}(-[A-Z]{2})?)\.mdx$/, '')
+ .replace(/\.mdx$/, '')
+ : filename
+ }
+}
+
+async function checkExistingEntry(
+ slug: string,
+ lang: string
+): Promise<{ id: number; documentId: string } | null> {
+ try {
+ const filters = [`filters[slug][$eq]=${slug}`]
+ // If checking for EN, also check for null lang as Strapi might have it as null
+ if (lang === 'en') {
+ filters.push(
+ `filters[$or][0][lang][$eq]=en&filters[$or][1][lang][$null]=true`
+ )
+ } else {
+ filters.push(`filters[lang][$eq]=${lang}`)
+ }
+
+ const response = await fetch(
+ `${STRAPI_URL}/api/blog-posts?${filters.join('&')}`,
+ {
+ headers: {
+ Authorization: `Bearer ${STRAPI_API_TOKEN}`
+ }
+ }
+ )
+
+ if (!response.ok) {
+ return null
+ }
+
+ const data = (await response.json()) as StrapiResponse
+ return data.data && data.data.length > 0
+ ? { id: data.data[0].id, documentId: data.data[0].documentId }
+ : null
+ } catch (error) {
+ console.error('❌ Error checking existing entry:', (error as Error).message)
+ return null
+ }
+}
+
+async function getEnglishPostImage(englishSlug: string): Promise<{
+ id: number
+ documentId: string
+ featuredImage?: number
+ ogImageUrl?: string
+ imageUrl?: string
+} | null> {
+ try {
+ const response = await fetch(
+ `${STRAPI_URL}/api/blog-posts?filters[slug][$eq]=${englishSlug}&populate=featuredImage`,
+ {
+ headers: {
+ Authorization: `Bearer ${STRAPI_API_TOKEN}`
+ }
+ }
+ )
+
+ if (!response.ok) {
+ return null
+ }
+
+ const data = (await response.json()) as FullStrapiResponse
+ if (data.data && data.data.length > 0) {
+ const post = data.data[0]
+ return {
+ id: post.id,
+ documentId: post.documentId,
+ featuredImage: post.featuredImage?.id,
+ ogImageUrl: post.ogImageUrl,
+ imageUrl: post.featuredImage?.url
+ }
+ }
+ return null
+ } catch (error) {
+ console.error(
+ '❌ Error fetching English post image:',
+ (error as Error).message
+ )
+ return null
+ }
+}
+
+async function createBlogPost(postData: StrapiBlogPost): Promise {
+ try {
+ const response = await fetch(`${STRAPI_URL}/api/blog-posts`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${STRAPI_API_TOKEN}`
+ },
+ body: JSON.stringify({
+ data: postData
+ })
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(
+ `Failed to create blog post: ${response.statusText} - ${errorText}`
+ )
+ }
+
+ const data = (await response.json()) as { data: { documentId: string } }
+ return data.data.documentId
+ } catch (error) {
+ console.error('❌ Error creating blog post:', (error as Error).message)
+ throw error
+ }
+}
+
+async function updateBlogPost(documentId: string, data: Partial): Promise {
+ try {
+ const response = await fetch(`${STRAPI_URL}/api/blog-posts/${documentId}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${STRAPI_API_TOKEN}`
+ },
+ body: JSON.stringify({
+ data
+ })
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(
+ `Failed to update blog post: ${response.statusText} - ${errorText}`
+ )
+ }
+ } catch (error) {
+ console.error('❌ Error updating blog post:', (error as Error).message)
+ throw error
+ }
+}
+
+async function importTranslations(): Promise {
+ console.log('🚀 Starting translation import...')
+
+ if (!fs.existsSync(IMPORTS_DIR)) {
+ console.log('ℹ️ No translations directory found at', IMPORTS_DIR)
+ return
+ }
+
+ const files = fs
+ .readdirSync(IMPORTS_DIR)
+ .filter((file) => file.endsWith('.mdx'))
+ .sort((a, b) => {
+ const langA = extractLangFromFilename(a) || 'en'
+ const langB = extractLangFromFilename(b) || 'en'
+ if (langA === 'en' && langB !== 'en') return -1
+ if (langA !== 'en' && langB === 'en') return 1
+ return a.localeCompare(b)
+ })
+
+ if (files.length === 0) {
+ console.log('ℹ️ No MDX files found in', IMPORTS_DIR)
+ return
+ }
+
+ console.log(`📂 Found ${files.length} MDX files to process\n`)
+
+ const failedImports: ImportError[] = []
+ const skippedImports: SkipInfo[] = []
+
+ for (const file of files) {
+ try {
+ console.log(`📖 Processing: ${file}`)
+
+ const filepath = path.join(IMPORTS_DIR, file)
+ const content = fs.readFileSync(filepath, 'utf-8')
+ const parsed = parseFrontmatter(content)
+
+ if (!parsed) {
+ console.log(` ⚠️ Skipped: Invalid frontmatter format\n`)
+ skippedImports.push({ file, reason: 'Invalid frontmatter format' })
+ continue
+ }
+
+ const { frontmatter, body } = parsed
+
+ // Check isTranslated flag
+ if (frontmatter.isTranslated === 'false') {
+ console.log(` ⚠️ Skipped: Translation not ready (isTranslated: false)\n`)
+ skippedImports.push({ file, reason: 'isTranslated is false' })
+ continue
+ }
+ const { date, slug } = extractSlugAndDate(file)
+ // Default to 'en' if no language code is found in the filename
+ const lang = extractLangFromFilename(file) || 'en'
+
+ const uniqueSlug = lang !== 'en' ? `${lang}-${slug}` : slug
+ const existingEntry = await checkExistingEntry(uniqueSlug, lang)
+
+ if (existingEntry && !options.force) {
+ console.log(
+ ` ⚠️ Skipping: Entry already exists for "${uniqueSlug}" in language "${lang}". Use --force to overwrite.\n`
+ )
+ skippedImports.push({ file, reason: 'Entry already exists' })
+ continue
+ }
+
+ let englishPostDocumentId: string | null = null // For linking
+ let englishImage: {
+ featuredImage?: number
+ ogImageUrl?: string
+ imageUrl?: string
+ } | null = null
+
+ if (lang !== 'en') {
+ const englishSlug = slug
+ const engData = await getEnglishPostImage(englishSlug)
+ if (engData) {
+ englishImage = engData
+ englishPostDocumentId = engData.documentId
+ console.log(
+ `Found English parent post: Document ID ${englishPostDocumentId}`
+ )
+ } else {
+ console.log(
+ `⚠️ Warning: English parent post not found for slug "${englishSlug}". Creating as standalone.`
+ )
+ }
+ }
+
+ const postData: StrapiBlogPost = {
+ title: frontmatter.title || slug,
+ description: frontmatter.description || '',
+ slug: uniqueSlug,
+ date: frontmatter.date || date || '',
+ content: body,
+ lang: lang,
+ publishedAt: new Date().toISOString()
+ }
+
+ // Set ogImageUrl from frontmatter or English post
+ if (frontmatter.ogImageUrl) {
+ postData.ogImageUrl = frontmatter.ogImageUrl
+ } else if (englishImage?.ogImageUrl) {
+ postData.ogImageUrl = englishImage.ogImageUrl
+ }
+
+ // Set featuredImage from English post for translated content
+ if (englishImage?.featuredImage) {
+ postData.featuredImage = { id: englishImage.featuredImage }
+ }
+
+ let targetDocId: string
+ if (existingEntry && options.force) {
+ console.log(` 🔄 Updating existing entry ${existingEntry.documentId}`)
+ await updateBlogPost(existingEntry.documentId, postData)
+ targetDocId = existingEntry.documentId
+ } else {
+ targetDocId = await createBlogPost(postData)
+ }
+
+ if (lang !== 'en' && englishPostDocumentId) {
+ // Link bidirectional
+ // 1. Update English post to connect to this new translation
+ await updateBlogPost(englishPostDocumentId, {
+ linked_translations: { connect: [targetDocId] }
+ } as any)
+
+ // 2. Update this translation to connect back to the English post
+ await updateBlogPost(targetDocId, {
+ linked_translations: { connect: [englishPostDocumentId] }
+ } as any)
+
+ console.log(
+ ` ✅ ${existingEntry ? 'Updated' : 'Imported'} (linked bidirectional to ${englishPostDocumentId}): "${postData.title}" (${lang})\n`
+ )
+ } else {
+ console.log(
+ ` ✅ ${existingEntry ? 'Updated' : 'Imported'}: "${postData.title}" (${lang})\n`
+ )
+ }
+ } catch (error) {
+ console.error(
+ ` ❌ Failed to import "${file}":`,
+ (error as Error).message,
+ '\n'
+ )
+ failedImports.push({ file, error: (error as Error).message })
+ }
+ }
+
+ const total = files.length
+ const success = total - failedImports.length - skippedImports.length
+
+ console.log(`\n✨ Import complete!`)
+ console.log(` ✅ Success: ${success}/${total}`)
+ console.log(` ⚠️ Skipped: ${skippedImports.length}/${total}`)
+ console.log(` ❌ Failed: ${failedImports.length}/${total}`)
+
+ if (skippedImports.length > 0) {
+ console.log(`\n⚠️ Skipped imports (${skippedImports.length}):`)
+ skippedImports.forEach(({ file, reason }) => {
+ console.log(` - ${file}: ${reason}`)
+ })
+ }
+
+ if (failedImports.length > 0) {
+ console.log(`\n❌ Failed imports (${failedImports.length}):`)
+ failedImports.forEach(({ file, error }) => {
+ console.log(` - ${file}: ${error}`)
+ })
+ }
+}
+
+importTranslations().catch((error) => {
+ console.error('❌ Unhandled error during import:', error)
+ process.exit(1)
+})
diff --git a/cms/src/api/blog-post/content-types/blog-post/lifecycles.ts b/cms/src/api/blog-post/content-types/blog-post/lifecycles.ts
index ca1715ec..2812db49 100644
--- a/cms/src/api/blog-post/content-types/blog-post/lifecycles.ts
+++ b/cms/src/api/blog-post/content-types/blog-post/lifecycles.ts
@@ -4,47 +4,44 @@
* Then commits and pushes to trigger Netlify preview builds
*/
-import fs from 'fs';
-import path from 'path';
-import { gitCommitAndPush } from '../../../../utils/gitSync';
-
-interface MediaFile {
- id: number;
- url: string;
- alternativeText?: string;
- name?: string;
- width?: number;
- height?: number;
- formats?: {
- thumbnail?: { url: string };
- small?: { url: string };
- medium?: { url: string };
- large?: { url: string };
- };
+import fs from 'fs'
+import path from 'path'
+import { gitCommitAndPush } from '../../../../utils/gitSync'
+
+const escapeQuotes = (str: string) => str?.replace(/"/g, '\\"') || ''
+
+const formatDate = (date: string) => {
+ if (!date) return ''
+ return date.split('T')[0]
}
-interface BlogPost {
- id: number;
- title: string;
- description: string;
- slug: string;
- date: string;
- content: string;
- featuredImage?: MediaFile;
- lang?: string;
- ogImageUrl?: string;
- publishedAt?: string;
+const generateFilename = (post: BlogPost) => {
+ const dateStr = formatDate(post.date)
+ const lang = post.lang || 'en'
+ return `${dateStr}-${post.slug}.${lang}.mdx`
}
-interface Event {
- result?: BlogPost;
+/**
+ * Gets the image URL from a media field
+ * Returns the full Strapi URL
+ */
+const getImageUrl = (media: any) => {
+ if (!media) return null
+ if (Array.isArray(media)) {
+ media = media[0]
+ }
+ const STRAPI_URL = process.env.STRAPI_URL || 'http://localhost:1337'
+ const url = media.url || media.attributes?.url
+ if (!url) return null
+ return url.startsWith('http') ? url : `${STRAPI_URL}${url}`
}
/**
- * Converts HTML content to markdown-like format
+ * Simple HTML to Markdown conversion (Regex-based as used originally)
+ * This handles basic tags and avoids over-escaping existing Markdown
*/
function htmlToMarkdown(html: string): string {
- if (!html) return '';
+ if (!html) return ''
return html
.replace(/ /gi, ' ')
@@ -72,132 +69,169 @@ function htmlToMarkdown(html: string): string {
.replace(/]*src="([^"]*)"[^>]*alt="([\s\S]*?)"[^>]*>/gi, '')
.replace(/]*src="([^"]*)"[^>]*>/gi, '')
.replace(/<[^>]+>/g, '')
- .trim();
+ .trim()
}
-function escapeQuotes(value: string): string {
- return value.replace(/"/g, '\\"');
-}
-
-function formatDate(dateString: string): string {
- if (!dateString) return '';
- const date = new Date(dateString);
- if (isNaN(date.getTime())) return dateString;
- return date.toISOString().split('T')[0];
+interface BlogPost {
+ id: number
+ documentId?: string
+ title: string
+ description: string
+ slug: string
+ date: string
+ content: string
+ lang?: string
+ ogImageUrl?: string
+ featuredImage?: any
+ publishedAt?: string
}
-function generateFilename(post: BlogPost): string {
- const date = formatDate(post.date);
- const prefix = date ? `${date}-` : '';
- return `${prefix}${post.slug}.mdx`;
+interface Translations {
+ [key: string]: string | boolean
}
-/**
- * Gets the image URL from a media field
- * Returns the full Strapi URL for local files, or the full URL for external
- */
-function getImageUrl(media: MediaFile | undefined): string | undefined {
- if (!media?.url) return undefined;
-
- // If it's a relative URL (starts with /uploads/), allow an optional base URL override, otherwise keep it relative
- if (media.url.startsWith('/uploads/')) {
- const uploadsBase = process.env.STRAPI_UPLOADS_BASE_URL;
- return uploadsBase ? `${uploadsBase.replace(/\/$/, '')}${media.url}` : media.url;
- }
+function generateMDX(post: BlogPost, translations?: Translations, isTranslated?: boolean): string {
+ const imageUrl = getImageUrl(post.featuredImage)
- // Return the URL as-is for external images
- return media.url;
-}
-
-function generateMDX(post: BlogPost): string {
- const imageUrl = getImageUrl(post.featuredImage);
-
const frontmatterLines = [
`title: "${escapeQuotes(post.title)}"`,
`description: "${escapeQuotes(post.description)}"`,
- post.ogImageUrl ? `ogImageUrl: "${escapeQuotes(post.ogImageUrl)}"` : undefined,
+ post.ogImageUrl
+ ? `ogImageUrl: "${escapeQuotes(post.ogImageUrl)}"`
+ : undefined,
`date: ${formatDate(post.date)}`,
`slug: ${post.slug}`,
post.lang ? `lang: "${escapeQuotes(post.lang)}"` : undefined,
imageUrl ? `image: "${escapeQuotes(imageUrl)}"` : undefined,
- ].filter(Boolean) as string[];
+ isTranslated !== undefined ? `isTranslated: ${isTranslated}` : undefined
+ ].filter(Boolean) as string[]
+
+ if (translations && Object.keys(translations).length > 0) {
+ frontmatterLines.push('translations:')
+ Object.entries(translations).forEach(([key, value]) => {
+ if (typeof value === 'boolean') {
+ frontmatterLines.push(` ${key}: ${value}`)
+ } else {
+ frontmatterLines.push(` ${key}: "${value}"`)
+ }
+ })
+ }
- const frontmatter = frontmatterLines.join('\n');
- const content = post.content ? htmlToMarkdown(post.content) : '';
+ const frontmatter = frontmatterLines.join('\n')
+ // Use simple regex-based conversion or direct content if it's already markdown
+ // CKEditor defaultMarkdown preset usually saves as markdown.
+ const content = htmlToMarkdown(post.content)
- return `---\n${frontmatter}\n---\n\n${content}\n`;
+ return `---\n${frontmatter}\n---\n\n${content}\n`
}
-async function writeMDXFile(post: BlogPost): Promise {
- const outputPath = process.env.BLOG_MDX_OUTPUT_PATH || '../src/content/blog';
- // Resolve from dist/src/api/blog-post/content-types/blog-post/ up to cms root then project root
- const baseDir = path.resolve(__dirname, '../../../../../../', outputPath);
+async function writeMDXFile(post: BlogPost, cascade = true): Promise {
+ // Use Document Service in Strapi v5 for better relation consistency
+ const populatedPost: any = await (strapi as any).documents('api::blog-post.blog-post').findOne({
+ documentId: post.documentId || (post as any).id,
+ populate: {
+ featuredImage: true,
+ linked_translations: {
+ populate: ['linked_translations']
+ }
+ }
+ })
+
+ if (!populatedPost) {
+ console.error(`❌ Could not find post to generate MDX: ${post.id}`)
+ return ''
+ }
+
+ const outputPath = process.env.BLOG_MDX_OUTPUT_PATH || '../src/content/blog'
+ const baseDir = path.resolve(__dirname, '../../../../../../', outputPath)
if (!fs.existsSync(baseDir)) {
- fs.mkdirSync(baseDir, { recursive: true });
+ fs.mkdirSync(baseDir, { recursive: true })
+ }
+
+ // Build translations map
+ const translations: Translations = {}
+ const addPostToMap = (p: any) => {
+ const lang = p.lang || 'en'
+ if (p.slug) {
+ translations[lang] = p.slug
+ }
+ }
+
+ addPostToMap(populatedPost)
+
+ if (populatedPost.linked_translations && Array.isArray(populatedPost.linked_translations)) {
+ populatedPost.linked_translations.forEach((loc: any) => {
+ addPostToMap(loc)
+ if (loc.linked_translations && Array.isArray(loc.linked_translations)) {
+ loc.linked_translations.forEach((sibling: any) => {
+ addPostToMap(sibling)
+ })
+ }
+ })
}
- const filename = generateFilename(post);
- const filepath = path.join(baseDir, filename);
- const mdxContent = generateMDX(post);
+ const mdxContent = generateMDX(populatedPost as BlogPost, translations, true)
+ const filename = generateFilename(populatedPost as BlogPost)
+ const filepath = path.join(baseDir, filename)
+
+ fs.writeFileSync(filepath, mdxContent, 'utf-8')
+ console.log(`✅ Generated Blog Post MDX file: ${filepath}`)
+
+ // Cascade to siblings (one level only)
+ if (cascade && populatedPost.linked_translations && Array.isArray(populatedPost.linked_translations)) {
+ for (const loc of populatedPost.linked_translations) {
+ await writeMDXFile(loc, false)
+ }
+ }
- fs.writeFileSync(filepath, mdxContent, 'utf-8');
- console.log(`✅ Generated Blog Post MDX file: ${filepath}`);
+ return filepath
}
-async function deleteMDXFile(post: BlogPost): Promise {
- const outputPath = process.env.BLOG_MDX_OUTPUT_PATH || '../src/content/blog';
- // Resolve from dist/src/api/blog-post/content-types/blog-post/ up to cms root then project root
- const baseDir = path.resolve(__dirname, '../../../../../../', outputPath);
- const filename = generateFilename(post);
- const filepath = path.join(baseDir, filename);
+async function deleteMDXFile(post: BlogPost): Promise {
+ const outputPath = process.env.BLOG_MDX_OUTPUT_PATH || '../src/content/blog'
+ const baseDir = path.resolve(__dirname, '../../../../../../', outputPath)
+ const filename = generateFilename(post)
+ const filepath = path.join(baseDir, filename)
if (fs.existsSync(filepath)) {
- fs.unlinkSync(filepath);
- console.log(`🗑️ Deleted Blog Post MDX file: ${filepath}`);
+ fs.unlinkSync(filepath)
+ console.log(`🗑️ Deleted Blog Post MDX file: ${filepath}`)
}
+ return filepath
}
export default {
- async afterCreate(event: Event) {
- const { result } = event;
+ async afterCreate(event: any) {
+ const { result } = event
if (result && result.publishedAt) {
- await writeMDXFile(result);
- const filename = generateFilename(result);
- const outputPath = process.env.BLOG_MDX_OUTPUT_PATH || '../src/content/blog';
- const baseDir = path.resolve(__dirname, '../../../../../../', outputPath);
- const filepath = path.join(baseDir, filename);
- await gitCommitAndPush(filepath, `blog: add "${result.title}"`);
+ const filepath = await writeMDXFile(result)
+ if (filepath) {
+ await gitCommitAndPush(filepath, `blog: add "${result.title}"`)
+ }
}
},
- async afterUpdate(event: Event) {
- const { result } = event;
+ async afterUpdate(event: any) {
+ const { result } = event
if (result) {
- const filename = generateFilename(result);
- const outputPath = process.env.BLOG_MDX_OUTPUT_PATH || '../src/content/blog';
- const baseDir = path.resolve(__dirname, '../../../../../../', outputPath);
- const filepath = path.join(baseDir, filename);
-
if (result.publishedAt) {
- await writeMDXFile(result);
- await gitCommitAndPush(filepath, `blog: update "${result.title}"`);
+ const filepath = await writeMDXFile(result)
+ if (filepath) {
+ await gitCommitAndPush(filepath, `blog: update "${result.title}"`)
+ }
} else {
- await deleteMDXFile(result);
- await gitCommitAndPush(filepath, `blog: unpublish "${result.title}"`);
+ const filepath = await deleteMDXFile(result)
+ await gitCommitAndPush(filepath, `blog: unpublish "${result.title}"`)
}
}
},
- async afterDelete(event: Event) {
- const { result } = event;
+ async afterDelete(event: any) {
+ const { result } = event
if (result) {
- await deleteMDXFile(result);
- const filename = generateFilename(result);
- const outputPath = process.env.BLOG_MDX_OUTPUT_PATH || '../src/content/blog';
- const baseDir = path.resolve(__dirname, '../../../../../../', outputPath);
- const filepath = path.join(baseDir, filename);
- await gitCommitAndPush(filepath, `blog: delete "${result.title}"`);
+ const filepath = await deleteMDXFile(result)
+ await gitCommitAndPush(filepath, `blog: delete "${result.title}"`)
}
- },
-};
+ }
+}
diff --git a/cms/src/api/blog-post/content-types/blog-post/schema.json b/cms/src/api/blog-post/content-types/blog-post/schema.json
index d1cb60fe..42f8cdcd 100644
--- a/cms/src/api/blog-post/content-types/blog-post/schema.json
+++ b/cms/src/api/blog-post/content-types/blog-post/schema.json
@@ -38,7 +38,9 @@
"type": "media",
"multiple": false,
"required": false,
- "allowedTypes": ["images"]
+ "allowedTypes": [
+ "images"
+ ]
},
"ogImageUrl": {
"type": "string",
@@ -50,6 +52,11 @@
"options": {
"preset": "defaultMarkdown"
}
+ },
+ "linked_translations": {
+ "type": "relation",
+ "relation": "manyToMany",
+ "target": "api::blog-post.blog-post"
}
}
-}
+}
\ No newline at end of file
diff --git a/cms/src/utils/contentUtils.ts b/cms/src/utils/contentUtils.ts
new file mode 100644
index 00000000..e00a77ac
--- /dev/null
+++ b/cms/src/utils/contentUtils.ts
@@ -0,0 +1,14 @@
+import MarkdownIt from 'markdown-it'
+import { NodeHtmlMarkdown } from 'node-html-markdown'
+
+const md = new MarkdownIt()
+
+export function htmlToMarkdown(html: string): string {
+ if (!html) return ''
+ return NodeHtmlMarkdown.translate(html)
+}
+
+export function markdownToHtml(markdown: string): string {
+ if (!markdown) return ''
+ return md.render(markdown)
+}
diff --git a/cms/translation-flow.md b/cms/translation-flow.md
new file mode 100644
index 00000000..d4f84999
--- /dev/null
+++ b/cms/translation-flow.md
@@ -0,0 +1,174 @@
+# Translation Management Implementation Report
+
+## Overview
+
+This report details the implementation of a translation management system for the Interledger.org site, migrating from Drupal's manual process to an automated Strapi + MDX workflow. The system supports multiple languages (Spanish, Chinese, German, French) with English as the default and fallback.
+
+## Codebase Search Findings
+
+**Existing Translation-Related Code:**
+
+- `cms/types/generated/contentTypes.d.ts`: Contains i18n-related TypeScript types including `'plugin::i18n.locale'` (auto-generated, indicates Strapi's built-in i18n support)
+- `src/content.config.ts`: Uses `i18nLoader` from Astro Starlight for documentation internationalization (unrelated to content translation)
+- `cms/src/api/press-item/content-types/press-item/schema.json`: Has field-level i18n configuration with `"i18n": { "localized": false }` for content field (not enabled for translation)
+- No existing translation scripts, workflows, or localized content found
+- Blog posts have `lang` field but no i18n localization enabled
+
+## Changes Made
+
+### 1. Strapi Configuration
+
+- **i18n Support**: Strapi v5 has built-in internationalization (no plugin installation required)
+- **Locales**: Configure in Strapi admin (Settings → Internationalization) for en, es, zh, de, fr
+- **Content Types**: Blog posts use `lang` field for locale identification (custom workflow, not Strapi's localized fields)
+
+### 2. Export/Import Scripts
+
+- **Export**: Queries Strapi API for English posts. Now supports selective export (skips locales that already have a translation in Strapi) and control via CLI arguments (`--limit`, `--since`, `--ids`, `--slugs`).
+- **Import**: Parses translated MDX, extracts data. Now supports idempotent re-import via the `--force` flag, which updates existing entries using a `PUT` request instead of skipping them.
+- **Bi-directional Linking**: Automatically maintains bi-directional relations between the English source and its translations in Strapi.
+- **Error Handling**: Continues processing on failures, logs failed files at end.
+
+## How It Works
+
+### Content Generation
+
+1. English content created in Strapi with `lang` empty or 'en'
+2. Lifecycle hooks generate MDX: `date-slug.mdx` (English) or `date-slug.es.mdx` (localized)
+3. MDX stored in `src/content/blog/`
+4. Astro content collections load all files, filter by `lang` in pages/components
+
+### Translation Handling
+
+- Custom components in MDX: Agency translates text strings only, preserves JSX
+- Tracking: Git diffs show changes between versions automatically
+
+### API Integration
+
+- Export: Fetches via Strapi REST API (`/api/blog-posts`)
+- Import: POSTs to create localized entries
+- Authentication: Uses `STRAPI_API_TOKEN` env var if needed
+
+## Translation Workflow
+
+### Step-by-Step Process
+
+```mermaid
+flowchart TD
+ A[Create English Content in Strapi] --> B[Run Export Script]
+ B --> C[MDX Files Generated in cms/exports/translations/]
+ C --> D[Send to Translation Agency]
+ D --> E[Agency Translates Text Content]
+ E --> F[Return Translated MDX Files]
+ F --> G[Place in cms/exports/translations-translated/]
+ G --> H[Run Import Script]
+ H --> I[Localized Entries Created in Strapi]
+ I --> J[Lifecycle Generates Localized MDX]
+ J --> K[Content Available on Site]
+```
+
+### Detailed Workflow
+
+1. **Content Creation**:
+ - Editor creates blog post in Strapi
+ - Sets `lang` to empty (defaults to English)
+
+2. **Export Phase**:
+ - Run: `npm run translations:export` (optionally add `--limit`, `--since`, `--ids`, or `--slugs`)
+ - Script fetches only English source posts (where `lang` is null or 'en')
+ - Checks Strapi for existing translations; only generates MDX for missing locales
+ - Generates MDX files with frontmatter and HTML-to-markdown converted content
+
+3. **Agency Translation**:
+ - Receive MDX files (e.g., `2024-01-01-my-post.es.mdx`)
+ - Translate frontmatter fields: `title`, `description`, `ogImageUrl`
+ - Translate body content (text, headers, component props)
+ - Preserve all JSX, component names, and non-text elements
+
+4. **Import Phase**:
+ - Place translated files in `cms/exports/translations/`
+ - Run: `npm run translations:import` (add `--force` to update existing entries)
+ - Script parses each file, extracts data
+ - Check for existing entry: skips by default, or updates if `--force` is provided
+ - Creates/Updates Strapi entry with `lang='es'`, adjusted slug, and translated content
+ - Verifies and maintains bi-directional links via the `linked_translations` relation
+ - Strapi lifecycle generates `2024-01-01-my-post.es.mdx` in `src/content/blog/`
+
+5. **Site Integration**:
+ - Astro loads all MDX files via content collections
+ - Pages filter posts by current language or fallback to English
+ - Language switcher allows users to change locale
+
+### Error Handling and Validation
+
+```mermaid
+flowchart TD
+ A[Import Script Runs] --> B{Valid MDX Format?}
+ B -->|Yes| C[Parse Frontmatter]
+ B -->|No| D[Log Error, Skip File]
+ C --> E{Required Fields Present?}
+ E -->|Yes| F[Create Strapi Entry]
+ E -->|No| G[Log Error, Skip File]
+ F --> H{Strapi API Success?}
+ H -->|Yes| I[Log Success]
+ H -->|No| J[Log API Error]
+ J --> K[Add to Failed List, Continue]
+ K --> L{More Files?}
+ L -->|Yes| A
+ L -->|No| M[Log Summary: Success/Failed Count]
+```
+
+### Benefits Over Drupal Process
+
+- **Automation**: No manual field extraction/copy-paste
+- **Reliability**: Structured MDX format vs. fragile Google Docs
+- **Tracking**: Git provides automatic diff tracking
+- **Efficiency**: Single file per post vs. multiple Drupal fields
+- **Consistency**: MDX includes all metadata, images, SEO fields
+
+### Technical Considerations
+
+- **CLI Arguments**:
+
+ **Export (`npm run translations:export -- [options]`)**:
+ - `--limit `: Process only the first N posts
+ - `--since `: Process posts published after this date
+ - `--ids `: Process specific post IDs
+ - `--slugs `: Process specific slugs
+
+ **Import (`npm run translations:import -- [options]`)**:
+ - `--force`: Overwrite existing entries in Strapi instead of skipping them
+
+ **Force Export Sync**:
+ If you use `--force` with the export script, it will generate MDX files even for locales that already exist in Strapi. For these existing translations:
+ - The content, title, and description will be pulled from the *translated* entry in Strapi, not the English source.
+ - The MDX will have `isTranslated: true` by default.
+ - This is useful for "pulling" current CMS state back into local files for editing or backup.
+
+- **Environment Variables**:
+ - `STRAPI_URL`: API endpoint (default: http://localhost:1337)
+ - `STRAPI_API_TOKEN`: For authenticated requests
+ - `BLOG_MDX_OUTPUT_PATH`: MDX output directory
+
+- **File Structure**:
+
+ ```
+ cms/
+ ├── scripts/
+ │ ├── export-translations.ts
+ │ └── import-translations.ts
+ └── exports/
+ └── translations/ # Both exported and incoming files
+ src/content/blog/ # Generated localized MDX
+ ```
+
+- **Slug Handling**: Localized posts get suffix (e.g., `my-post-es`) to avoid conflicts
+
+### Locale Fallback Ideas
+
+- **Astro Level**: Filter posts by `lang` first, fall back to English if empty
+- **Page Logic**: `const posts = await getCollection('blog'); const filtered = posts.filter(p => p.data.lang === currentLang || (!p.data.lang && currentLang === 'en'))`
+- **Component Fallback**: If no posts in requested locale, show English with "Translation pending" notice
+- **URL Handling**: `/es/blog/` shows Spanish posts, falls back to `/blog/` for English
+
+
diff --git a/cms/tsconfig.json b/cms/tsconfig.json
index dcd4a620..0d7386a2 100644
--- a/cms/tsconfig.json
+++ b/cms/tsconfig.json
@@ -16,13 +16,8 @@
"include": [
"src/**/*.ts",
"src/**/*.json",
- "config/**/*.ts"
+ "config/**/*.ts",
+ "scripts/**/*.ts"
],
- "exclude": [
- "node_modules/**",
- "build/**",
- "dist/**",
- ".cache/**",
- ".tmp/**"
- ]
+ "exclude": ["node_modules/**", "build/**", "dist/**", ".cache/**", ".tmp/**"]
}
diff --git a/cms/types/generated/contentTypes.d.ts b/cms/types/generated/contentTypes.d.ts
index b41b10a7..81265590 100644
--- a/cms/types/generated/contentTypes.d.ts
+++ b/cms/types/generated/contentTypes.d.ts
@@ -459,6 +459,10 @@ export interface ApiBlogPostBlogPost extends Struct.CollectionTypeSchema {
Schema.Attribute.SetMinMaxLength<{
maxLength: 10
}>
+ linked_translations: Schema.Attribute.Relation<
+ 'manyToMany',
+ 'api::blog-post.blog-post'
+ >
locale: Schema.Attribute.String & Schema.Attribute.Private
localizations: Schema.Attribute.Relation<
'oneToMany',
diff --git a/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp
new file mode 100644
index 00000000..9583dd2a
Binary files /dev/null and b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp differ
diff --git a/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp
new file mode 100644
index 00000000..9583dd2a
Binary files /dev/null and b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp differ
diff --git a/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp
new file mode 100644
index 00000000..9583dd2a
Binary files /dev/null and b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp differ
diff --git a/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp
new file mode 100644
index 00000000..9583dd2a
Binary files /dev/null and b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp differ
diff --git a/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp
new file mode 100644
index 00000000..9583dd2a
Binary files /dev/null and b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp differ
diff --git a/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp
new file mode 100644
index 00000000..9583dd2a
Binary files /dev/null and b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp differ
diff --git a/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp
new file mode 100644
index 00000000..9583dd2a
Binary files /dev/null and b/public/uploads/besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp differ
diff --git a/public/uploads/email_sig_1_78e70612e3.png b/public/uploads/email_sig_1_78e70612e3.png
new file mode 100644
index 00000000..bbad2188
Binary files /dev/null and b/public/uploads/email_sig_1_78e70612e3.png differ
diff --git a/public/uploads/email_sig_1_d3b9c430ee.png b/public/uploads/email_sig_1_d3b9c430ee.png
new file mode 100644
index 00000000..bbad2188
Binary files /dev/null and b/public/uploads/email_sig_1_d3b9c430ee.png differ
diff --git a/public/uploads/email_sig_1_eb785e6d0d.png b/public/uploads/email_sig_1_eb785e6d0d.png
new file mode 100644
index 00000000..bbad2188
Binary files /dev/null and b/public/uploads/email_sig_1_eb785e6d0d.png differ
diff --git a/public/uploads/large_email_sig_1_78e70612e3.png b/public/uploads/large_email_sig_1_78e70612e3.png
new file mode 100644
index 00000000..44d46b3c
Binary files /dev/null and b/public/uploads/large_email_sig_1_78e70612e3.png differ
diff --git a/public/uploads/large_email_sig_1_d3b9c430ee.png b/public/uploads/large_email_sig_1_d3b9c430ee.png
new file mode 100644
index 00000000..44d46b3c
Binary files /dev/null and b/public/uploads/large_email_sig_1_d3b9c430ee.png differ
diff --git a/public/uploads/large_email_sig_1_eb785e6d0d.png b/public/uploads/large_email_sig_1_eb785e6d0d.png
new file mode 100644
index 00000000..44d46b3c
Binary files /dev/null and b/public/uploads/large_email_sig_1_eb785e6d0d.png differ
diff --git a/public/uploads/large_og_image_002741284c.png b/public/uploads/large_og_image_002741284c.png
new file mode 100644
index 00000000..69f9db80
Binary files /dev/null and b/public/uploads/large_og_image_002741284c.png differ
diff --git a/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp
new file mode 100644
index 00000000..3999e118
Binary files /dev/null and b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp differ
diff --git a/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp
new file mode 100644
index 00000000..3999e118
Binary files /dev/null and b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp differ
diff --git a/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp
new file mode 100644
index 00000000..3999e118
Binary files /dev/null and b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp differ
diff --git a/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp
new file mode 100644
index 00000000..3999e118
Binary files /dev/null and b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp differ
diff --git a/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp
new file mode 100644
index 00000000..3999e118
Binary files /dev/null and b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp differ
diff --git a/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp
new file mode 100644
index 00000000..3999e118
Binary files /dev/null and b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp differ
diff --git a/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp
new file mode 100644
index 00000000..3999e118
Binary files /dev/null and b/public/uploads/medium_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp differ
diff --git a/public/uploads/medium_email_sig_1_78e70612e3.png b/public/uploads/medium_email_sig_1_78e70612e3.png
new file mode 100644
index 00000000..5624298f
Binary files /dev/null and b/public/uploads/medium_email_sig_1_78e70612e3.png differ
diff --git a/public/uploads/medium_email_sig_1_d3b9c430ee.png b/public/uploads/medium_email_sig_1_d3b9c430ee.png
new file mode 100644
index 00000000..5624298f
Binary files /dev/null and b/public/uploads/medium_email_sig_1_d3b9c430ee.png differ
diff --git a/public/uploads/medium_email_sig_1_eb785e6d0d.png b/public/uploads/medium_email_sig_1_eb785e6d0d.png
new file mode 100644
index 00000000..5624298f
Binary files /dev/null and b/public/uploads/medium_email_sig_1_eb785e6d0d.png differ
diff --git a/public/uploads/medium_og_image_002741284c.png b/public/uploads/medium_og_image_002741284c.png
new file mode 100644
index 00000000..064c19a5
Binary files /dev/null and b/public/uploads/medium_og_image_002741284c.png differ
diff --git a/public/uploads/og_image_002741284c.png b/public/uploads/og_image_002741284c.png
new file mode 100644
index 00000000..a08322f0
Binary files /dev/null and b/public/uploads/og_image_002741284c.png differ
diff --git a/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp
new file mode 100644
index 00000000..378e3428
Binary files /dev/null and b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp differ
diff --git a/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp
new file mode 100644
index 00000000..378e3428
Binary files /dev/null and b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp differ
diff --git a/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp
new file mode 100644
index 00000000..378e3428
Binary files /dev/null and b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp differ
diff --git a/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp
new file mode 100644
index 00000000..378e3428
Binary files /dev/null and b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp differ
diff --git a/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp
new file mode 100644
index 00000000..378e3428
Binary files /dev/null and b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp differ
diff --git a/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp
new file mode 100644
index 00000000..378e3428
Binary files /dev/null and b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp differ
diff --git a/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp
new file mode 100644
index 00000000..378e3428
Binary files /dev/null and b/public/uploads/small_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp differ
diff --git a/public/uploads/small_email_sig_1_78e70612e3.png b/public/uploads/small_email_sig_1_78e70612e3.png
new file mode 100644
index 00000000..b96c58f2
Binary files /dev/null and b/public/uploads/small_email_sig_1_78e70612e3.png differ
diff --git a/public/uploads/small_email_sig_1_d3b9c430ee.png b/public/uploads/small_email_sig_1_d3b9c430ee.png
new file mode 100644
index 00000000..b96c58f2
Binary files /dev/null and b/public/uploads/small_email_sig_1_d3b9c430ee.png differ
diff --git a/public/uploads/small_email_sig_1_eb785e6d0d.png b/public/uploads/small_email_sig_1_eb785e6d0d.png
new file mode 100644
index 00000000..b96c58f2
Binary files /dev/null and b/public/uploads/small_email_sig_1_eb785e6d0d.png differ
diff --git a/public/uploads/small_og_image_002741284c.png b/public/uploads/small_og_image_002741284c.png
new file mode 100644
index 00000000..7290e348
Binary files /dev/null and b/public/uploads/small_og_image_002741284c.png differ
diff --git a/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp
new file mode 100644
index 00000000..666f9389
Binary files /dev/null and b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_0e3e75fe08.webp differ
diff --git a/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp
new file mode 100644
index 00000000..666f9389
Binary files /dev/null and b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_454a5cf763.webp differ
diff --git a/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp
new file mode 100644
index 00000000..666f9389
Binary files /dev/null and b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_7ce4c46a4b.webp differ
diff --git a/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp
new file mode 100644
index 00000000..666f9389
Binary files /dev/null and b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_836c6cde52.webp differ
diff --git a/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp
new file mode 100644
index 00000000..666f9389
Binary files /dev/null and b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_b4b8bb742f.webp differ
diff --git a/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp
new file mode 100644
index 00000000..666f9389
Binary files /dev/null and b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_c119699a65.webp differ
diff --git a/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp
new file mode 100644
index 00000000..666f9389
Binary files /dev/null and b/public/uploads/thumbnail_besspay_grantee_banner_jpg_ac0db7f91f_c7e4c460a2.webp differ
diff --git a/public/uploads/thumbnail_email_sig_1_78e70612e3.png b/public/uploads/thumbnail_email_sig_1_78e70612e3.png
new file mode 100644
index 00000000..7260939a
Binary files /dev/null and b/public/uploads/thumbnail_email_sig_1_78e70612e3.png differ
diff --git a/public/uploads/thumbnail_email_sig_1_d3b9c430ee.png b/public/uploads/thumbnail_email_sig_1_d3b9c430ee.png
new file mode 100644
index 00000000..7260939a
Binary files /dev/null and b/public/uploads/thumbnail_email_sig_1_d3b9c430ee.png differ
diff --git a/public/uploads/thumbnail_email_sig_1_eb785e6d0d.png b/public/uploads/thumbnail_email_sig_1_eb785e6d0d.png
new file mode 100644
index 00000000..7260939a
Binary files /dev/null and b/public/uploads/thumbnail_email_sig_1_eb785e6d0d.png differ
diff --git a/public/uploads/thumbnail_og_image_002741284c.png b/public/uploads/thumbnail_og_image_002741284c.png
new file mode 100644
index 00000000..7ba863e4
Binary files /dev/null and b/public/uploads/thumbnail_og_image_002741284c.png differ
diff --git a/src/content.config.ts b/src/content.config.ts
index ab9eac39..5741a147 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -12,7 +12,9 @@ const blogCollection = defineCollection({
date: z.date(),
lang: z.string().optional(),
image: z.string().optional(),
- ogImageUrl: z.string().optional()
+ ogImageUrl: z.string().optional(),
+ translations: z.record(z.union([z.string(), z.boolean()])).optional(),
+ isTranslated: z.boolean().optional()
})
})
diff --git a/src/content/blog/2025-12-04-building-real-solutions-the-interledger-hackathon-winners-2025.en.mdx b/src/content/blog/2025-12-04-building-real-solutions-the-interledger-hackathon-winners-2025.en.mdx
new file mode 100644
index 00000000..da00ec18
--- /dev/null
+++ b/src/content/blog/2025-12-04-building-real-solutions-the-interledger-hackathon-winners-2025.en.mdx
@@ -0,0 +1,12 @@
+---
+title: "Building real solutions in 2026"
+description: "On November 8 & 9, 2025, more than 170 developers, students, designers, and entrepreneurs gathered at InSpark in Mexico City for the Interledger Hackathon. Over 24 hours, 47 teams worked side by side to turn the promise of interoperable payments into working prototypes."
+date: 2025-12-04
+slug: building-real-solutions-the-interledger-hackathon-winners-2025
+isTranslated: true
+translations:
+ de: "de-building-real-solutions-the-interledger-hackathon-winners-2025"
+ es: "es-building-real-solutions-the-interledger-hackathon-winners-2025"
+---
+
+
On November 8 & 9, 2025, more than 170 developers, students, designers, and entrepreneurs gathered at InSpark in Mexico City for the Interledger Hackathon. Over 24 hours, 47 teams worked side by side to turn the promise of interoperable payments into working prototypes.
The challenge was clear: how can open-source tools like the Interledger Protocol (ILP) and Open Payments API help reduce cash dependency, lower remittance costs, and improve digital acceptance for small businesses?
First place went to Los VibeCoders with _VibePayments_, a cloud-based SaaS platform designed to tackle the barriers of currency exchange, high bank fees, and slow processing times that affect international tourism and global supply chains.
Their solution simplifies global payment management by enabling simultaneous transactions: payrolls, supplier payments, tips, and services, all through ILP. With features like split and merge payments, the platform unifies financial networks and currencies into one interoperable system, allowing instant, secure, and low-cost payments across industries worldwide.
Looking ahead, the team envisions scaling their solution across sectors of the economy, fostering not just financial interoperability but also a fairer distribution of resources globally. As they put it: “_We are not only connecting financial systems, but also people, communities, and opportunities. Fostering a new paradigm of global economic equity built on interoperability and open technology_.”
Second place was awarded to Haki Ando for _Pagos_, a device-less payment system that turns an individual into their own payment method.
Their vision is to eliminate friction in everyday transactions by using biometric identity (face and voice) as the only key needed to authorize payments. This approach transforms daily convenience (no wallet needed on a run, no risk of card cloning) while addressing deeper issues of accessibility. For people with motor disabilities who cannot easily handle a card or phone, Pagos offers fast, dignified, and inclusive payments.
Third place went to Team Kanzu with _Shabaha_, a platform that bridges education and payments.
Their vision is captured in their rallying call: “_Convert learning into earning with Shabaha!_” Students earn points as they learn, and funders can sponsor their education with instant payouts via Interledger. Built on the Open Payments API, _Shabaha_ enables direct, instant cross-border payments from funders to students based on learning achievements.
By rewarding progress with tangible financial support, _Shabaha_ demonstrates how open payments can empower learners, create accountability, and open new pathways for education globally.
Community Prize - Máquinas de Boltzmann - _Paguito_
The Community Prize recognized Máquinas de Boltzmann for _Paguito_, a peer-to-peer payment platform that integrates Open Payments with a WhatsApp AI bot.
Their vision is to make financial transactions as intuitive as everyday conversations. Users simply chat with the bot, which detects transfer requests and triggers payment flows. By combining conversational interfaces with open payments, _Paguito_ highlights how familiar tools can lower barriers to adoption and make digital finance more accessible.
Why this Hackathon matters
Mexico’s payments landscape is often described as a paradox: the industry is technically ready for interoperability, yet cash remains dominant. According to the report, _The Internet of Opportunity: Unlocking Financial Interoperability in Latin America_ by the Interledger Foundation and Finnosummit, the financial industries are technically ready for interoperability, with 63% architectural capacity and 62% team readiness, yet 92% of adults still rely on cash, and SMEs face an average merchant discount rate of 2.0%, more than double Europe’s average.
The hackathon was designed to respond to this gap with practical, testable solutions. As Briana Marbury, President & CEO of the Interledger Foundation, reflects: “_Mexico’s tech and financial communities did not just explore payment interoperability; they built prototypes that bring it closer to measurable, real-world use cases across the region._”
Her words were echoed by Rafael Odreman, Head of Strategic Partnerships at Finnosummit, who emphasized the broader impact: “_We saw the Interledger Protocol move from a technical concept to a practical tool for financial inclusion. We can build a payments system where sending money is as simple and inexpensive as sending an email, breaking barriers that currently exclude millions of Mexicans and Latin Americans._”
Together, these perspectives highlight the hackathon’s role in shifting open payments from theory into practice.
Behind the scenes
Judges from across technology, finance, and academia reviewed each project for idea quality, strategic impact, and implementation. The panel included Alex Lakatos (Chief Technical Officer, Interledger Foundation), Roberto Valdovinos (CFO, People’s Clearinghouse), Dr. Andrew Mangle (Associate Professor, Bowie State University), Afua Bruce (Executive Advisor), and Sabine Schaller (Engineering Manager, Interledger Foundation).
Looking ahead
For the Interledger Foundation and our partner, Finnosummit, the Hackathon is a starting point for deeper collaboration to make Mexico an innovation hub for open payments in Latin America.
Hackathons like this are not just about competition. They are about building trust in new technologies, testing ideas against real-world challenges, and creating networks of support that last beyond the event.
The 2025 winners: Los VibeCoders, Haki Ando, Team Kanzu, and Máquinas de Boltzmann, showed that when diverse teams come together with shared purpose, they can design solutions that are practical, inclusive, and ready to make a difference.
diff --git a/src/content/blog/2025-12-04-building-real-solutions-the-interledger-hackathon-winners-2025.mdx b/src/content/blog/2025-12-04-building-real-solutions-the-interledger-hackathon-winners-2025.mdx
index 4e027a8f..e4394d43 100644
--- a/src/content/blog/2025-12-04-building-real-solutions-the-interledger-hackathon-winners-2025.mdx
+++ b/src/content/blog/2025-12-04-building-real-solutions-the-interledger-hackathon-winners-2025.mdx
@@ -6,6 +6,7 @@ description: "On November 8 & 9, 2025, more than 170 developers, students, desig
date: 2025-12-04
slug: building-real-solutions-the-interledger-hackathon-winners-2025
image: "/uploads/ilf_hackathon_banner_jpg_3bcd96af1d.webp"
+isTranslated: true
---
On November 8 & 9, 2025, more than 170 developers, students, designers, and entrepreneurs gathered at InSpark in Mexico City for the Interledger Hackathon. Over 24 hours, 47 teams worked side by side to turn the promise of interoperable payments into working prototypes.
diff --git a/src/content/blog/2025-12-04-de-building-real-solutions-the-interledger-hackathon-winners-2025.de.mdx b/src/content/blog/2025-12-04-de-building-real-solutions-the-interledger-hackathon-winners-2025.de.mdx
new file mode 100644
index 00000000..43b14f0a
--- /dev/null
+++ b/src/content/blog/2025-12-04-de-building-real-solutions-the-interledger-hackathon-winners-2025.de.mdx
@@ -0,0 +1,15 @@
+---
+title: "Building real solutions in 2026"
+description: "On November 8 & 9, 2025, more than 170 developers, students, designers, and entrepreneurs gathered at InSpark in Mexico City for the Interledger Hackathon. Over 24 hours, 47 teams worked side by side to turn the promise of interoperable payments into working prototypes."
+date: 2025-12-04
+slug: de-building-real-solutions-the-interledger-hackathon-winners-2025
+lang: "de"
+isTranslated: true
+translations:
+ de: "de-building-real-solutions-the-interledger-hackathon-winners-2025"
+ es: "es-building-real-solutions-the-interledger-hackathon-winners-2025"
+---
+
+
+
On November 8 & 9, 2025, more than 170 developers, students, designers, and entrepreneurs gathered at InSpark in Mexico City for the Interledger Hackathon. Over 24 hours, 47 teams worked side by side to turn the promise of interoperable payments into working prototypes.
The challenge was clear: how can open-source tools like the Interledger Protocol (ILP) and Open Payments API help reduce cash dependency, lower remittance costs, and improve digital acceptance for small businesses?
First place went to Los VibeCoders with _VibePayments_, a cloud-based SaaS platform designed to tackle the barriers of currency exchange, high bank fees, and slow processing times that affect international tourism and global supply chains.
Their solution simplifies global payment management by enabling simultaneous transactions: payrolls, supplier payments, tips, and services, all through ILP. With features like split and merge payments, the platform unifies financial networks and currencies into one interoperable system, allowing instant, secure, and low-cost payments across industries worldwide.
Looking ahead, the team envisions scaling their solution across sectors of the economy, fostering not just financial interoperability but also a fairer distribution of resources globally. As they put it: “_We are not only connecting financial systems, but also people, communities, and opportunities. Fostering a new paradigm of global economic equity built on interoperability and open technology_.”
Second place was awarded to Haki Ando for _Pagos_, a device-less payment system that turns an individual into their own payment method.
Their vision is to eliminate friction in everyday transactions by using biometric identity (face and voice) as the only key needed to authorize payments. This approach transforms daily convenience (no wallet needed on a run, no risk of card cloning) while addressing deeper issues of accessibility. For people with motor disabilities who cannot easily handle a card or phone, Pagos offers fast, dignified, and inclusive payments.
Third place went to Team Kanzu with _Shabaha_, a platform that bridges education and payments.
Their vision is captured in their rallying call: “_Convert learning into earning with Shabaha!_” Students earn points as they learn, and funders can sponsor their education with instant payouts via Interledger. Built on the Open Payments API, _Shabaha_ enables direct, instant cross-border payments from funders to students based on learning achievements.
By rewarding progress with tangible financial support, _Shabaha_ demonstrates how open payments can empower learners, create accountability, and open new pathways for education globally.
Community Prize - Máquinas de Boltzmann - _Paguito_
The Community Prize recognized Máquinas de Boltzmann for _Paguito_, a peer-to-peer payment platform that integrates Open Payments with a WhatsApp AI bot.
Their vision is to make financial transactions as intuitive as everyday conversations. Users simply chat with the bot, which detects transfer requests and triggers payment flows. By combining conversational interfaces with open payments, _Paguito_ highlights how familiar tools can lower barriers to adoption and make digital finance more accessible.
Why this Hackathon matters
Mexico’s payments landscape is often described as a paradox: the industry is technically ready for interoperability, yet cash remains dominant. According to the report, _The Internet of Opportunity: Unlocking Financial Interoperability in Latin America_ by the Interledger Foundation and Finnosummit, the financial industries are technically ready for interoperability, with 63% architectural capacity and 62% team readiness, yet 92% of adults still rely on cash, and SMEs face an average merchant discount rate of 2.0%, more than double Europe’s average.
The hackathon was designed to respond to this gap with practical, testable solutions. As Briana Marbury, President & CEO of the Interledger Foundation, reflects: “_Mexico’s tech and financial communities did not just explore payment interoperability; they built prototypes that bring it closer to measurable, real-world use cases across the region._”
Her words were echoed by Rafael Odreman, Head of Strategic Partnerships at Finnosummit, who emphasized the broader impact: “_We saw the Interledger Protocol move from a technical concept to a practical tool for financial inclusion. We can build a payments system where sending money is as simple and inexpensive as sending an email, breaking barriers that currently exclude millions of Mexicans and Latin Americans._”
Together, these perspectives highlight the hackathon’s role in shifting open payments from theory into practice.
Behind the scenes
Judges from across technology, finance, and academia reviewed each project for idea quality, strategic impact, and implementation. The panel included Alex Lakatos (Chief Technical Officer, Interledger Foundation), Roberto Valdovinos (CFO, People’s Clearinghouse), Dr. Andrew Mangle (Associate Professor, Bowie State University), Afua Bruce (Executive Advisor), and Sabine Schaller (Engineering Manager, Interledger Foundation).
Looking ahead
For the Interledger Foundation and our partner, Finnosummit, the Hackathon is a starting point for deeper collaboration to make Mexico an innovation hub for open payments in Latin America.
Hackathons like this are not just about competition. They are about building trust in new technologies, testing ideas against real-world challenges, and creating networks of support that last beyond the event.
The 2025 winners: Los VibeCoders, Haki Ando, Team Kanzu, and Máquinas de Boltzmann, showed that when diverse teams come together with shared purpose, they can design solutions that are practical, inclusive, and ready to make a difference.
+
diff --git a/src/content/blog/2025-12-04-es-building-real-solutions-the-interledger-hackathon-winners-2025.es.mdx b/src/content/blog/2025-12-04-es-building-real-solutions-the-interledger-hackathon-winners-2025.es.mdx
new file mode 100644
index 00000000..3683206f
--- /dev/null
+++ b/src/content/blog/2025-12-04-es-building-real-solutions-the-interledger-hackathon-winners-2025.es.mdx
@@ -0,0 +1,15 @@
+---
+title: "Building real solutions in 2026"
+description: "On November 8 & 9, 2025, more than 170 developers, students, designers, and entrepreneurs gathered at InSpark in Mexico City for the Interledger Hackathon. Over 24 hours, 47 teams worked side by side to turn the promise of interoperable payments into working prototypes."
+date: 2025-12-04
+slug: es-building-real-solutions-the-interledger-hackathon-winners-2025
+lang: "es"
+isTranslated: true
+translations:
+ es: "es-building-real-solutions-the-interledger-hackathon-winners-2025"
+ de: "de-building-real-solutions-the-interledger-hackathon-winners-2025"
+---
+
+
+
On November 8 & 9, 2025, more than 170 developers, students, designers, and entrepreneurs gathered at InSpark in Mexico City for the Interledger Hackathon. Over 24 hours, 47 teams worked side by side to turn the promise of interoperable payments into working prototypes.
The challenge was clear: how can open-source tools like the Interledger Protocol (ILP) and Open Payments API help reduce cash dependency, lower remittance costs, and improve digital acceptance for small businesses?
First place went to Los VibeCoders with _VibePayments_, a cloud-based SaaS platform designed to tackle the barriers of currency exchange, high bank fees, and slow processing times that affect international tourism and global supply chains.
Their solution simplifies global payment management by enabling simultaneous transactions: payrolls, supplier payments, tips, and services, all through ILP. With features like split and merge payments, the platform unifies financial networks and currencies into one interoperable system, allowing instant, secure, and low-cost payments across industries worldwide.
Looking ahead, the team envisions scaling their solution across sectors of the economy, fostering not just financial interoperability but also a fairer distribution of resources globally. As they put it: “_We are not only connecting financial systems, but also people, communities, and opportunities. Fostering a new paradigm of global economic equity built on interoperability and open technology_.”
Second place was awarded to Haki Ando for _Pagos_, a device-less payment system that turns an individual into their own payment method.
Their vision is to eliminate friction in everyday transactions by using biometric identity (face and voice) as the only key needed to authorize payments. This approach transforms daily convenience (no wallet needed on a run, no risk of card cloning) while addressing deeper issues of accessibility. For people with motor disabilities who cannot easily handle a card or phone, Pagos offers fast, dignified, and inclusive payments.
Third place went to Team Kanzu with _Shabaha_, a platform that bridges education and payments.
Their vision is captured in their rallying call: “_Convert learning into earning with Shabaha!_” Students earn points as they learn, and funders can sponsor their education with instant payouts via Interledger. Built on the Open Payments API, _Shabaha_ enables direct, instant cross-border payments from funders to students based on learning achievements.
By rewarding progress with tangible financial support, _Shabaha_ demonstrates how open payments can empower learners, create accountability, and open new pathways for education globally.
Community Prize - Máquinas de Boltzmann - _Paguito_
The Community Prize recognized Máquinas de Boltzmann for _Paguito_, a peer-to-peer payment platform that integrates Open Payments with a WhatsApp AI bot.
Their vision is to make financial transactions as intuitive as everyday conversations. Users simply chat with the bot, which detects transfer requests and triggers payment flows. By combining conversational interfaces with open payments, _Paguito_ highlights how familiar tools can lower barriers to adoption and make digital finance more accessible.
Why this Hackathon matters
Mexico’s payments landscape is often described as a paradox: the industry is technically ready for interoperability, yet cash remains dominant. According to the report, _The Internet of Opportunity: Unlocking Financial Interoperability in Latin America_ by the Interledger Foundation and Finnosummit, the financial industries are technically ready for interoperability, with 63% architectural capacity and 62% team readiness, yet 92% of adults still rely on cash, and SMEs face an average merchant discount rate of 2.0%, more than double Europe’s average.
The hackathon was designed to respond to this gap with practical, testable solutions. As Briana Marbury, President & CEO of the Interledger Foundation, reflects: “_Mexico’s tech and financial communities did not just explore payment interoperability; they built prototypes that bring it closer to measurable, real-world use cases across the region._”
Her words were echoed by Rafael Odreman, Head of Strategic Partnerships at Finnosummit, who emphasized the broader impact: “_We saw the Interledger Protocol move from a technical concept to a practical tool for financial inclusion. We can build a payments system where sending money is as simple and inexpensive as sending an email, breaking barriers that currently exclude millions of Mexicans and Latin Americans._”
Together, these perspectives highlight the hackathon’s role in shifting open payments from theory into practice.
Behind the scenes
Judges from across technology, finance, and academia reviewed each project for idea quality, strategic impact, and implementation. The panel included Alex Lakatos (Chief Technical Officer, Interledger Foundation), Roberto Valdovinos (CFO, People’s Clearinghouse), Dr. Andrew Mangle (Associate Professor, Bowie State University), Afua Bruce (Executive Advisor), and Sabine Schaller (Engineering Manager, Interledger Foundation).
Looking ahead
For the Interledger Foundation and our partner, Finnosummit, the Hackathon is a starting point for deeper collaboration to make Mexico an innovation hub for open payments in Latin America.
Hackathons like this are not just about competition. They are about building trust in new technologies, testing ideas against real-world challenges, and creating networks of support that last beyond the event.
The 2025 winners: Los VibeCoders, Haki Ando, Team Kanzu, and Máquinas de Boltzmann, showed that when diverse teams come together with shared purpose, they can design solutions that are practical, inclusive, and ready to make a difference.