chore: heygermany customer project
This commit is contained in:
1
backends/i18n-generator/README.md
Normal file
1
backends/i18n-generator/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# i18n-generator
|
||||
13
backends/i18n-generator/package.json
Normal file
13
backends/i18n-generator/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "i18n-generator",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.9.0",
|
||||
"google-spreadsheet": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.20.6"
|
||||
}
|
||||
}
|
||||
96
backends/i18n-generator/src/generate-language-types.ts
Normal file
96
backends/i18n-generator/src/generate-language-types.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
interface JsonObject {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively flattens a JSON object into dot-notation paths with array indices
|
||||
* Example: { a: { b: ["x", "y"] } } => ["a.b[0]", "a.b[1]"]
|
||||
*/
|
||||
function flattenKeys(obj: JsonObject, prefix = ''): string[] {
|
||||
const keys: string[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newPrefix = prefix ? `${prefix}.${key}` : key
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// For arrays, create keys with indices
|
||||
value.forEach((_, index) => {
|
||||
keys.push(`${newPrefix}[${index}]`)
|
||||
})
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recursively process nested objects
|
||||
keys.push(...flattenKeys(value, newPrefix))
|
||||
} else {
|
||||
// Leaf node (string/number/boolean/null)
|
||||
keys.push(newPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all JSON files from a directory and returns flattened keys
|
||||
*/
|
||||
async function getKeysFromDirectory(dirPath: string): Promise<Set<string>> {
|
||||
const allKeys = new Set<string>()
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Recursively process subdirectories
|
||||
const subKeys = await getKeysFromDirectory(fullPath)
|
||||
subKeys.forEach(key => allKeys.add(key))
|
||||
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
// Process JSON file
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
const json = JSON.parse(content)
|
||||
const keys = flattenKeys(json)
|
||||
keys.forEach(key => allKeys.add(key))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dirPath}:`, error)
|
||||
}
|
||||
|
||||
return allKeys
|
||||
}
|
||||
|
||||
export async function generateLanguageTypes(rootDir: string) {
|
||||
const localesDir = path.join(rootDir, 'packages/functions/locales')
|
||||
|
||||
// Get all unique translation keys
|
||||
const allKeys = await getKeysFromDirectory(localesDir)
|
||||
|
||||
// Sort keys for consistent output
|
||||
const sortedKeys = Array.from(allKeys).sort()
|
||||
|
||||
// Generate TypeScript type definition
|
||||
const typeDefinition = `/**
|
||||
* Auto-generated translation token types
|
||||
* @generated - DO NOT EDIT MANUALLY
|
||||
*/
|
||||
|
||||
export type TranslationTokens =
|
||||
${sortedKeys.map(key => ` | '${key}'`).join('\n')}
|
||||
|
||||
/**
|
||||
* Alias for backwards compatibility
|
||||
*/
|
||||
export type Tokens = TranslationTokens
|
||||
`
|
||||
|
||||
// Write to file
|
||||
const outputPath = path.join(rootDir, 'packages/functions/src/engines/language.types.gen.d.ts')
|
||||
await fs.writeFile(outputPath, typeDefinition, 'utf-8')
|
||||
|
||||
console.log(`✅ Generated language types with ${sortedKeys.length} tokens`)
|
||||
console.log(`📝 Output: ${outputPath}`)
|
||||
}
|
||||
226
backends/i18n-generator/src/google-sheets.ts
Normal file
226
backends/i18n-generator/src/google-sheets.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { GoogleSpreadsheet } from 'google-spreadsheet'
|
||||
import { JWT } from 'google-auth-library'
|
||||
|
||||
function setNestedProperty(obj: any, path: string, value: string) {
|
||||
const keys = path.split('.')
|
||||
let current = obj
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i]
|
||||
if (!current[key]) {
|
||||
current[key] = {}
|
||||
}
|
||||
current = current[key]
|
||||
}
|
||||
|
||||
const lastKey = keys[keys.length - 1]
|
||||
|
||||
// Check if the key has an array index pattern like "requirements[0]"
|
||||
const arrayMatch = lastKey.match(/^([a-z]+)\[(\d+)\]$/i)
|
||||
if (arrayMatch) {
|
||||
const arrayName = arrayMatch[1]
|
||||
const index = parseInt(arrayMatch[2], 10)
|
||||
|
||||
// Initialize array if it doesn't exist
|
||||
if (!Array.isArray(current[arrayName])) {
|
||||
current[arrayName] = []
|
||||
}
|
||||
|
||||
current[arrayName][index] = value
|
||||
} else {
|
||||
current[lastKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
export class GoogleSheets {
|
||||
#doc: GoogleSpreadsheet
|
||||
|
||||
constructor(creds: any, docId: string) {
|
||||
const jwt = new JWT({
|
||||
email: creds.client_email,
|
||||
key: creds.private_key,
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/drive.file',
|
||||
],
|
||||
});
|
||||
this.#doc = new GoogleSpreadsheet(docId, jwt)
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.#doc.loadInfo()
|
||||
}
|
||||
|
||||
async getI18n(languages: string[]): Promise<Record<string, Record<string, string>>> {
|
||||
const i18n: Record<string, Record<string, string>> = {}
|
||||
for (const [sheetTitle, sheet] of Object.entries(this.#doc.sheetsByTitle)) {
|
||||
if (sheetTitle.includes('result-')) {
|
||||
continue
|
||||
}
|
||||
const rows = await sheet.getRows()
|
||||
if (rows.length === 0) continue
|
||||
|
||||
// Get header names from the first row to find the correct column names
|
||||
const headerValues = Object.keys(rows[0].toObject())
|
||||
|
||||
for (const row of rows) {
|
||||
let root = row.get('Root')
|
||||
const key = row.get('Token')
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
if (!root) {
|
||||
root = sheetTitle
|
||||
}
|
||||
for (const lang of languages) {
|
||||
i18n[lang] = i18n[lang] || {}
|
||||
|
||||
// Find the column that matches the language (either "locale" or "locale (Country)")
|
||||
const columnName = headerValues.find((header: string) => {
|
||||
const normalizedHeader = header.toLowerCase()
|
||||
return normalizedHeader === lang || normalizedHeader.startsWith(`${lang} (`)
|
||||
})
|
||||
|
||||
if (columnName && row.get(columnName)) {
|
||||
i18n[lang][`${root}.${key}`.toLowerCase()] = row.get(columnName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return i18n
|
||||
}
|
||||
|
||||
async getResultI18n(languages: string[]): Promise<Record<string, Record<string, any>>> {
|
||||
const i18n: Record<string, Record<string, any>> = {}
|
||||
for (const [sheetTitle, sheet] of Object.entries(this.#doc.sheetsByTitle)) {
|
||||
if (!sheetTitle.includes('result-')) {
|
||||
continue
|
||||
}
|
||||
const title = sheetTitle.replace('result-', '')
|
||||
const rows = await sheet.getRows()
|
||||
if (rows.length === 0) continue
|
||||
|
||||
// Get header names from the first row to find the correct column names
|
||||
const headerValues = Object.keys(rows[0].toObject())
|
||||
|
||||
for (const row of rows) {
|
||||
const key = row.get('Token')
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
for (const lang of languages) {
|
||||
i18n[lang] = i18n[lang] || {}
|
||||
i18n[lang][title] = i18n[lang][title] || {}
|
||||
|
||||
// Find the column that matches the language (either "locale" or "locale (Country)")
|
||||
const columnName = headerValues.find((header: string) => {
|
||||
const normalizedHeader = header.toLowerCase()
|
||||
return normalizedHeader === lang || normalizedHeader.startsWith(`${lang} (`)
|
||||
})
|
||||
|
||||
if (columnName && row.get(columnName)) {
|
||||
setNestedProperty(i18n[lang][title], key.toLowerCase(), row.get(columnName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return i18n
|
||||
}
|
||||
|
||||
async syncLocaleToSheet(localeData: Record<string, string>, languages: string[]): Promise<void> {
|
||||
// Get all existing keys from all sheets
|
||||
const existingKeys = new Set<string>()
|
||||
const sheetsByName: Record<string, any> = {}
|
||||
|
||||
for (const [sheetTitle, sheet] of Object.entries(this.#doc.sheetsByTitle)) {
|
||||
if (sheetTitle.includes('result-')) {
|
||||
continue
|
||||
}
|
||||
sheetsByName[sheetTitle.toLowerCase()] = sheet
|
||||
const rows = await sheet.getRows()
|
||||
|
||||
for (const row of rows) {
|
||||
const root = row.get('Root')
|
||||
const key = row.get('Token')
|
||||
if (key) {
|
||||
const fullKey = root ? `${root}.${key}`.toLowerCase() : `${sheetTitle}.${key}`.toLowerCase()
|
||||
existingKeys.add(fullKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find missing keys
|
||||
const missingKeys: Array<{ fullKey: string, sheetName: string, token: string, value: string }> = []
|
||||
|
||||
for (const [fullKey, value] of Object.entries(localeData)) {
|
||||
const normalizedKey = fullKey.toLowerCase()
|
||||
if (!existingKeys.has(normalizedKey)) {
|
||||
// Determine sheet name from first word
|
||||
const parts = normalizedKey.split('.')
|
||||
let sheetName = parts[0]
|
||||
let token = parts.slice(1).join('.')
|
||||
|
||||
// If no matching sheet exists, use 'common' sheet
|
||||
if (!sheetsByName[sheetName]) {
|
||||
sheetName = 'common'
|
||||
token = normalizedKey
|
||||
}
|
||||
|
||||
missingKeys.push({ fullKey: normalizedKey, sheetName, token, value })
|
||||
}
|
||||
}
|
||||
|
||||
if (missingKeys.length === 0) {
|
||||
console.log('No missing keys found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Found ${missingKeys.length} missing keys, adding to sheets...`)
|
||||
|
||||
// Group missing keys by sheet
|
||||
const keysBySheet: Record<string, Array<{ token: string, value: string }>> = {}
|
||||
for (const { sheetName, token, value } of missingKeys) {
|
||||
if (!keysBySheet[sheetName]) {
|
||||
keysBySheet[sheetName] = []
|
||||
}
|
||||
keysBySheet[sheetName].push({ token, value })
|
||||
}
|
||||
|
||||
// Add rows to appropriate sheets
|
||||
for (const [sheetName, keys] of Object.entries(keysBySheet)) {
|
||||
let sheet = sheetsByName[sheetName]
|
||||
|
||||
// If sheet doesn't exist
|
||||
if (!sheet) {
|
||||
console.log(`Creating new sheet: ${sheetName}`)
|
||||
sheet = await this.#doc.addSheet({
|
||||
title: sheetName,
|
||||
headerValues: ['Root', 'Token', 'en', ...languages.filter(l => l !== 'en')]
|
||||
})
|
||||
sheetsByName[sheetName] = sheet
|
||||
}
|
||||
|
||||
// Get the header row to find column positions
|
||||
await sheet.loadHeaderRow()
|
||||
const headers = sheet.headerValues
|
||||
|
||||
console.log(`Adding ${keys.length} keys to sheet: ${sheetName}`)
|
||||
|
||||
// Prepare rows to add
|
||||
const rowsToAdd = keys.map(({ token, value }) => {
|
||||
const row: Record<string, string> = {
|
||||
'Root': '',
|
||||
'Token': token,
|
||||
'EN': value
|
||||
}
|
||||
|
||||
return row
|
||||
})
|
||||
|
||||
// Add all rows at once
|
||||
await sheet.addRows(rowsToAdd)
|
||||
}
|
||||
|
||||
console.log('Successfully synced missing keys to sheets')
|
||||
}
|
||||
}
|
||||
85
backends/i18n-generator/src/index.ts
Normal file
85
backends/i18n-generator/src/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { GoogleSheets } from './google-sheets'
|
||||
import { generateLanguageTypes } from './generate-language-types'
|
||||
import { identOf } from './ident'
|
||||
import fs from 'fs/promises'
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const rootDir = join(__dirname, '../../..');
|
||||
|
||||
// Flatten the sheet's dotted i18n map to inlang's snake_case token catalog.
|
||||
// `website.hero.title` → `website_hero_title`. Identical transform to the app
|
||||
// scaffold's identOf, so generator output ↔ runtime lookup stay aligned.
|
||||
const toInlangCatalog = (flat: Record<string, string>): Record<string, string> => {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(flat)) {
|
||||
out[identOf(key)] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const main = async () => {
|
||||
let creds: any
|
||||
if (process.env.GOOGLE_SHEETS_SERVICE_ACCOUNT) {
|
||||
creds = JSON.parse(process.env.GOOGLE_SHEETS_SERVICE_ACCOUNT)
|
||||
} else {
|
||||
throw new Error('GOOGLE_SHEETS_SERVICE_ACCOUNT must be set')
|
||||
}
|
||||
const googleSheets = new GoogleSheets({
|
||||
client_email: creds.client_email,
|
||||
private_key: creds.private_key,
|
||||
}, '18Ct1kIbxJQXr5LQ8vDGRQkKUmWliieewkDaMLct91XE')
|
||||
|
||||
let success = false
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
await googleSheets.init()
|
||||
|
||||
const languages = ['en', 'de', 'uk', 'fil', 'tr', 'bs', 'sq', 'vi', 'es', 'hi', 'ar']
|
||||
|
||||
// Read existing en.json locale file and sync missing keys to sheet
|
||||
try {
|
||||
const enLocaleData = await fs.readFile(`${rootDir}/apps/website/locales/en.json`, 'utf-8')
|
||||
const enLocale = JSON.parse(enLocaleData)
|
||||
console.log('Syncing locale keys to Google Sheets...')
|
||||
await googleSheets.syncLocaleToSheet(enLocale, languages)
|
||||
} catch (error) {
|
||||
console.log('No existing locales/en.json found or error syncing:', error)
|
||||
}
|
||||
|
||||
const i18n = await googleSheets.getI18n(languages)
|
||||
for (const lang of languages) {
|
||||
await fs.mkdir(`${rootDir}/apps/website/locales/`, { recursive: true })
|
||||
await fs.writeFile(`${rootDir}/apps/website/locales/${lang.toLowerCase()}.json`, JSON.stringify(i18n[lang], null, 2))
|
||||
console.log(`Wrote ${Object.keys(i18n[lang]).length} keys for ${lang}`)
|
||||
}
|
||||
|
||||
const resultI18n = await googleSheets.getResultI18n(languages)
|
||||
for (const lang of Object.keys(resultI18n)) {
|
||||
for (const sheet of Object.keys(resultI18n[lang])) {
|
||||
await fs.mkdir(`${rootDir}/packages/functions/locales/${lang.toLowerCase()}`, { recursive: true })
|
||||
await fs.writeFile(`${rootDir}/packages/functions/locales/${lang.toLowerCase()}/${sheet}.json`, JSON.stringify(resultI18n[lang][sheet], null, 2))
|
||||
console.log(`Wrote ${Object.keys(resultI18n[lang][sheet]).length} keys for ${lang}/${sheet}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TypeScript type definitions
|
||||
await generateLanguageTypes(rootDir)
|
||||
|
||||
success = true
|
||||
break
|
||||
} catch (e) {
|
||||
await new Promise(res => setTimeout(res, 5000))
|
||||
console.error('Error fetching or writing i18n data:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.error('Failed to generate i18n files after 3 attempts')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user