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/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, read } from '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 fetchGlotstack = async (apiOrigin: string, apiKey: string, body: object) => {
14
- const url = `${apiOrigin}/api/translations`
15
- console.info(`Fetching translations from: ${url}`)
21
+ const headers: Record<string, any> = {
22
+ 'authorization': `Bearer ${apiKey}`,
23
+ ...(overrideHeaders == null ? {} : overrideHeaders),
24
+ }
25
+
26
+ let payloadBody: FormData | string
16
27
 
17
- const headers = {
18
- 'Content-Type': 'application/json',
19
- 'Authorization': `Bearer ${apiKey}`,
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: JSON.stringify(body), headers })
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 = merge(config, options, { outputLocales })
101
- const { apiOrigin, sourcePath, outputDir, projectId } = resolved
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 data = await fetchGlotstack(apiOrigin, resolved.apiKey, body)
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, isEqual } from './util/object'
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: (locale: LocaleRegion) => Promise<Translations>
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
- loadingRef.current[locale] = importMethod(locale)
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
- console.error('Unable to import translations', err)
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
- const result = await loadTranslations(locale)
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
- React.useEffect(() => {
117
- if (opts?.locale == null) {
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
package/tsconfig.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "module": "CommonJS",
9
9
  "declaration": true,
10
10
  "strict": true,
11
- "jsx": "react",
11
+ "jsx": "react-jsx",
12
12
  "sourceMap": true,
13
13
  "baseUrl": ".",
14
14
  "paths": {