glotstack 0.0.6 → 0.0.7
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/dist/cli.js +133 -32
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.js +63 -19
- package/dist/index.js.map +1 -1
- package/eslint-raw-string.mjs +5 -1
- package/package.json +13 -8
- package/src/cli.tsx +135 -27
- package/src/index.tsx +70 -29
- package/tsconfig.json +1 -1
- package/package-lock.json +0 -4356
package/src/cli.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command, program } from 'commander'
|
|
2
2
|
import path from 'path'
|
|
3
|
-
import { promises as fs,
|
|
3
|
+
import { promises as fs, createReadStream } from 'fs'
|
|
4
4
|
import { findGlotstackConfig } from './util/findConfig'
|
|
5
5
|
import { cwd } from 'process'
|
|
6
6
|
import { merge } from './util/object'
|
|
@@ -8,34 +8,96 @@ import { loadYaml } from './util/yaml'
|
|
|
8
8
|
import eslint from 'eslint'
|
|
9
9
|
import * as readline from "node:readline/promises"
|
|
10
10
|
import { stdin as input, stdout as output } from "node:process"
|
|
11
|
+
import { Translations } from 'src'
|
|
12
|
+
import { fetch } from 'undici'
|
|
13
|
+
import { FormData } from 'undici'
|
|
14
|
+
import { openAsBlob } from 'node:fs'
|
|
15
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { resolve } from 'node:path'
|
|
11
17
|
|
|
18
|
+
const fetchGlotstack = async function <T>(url: string, apiKey: string, body: Record<string, any> | FormData, overrideHeaders?: Record<string, any>): Promise<T> {
|
|
19
|
+
console.info(`Extracting translations with: ${url}`)
|
|
12
20
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
const headers: Record<string, any> = {
|
|
22
|
+
'authorization': `Bearer ${apiKey}`,
|
|
23
|
+
...(overrideHeaders == null ? {} : overrideHeaders),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let payloadBody: FormData | string
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
'
|
|
19
|
-
|
|
28
|
+
if (!(body instanceof FormData)) {
|
|
29
|
+
headers['content-type'] = 'application/json'
|
|
30
|
+
payloadBody = JSON.stringify(body)
|
|
31
|
+
} else {
|
|
32
|
+
payloadBody = body
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
try {
|
|
23
|
-
const res = await fetch(url, { method: 'POST', body:
|
|
36
|
+
const res = await fetch(url, { method: 'POST', body: payloadBody, headers })
|
|
24
37
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
|
25
|
-
return res.json()
|
|
26
|
-
// fs.writeFile(`${outputDir}/source.json`, JSON.stringify(json, null, 2))
|
|
38
|
+
return res.json() as T
|
|
27
39
|
} catch (err) {
|
|
28
40
|
console.error('Fetch failed:', err)
|
|
29
41
|
process.exit(1)
|
|
30
42
|
}
|
|
31
43
|
}
|
|
32
44
|
|
|
45
|
+
function unflatten(flat: Record<string, any>): Record<string, any> {
|
|
46
|
+
const result: Record<string, any> = {}
|
|
47
|
+
|
|
48
|
+
for (const flatKey in flat) {
|
|
49
|
+
const parts = flatKey.split('.')
|
|
50
|
+
let current = result
|
|
51
|
+
|
|
52
|
+
parts.forEach((part, idx) => {
|
|
53
|
+
if (idx === parts.length - 1) {
|
|
54
|
+
current[part] = flat[flatKey]
|
|
55
|
+
} else {
|
|
56
|
+
if (!(part in current)) {
|
|
57
|
+
current[part] = {}
|
|
58
|
+
}
|
|
59
|
+
current = current[part]
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveConfigAndOptions(options: Record<string, any>) {
|
|
68
|
+
|
|
69
|
+
const configPath = findGlotstackConfig(cwd())
|
|
70
|
+
let config = {}
|
|
71
|
+
|
|
72
|
+
if (configPath != null) {
|
|
73
|
+
console.info('Loading config file at ', configPath)
|
|
74
|
+
try {
|
|
75
|
+
const text = await fs.readFile(configPath, 'utf-8')
|
|
76
|
+
config = JSON.parse(text)
|
|
77
|
+
console.info('Loaded config file', config)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
//pass
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if ('outputLocales' in options) {
|
|
84
|
+
if ((options.outputLocales as string[]).includes('en-US')) {
|
|
85
|
+
console.warn('en-US detected in outputLocales, removing')
|
|
86
|
+
options.outputLocales = options.outputLocales.filter((x: string) => x !== 'en-US')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return merge(config, options)
|
|
91
|
+
}
|
|
92
|
+
|
|
33
93
|
|
|
34
94
|
async function run(args: string[]) {
|
|
35
95
|
program
|
|
36
96
|
.command('extract-translations')
|
|
97
|
+
.description('extract translations from all compatible source files.')
|
|
37
98
|
.option('--source-path [path]', 'to source files root directory', '.')
|
|
38
99
|
.option('--api-origin [url]', 'glotstack api origin', process.env.GLOTSTACK_HOST ?? 'https://glotstack.ai')
|
|
100
|
+
.option('--api-key [key]', 'api key for glotstack.ai')
|
|
39
101
|
.option('--yes', 'skip confirm checks', false)
|
|
40
102
|
.action(async (options: Record<string, any>) => {
|
|
41
103
|
if (!options.apiOrigin) {
|
|
@@ -49,7 +111,6 @@ async function run(args: string[]) {
|
|
|
49
111
|
.map((r) => r.filePath)
|
|
50
112
|
|
|
51
113
|
const rl = readline.createInterface({ input, output })
|
|
52
|
-
|
|
53
114
|
const askToSend = async (): Promise<boolean> => {
|
|
54
115
|
if (options.yes) {
|
|
55
116
|
return true
|
|
@@ -68,6 +129,16 @@ async function run(args: string[]) {
|
|
|
68
129
|
const send = await askToSend()
|
|
69
130
|
if (send) {
|
|
70
131
|
console.info('Sending files to LLM')
|
|
132
|
+
const url = `${options.apiOrigin}/uploads/translations/extract`
|
|
133
|
+
const form = new FormData()
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < filesWithIssues.length; i++) {
|
|
136
|
+
const filePath = filesWithIssues[i]
|
|
137
|
+
form.append(`file_${i}`, await openAsBlob(filePath), filePath)
|
|
138
|
+
console.debug(`Uploading file: ${filePath}`)
|
|
139
|
+
}
|
|
140
|
+
const data = await fetchGlotstack<{ translations: { name: string; modified_source: { url: string } }[] }>(url, options.apiKey, form)
|
|
141
|
+
data.translations.map(elem => console.info(`${elem.name}:\n ${elem.modified_source.url}\n\n`))
|
|
71
142
|
rl.close()
|
|
72
143
|
} else {
|
|
73
144
|
rl.close()
|
|
@@ -76,6 +147,7 @@ async function run(args: string[]) {
|
|
|
76
147
|
|
|
77
148
|
program
|
|
78
149
|
.command('get-translations')
|
|
150
|
+
.description('fetch translations for all [output-locals...]. Use .glotstack.json for repeatable results.')
|
|
79
151
|
.option('--source-path [path]', 'path to en-US.json (or your canonical source json)')
|
|
80
152
|
.option('--api-origin [url]', 'glotstack api origin', process.env.GLOTSTACK_HOST ?? 'https://glotstack.ai')
|
|
81
153
|
.option('--output-dir [path]', 'path to output directory')
|
|
@@ -97,25 +169,18 @@ async function run(args: string[]) {
|
|
|
97
169
|
}
|
|
98
170
|
}
|
|
99
171
|
|
|
100
|
-
const resolved =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if ((resolved.outputLocales as string[]).includes('en-US')) {
|
|
104
|
-
console.warn('en-US detected in outputLocales, removing')
|
|
105
|
-
resolved.outputLocales = resolved.outputLocales.filter((x: string) => x !== 'en-US')
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (!sourcePath) {
|
|
172
|
+
const resolved = await resolveConfigAndOptions({...options, outputLocales: outputLocales})
|
|
173
|
+
if (!resolved.sourcePath) {
|
|
109
174
|
throw new Error('sourcePath must be specified')
|
|
110
175
|
}
|
|
111
|
-
if (!apiOrigin) {
|
|
176
|
+
if (!resolved.apiOrigin) {
|
|
112
177
|
throw new Error('apiOrigin must be specified')
|
|
113
178
|
}
|
|
114
|
-
if (!outputDir) {
|
|
179
|
+
if (!resolved.outputDir) {
|
|
115
180
|
throw new Error('outputDir must be specified')
|
|
116
181
|
}
|
|
117
182
|
|
|
118
|
-
const absPath = path.resolve(sourcePath)
|
|
183
|
+
const absPath = path.resolve(resolved.sourcePath)
|
|
119
184
|
const fileContent = await fs.readFile(absPath, 'utf-8')
|
|
120
185
|
|
|
121
186
|
let json = null
|
|
@@ -133,18 +198,61 @@ async function run(args: string[]) {
|
|
|
133
198
|
const body = {
|
|
134
199
|
locales: resolved.outputLocales,
|
|
135
200
|
translations: json,
|
|
136
|
-
...{ ... (projectId != null ? { projectId } : {}) },
|
|
201
|
+
...{ ... (resolved.projectId != null ? { projectId: resolved.projectId } : {}) },
|
|
137
202
|
}
|
|
138
203
|
|
|
139
|
-
const
|
|
204
|
+
const url = `${options.apiOrigin}/api/translations`
|
|
205
|
+
const data = await fetchGlotstack<{ data: Translations }>(url, resolved.apiKey, body)
|
|
140
206
|
console.info('Received translations:', data)
|
|
141
207
|
Object.entries(data.data).map(([key, val]) => {
|
|
142
|
-
const p = `${outputDir}/${key}.json`
|
|
208
|
+
const p = `${resolved.outputDir}/${key}.json`
|
|
143
209
|
console.info(`Writing file ${p}`)
|
|
144
|
-
fs.writeFile(`${outputDir}/${key}.json`, JSON.stringify(val, null, 2))
|
|
210
|
+
fs.writeFile(`${resolved.outputDir}/${key}.json`, JSON.stringify(val, null, 2))
|
|
145
211
|
})
|
|
146
212
|
})
|
|
147
213
|
|
|
214
|
+
program
|
|
215
|
+
.command('format-json')
|
|
216
|
+
.description('format files in --source-path [path] to nested (not flat)')
|
|
217
|
+
.option('--source-path [path]', 'to source files root directory', '.')
|
|
218
|
+
.option('--yes', 'skip confirm checks', false)
|
|
219
|
+
.action(async (options: Record<string, any>) => {
|
|
220
|
+
|
|
221
|
+
if (!options.sourcePath) {
|
|
222
|
+
throw new Error('sourcePath must be specified')
|
|
223
|
+
}
|
|
224
|
+
const rl = readline.createInterface({ input, output })
|
|
225
|
+
const askToSend = async (): Promise<boolean> => {
|
|
226
|
+
if (options.yes) {
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
const response = await rl.question(`This will update your source files -- have you checked them into SCM/git? Type yes to proceed (yes/no):`)
|
|
230
|
+
if (response === 'yes') {
|
|
231
|
+
return true
|
|
232
|
+
} else if (response !== 'no') {
|
|
233
|
+
console.error('Please respond with yes or no.')
|
|
234
|
+
return askToSend()
|
|
235
|
+
} else {
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const yes = await askToSend()
|
|
240
|
+
if (yes) {
|
|
241
|
+
const files = await readdir(options.sourcePath)
|
|
242
|
+
for (let i = 0; i < (await files).length; i++) {
|
|
243
|
+
const fp = resolve(options.sourcePath, files[i])
|
|
244
|
+
const text = await readFile(fp, 'utf-8')
|
|
245
|
+
const json = JSON.parse(text)
|
|
246
|
+
const formatted = JSON.stringify(unflatten(json), null, 2)
|
|
247
|
+
await writeFile(fp, formatted)
|
|
248
|
+
}
|
|
249
|
+
rl.close()
|
|
250
|
+
}
|
|
251
|
+
rl.close()
|
|
252
|
+
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
|
|
148
256
|
await program.parseAsync(args)
|
|
149
257
|
}
|
|
150
258
|
|
package/src/index.tsx
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
-
import { merge
|
|
2
|
+
import { merge } from './util/object'
|
|
3
|
+
import { debug } from 'node:console'
|
|
3
4
|
|
|
4
|
-
export type LocaleRegion =
|
|
5
|
-
| 'en'
|
|
6
|
-
| 'en-US'
|
|
7
|
-
| 'en-US-genz'
|
|
8
|
-
| 'fr-FR'
|
|
9
|
-
| 'de-DE'
|
|
10
|
-
| 'nl-NL'
|
|
11
|
-
| 'jp-JP'
|
|
12
|
-
| string
|
|
5
|
+
export type LocaleRegion = string
|
|
13
6
|
|
|
14
7
|
|
|
15
8
|
export interface TranslationLeaf {
|
|
@@ -21,13 +14,15 @@ export interface Translations {
|
|
|
21
14
|
[key: string]: Translations | TranslationLeaf
|
|
22
15
|
}
|
|
23
16
|
|
|
17
|
+
|
|
24
18
|
export interface ContextType {
|
|
25
19
|
translations: Translations
|
|
26
20
|
locale: string | null
|
|
27
21
|
loadTranslations: (locale: LocaleRegion) => Promise<Translations>
|
|
28
22
|
setLocale: (locale: LocaleRegion) => void
|
|
29
|
-
importMethod: (locale: LocaleRegion) => Promise<Translations>
|
|
23
|
+
importMethod: (locale: LocaleRegion) => Promise<Translations>
|
|
30
24
|
t: (key: string, options?: { locale?: LocaleRegion }) => string
|
|
25
|
+
ssr: boolean
|
|
31
26
|
}
|
|
32
27
|
|
|
33
28
|
export const GlotstackContext = React.createContext<ContextType>({
|
|
@@ -37,18 +32,61 @@ export const GlotstackContext = React.createContext<ContextType>({
|
|
|
37
32
|
locale: null,
|
|
38
33
|
importMethod: (_locale: LocaleRegion) => { throw new Error('import method not set') },
|
|
39
34
|
t: () => { throw new Error('import method not set') },
|
|
35
|
+
ssr: false
|
|
40
36
|
})
|
|
41
37
|
|
|
42
38
|
interface GlotstackProviderProps {
|
|
43
39
|
children: React.ReactNode
|
|
44
|
-
initialTranslations?: Translations
|
|
40
|
+
initialTranslations?: Record<string, Translations>
|
|
45
41
|
initialLocale?: LocaleRegion
|
|
46
42
|
onTranslationLoaded?: (locale: LocaleRegion, translations: Translations) => void
|
|
47
43
|
onLocaleChange?: (locale: LocaleRegion) => void
|
|
48
|
-
importMethod:
|
|
44
|
+
importMethod: ContextType['importMethod']
|
|
45
|
+
ssr?: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export enum LogLevel {
|
|
49
|
+
DEBUG = 0,
|
|
50
|
+
LOG = 1,
|
|
51
|
+
INFO = 2,
|
|
52
|
+
WARNING = 3,
|
|
53
|
+
ERROR = 4,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LogLevelToFunc: Record<LogLevel, (...args: Parameters<typeof console.info>) => void> = {
|
|
57
|
+
[LogLevel.DEBUG]: console.debug,
|
|
58
|
+
[LogLevel.INFO]: console.info,
|
|
59
|
+
[LogLevel.LOG]: console.log,
|
|
60
|
+
[LogLevel.WARNING]: console.warn,
|
|
61
|
+
[LogLevel.ERROR]: console.error,
|
|
62
|
+
} as const
|
|
63
|
+
|
|
64
|
+
let logLevel: LogLevel = LogLevel.DEBUG
|
|
65
|
+
|
|
66
|
+
export const setLogLevel = (level: LogLevel) => {
|
|
67
|
+
logLevel = level
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const makeLoggingFunction = (level: LogLevel) => (...args: Parameters<typeof console.info>) => {
|
|
71
|
+
const func = LogLevelToFunc[level]
|
|
72
|
+
if (level < logLevel) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
return func(`[level=${level} logLevel=${logLevel}][glotstack.ai]`, ...args)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const logger = {
|
|
79
|
+
debug: makeLoggingFunction(LogLevel.DEBUG),
|
|
80
|
+
info: makeLoggingFunction(LogLevel.INFO),
|
|
81
|
+
warn: makeLoggingFunction(LogLevel.WARNING),
|
|
82
|
+
error: makeLoggingFunction(LogLevel.ERROR),
|
|
83
|
+
|
|
49
84
|
}
|
|
50
85
|
|
|
51
86
|
export const access = (key: string, locale: LocaleRegion, translations: Translations) => {
|
|
87
|
+
if (translations == null) {
|
|
88
|
+
return key
|
|
89
|
+
}
|
|
52
90
|
const access = [...key.split('.')] as [LocaleRegion, ...string[]]
|
|
53
91
|
const localeTranslations = translations?.[locale]
|
|
54
92
|
|
|
@@ -64,21 +102,29 @@ export const access = (key: string, locale: LocaleRegion, translations: Translat
|
|
|
64
102
|
return (value?.value ?? key) as string
|
|
65
103
|
}
|
|
66
104
|
|
|
67
|
-
export const GlotstackProvider = ({ children, initialLocale, initialTranslations, onLocaleChange, onTranslationLoaded, importMethod }: GlotstackProviderProps) => {
|
|
105
|
+
export const GlotstackProvider = ({ children, initialLocale, initialTranslations, onLocaleChange, onTranslationLoaded, importMethod, ssr}: GlotstackProviderProps) => {
|
|
68
106
|
if (initialLocale == null) {
|
|
69
107
|
throw new Error('initialLocale must be set')
|
|
70
108
|
}
|
|
71
109
|
const [locale, setLocale] = React.useState<LocaleRegion>(initialLocale)
|
|
72
|
-
const translationsRef = React.useRef(initialTranslations)
|
|
110
|
+
const translationsRef = React.useRef<Record<string, Translations>|null>(initialTranslations || null)
|
|
73
111
|
const loadingRef = React.useRef<Record<string, Promise<Translations>>>({})
|
|
74
112
|
|
|
75
|
-
const loadTranslations = React.useCallback(async (locale: string) => {
|
|
113
|
+
const loadTranslations = React.useCallback(async (locale: string, opts?: {force?: boolean}) => {
|
|
76
114
|
// TODO: if translations are loaded only reload if some condition is
|
|
77
115
|
try {
|
|
78
|
-
if (loadingRef.current?.[locale] != null) {
|
|
116
|
+
if (loadingRef.current?.[locale] != null && opts?.force != true) {
|
|
117
|
+
logger.debug('Waiting for translations already loading', locale)
|
|
79
118
|
return (await loadingRef.current?.[locale])
|
|
80
119
|
}
|
|
81
|
-
|
|
120
|
+
if (translationsRef.current?.[locale] != null && opts?.force != true) {
|
|
121
|
+
logger.debug('Skipping load for translations', locale, translationsRef.current?.[locale], translationsRef.current)
|
|
122
|
+
return translationsRef.current?.[locale]
|
|
123
|
+
}
|
|
124
|
+
if (loadingRef.current != null) {
|
|
125
|
+
logger.debug('Loading translations', locale, merge({}, translationsRef.current ?? {}))
|
|
126
|
+
loadingRef.current[locale] = importMethod(locale)
|
|
127
|
+
}
|
|
82
128
|
const result = await loadingRef.current[locale]
|
|
83
129
|
|
|
84
130
|
if (result == null) {
|
|
@@ -90,7 +136,7 @@ export const GlotstackProvider = ({ children, initialLocale, initialTranslations
|
|
|
90
136
|
onTranslationLoaded?.(locale, result)
|
|
91
137
|
return result
|
|
92
138
|
} catch (err) {
|
|
93
|
-
|
|
139
|
+
logger.error('Unable to import translations', err)
|
|
94
140
|
throw err
|
|
95
141
|
}
|
|
96
142
|
}, [importMethod, onTranslationLoaded])
|
|
@@ -98,7 +144,7 @@ export const GlotstackProvider = ({ children, initialLocale, initialTranslations
|
|
|
98
144
|
React.useEffect(() => {
|
|
99
145
|
const run = async () => {
|
|
100
146
|
onLocaleChange?.(locale)
|
|
101
|
-
|
|
147
|
+
await loadTranslations(locale)
|
|
102
148
|
}
|
|
103
149
|
React.startTransition(() => {
|
|
104
150
|
run()
|
|
@@ -113,15 +159,11 @@ export const GlotstackProvider = ({ children, initialLocale, initialTranslations
|
|
|
113
159
|
importMethod,
|
|
114
160
|
loadTranslations,
|
|
115
161
|
t: (key: string, opts?: { locale?: LocaleRegion }) => {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
loadTranslations(opts?.locale)
|
|
121
|
-
}, [locale, opts?.locale])
|
|
162
|
+
const resolvedLocale = opts?.locale ?? locale
|
|
163
|
+
loadTranslations(resolvedLocale)
|
|
122
164
|
return access(key, opts?.locale ?? locale, translationsRef.current ?? {})
|
|
123
|
-
}
|
|
124
|
-
|
|
165
|
+
},
|
|
166
|
+
ssr: ssr == true,
|
|
125
167
|
}
|
|
126
168
|
}, [locale, importMethod])
|
|
127
169
|
|
|
@@ -134,7 +176,6 @@ export const useGlotstack = () => {
|
|
|
134
176
|
return React.useContext(GlotstackContext)
|
|
135
177
|
}
|
|
136
178
|
|
|
137
|
-
|
|
138
179
|
export const useTranslations = (_options?: Record<never, never>) => {
|
|
139
180
|
const context = React.useContext(GlotstackContext)
|
|
140
181
|
return context
|