scratch-l10n 6.0.74 → 6.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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to this project will be documented in this file. See
4
4
  [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [6.1.0](https://github.com/scratchfoundation/scratch-l10n/compare/v6.0.75...v6.1.0) (2025-10-30)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * apply suggestions from code review ([19fb2c4](https://github.com/scratchfoundation/scratch-l10n/commit/19fb2c424025360955d64b2dec84b22c3bf9c19d))
12
+
13
+
14
+ ### Features
15
+
16
+ * continue after validation error ([08be90e](https://github.com/scratchfoundation/scratch-l10n/commit/08be90e3dc362fb0104e29cb96fcd60e8497b6a8))
17
+
18
+ ## [6.0.75](https://github.com/scratchfoundation/scratch-l10n/compare/v6.0.74...v6.0.75) (2025-10-30)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * **deps:** lock file maintenance ([78aca24](https://github.com/scratchfoundation/scratch-l10n/commit/78aca246ce71efb0491ec24be194dd91da0f3d69))
24
+
6
25
  ## [6.0.74](https://github.com/scratchfoundation/scratch-l10n/compare/v6.0.73...v6.0.74) (2025-10-28)
7
26
 
8
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scratch-l10n",
3
- "version": "6.0.74",
3
+ "version": "6.1.0",
4
4
  "description": "Localization for the Scratch 3.0 components",
5
5
  "main": "./dist/l10n.js",
6
6
  "browser": "./src/index.mjs",
@@ -0,0 +1,142 @@
1
+ import locales, { localeMap } from '../../src/supported-locales.mjs'
2
+ import { poolMap } from './concurrent.mts'
3
+ import { ProgressLogger } from './progress-logger.mts'
4
+ import { TransifexStringsKeyValueJson } from './transifex-formats.mts'
5
+ import { txPull, txResources } from './transifex.mts'
6
+ import { filterInvalidTranslations, TransifexEditorString } from './validate.mts'
7
+
8
+ const CONCURRENCY_LIMIT = 36
9
+ const SOURCE_LOCALE = 'en' // TODO: don't hardcode this
10
+
11
+ /**
12
+ * @param resources - A list of Transifex resource names
13
+ * @param selectedLocales - A list of Scratch locale codes
14
+ * @returns The list of all resource/locale combinations to pull. The source locale is always excluded.
15
+ */
16
+ const expandResourceFiles = (resources: string[], selectedLocales: string[]) => {
17
+ const files = []
18
+ for (const resource of resources) {
19
+ for (const locale of selectedLocales) {
20
+ if (locale === SOURCE_LOCALE) {
21
+ continue
22
+ }
23
+ files.push({ resource: resource, locale: locale })
24
+ }
25
+ }
26
+ return files
27
+ }
28
+
29
+ /**
30
+ * Pull and validate a single Transifex "file" (resource + locale).
31
+ * @param o - The options for pulling and validating a single file.
32
+ * @param o.allStrings - All pulled strings so far, used to get source strings for validation
33
+ * @param o.project - The Transifex project to pull from
34
+ * @param o.resource - The Transifex resource to pull from
35
+ * @param o.locale - The locale to pull
36
+ * @param o.mode - The mode to use when pulling (e.g., "reviewed")
37
+ * @returns A list of messages about errors encountered during validation, if any.
38
+ */
39
+ async function pullAndValidateFile({
40
+ allStrings,
41
+ project,
42
+ resource,
43
+ locale,
44
+ mode,
45
+ }: {
46
+ allStrings: Record<string, Record<string, TransifexStringsKeyValueJson>>
47
+ project: string
48
+ resource: string
49
+ locale: string
50
+ mode?: string
51
+ }) {
52
+ const messages: string[] = []
53
+ const txLocale = localeMap[locale] || locale
54
+ const fileContent = await txPull<TransifexEditorString>(project, resource, txLocale, mode)
55
+
56
+ // if fileContent has message & description, we only want the message
57
+ const translations: TransifexStringsKeyValueJson = {}
58
+ for (const key of Object.keys(fileContent)) {
59
+ const tx = fileContent[key]
60
+ if (typeof tx === 'string') {
61
+ translations[key] = tx
62
+ } else {
63
+ translations[key] = tx.message
64
+ }
65
+ }
66
+
67
+ if (!(resource in allStrings)) {
68
+ allStrings[resource] = {}
69
+ }
70
+ allStrings[resource][locale] = translations
71
+
72
+ // may or may not be the same as `translations`
73
+ const sourceStrings = allStrings[resource][SOURCE_LOCALE]
74
+
75
+ // some of the validation checks may still be relevant even if locale === SOURCE_LOCALE
76
+ // console.log({ resource, locale, translations, sourceStrings })
77
+ messages.push(...filterInvalidTranslations(locale, translations, sourceStrings))
78
+
79
+ return messages
80
+ }
81
+
82
+ /**
83
+ * Pull one or more resource(s) from a transifex project and validate the strings.
84
+ * Return any error messages from the validation process along with all valid strings.
85
+ * @param o - The options for pulling, validating, and saving translations.
86
+ * @param o.project - The Transifex project to pull translations from.
87
+ * @param o.mode - The mode to use when pulling translations (e.g., "reviewed").
88
+ * @param o.resources - The resources within the project to pull translations for.
89
+ * @param o.selectedLocales - The locales to pull translations for. Defaults to all supported locales.
90
+ * @returns Translation strings and a list of messages about errors encountered during validation, if any.
91
+ */
92
+ export async function pullAndValidateProject({
93
+ project,
94
+ resources,
95
+ mode,
96
+ selectedLocales,
97
+ }: {
98
+ project: string
99
+ mode?: string
100
+ resources?: string[]
101
+ selectedLocales?: string | string[]
102
+ }) {
103
+ const selectedResources = resources ?? (await txResources(project))
104
+ selectedLocales =
105
+ typeof selectedLocales === 'string' ? [selectedLocales] : (selectedLocales ?? Object.keys(locales))
106
+
107
+ const files = expandResourceFiles(selectedResources, selectedLocales)
108
+
109
+ const allStrings: Record<string, Record<string, TransifexStringsKeyValueJson>> = {}
110
+ const messages: string[] = []
111
+
112
+ const progress = new ProgressLogger(selectedResources.length + files.length)
113
+
114
+ const handleFile = async (resource: string, locale: string) => {
115
+ try {
116
+ const fileMessages = await pullAndValidateFile({
117
+ allStrings,
118
+ project,
119
+ resource,
120
+ locale,
121
+ mode,
122
+ })
123
+ for (const message of fileMessages) {
124
+ // `message` already contains locale and/or string info if appropriate
125
+ messages.push(`resource ${resource} / ${message}`)
126
+ }
127
+ } finally {
128
+ progress.increment()
129
+ }
130
+ }
131
+
132
+ // Ensure source locale is available for validation
133
+ await poolMap(selectedResources, CONCURRENCY_LIMIT, async resource => handleFile(resource, SOURCE_LOCALE))
134
+
135
+ // Non-source locales
136
+ await poolMap(files, CONCURRENCY_LIMIT, async ({ resource, locale }) => handleFile(resource, locale))
137
+
138
+ return {
139
+ allStrings,
140
+ messages,
141
+ }
142
+ }
@@ -1,4 +1,3 @@
1
- import assert from 'assert'
2
1
  import parse from 'format-message-parse'
3
2
  import {
4
3
  TransifexStringChrome,
@@ -84,24 +83,52 @@ export const validMessage = (message: TransifexEditorString, source: TransifexEd
84
83
  }
85
84
 
86
85
  /**
87
- * @param translation - the translations to validate and their corresponding source strings
88
- * @param translation.locale - the Transifex locale, for error reporting
89
- * @param translation.translations - the translations to validate
86
+ * Validate and filter translations.
87
+ * WARNING: Modifies the translations object in place by replacing invalid translations with source strings.
88
+ * @param locale - the Transifex locale, for error reporting
89
+ * @param translations - the translations to validate and filter
90
90
  * @param source - the source strings for the translations
91
+ * @returns a list of messages about errors encountered during validation. Every removed translation will have a
92
+ * message. Some messages may not correspond to removed translations (e.g., when the number of keys differ).
91
93
  */
92
- export const validateTranslations = (
93
- { locale, translations }: { locale: string; translations: TransifexEditorStrings },
94
+ export const filterInvalidTranslations = (
95
+ locale: string,
96
+ translations: TransifexEditorStrings,
94
97
  source: TransifexEditorStrings,
95
- ) => {
96
- const transKeys = Object.keys(translations)
98
+ ): string[] => {
99
+ const messages: string[] = []
100
+
97
101
  const sourceKeys = Object.keys(source)
98
- assert.strictEqual(transKeys.length, sourceKeys.length, `locale ${locale} has a different number of message keys`)
99
- transKeys.forEach(item => assert(sourceKeys.includes(item), `locale ${locale} has key ${item} not in the source`))
100
- sourceKeys.forEach(item => assert(transKeys.includes(item), `locale ${locale} is missing key ${item}`))
101
- sourceKeys.forEach(item =>
102
- assert(
103
- validMessage(translations[item], source[item]),
104
- `locale ${locale} / item ${item}: message validation failed:\n msg: ${getMessageText(translations[item])}\n src: ${getMessageText(source[item])}`,
105
- ),
106
- )
102
+
103
+ const transKeys = Object.keys(translations).filter(item => {
104
+ if (!sourceKeys.includes(item)) {
105
+ messages.push(`locale ${locale} has key ${item} not in the source`)
106
+ delete translations[item]
107
+ return false
108
+ }
109
+ return true
110
+ })
111
+
112
+ sourceKeys.forEach(item => {
113
+ if (!transKeys.includes(item)) {
114
+ messages.push(`locale ${locale} is missing key ${item}`)
115
+ }
116
+ })
117
+
118
+ transKeys.forEach(item => {
119
+ if (!validMessage(translations[item], source[item])) {
120
+ messages.push(
121
+ [
122
+ `locale ${locale} / item ${item}: message validation failed:`,
123
+ ` msg: ${getMessageText(translations[item])}`,
124
+ ` src: ${getMessageText(source[item])}`,
125
+ ].join('\n'),
126
+ )
127
+
128
+ // fall back to source message
129
+ translations[item] = source[item]
130
+ }
131
+ })
132
+
133
+ return messages
107
134
  }
@@ -8,11 +8,7 @@
8
8
  */
9
9
  import fs from 'fs'
10
10
  import path from 'path'
11
- import locales, { localeMap } from '../src/supported-locales.mjs'
12
- import { poolMap } from './lib/concurrent.mts'
13
- import { TransifexStringsKeyValueJson } from './lib/transifex-formats.mts'
14
- import { txPull } from './lib/transifex.mts'
15
- import { TransifexEditorStrings, validateTranslations } from './lib/validate.mts'
11
+ import { pullAndValidateProject } from './lib/pull-and-validate.mts'
16
12
 
17
13
  const args = process.argv.slice(2)
18
14
 
@@ -37,38 +33,20 @@ const PROJECT = args[0]
37
33
  const RESOURCE = args[1]
38
34
  const OUTPUT_DIR = path.resolve(args[2])
39
35
  const MODE = 'reviewed'
40
- const CONCURRENCY_LIMIT = 36
41
36
 
42
- const getLocaleData = async function (locale: string) {
43
- const txLocale = localeMap[locale] || locale
44
- const data = (await txPull(PROJECT, RESOURCE, txLocale, MODE)) as TransifexEditorStrings
45
- return {
46
- locale: locale,
47
- translations: data,
48
- }
49
- }
37
+ const validationResults = await pullAndValidateProject({
38
+ project: PROJECT,
39
+ resources: [RESOURCE],
40
+ mode: MODE,
41
+ })
50
42
 
51
- const pullTranslations = async function () {
52
- const values = await poolMap(Object.keys(locales), CONCURRENCY_LIMIT, getLocaleData)
53
- const source = values.find(elt => elt.locale === 'en')?.translations
54
- if (!source) {
55
- throw new Error('Could not find source strings')
43
+ for (const resource of Object.values(validationResults.allStrings)) {
44
+ for (const [locale, strings] of Object.entries(resource)) {
45
+ const file = JSON.stringify(strings, null, 4)
46
+ fs.writeFileSync(`${OUTPUT_DIR}/${locale}.json`, file)
56
47
  }
57
- values.forEach(translation => {
58
- validateTranslations({ locale: translation.locale, translations: translation.translations }, source)
59
- // if translation has message & description, we only want the message
60
- const txs: TransifexStringsKeyValueJson = {}
61
- for (const key of Object.keys(translation.translations)) {
62
- const tx = translation.translations[key]
63
- if (typeof tx === 'string') {
64
- txs[key] = tx
65
- } else {
66
- txs[key] = tx.message
67
- }
68
- }
69
- const file = JSON.stringify(txs, null, 4)
70
- fs.writeFileSync(`${OUTPUT_DIR}/${translation.locale}.json`, file)
71
- })
72
48
  }
73
49
 
74
- await pullTranslations()
50
+ if (validationResults.messages.length > 0) {
51
+ console.error(validationResults.messages.join('\n\n'))
52
+ }
@@ -9,10 +9,7 @@
9
9
  import fs from 'fs/promises'
10
10
  import { mkdirp } from 'mkdirp'
11
11
  import path from 'path'
12
- import locales, { localeMap } from '../src/supported-locales.mjs'
13
- import { poolMap } from './lib/concurrent.mts'
14
- import { ProgressLogger } from './lib/progress-logger.mjs'
15
- import { txPull, txResources } from './lib/transifex.mts'
12
+ import { pullAndValidateProject } from './lib/pull-and-validate.mts'
16
13
 
17
14
  const args = process.argv.slice(2)
18
15
 
@@ -37,68 +34,34 @@ if (!process.env.TX_TOKEN || args.length < 1) {
37
34
  const PROJECT = 'scratch-website'
38
35
  const OUTPUT_DIR = path.resolve(args[0])
39
36
  // const MODE = {mode: 'reviewed'}; // default is everything for www
40
- const CONCURRENCY_LIMIT = 36
41
37
 
42
- const lang = args.length === 2 ? args[1] : ''
38
+ const lang = args.length === 2 ? args[1] : undefined
39
+ const validationResults = await pullAndValidateProject({
40
+ project: PROJECT,
41
+ selectedLocales: lang,
42
+ })
43
43
 
44
- const getLocaleData = async function (item: { locale: string; resource: string }) {
45
- const locale = item.locale
46
- const resource = item.resource
47
- const txLocale = localeMap[locale] || locale
44
+ for (const [resourceName, resource] of Object.entries(validationResults.allStrings)) {
45
+ for (const [locale, translations] of Object.entries(resource)) {
46
+ const txOutdir = `${OUTPUT_DIR}/${PROJECT}.${resourceName}`
47
+ const fileName = `${txOutdir}/${locale}.json`
48
48
 
49
- const translations = await txPull(PROJECT, resource, txLocale)
50
-
51
- const txOutdir = `${OUTPUT_DIR}/${PROJECT}.${resource}`
52
- const fileName = `${txOutdir}/${locale}.json`
53
-
54
- try {
55
- mkdirp.sync(txOutdir)
56
- await fs.writeFile(fileName, JSON.stringify(translations, null, 4))
57
-
58
- return {
59
- resource,
60
- locale,
61
- fileName,
62
- }
63
- } catch (e) {
64
- ;(e as Error).cause = {
65
- resource,
66
- locale,
67
- translations,
68
- txOutdir,
69
- fileName,
70
- }
71
- throw e
72
- }
73
- }
74
-
75
- const expandResourceFiles = (resources: string[]) => {
76
- const items = []
77
- for (const resource of resources) {
78
- if (lang) {
79
- items.push({ resource: resource, locale: lang })
80
- } else {
81
- for (const locale of Object.keys(locales)) {
82
- items.push({ resource: resource, locale: locale })
49
+ try {
50
+ mkdirp.sync(txOutdir)
51
+ await fs.writeFile(fileName, JSON.stringify(translations, null, 4))
52
+ } catch (e) {
53
+ ;(e as Error).cause = {
54
+ resourceName,
55
+ locale,
56
+ translations,
57
+ txOutdir,
58
+ fileName,
83
59
  }
60
+ throw e
84
61
  }
85
62
  }
86
- return items
87
63
  }
88
64
 
89
- const pullTranslations = async function () {
90
- const resources = await txResources(PROJECT)
91
- const allFiles = expandResourceFiles(resources)
92
-
93
- const progress = new ProgressLogger(allFiles.length)
94
-
95
- await poolMap(allFiles, CONCURRENCY_LIMIT, async item => {
96
- try {
97
- await getLocaleData(item)
98
- } finally {
99
- progress.increment()
100
- }
101
- })
65
+ if (validationResults.messages.length > 0) {
66
+ console.error(validationResults.messages.join('\n\n'))
102
67
  }
103
-
104
- await pullTranslations()
@@ -7,7 +7,7 @@ import async from 'async'
7
7
  import fs from 'fs'
8
8
  import path from 'path'
9
9
  import locales from '../src/supported-locales.mjs'
10
- import { TransifexEditorStrings, validateTranslations } from './lib/validate.mts'
10
+ import { filterInvalidTranslations, TransifexEditorStrings } from './lib/validate.mts'
11
11
 
12
12
  const args = process.argv.slice(2)
13
13
  const usage = `
@@ -31,11 +31,10 @@ const validate = (locale: string, callback: async.ErrorCallback) => {
31
31
  if (err) callback(err)
32
32
  // let this throw an error if invalid json
33
33
  const strings = JSON.parse(data) as TransifexEditorStrings
34
- const translations = {
35
- locale: locale,
36
- translations: strings,
34
+ const messages = filterInvalidTranslations(locale, strings, source)
35
+ if (messages.length > 0) {
36
+ callback(new Error(`Locale ${locale} has validation errors:\n${messages.join('\n')}`))
37
37
  }
38
- validateTranslations(translations, source)
39
38
  })
40
39
  }
41
40
 
@@ -9,7 +9,7 @@ import glob from 'glob'
9
9
  import path from 'path'
10
10
  import locales from '../src/supported-locales.mjs'
11
11
  import { TransifexStringsKeyValueJson, TransifexStrings } from './lib/transifex-formats.mts'
12
- import { validateTranslations } from './lib/validate.mts'
12
+ import { filterInvalidTranslations } from './lib/validate.mts'
13
13
 
14
14
  const args = process.argv.slice(2)
15
15
  const usage = `
@@ -37,11 +37,10 @@ const validate = (localeData: LocaleData, callback: async.ErrorCallback) => {
37
37
  if (err) callback(err)
38
38
  // let this throw an error if invalid json
39
39
  const strings = JSON.parse(data) as TransifexStringsKeyValueJson
40
- const translations = {
41
- locale: localeData.locale,
42
- translations: strings,
40
+ const messages = filterInvalidTranslations(localeData.locale, strings, localeData.sourceData)
41
+ if (messages.length > 0) {
42
+ callback(new Error(`Locale ${localeData.locale} has validation errors:\n${messages.join('\n')}`))
43
43
  }
44
- validateTranslations(translations, localeData.sourceData)
45
44
  })
46
45
  }
47
46