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.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/bin/geeknative.js +12 -0
  3. package/package.json +31 -4
  4. package/src/cli.js +61 -0
  5. package/src/codegen/app.js +97 -0
  6. package/src/codegen/icons.js +87 -0
  7. package/src/codegen/index.js +44 -0
  8. package/src/codegen/strings.js +308 -0
  9. package/src/codegen/tokens.js +449 -0
  10. package/src/codegen/util.js +122 -0
  11. package/src/commands/check.js +143 -0
  12. package/src/commands/create.js +78 -0
  13. package/src/commands/doctor.js +108 -0
  14. package/src/commands/gen.js +44 -0
  15. package/src/commands/parity.js +134 -0
  16. package/src/commands/run.js +91 -0
  17. package/src/lib/exec.js +77 -0
  18. package/src/lib/fsutil.js +92 -0
  19. package/src/lib/log.js +37 -0
  20. package/src/lib/managed.js +80 -0
  21. package/src/lib/project.js +52 -0
  22. package/src/lib/toolchains.js +126 -0
  23. package/template/.claude/agents/android-engineer.md +15 -0
  24. package/template/.claude/agents/ios-engineer.md +15 -0
  25. package/template/.claude/agents/web-engineer.md +15 -0
  26. package/template/.claude/settings.json +65 -0
  27. package/template/.geeknative/framework.mk +41 -0
  28. package/template/.geeknative/hooks/after-edit.mjs +28 -0
  29. package/template/.geeknative/hooks/parity-guard.mjs +49 -0
  30. package/template/.geeknative/hooks/subagent-context.mjs +24 -0
  31. package/template/.geeknative/playbooks/bug.md +7 -0
  32. package/template/.geeknative/playbooks/design.md +10 -0
  33. package/template/.geeknative/playbooks/engineering.md +9 -0
  34. package/template/.geeknative/playbooks/feature.md +11 -0
  35. package/template/.geeknative/playbooks/platform-specific.md +8 -0
  36. package/template/.geeknative/playbooks/product.md +12 -0
  37. package/template/.geeknative/templates/feature.md +49 -0
  38. package/template/.gemini/settings.json +5 -0
  39. package/template/.github/dependabot.yml +18 -0
  40. package/template/AGENTS.md +97 -0
  41. package/template/CLAUDE.md +9 -0
  42. package/template/DESIGN.md +65 -0
  43. package/template/Makefile +6 -0
  44. package/template/PRODUCT.md +40 -0
  45. package/template/README.md +28 -0
  46. package/template/_gitignore +33 -0
  47. package/template/apps/android/AGENTS.md +66 -0
  48. package/template/apps/android/CLAUDE.md +1 -0
  49. package/template/apps/android/app/build.gradle.kts +68 -0
  50. package/template/apps/android/app/identity.properties +4 -0
  51. package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
  52. package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
  53. package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
  54. package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
  55. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
  56. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
  57. package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
  58. package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
  59. package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
  60. package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
  61. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
  62. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
  63. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
  64. package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
  65. package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
  66. package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
  67. package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
  68. package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
  69. package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
  70. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  71. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  72. package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
  73. package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
  74. package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  75. package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
  76. package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
  77. package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
  78. package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
  79. package/template/apps/android/build.gradle.kts +5 -0
  80. package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
  81. package/template/apps/android/gradle/libs.versions.toml +37 -0
  82. package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  83. package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  84. package/template/apps/android/gradle.properties +7 -0
  85. package/template/apps/android/gradlew +248 -0
  86. package/template/apps/android/gradlew.bat +82 -0
  87. package/template/apps/android/settings.gradle.kts +29 -0
  88. package/template/apps/ios/AGENTS.md +70 -0
  89. package/template/apps/ios/App/AppMain.swift +13 -0
  90. package/template/apps/ios/App/Core/AppContainer.swift +22 -0
  91. package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
  92. package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
  93. package/template/apps/ios/App/Core/RootView.swift +32 -0
  94. package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
  95. package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
  96. package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
  97. package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
  98. package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
  99. package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
  100. package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
  101. package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
  102. package/template/apps/ios/App/Generated/Icons.swift +8 -0
  103. package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
  104. package/template/apps/ios/App/Generated/Strings.swift +40 -0
  105. package/template/apps/ios/App/Generated/Tokens.swift +65 -0
  106. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  107. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  108. package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
  109. package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
  110. package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  111. package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
  112. package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
  113. package/template/apps/ios/CLAUDE.md +1 -0
  114. package/template/apps/ios/Config/App.xcconfig +23 -0
  115. package/template/apps/ios/Config/AppTests.xcconfig +10 -0
  116. package/template/apps/ios/Config/Base.xcconfig +25 -0
  117. package/template/apps/ios/Config/Debug.xcconfig +9 -0
  118. package/template/apps/ios/Config/Identity.xcconfig +5 -0
  119. package/template/apps/ios/Config/Release.xcconfig +6 -0
  120. package/template/apps/web/AGENTS.md +63 -0
  121. package/template/apps/web/CLAUDE.md +1 -0
  122. package/template/apps/web/package.json +38 -0
  123. package/template/apps/web/public/favicon.svg +6 -0
  124. package/template/apps/web/src/core/AppContainer.ts +18 -0
  125. package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
  126. package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
  127. package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
  128. package/template/apps/web/src/features/home/home.test.tsx +22 -0
  129. package/template/apps/web/src/features/settings/Appearance.ts +29 -0
  130. package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
  131. package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
  132. package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
  133. package/template/apps/web/src/features/settings/settings.test.ts +45 -0
  134. package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
  135. package/template/apps/web/src/generated/app.ts +9 -0
  136. package/template/apps/web/src/generated/icons.ts +9 -0
  137. package/template/apps/web/src/generated/strings.ts +88 -0
  138. package/template/apps/web/src/generated/tokens.css +86 -0
  139. package/template/apps/web/src/routeTree.gen.ts +86 -0
  140. package/template/apps/web/src/router.tsx +23 -0
  141. package/template/apps/web/src/routes/__root.tsx +72 -0
  142. package/template/apps/web/src/routes/index.tsx +6 -0
  143. package/template/apps/web/src/routes/settings.tsx +9 -0
  144. package/template/apps/web/src/styles.css +9 -0
  145. package/template/apps/web/tsconfig.json +26 -0
  146. package/template/apps/web/tsr.config.json +3 -0
  147. package/template/apps/web/vite.config.ts +9 -0
  148. package/template/apps/web/vitest.config.ts +9 -0
  149. package/template/contracts/app.json +7 -0
  150. package/template/contracts/icons.json +5 -0
  151. package/template/contracts/strings/en.json +13 -0
  152. package/template/contracts/tokens.json +40 -0
  153. package/template/package.json +17 -0
  154. package/template/spec/decisions.md +10 -0
  155. package/template/spec/features/home.md +39 -0
  156. package/template/spec/features/settings.md +50 -0
  157. package/template/spec/navigation.md +16 -0
  158. package/template/spec/parity.md +29 -0
  159. 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, '&amp;')
197
+ .replace(/</g, '&lt;')
198
+ .replace(/>/g, '&gt;')
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
+ }