geeknative 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/bin/geeknative.js +12 -0
- package/package.json +31 -4
- package/src/cli.js +61 -0
- package/src/codegen/app.js +97 -0
- package/src/codegen/icons.js +87 -0
- package/src/codegen/index.js +44 -0
- package/src/codegen/strings.js +308 -0
- package/src/codegen/tokens.js +449 -0
- package/src/codegen/util.js +122 -0
- package/src/commands/check.js +143 -0
- package/src/commands/create.js +78 -0
- package/src/commands/doctor.js +108 -0
- package/src/commands/gen.js +44 -0
- package/src/commands/parity.js +134 -0
- package/src/commands/run.js +91 -0
- package/src/lib/exec.js +77 -0
- package/src/lib/fsutil.js +92 -0
- package/src/lib/log.js +37 -0
- package/src/lib/managed.js +80 -0
- package/src/lib/project.js +52 -0
- package/src/lib/toolchains.js +126 -0
- package/template/.claude/agents/android-engineer.md +15 -0
- package/template/.claude/agents/ios-engineer.md +15 -0
- package/template/.claude/agents/web-engineer.md +15 -0
- package/template/.claude/settings.json +65 -0
- package/template/.geeknative/framework.mk +41 -0
- package/template/.geeknative/hooks/after-edit.mjs +28 -0
- package/template/.geeknative/hooks/parity-guard.mjs +49 -0
- package/template/.geeknative/hooks/subagent-context.mjs +24 -0
- package/template/.geeknative/playbooks/bug.md +7 -0
- package/template/.geeknative/playbooks/design.md +10 -0
- package/template/.geeknative/playbooks/engineering.md +9 -0
- package/template/.geeknative/playbooks/feature.md +11 -0
- package/template/.geeknative/playbooks/platform-specific.md +8 -0
- package/template/.geeknative/playbooks/product.md +12 -0
- package/template/.geeknative/templates/feature.md +49 -0
- package/template/.gemini/settings.json +5 -0
- package/template/.github/dependabot.yml +18 -0
- package/template/AGENTS.md +97 -0
- package/template/CLAUDE.md +9 -0
- package/template/DESIGN.md +65 -0
- package/template/Makefile +6 -0
- package/template/PRODUCT.md +40 -0
- package/template/README.md +28 -0
- package/template/_gitignore +33 -0
- package/template/apps/android/AGENTS.md +66 -0
- package/template/apps/android/CLAUDE.md +1 -0
- package/template/apps/android/app/build.gradle.kts +68 -0
- package/template/apps/android/app/identity.properties +4 -0
- package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
- package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
- package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
- package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
- package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
- package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
- package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
- package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
- package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
- package/template/apps/android/build.gradle.kts +5 -0
- package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
- package/template/apps/android/gradle/libs.versions.toml +37 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/template/apps/android/gradle.properties +7 -0
- package/template/apps/android/gradlew +248 -0
- package/template/apps/android/gradlew.bat +82 -0
- package/template/apps/android/settings.gradle.kts +29 -0
- package/template/apps/ios/AGENTS.md +70 -0
- package/template/apps/ios/App/AppMain.swift +13 -0
- package/template/apps/ios/App/Core/AppContainer.swift +22 -0
- package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
- package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
- package/template/apps/ios/App/Core/RootView.swift +32 -0
- package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
- package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
- package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
- package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
- package/template/apps/ios/App/Generated/Icons.swift +8 -0
- package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
- package/template/apps/ios/App/Generated/Strings.swift +40 -0
- package/template/apps/ios/App/Generated/Tokens.swift +65 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
- package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
- package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
- package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
- package/template/apps/ios/CLAUDE.md +1 -0
- package/template/apps/ios/Config/App.xcconfig +23 -0
- package/template/apps/ios/Config/AppTests.xcconfig +10 -0
- package/template/apps/ios/Config/Base.xcconfig +25 -0
- package/template/apps/ios/Config/Debug.xcconfig +9 -0
- package/template/apps/ios/Config/Identity.xcconfig +5 -0
- package/template/apps/ios/Config/Release.xcconfig +6 -0
- package/template/apps/web/AGENTS.md +63 -0
- package/template/apps/web/CLAUDE.md +1 -0
- package/template/apps/web/package.json +38 -0
- package/template/apps/web/public/favicon.svg +6 -0
- package/template/apps/web/src/core/AppContainer.ts +18 -0
- package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
- package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
- package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
- package/template/apps/web/src/features/home/home.test.tsx +22 -0
- package/template/apps/web/src/features/settings/Appearance.ts +29 -0
- package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
- package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
- package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
- package/template/apps/web/src/features/settings/settings.test.ts +45 -0
- package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
- package/template/apps/web/src/generated/app.ts +9 -0
- package/template/apps/web/src/generated/icons.ts +9 -0
- package/template/apps/web/src/generated/strings.ts +88 -0
- package/template/apps/web/src/generated/tokens.css +86 -0
- package/template/apps/web/src/routeTree.gen.ts +86 -0
- package/template/apps/web/src/router.tsx +23 -0
- package/template/apps/web/src/routes/__root.tsx +72 -0
- package/template/apps/web/src/routes/index.tsx +6 -0
- package/template/apps/web/src/routes/settings.tsx +9 -0
- package/template/apps/web/src/styles.css +9 -0
- package/template/apps/web/tsconfig.json +26 -0
- package/template/apps/web/tsr.config.json +3 -0
- package/template/apps/web/vite.config.ts +9 -0
- package/template/apps/web/vitest.config.ts +9 -0
- package/template/contracts/app.json +7 -0
- package/template/contracts/icons.json +5 -0
- package/template/contracts/strings/en.json +13 -0
- package/template/contracts/tokens.json +40 -0
- package/template/package.json +17 -0
- package/template/spec/decisions.md +10 -0
- package/template/spec/features/home.md +39 -0
- package/template/spec/features/settings.md +50 -0
- package/template/spec/navigation.md +16 -0
- package/template/spec/parity.md +29 -0
- package/README.md +0 -3
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readJson } from '../lib/fsutil.js'
|
|
5
|
+
import { CliError } from '../lib/log.js'
|
|
6
|
+
import { banner, camelKey, docText, OUT, snakeKey, swiftString, xcodeJson } from './util.js'
|
|
7
|
+
|
|
8
|
+
const SOURCE = 'contracts/strings'
|
|
9
|
+
const KEY = /^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)*$/
|
|
10
|
+
const PLACEHOLDER = /\{([a-zA-Z][a-zA-Z0-9]*)(?::(int|string))?\}/g
|
|
11
|
+
const PLURAL_FORMS = ['zero', 'one', 'two', 'few', 'many', 'other']
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {{ name: string, type: 'int' | 'string' }} Placeholder
|
|
15
|
+
* @typedef {string | Record<string, string>} Value
|
|
16
|
+
* @typedef {{ key: string, plural: boolean, placeholders: Placeholder[], values: Record<string, Value> }} Entry
|
|
17
|
+
* @typedef {{ locales: string[], defaultLocale: string, entries: Entry[] }} Strings
|
|
18
|
+
* @typedef {{ path: string, content: string | Buffer }} Output
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** @param {string} text @returns {Placeholder[]} */
|
|
22
|
+
function placeholdersIn(text) {
|
|
23
|
+
/** @type {Placeholder[]} */
|
|
24
|
+
const found = []
|
|
25
|
+
for (const match of text.matchAll(PLACEHOLDER)) {
|
|
26
|
+
const type = /** @type {'int' | 'string'} */ (match[2] ?? 'string')
|
|
27
|
+
const existing = found.find((p) => p.name === match[1])
|
|
28
|
+
if (existing && existing.type !== type) throw new CliError(`Placeholder {${match[1]}} is used with two types in "${text}".`)
|
|
29
|
+
if (!existing) found.push({ name: match[1], type })
|
|
30
|
+
}
|
|
31
|
+
return found
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads contracts/strings/<locale>.json. The default locale is the source of truth;
|
|
36
|
+
* other locales are translations of its keys.
|
|
37
|
+
* @param {string} root
|
|
38
|
+
* @param {string} defaultLocale
|
|
39
|
+
* @returns {Strings}
|
|
40
|
+
*/
|
|
41
|
+
export function readStrings(root, defaultLocale) {
|
|
42
|
+
const dir = path.join(root, 'contracts', 'strings')
|
|
43
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.json')) : []
|
|
44
|
+
const locales = files.map((f) => f.slice(0, -5))
|
|
45
|
+
if (!locales.includes(defaultLocale)) {
|
|
46
|
+
throw new CliError(`${SOURCE}/${defaultLocale}.json is missing (the default locale from contracts/app.json).`)
|
|
47
|
+
}
|
|
48
|
+
/** @type {Record<string, Record<string, unknown>>} */
|
|
49
|
+
const byLocale = {}
|
|
50
|
+
for (const locale of locales) byLocale[locale] = readJson(path.join(dir, `${locale}.json`), `${SOURCE}/${locale}.json`)
|
|
51
|
+
|
|
52
|
+
/** @type {Entry[]} */
|
|
53
|
+
const entries = []
|
|
54
|
+
const seen = new Map()
|
|
55
|
+
for (const [key, value] of Object.entries(byLocale[defaultLocale])) {
|
|
56
|
+
if (key.startsWith('$')) continue
|
|
57
|
+
const where = `${SOURCE}/${defaultLocale}.json "${key}"`
|
|
58
|
+
if (!KEY.test(key)) throw new CliError(`${where}: keys are dot-separated camelCase words, like "settings.appearance.title".`)
|
|
59
|
+
if (key === 'app.name') throw new CliError(`${where}: the app name comes from contracts/app.json; remove this key.`)
|
|
60
|
+
const id = camelKey(key)
|
|
61
|
+
if (seen.has(id)) throw new CliError(`${where} collides with "${seen.get(id)}" (both become ${id}).`)
|
|
62
|
+
seen.set(id, key)
|
|
63
|
+
|
|
64
|
+
const entry = describe(key, value, where)
|
|
65
|
+
entry.values[defaultLocale] = /** @type {Value} */ (value)
|
|
66
|
+
for (const locale of locales) {
|
|
67
|
+
if (locale === defaultLocale || byLocale[locale][key] === undefined) continue
|
|
68
|
+
const translation = describe(key, byLocale[locale][key], `${SOURCE}/${locale}.json "${key}"`)
|
|
69
|
+
const expected = entry.placeholders.map((p) => p.name).sort().join(',')
|
|
70
|
+
const got = translation.placeholders.map((p) => p.name).sort().join(',')
|
|
71
|
+
if (translation.plural !== entry.plural || expected !== got) {
|
|
72
|
+
throw new CliError(`${SOURCE}/${locale}.json "${key}" must use the same placeholders and plural shape as ${defaultLocale}.json.`)
|
|
73
|
+
}
|
|
74
|
+
entry.values[locale] = /** @type {Value} */ (byLocale[locale][key])
|
|
75
|
+
}
|
|
76
|
+
entries.push(entry)
|
|
77
|
+
}
|
|
78
|
+
for (const locale of locales) {
|
|
79
|
+
const extra = Object.keys(byLocale[locale]).filter((k) => !k.startsWith('$') && byLocale[defaultLocale][k] === undefined)
|
|
80
|
+
if (extra.length) throw new CliError(`${SOURCE}/${locale}.json has keys missing from ${defaultLocale}.json: ${extra.join(', ')}.`)
|
|
81
|
+
}
|
|
82
|
+
entries.sort((a, b) => (a.key < b.key ? -1 : 1))
|
|
83
|
+
return { locales: [defaultLocale, ...locales.filter((l) => l !== defaultLocale).sort()], defaultLocale, entries }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {string} key @param {unknown} value @param {string} where @returns {Entry} */
|
|
87
|
+
function describe(key, value, where) {
|
|
88
|
+
if (typeof value === 'string') return { key, plural: false, placeholders: placeholdersIn(value), values: {} }
|
|
89
|
+
if (value && typeof value === 'object') {
|
|
90
|
+
const forms = Object.entries(value)
|
|
91
|
+
for (const [form, text] of forms) {
|
|
92
|
+
if (!PLURAL_FORMS.includes(form) || typeof text !== 'string') {
|
|
93
|
+
throw new CliError(`${where}: plural strings are objects with ${PLURAL_FORMS.join('/')} keys and string values.`)
|
|
94
|
+
}
|
|
95
|
+
const names = placeholdersIn(text).map((p) => p.name)
|
|
96
|
+
if (names.some((n) => n !== 'count')) throw new CliError(`${where}: plural strings can only use the {count} placeholder.`)
|
|
97
|
+
}
|
|
98
|
+
if (!('other' in value)) throw new CliError(`${where}: plural strings need an "other" form.`)
|
|
99
|
+
return { key, plural: true, placeholders: [{ name: 'count', type: 'int' }], values: {} }
|
|
100
|
+
}
|
|
101
|
+
throw new CliError(`${where} must be a string, or a plural object like { "one": "{count} item", "other": "{count} items" }.`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** "{build:int}" → "{build}", for doc comments. @param {string} text */
|
|
105
|
+
const plainPlaceholders = (text) => text.replace(PLACEHOLDER, '{$1}')
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Converts "{name}" placeholders to positional printf specifiers.
|
|
109
|
+
* @param {string} text
|
|
110
|
+
* @param {Placeholder[]} placeholders order defines the argument positions
|
|
111
|
+
* @param {{ string: string, int: string }} specifiers e.g. { string: '@', int: 'lld' }
|
|
112
|
+
*/
|
|
113
|
+
function printf(text, placeholders, specifiers) {
|
|
114
|
+
if (!placeholders.length) return text
|
|
115
|
+
return text.replace(/%/g, '%%').replace(PLACEHOLDER, (_, name) => {
|
|
116
|
+
const index = placeholders.findIndex((p) => p.name === name)
|
|
117
|
+
return `%${index + 1}$${specifiers[placeholders[index].type]}`
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {Strings} strings @param {string} appName @returns {Output[]} */
|
|
122
|
+
export function stringOutputs(strings, appName) {
|
|
123
|
+
return [
|
|
124
|
+
{ path: `${OUT.iosGenerated}/Localizable.xcstrings`, content: xcstrings(strings) },
|
|
125
|
+
{ path: `${OUT.iosGenerated}/Strings.swift`, content: swift(strings) },
|
|
126
|
+
...strings.locales.map((locale) => ({
|
|
127
|
+
path: `${OUT.androidRes}/${androidValuesDir(locale, strings.defaultLocale)}/generated_strings.xml`,
|
|
128
|
+
content: androidXml(strings, locale, locale === strings.defaultLocale ? appName : null),
|
|
129
|
+
})),
|
|
130
|
+
{ path: `${OUT.webGenerated}/strings.ts`, content: typescript(strings) },
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- iOS ----------
|
|
135
|
+
|
|
136
|
+
const IOS_SPECIFIERS = { string: '@', int: 'lld' }
|
|
137
|
+
|
|
138
|
+
/** @param {Strings} strings */
|
|
139
|
+
function xcstrings(strings) {
|
|
140
|
+
/** @type {Record<string, unknown>} */
|
|
141
|
+
const catalog = {}
|
|
142
|
+
for (const entry of strings.entries) {
|
|
143
|
+
/** @type {Record<string, unknown>} */
|
|
144
|
+
const localizations = {}
|
|
145
|
+
for (const [locale, value] of Object.entries(entry.values)) {
|
|
146
|
+
const unit = (/** @type {string} */ text) => ({
|
|
147
|
+
stringUnit: { state: 'translated', value: printf(text, entry.placeholders, IOS_SPECIFIERS) },
|
|
148
|
+
})
|
|
149
|
+
localizations[locale] =
|
|
150
|
+
typeof value === 'string'
|
|
151
|
+
? unit(value)
|
|
152
|
+
: { variations: { plural: Object.fromEntries(Object.entries(value).map(([form, text]) => [form, unit(text)])) } }
|
|
153
|
+
}
|
|
154
|
+
catalog[entry.key] = { extractionState: 'manual', localizations }
|
|
155
|
+
}
|
|
156
|
+
return `${xcodeJson({ sourceLanguage: strings.defaultLocale, strings: catalog, version: '1.0' })}\n`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {Strings} strings */
|
|
160
|
+
function swift(strings) {
|
|
161
|
+
const members = strings.entries.map((entry) => {
|
|
162
|
+
const source = entry.values[strings.defaultLocale]
|
|
163
|
+
const doc = ` /// ${docText(plainPlaceholders(typeof source === 'string' ? source : source.other))}`
|
|
164
|
+
const lookup = `NSLocalizedString(${swiftString(entry.key)}, comment: "")`
|
|
165
|
+
const id = camelKey(entry.key)
|
|
166
|
+
if (!entry.placeholders.length) return `${doc}\n static var ${id}: String { ${lookup} }`
|
|
167
|
+
const params = entry.placeholders.map((p) => `${p.name}: ${p.type === 'int' ? 'Int' : 'String'}`).join(', ')
|
|
168
|
+
const args = entry.placeholders.map((p) => p.name).join(', ')
|
|
169
|
+
return `${doc}\n static func ${id}(${params}) -> String {\n String.localizedStringWithFormat(${lookup}, ${args})\n }`
|
|
170
|
+
})
|
|
171
|
+
return `// ${banner(SOURCE)}
|
|
172
|
+
import Foundation
|
|
173
|
+
|
|
174
|
+
/// User-facing copy. Keys and placeholders match the Android and web apps.
|
|
175
|
+
nonisolated enum Strings {
|
|
176
|
+
${members.join('\n\n')}
|
|
177
|
+
}
|
|
178
|
+
`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------- Android ----------
|
|
182
|
+
|
|
183
|
+
/** Android resource folder for a locale: values, values-fr, values-pt-rBR, values-b+zh+Hans. @param {string} locale @param {string} defaultLocale */
|
|
184
|
+
function androidValuesDir(locale, defaultLocale) {
|
|
185
|
+
if (locale === defaultLocale) return 'values'
|
|
186
|
+
const [language, ...rest] = locale.split('-')
|
|
187
|
+
if (!rest.length) return `values-${language}`
|
|
188
|
+
if (rest.length === 1 && /^[A-Z]{2}$/.test(rest[0])) return `values-${language}-r${rest[0]}`
|
|
189
|
+
return `values-b+${[language, ...rest].join('+')}`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @param {string} text */
|
|
193
|
+
function androidEscape(text) {
|
|
194
|
+
return text
|
|
195
|
+
.replace(/\\/g, '\\\\')
|
|
196
|
+
.replace(/&/g, '&')
|
|
197
|
+
.replace(/</g, '<')
|
|
198
|
+
.replace(/>/g, '>')
|
|
199
|
+
.replace(/'/g, "\\'")
|
|
200
|
+
.replace(/"/g, '\\"')
|
|
201
|
+
.replace(/\n/g, '\\n')
|
|
202
|
+
.replace(/^([@?])/, '\\$1')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const ANDROID_SPECIFIERS = { string: 's', int: 'd' }
|
|
206
|
+
|
|
207
|
+
/** @param {Strings} strings @param {string} locale @param {string | null} appName */
|
|
208
|
+
function androidXml(strings, locale, appName) {
|
|
209
|
+
const lines = []
|
|
210
|
+
if (appName) lines.push(` <string name="app_name" translatable="false">${androidEscape(appName)}</string>`)
|
|
211
|
+
for (const entry of strings.entries) {
|
|
212
|
+
const value = entry.values[locale]
|
|
213
|
+
if (value === undefined) continue
|
|
214
|
+
const name = snakeKey(entry.key)
|
|
215
|
+
const text = (/** @type {string} */ t) => androidEscape(printf(t, entry.placeholders, ANDROID_SPECIFIERS))
|
|
216
|
+
if (typeof value === 'string') {
|
|
217
|
+
const unformatted = !entry.placeholders.length && value.includes('%') ? ' formatted="false"' : ''
|
|
218
|
+
lines.push(` <string name="${name}"${unformatted}>${text(value)}</string>`)
|
|
219
|
+
} else {
|
|
220
|
+
lines.push(` <plurals name="${name}">`)
|
|
221
|
+
for (const [form, t] of Object.entries(value)) lines.push(` <item quantity="${form}">${text(t)}</item>`)
|
|
222
|
+
lines.push(' </plurals>')
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return `<?xml version="1.0" encoding="utf-8"?>
|
|
226
|
+
<!-- ${banner(SOURCE)} -->
|
|
227
|
+
<resources>
|
|
228
|
+
${lines.join('\n')}
|
|
229
|
+
</resources>
|
|
230
|
+
`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- Web ----------
|
|
234
|
+
|
|
235
|
+
/** @param {Strings} strings */
|
|
236
|
+
function typescript(strings) {
|
|
237
|
+
const hasArgs = strings.entries.some((e) => !e.plural && e.placeholders.length)
|
|
238
|
+
const hasPlurals = strings.entries.some((e) => e.plural)
|
|
239
|
+
const messages = strings.locales
|
|
240
|
+
.map((locale) => {
|
|
241
|
+
const body = strings.entries
|
|
242
|
+
.filter((e) => e.values[locale] !== undefined)
|
|
243
|
+
.map((e) => ` ${JSON.stringify(e.key)}: ${JSON.stringify(e.values[locale])},`)
|
|
244
|
+
.join('\n')
|
|
245
|
+
return ` ${JSON.stringify(locale)}: {\n${body}\n },`
|
|
246
|
+
})
|
|
247
|
+
.join('\n')
|
|
248
|
+
const members = strings.entries.map((entry) => {
|
|
249
|
+
const source = entry.values[strings.defaultLocale]
|
|
250
|
+
const doc = ` /** ${docText(plainPlaceholders(typeof source === 'string' ? source : source.other))} */`
|
|
251
|
+
const id = camelKey(entry.key)
|
|
252
|
+
const key = JSON.stringify(entry.key)
|
|
253
|
+
if (entry.plural) return `${doc}\n ${id}: (count: number) => plural(${key}, count),`
|
|
254
|
+
if (!entry.placeholders.length) return `${doc}\n get ${id}() {\n return text(${key})\n },`
|
|
255
|
+
const type = entry.placeholders.map((p) => `${p.name}: ${p.type === 'int' ? 'number' : 'string'}`).join('; ')
|
|
256
|
+
return `${doc}\n ${id}: (args: { ${type} }) => text(${key}, args),`
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
return `// ${banner(SOURCE)}
|
|
260
|
+
|
|
261
|
+
export const locales = ${JSON.stringify(strings.locales)} as const
|
|
262
|
+
export type Locale = (typeof locales)[number]
|
|
263
|
+
export const defaultLocale: Locale = ${JSON.stringify(strings.defaultLocale)}
|
|
264
|
+
|
|
265
|
+
type Plural = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string }
|
|
266
|
+
|
|
267
|
+
const messages: Record<Locale, Record<string, string | Plural>> = {
|
|
268
|
+
${messages}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let currentLocale: Locale = defaultLocale
|
|
272
|
+
|
|
273
|
+
/** Switches the language \`strings\` returns. */
|
|
274
|
+
export function setLocale(locale: Locale) {
|
|
275
|
+
currentLocale = locale
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function lookup(key: string) {
|
|
279
|
+
return messages[currentLocale][key] ?? messages[defaultLocale][key]
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function text(key: string${hasArgs ? ', args: Record<string, string | number> = {}' : ''}): string {
|
|
283
|
+
${hasArgs ? 'return fill(lookup(key) as string, args)' : 'return lookup(key) as string'}
|
|
284
|
+
}
|
|
285
|
+
${
|
|
286
|
+
hasArgs || hasPlurals
|
|
287
|
+
? `
|
|
288
|
+
function fill(template: string, args: Record<string, string | number>): string {
|
|
289
|
+
return template.replace(/\\{(\\w+)(?::\\w+)?\\}/g, (_, name: string) => String(args[name]))
|
|
290
|
+
}
|
|
291
|
+
`
|
|
292
|
+
: ''
|
|
293
|
+
}${
|
|
294
|
+
hasPlurals
|
|
295
|
+
? `
|
|
296
|
+
function plural(key: string, count: number): string {
|
|
297
|
+
const forms = lookup(key) as Plural
|
|
298
|
+
return fill(forms[new Intl.PluralRules(currentLocale).select(count)] ?? forms.other, { count })
|
|
299
|
+
}
|
|
300
|
+
`
|
|
301
|
+
: ''
|
|
302
|
+
}
|
|
303
|
+
/** User-facing copy. Keys and placeholders match the iOS and Android apps. */
|
|
304
|
+
export const strings = {
|
|
305
|
+
${members.join('\n')}
|
|
306
|
+
}
|
|
307
|
+
`
|
|
308
|
+
}
|