Jelajahi Sumber

Refactor locale scripts & generate types and docs (#3276)

Closes #3232 

### Summary

- Create `locale.js` for every plugin instead of `this.defaultLocale` in the class
- Complete refactor of locale scripts
  - No dynamic plugin class imports with hacks to mimic the environment in order to get the `defaultLocale`.
  - Separate tests and build in separate files
  - Add two modes for test file, `unused` (fails hard) and `warnings`
- Generate docs
  - Use remark to cleanly mutate the file
  - Rename some doc files to match the plugin name (such as `statusbar` --> `status-bar`)
  - Comments in `locale.js` are kept and are present in the docs as well.
- Generate types
Merlijn Vos 3 tahun lalu
induk
melakukan
00094af525
67 mengubah file dengan 1177 tambahan dan 883 penghapusan
  1. 0 295
      bin/locale-packs.js
  2. 3 2
      package.json
  3. 3 5
      packages/@uppy/aws-s3/src/index.js
  4. 5 0
      packages/@uppy/aws-s3/src/locale.js
  5. 4 5
      packages/@uppy/box/src/index.js
  6. 5 0
      packages/@uppy/box/src/locale.js
  7. 3 54
      packages/@uppy/core/src/Uppy.js
  8. 58 0
      packages/@uppy/core/src/locale.js
  9. 3 64
      packages/@uppy/dashboard/src/index.js
  10. 88 0
      packages/@uppy/dashboard/src/locale.js
  11. 3 6
      packages/@uppy/drag-drop/src/index.js
  12. 9 0
      packages/@uppy/drag-drop/src/locale.js
  13. 4 5
      packages/@uppy/dropbox/src/index.js
  14. 5 0
      packages/@uppy/dropbox/src/locale.js
  15. 4 5
      packages/@uppy/facebook/src/index.js
  16. 5 0
      packages/@uppy/facebook/src/locale.js
  17. 3 8
      packages/@uppy/file-input/src/index.js
  18. 8 0
      packages/@uppy/file-input/src/locale.js
  19. 4 5
      packages/@uppy/google-drive/src/index.js
  20. 5 0
      packages/@uppy/google-drive/src/locale.js
  21. 3 12
      packages/@uppy/image-editor/src/index.js
  22. 12 0
      packages/@uppy/image-editor/src/locale.js
  23. 4 5
      packages/@uppy/instagram/src/index.js
  24. 5 0
      packages/@uppy/instagram/src/locale.js
  25. 5 5
      packages/@uppy/locales/src/en_US.js
  26. 4 5
      packages/@uppy/onedrive/src/index.js
  27. 5 0
      packages/@uppy/onedrive/src/locale.js
  28. 3 11
      packages/@uppy/screen-capture/src/index.js
  29. 11 0
      packages/@uppy/screen-capture/src/locale.js
  30. 3 33
      packages/@uppy/status-bar/src/index.js
  31. 48 0
      packages/@uppy/status-bar/src/locale.js
  32. 3 5
      packages/@uppy/thumbnail-generator/src/index.js
  33. 5 0
      packages/@uppy/thumbnail-generator/src/locale.js
  34. 3 7
      packages/@uppy/transloadit/src/index.js
  35. 11 0
      packages/@uppy/transloadit/src/locale.js
  36. 3 8
      packages/@uppy/url/src/index.js
  37. 12 0
      packages/@uppy/url/src/locale.js
  38. 2 17
      packages/@uppy/webcam/src/index.js
  39. 28 0
      packages/@uppy/webcam/src/locale.js
  40. 3 5
      packages/@uppy/xhr-upload/src/index.js
  41. 6 0
      packages/@uppy/xhr-upload/src/locale.js
  42. 4 5
      packages/@uppy/zoom/src/index.js
  43. 5 0
      packages/@uppy/zoom/src/locale.js
  44. 22 0
      private/locale-pack/helpers.mjs
  45. 168 0
      private/locale-pack/index.mjs
  46. 20 0
      private/locale-pack/package.json
  47. 159 0
      private/locale-pack/test.mjs
  48. 7 33
      test/endtoend/create-react-app/package-lock.json
  49. 4 6
      website/src/docs/aws-s3.md
  50. 4 5
      website/src/docs/box.md
  51. 57 44
      website/src/docs/core.md
  52. 88 94
      website/src/docs/dashboard.md
  53. 14 1
      website/src/docs/drag-drop.md
  54. 4 5
      website/src/docs/dropbox.md
  55. 4 5
      website/src/docs/facebook.md
  56. 6 2
      website/src/docs/file-input.md
  57. 5 6
      website/src/docs/google-drive.md
  58. 4 5
      website/src/docs/instagram.md
  59. 4 5
      website/src/docs/onedrive.md
  60. 17 0
      website/src/docs/screen-capture.md
  61. 48 41
      website/src/docs/status-bar.md
  62. 12 11
      website/src/docs/transloadit.md
  63. 13 12
      website/src/docs/url.md
  64. 29 22
      website/src/docs/webcam.md
  65. 7 6
      website/src/docs/xhr-upload.md
  66. 6 5
      website/src/docs/zoom.md
  67. 65 3
      yarn.lock

+ 0 - 295
bin/locale-packs.js

@@ -1,295 +0,0 @@
-const glob = require('glob')
-const { ESLint } = require('eslint')
-const chalk = require('chalk')
-const path = require('path')
-const dedent = require('dedent')
-const stringifyObject = require('stringify-object')
-const fs = require('fs')
-const Uppy = require('../packages/@uppy/core')
-
-const uppy = new Uppy()
-
-function getSources (pluginName) {
-  const dependencies = {
-    // because 'provider-views' doesn't have its own locale, it uses Core's defaultLocale
-    core: ['provider-views'],
-  }
-
-  const globPath = path.join(__dirname, '..', 'packages', '@uppy', pluginName, 'lib', '**', '*.js')
-  let contents = glob.sync(globPath).map((file) => {
-    return fs.readFileSync(file, 'utf-8')
-  })
-
-  if (dependencies[pluginName]) {
-    dependencies[pluginName].forEach((addPlugin) => {
-      contents = contents.concat(getSources(addPlugin))
-    })
-  }
-
-  return contents
-}
-
-function buildPluginsList () {
-  const plugins = {}
-  const sources = {}
-
-  // Go over all uppy plugins, check if they are constructors
-  // and instanciate them, check for defaultLocale property,
-  // then add to plugins object
-
-  const packagesGlobPath = path.join(__dirname, '..', 'packages', '@uppy', '*', 'package.json')
-  const files = glob.sync(packagesGlobPath)
-
-  console.log('--> Checked plugins could be instantiated and have defaultLocale in them:\n')
-  for (const file of files) {
-    const dirName = path.dirname(file)
-    const pluginName = path.basename(dirName)
-    if (pluginName === 'locales'
-        || pluginName === 'react-native'
-        || pluginName === 'vue'
-        || pluginName === 'svelte'
-        || pluginName === 'angular') {
-      continue // eslint-disable-line no-continue
-    }
-    const Plugin = require(dirName) // eslint-disable-line global-require, import/no-dynamic-require
-    let plugin
-
-    // A few hacks to emulate browser environment because e.g.:
-    // GoldenRetrieves calls upon MetaDataStore in the constructor, which uses localStorage
-    // @TODO Consider rewriting constructors so they don't make imperative calls that rely on browser environment
-    // (OR: just keep this browser mocking, if it's only causing issues for this script, it doesn't matter)
-    global.location = { protocol: 'https' }
-    global.navigator = { userAgent: '' }
-    global.localStorage = {
-      key: () => { },
-      getItem: () => { },
-    }
-    global.window = {
-      indexedDB: {
-        open: () => { return {} },
-      },
-    }
-    global.document = {
-      createElement: () => {
-        return { style: {} }
-      },
-      get body () { return this.createElement() },
-    }
-
-    try {
-      if (pluginName === 'provider-views') {
-        plugin = new Plugin(plugins['drag-drop'], {
-          companionPattern: '',
-          companionUrl: 'https://companion.uppy.io',
-        })
-      } else if (pluginName === 'store-redux') {
-        plugin = new Plugin({ store: { dispatch: () => { } } })
-      } else {
-        plugin = new Plugin(uppy, {
-          companionPattern: '',
-          companionUrl: 'https://companion.uppy.io',
-          params: {
-            auth: {
-              key: 'x',
-            },
-          },
-        })
-      }
-    } catch (err) {
-      if (err.message !== 'Plugin is not a constructor') {
-        console.error(`--> While trying to instantiate plugin: ${pluginName}, this error was thrown: `)
-        throw err
-      }
-    }
-
-    if (plugin && plugin.defaultLocale) {
-      console.log(`[x] Check plugin: ${pluginName}`)
-      plugins[pluginName] = plugin
-      sources[pluginName] = getSources(pluginName)
-    } else {
-      console.log(`[ ] Check plugin: ${pluginName}`)
-    }
-  }
-
-  console.log('')
-
-  return { plugins, sources }
-}
-
-function addLocaleToPack (localePack, plugin, pluginName) {
-  const localeStrings = plugin.defaultLocale.strings
-
-  for (const key of Object.keys(localeStrings)) {
-    const valueInPlugin = JSON.stringify(localeStrings[key])
-    const valueInPack = JSON.stringify(localePack[key])
-
-    if (key in localePack && valueInPlugin !== valueInPack) {
-      console.error(`⚠ Plugin ${chalk.magenta(pluginName)} has a duplicate key: ${chalk.magenta(key)}`)
-      console.error(`  Value in plugin: ${chalk.cyan(valueInPlugin)}`)
-      console.error(`  Value in pack  : ${chalk.yellow(valueInPack)}`)
-      console.error()
-      throw new Error(`Duplicate locale key: '${key}'`)
-    }
-    localePack[key] = localeStrings[key] // eslint-disable-line no-param-reassign
-  }
-}
-
-function checkForUnused (fileContents, pluginName, localePack) {
-  const buff = fileContents.join('\n')
-  for (const key of Object.keys(localePack)) {
-    const regPat = new RegExp(`(i18n|i18nArray)\\([^\\)]*['\`"]${key}['\`"]`, 'g')
-    if (!buff.match(regPat)) {
-      console.error(`⚠ defaultLocale key: ${chalk.magenta(key)} not used in plugin: ${chalk.cyan(pluginName)}`)
-      throw new Error(`Unused locale key: '${key}'`)
-    }
-  }
-}
-
-function sortObjectAlphabetically (obj) {
-  return Object.fromEntries(Object.entries(obj).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)))
-}
-
-function createTypeScriptLocale (plugin, pluginName) {
-  const allowedStringTypes = Object.keys(plugin.defaultLocale.strings)
-    .map(key => `  | '${key}'`)
-    .join('\n')
-
-  const pluginClassName = pluginName === 'core' ? 'Core' : plugin.id
-  const localePath = path.join(__dirname, '..', 'packages', '@uppy', pluginName, 'types', 'generatedLocale.d.ts')
-
-  const localeTypes = dedent`
-    /* eslint-disable */
-    import type { Locale } from '@uppy/core'
-
-    type ${pluginClassName}Locale = Locale<
-      ${allowedStringTypes}
-    >
-
-    export default ${pluginClassName}Locale
-  `
-
-  fs.writeFileSync(localePath, localeTypes)
-}
-
-async function build () {
-  let localePack = {}
-  const { plugins, sources } = buildPluginsList()
-
-  for (const [pluginName, plugin] of Object.entries(plugins)) {
-    addLocaleToPack(localePack, plugin, pluginName)
-  }
-
-  for (const [pluginName, plugin] of Object.entries(plugins)) {
-    createTypeScriptLocale(plugin, pluginName)
-  }
-
-  localePack = sortObjectAlphabetically(localePack)
-
-  for (const [pluginName, source] of Object.entries(sources)) {
-    checkForUnused(source, pluginName, sortObjectAlphabetically(plugins[pluginName].defaultLocale.strings))
-  }
-
-  const prettyLocale = stringifyObject(localePack, {
-    indent: '  ',
-    singleQuotes: true,
-    inlineCharacterLimit: 12,
-  })
-
-  const localeTemplatePath = path.join(__dirname, '..', 'packages', '@uppy', 'locales', 'template.js')
-  const template = fs.readFileSync(localeTemplatePath, 'utf-8')
-
-  const finalLocale = template.replace('en_US.strings = {}', `en_US.strings = ${prettyLocale}`)
-
-  const localePackagePath = path.join(__dirname, '..', 'packages', '@uppy', 'locales', 'src', 'en_US.js')
-
-  const linter = new ESLint({
-    fix: true,
-  })
-
-  const [lintResult] = await linter.lintText(finalLocale, {
-    filePath: localePackagePath,
-  })
-  fs.writeFileSync(localePackagePath, lintResult.output, 'utf8')
-
-  console.log(`✅ Written '${localePackagePath}'`)
-}
-
-function test () {
-  const leadingLocaleName = 'en_US'
-
-  const followerLocales = {}
-  const followerValues = {}
-  const localePackagePath = path.join(__dirname, '..', 'packages', '@uppy', 'locales', 'src', '*.js')
-  glob.sync(localePackagePath).forEach((localePath) => {
-    const localeName = path.basename(localePath, '.js')
-
-    // Builds array with items like: 'uploadingXFiles'
-    // We do not check nested items because different languages may have different amounts of plural forms.
-    // eslint-disable-next-line global-require, import/no-dynamic-require
-    followerValues[localeName] = require(localePath).strings
-    followerLocales[localeName] = Object.keys(followerValues[localeName])
-  })
-
-  // Take aside our leading locale: en_US
-  const leadingLocale = followerLocales[leadingLocaleName]
-  const leadingValues = followerValues[leadingLocaleName]
-  delete followerLocales[leadingLocaleName]
-
-  // Compare all follower Locales (RU, DE, etc) with our leader en_US
-  const warnings = []
-  const fatals = []
-  for (const [followerName, followerLocale] of Object.entries(followerLocales)) {
-    const missing = leadingLocale.filter((key) => !followerLocale.includes(key))
-    const excess = followerLocale.filter((key) => !leadingLocale.includes(key))
-
-    missing.forEach((key) => {
-      // Items missing are a non-fatal warning because we don't want CI to bum out over all languages
-      // as soon as we add some English
-      let value = leadingValues[key]
-      if (typeof value === 'object') {
-        // For values with plural forms, just take the first one right now
-        value = value[Object.keys(value)[0]]
-      }
-      warnings.push(`${chalk.cyan(followerName)} locale has missing string: '${chalk.red(key)}' that is present in ${chalk.cyan(leadingLocaleName)} with value: ${chalk.yellow(leadingValues[key])}`)
-    })
-    excess.forEach((key) => {
-      // Items in excess are a fatal because we should clean up follower languages once we remove English strings
-      fatals.push(`${chalk.cyan(followerName)} locale has excess string: '${chalk.yellow(key)}' that is not present in ${chalk.cyan(leadingLocaleName)}. `)
-    })
-  }
-
-  if (warnings.length) {
-    console.error('--> Locale warnings: ')
-    console.error(warnings.join('\n'))
-    console.error('')
-  }
-  if (fatals.length) {
-    console.error('--> Locale fatal warnings: ')
-    console.error(fatals.join('\n'))
-    console.error('')
-    process.exit(1)
-  }
-
-  if (!warnings.length && !fatals.length) {
-    console.log(`--> All locale strings have matching keys ${chalk.green(': )')}`)
-    console.log('')
-  }
-}
-
-async function main () {
-  console.warn('\n--> Make sure to run `npm run build:lib` for this locale script to work properly. ')
-
-  const mode = process.argv[2]
-  if (mode === 'build') {
-    await build()
-  } else if (mode === 'test') {
-    test()
-  } else {
-    throw new Error("First argument must be either 'build' or 'test'")
-  }
-}
-
-main().catch((err) => {
-  console.error(err)
-  process.exit(1)
-})

+ 3 - 2
package.json

@@ -135,7 +135,7 @@
     "build:angular": "yarn workspace @uppy/angular build:release",
     "build:js": "npm-run-all build:lib build:companion build:locale-pack build:svelte build:angular build:bundle",
     "build:lib": "yarn node ./bin/build-lib.js",
-    "build:locale-pack": "yarn node --experimental-abortcontroller ./bin/locale-packs.js build",
+    "build:locale-pack": "yarn workspace locale-pack build && eslint packages/@uppy/locales/src/en_US.js --fix && yarn workspace locale-pack test unused",
     "build": "npm-run-all --parallel build:js build:css --serial size",
     "contributors:save": "yarn node ./bin/update-contributors.mjs",
     "dev:browsersync": "yarn workspace @uppy-example/dev start",
@@ -156,7 +156,8 @@
     "test:companion": "yarn workspace @uppy/companion test",
     "test:endtoend:local": "yarn workspace @uppy-tests/end2end test:endtoend:local",
     "test:endtoend": "yarn workspace @uppy-tests/end2end test:endtoend",
-    "test:locale-packs": "yarn node ./bin/locale-packs.js test",
+    "test:locale-packs:unused": "yarn workspace locale-pack test unused",
+    "test:locale-packs:warnings": "yarn workspace locale-pack test warnings",
     "test:type": "yarn workspaces foreach -piv --include '@uppy/*' --exclude '@uppy/{angular,react-native,locales,companion,provider-views,robodog,svelte}' exec tsd",
     "test:unit": "yarn run build:lib && jest --env jsdom",
     "test:watch": "jest --env jsdom --watch",

+ 3 - 5
packages/@uppy/aws-s3/src/index.js

@@ -31,6 +31,8 @@ const { RequestClient } = require('@uppy/companion-client')
 const MiniXHRUpload = require('./MiniXHRUpload')
 const isXml = require('./isXml')
 
+const locale = require('./locale')
+
 function resolveUrl (origin, link) {
   return new URL(link, origin || undefined).toString()
 }
@@ -108,11 +110,7 @@ module.exports = class AwsS3 extends BasePlugin {
     this.id = this.opts.id || 'AwsS3'
     this.title = 'AWS S3'
 
-    this.defaultLocale = {
-      strings: {
-        timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
-      },
-    }
+    this.defaultLocale = locale
 
     const defaultOptions = {
       timeout: 30 * 1000,

+ 5 - 0
packages/@uppy/aws-s3/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
+  },
+}

+ 4 - 5
packages/@uppy/box/src/index.js

@@ -3,6 +3,8 @@ const { Provider } = require('@uppy/companion-client')
 const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
+const locale = require('./locale')
+
 module.exports = class Box extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -32,11 +34,8 @@ module.exports = class Box extends UIPlugin {
       pluginId: this.id,
     })
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameBox: 'Box',
-      },
-    }
+    this.defaultLocale = locale
+
     this.i18nInit()
     this.title = this.i18n('pluginNameBox')
 

+ 5 - 0
packages/@uppy/box/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    pluginNameBox: 'Box',
+  },
+}

+ 3 - 54
packages/@uppy/core/src/Uppy.js

@@ -16,6 +16,8 @@ const supportsUploadProgress = require('./supportsUploadProgress')
 const getFileName = require('./getFileName')
 const { justErrorsLogger, debugLogger } = require('./loggers')
 
+const locale = require('./locale')
+
 // Exported from here.
 class RestrictionError extends Error {
   constructor (...args) {
@@ -68,60 +70,7 @@ class Uppy {
    * @param {object} opts — Uppy options
    */
   constructor (opts) {
-    this.defaultLocale = {
-      strings: {
-        addBulkFilesFailed: {
-          0: 'Failed to add %{smart_count} file due to an internal error',
-          1: 'Failed to add %{smart_count} files due to internal errors',
-        },
-        youCanOnlyUploadX: {
-          0: 'You can only upload %{smart_count} file',
-          1: 'You can only upload %{smart_count} files',
-        },
-        youHaveToAtLeastSelectX: {
-          0: 'You have to select at least %{smart_count} file',
-          1: 'You have to select at least %{smart_count} files',
-        },
-        exceedsSize: '%{file} exceeds maximum allowed size of %{size}',
-        missingRequiredMetaField: 'Missing required meta fields',
-        missingRequiredMetaFieldOnFile: 'Missing required meta fields in %{fileName}',
-        inferiorSize: 'This file is smaller than the allowed size of %{size}',
-        youCanOnlyUploadFileTypes: 'You can only upload: %{types}',
-        noMoreFilesAllowed: 'Cannot add more files',
-        noDuplicates: 'Cannot add the duplicate file \'%{fileName}\', it already exists',
-        companionError: 'Connection with Companion failed',
-        authAborted: 'Authentication aborted',
-        companionUnauthorizeHint: 'To unauthorize to your %{provider} account, please go to %{url}',
-        failedToUpload: 'Failed to upload %{file}',
-        noInternetConnection: 'No Internet connection',
-        connectedToInternet: 'Connected to the Internet',
-        // Strings for remote providers
-        noFilesFound: 'You have no files or folders here',
-        selectX: {
-          0: 'Select %{smart_count}',
-          1: 'Select %{smart_count}',
-        },
-        allFilesFromFolderNamed: 'All files from folder %{name}',
-        openFolderNamed: 'Open folder %{name}',
-        cancel: 'Cancel',
-        logOut: 'Log out',
-        filter: 'Filter',
-        resetFilter: 'Reset filter',
-        loading: 'Loading...',
-        authenticateWithTitle: 'Please authenticate with %{pluginName} to select files',
-        authenticateWith: 'Connect to %{pluginName}',
-        signInWithGoogle: 'Sign in with Google',
-        searchImages: 'Search for images',
-        enterTextToSearch: 'Enter text to search for images',
-        backToSearch: 'Back to Search',
-        emptyFolderAdded: 'No files were added from empty folder',
-        folderAlreadyAdded: 'The folder "%{folder}" was already added',
-        folderAdded: {
-          0: 'Added %{smart_count} file from %{folder}',
-          1: 'Added %{smart_count} files from %{folder}',
-        },
-      },
-    }
+    this.defaultLocale = locale
 
     const defaultOptions = {
       id: 'uppy',

+ 58 - 0
packages/@uppy/core/src/locale.js

@@ -0,0 +1,58 @@
+module.exports = {
+  strings: {
+    addBulkFilesFailed: {
+      0: 'Failed to add %{smart_count} file due to an internal error',
+      1: 'Failed to add %{smart_count} files due to internal errors',
+    },
+    youCanOnlyUploadX: {
+      0: 'You can only upload %{smart_count} file',
+      1: 'You can only upload %{smart_count} files',
+    },
+    youHaveToAtLeastSelectX: {
+      0: 'You have to select at least %{smart_count} file',
+      1: 'You have to select at least %{smart_count} files',
+    },
+    exceedsSize: '%{file} exceeds maximum allowed size of %{size}',
+    missingRequiredMetaField: 'Missing required meta fields',
+    missingRequiredMetaFieldOnFile:
+      'Missing required meta fields in %{fileName}',
+    inferiorSize: 'This file is smaller than the allowed size of %{size}',
+    youCanOnlyUploadFileTypes: 'You can only upload: %{types}',
+    noMoreFilesAllowed: 'Cannot add more files',
+    noDuplicates:
+      "Cannot add the duplicate file '%{fileName}', it already exists",
+    companionError: 'Connection with Companion failed',
+    authAborted: 'Authentication aborted',
+    companionUnauthorizeHint:
+      'To unauthorize to your %{provider} account, please go to %{url}',
+    failedToUpload: 'Failed to upload %{file}',
+    noInternetConnection: 'No Internet connection',
+    connectedToInternet: 'Connected to the Internet',
+    // Strings for remote providers
+    noFilesFound: 'You have no files or folders here',
+    selectX: {
+      0: 'Select %{smart_count}',
+      1: 'Select %{smart_count}',
+    },
+    allFilesFromFolderNamed: 'All files from folder %{name}',
+    openFolderNamed: 'Open folder %{name}',
+    cancel: 'Cancel',
+    logOut: 'Log out',
+    filter: 'Filter',
+    resetFilter: 'Reset filter',
+    loading: 'Loading...',
+    authenticateWithTitle:
+      'Please authenticate with %{pluginName} to select files',
+    authenticateWith: 'Connect to %{pluginName}',
+    signInWithGoogle: 'Sign in with Google',
+    searchImages: 'Search for images',
+    enterTextToSearch: 'Enter text to search for images',
+    backToSearch: 'Back to Search',
+    emptyFolderAdded: 'No files were added from empty folder',
+    folderAlreadyAdded: 'The folder "%{folder}" was already added',
+    folderAdded: {
+      0: 'Added %{smart_count} file from %{folder}',
+      1: 'Added %{smart_count} files from %{folder}',
+    },
+  },
+}

+ 3 - 64
packages/@uppy/dashboard/src/index.js

@@ -14,6 +14,8 @@ const memoize = require('memoize-one').default || require('memoize-one')
 const FOCUSABLE_ELEMENTS = require('@uppy/utils/lib/FOCUSABLE_ELEMENTS')
 const DashboardUI = require('./components/Dashboard')
 
+const locale = require('./locale')
+
 const TAB_KEY = 9
 const ESC_KEY = 27
 
@@ -47,70 +49,7 @@ module.exports = class Dashboard extends UIPlugin {
     this.type = 'orchestrator'
     this.modalName = `uppy-Dashboard-${nanoid()}`
 
-    this.defaultLocale = {
-      strings: {
-        closeModal: 'Close Modal',
-        importFrom: 'Import from %{name}',
-        addingMoreFiles: 'Adding more files',
-        addMoreFiles: 'Add more files',
-        dashboardWindowTitle: 'File Uploader Window (Press escape to close)',
-        dashboardTitle: 'File Uploader',
-        copyLinkToClipboardSuccess: 'Link copied to clipboard',
-        copyLinkToClipboardFallback: 'Copy the URL below',
-        copyLink: 'Copy link',
-        back: 'Back',
-        addMore: 'Add more',
-        removeFile: 'Remove file %{file}',
-        editFile: 'Edit file',
-        editFileWithFilename: 'Edit file %{file}',
-        editing: 'Editing %{file}',
-        finishEditingFile: 'Finish editing file',
-        save: 'Save',
-        saveChanges: 'Save changes',
-        cancel: 'Cancel',
-        myDevice: 'My Device',
-        dropPasteFiles: 'Drop files here or %{browseFiles}',
-        dropPasteFolders: 'Drop files here or %{browseFolders}',
-        dropPasteBoth: 'Drop files here, %{browseFiles} or %{browseFolders}',
-        dropPasteImportFiles: 'Drop files here, %{browseFiles} or import from:',
-        dropPasteImportFolders: 'Drop files here, %{browseFolders} or import from:',
-        dropPasteImportBoth: 'Drop files here, %{browseFiles}, %{browseFolders} or import from:',
-        importFiles: 'Import files from:',
-        dropHint: 'Drop your files here',
-        browseFiles: 'browse files',
-        browseFolders: 'browse folders',
-        uploadComplete: 'Upload complete',
-        uploadPaused: 'Upload paused',
-        resumeUpload: 'Resume upload',
-        pauseUpload: 'Pause upload',
-        retryUpload: 'Retry upload',
-        cancelUpload: 'Cancel upload',
-        xFilesSelected: {
-          0: '%{smart_count} file selected',
-          1: '%{smart_count} files selected',
-        },
-        uploadingXFiles: {
-          0: 'Uploading %{smart_count} file',
-          1: 'Uploading %{smart_count} files',
-        },
-        processingXFiles: {
-          0: 'Processing %{smart_count} file',
-          1: 'Processing %{smart_count} files',
-        },
-        recoveredXFiles: {
-          0: 'We could not fully recover 1 file. Please re-select it and resume the upload.',
-          1: 'We could not fully recover %{smart_count} files. Please re-select them and resume the upload.',
-        },
-        recoveredAllFiles: 'We restored all files. You can now resume the upload.',
-        sessionRestored: 'Session restored',
-        reSelect: 'Re-select',
-        poweredBy: 'Powered by %{uppy}',
-        missingRequiredMetaFields: {
-          0: 'Missing required meta field: %{fields}.',
-          1: 'Missing required meta fields: %{fields}.',
-        },
-      },
-    }
+    this.defaultLocale = locale
 
     // set default options
     const defaultOptions = {

+ 88 - 0
packages/@uppy/dashboard/src/locale.js

@@ -0,0 +1,88 @@
+module.exports = {
+  strings: {
+    // When `inline: false`, used as the screen reader label for the button that closes the modal.
+    closeModal: 'Close Modal',
+    // Used as the screen reader label for the plus (+) button that shows the “Add more files” screen
+    addMoreFiles: 'Add more files',
+    addingMoreFiles: 'Adding more files',
+    // Used as the header for import panels, e.g., “Import from Google Drive”.
+    importFrom: 'Import from %{name}',
+    // When `inline: false`, used as the screen reader label for the dashboard modal.
+    dashboardWindowTitle: 'Uppy Dashboard Window (Press escape to close)',
+    // When `inline: true`, used as the screen reader label for the dashboard area.
+    dashboardTitle: 'Uppy Dashboard',
+    // Shown in the Informer when a link to a file was copied to the clipboard.
+    copyLinkToClipboardSuccess: 'Link copied to clipboard.',
+    // Used when a link cannot be copied automatically — the user has to select the text from the
+    // input element below this string.
+    copyLinkToClipboardFallback: 'Copy the URL below',
+    // Used as the hover title and screen reader label for buttons that copy a file link.
+    copyLink: 'Copy link',
+    back: 'Back',
+    // Used as the screen reader label for buttons that remove a file.
+    removeFile: 'Remove file',
+    // Used as the screen reader label for buttons that open the metadata editor panel for a file.
+    editFile: 'Edit file',
+    // Shown in the panel header for the metadata editor. Rendered as “Editing image.png”.
+    editing: 'Editing %{file}',
+    // Used as the screen reader label for the button that saves metadata edits and returns to the
+    // file list view.
+    finishEditingFile: 'Finish editing file',
+    saveChanges: 'Save changes',
+    // Used as the label for the tab button that opens the system file selection dialog.
+    myDevice: 'My Device',
+    dropHint: 'Drop your files here',
+    // Used as the hover text and screen reader label for file progress indicators when
+    // they have been fully uploaded.
+    uploadComplete: 'Upload complete',
+    uploadPaused: 'Upload paused',
+    // Used as the hover text and screen reader label for the buttons to resume paused uploads.
+    resumeUpload: 'Resume upload',
+    // Used as the hover text and screen reader label for the buttons to pause uploads.
+    pauseUpload: 'Pause upload',
+    // Used as the hover text and screen reader label for the buttons to retry failed uploads.
+    retryUpload: 'Retry upload',
+    // Used as the hover text and screen reader label for the buttons to cancel uploads.
+    cancelUpload: 'Cancel upload',
+    // Used in a title, how many files are currently selected
+    xFilesSelected: {
+      0: '%{smart_count} file selected',
+      1: '%{smart_count} files selected',
+    },
+    uploadingXFiles: {
+      0: 'Uploading %{smart_count} file',
+      1: 'Uploading %{smart_count} files',
+    },
+    processingXFiles: {
+      0: 'Processing %{smart_count} file',
+      1: 'Processing %{smart_count} files',
+    },
+    // The "powered by Uppy" link at the bottom of the Dashboard.
+    poweredBy: 'Powered by %{uppy}',
+    addMore: 'Add more',
+    editFileWithFilename: 'Edit file %{file}',
+    save: 'Save',
+    cancel: 'Cancel',
+    dropPasteFiles: 'Drop files here or %{browseFiles}',
+    dropPasteFolders: 'Drop files here or %{browseFolders}',
+    dropPasteBoth: 'Drop files here, %{browseFiles} or %{browseFolders}',
+    dropPasteImportFiles: 'Drop files here, %{browseFiles} or import from:',
+    dropPasteImportFolders: 'Drop files here, %{browseFolders} or import from:',
+    dropPasteImportBoth:
+      'Drop files here, %{browseFiles}, %{browseFolders} or import from:',
+    importFiles: 'Import files from:',
+    browseFiles: 'browse files',
+    browseFolders: 'browse folders',
+    recoveredXFiles: {
+      0: 'We could not fully recover 1 file. Please re-select it and resume the upload.',
+      1: 'We could not fully recover %{smart_count} files. Please re-select them and resume the upload.',
+    },
+    recoveredAllFiles: 'We restored all files. You can now resume the upload.',
+    sessionRestored: 'Session restored',
+    reSelect: 'Re-select',
+    missingRequiredMetaFields: {
+      0: 'Missing required meta field: %{fields}.',
+      1: 'Missing required meta fields: %{fields}.',
+    },
+  },
+}

+ 3 - 6
packages/@uppy/drag-drop/src/index.js

@@ -4,6 +4,8 @@ const isDragDropSupported = require('@uppy/utils/lib/isDragDropSupported')
 const getDroppedFiles = require('@uppy/utils/lib/getDroppedFiles')
 const { h } = require('preact')
 
+const locale = require('./locale.js')
+
 /**
  * Drag & Drop plugin
  *
@@ -18,12 +20,7 @@ module.exports = class DragDrop extends UIPlugin {
     this.id = this.opts.id || 'DragDrop'
     this.title = 'Drag & Drop'
 
-    this.defaultLocale = {
-      strings: {
-        dropHereOr: 'Drop files here or %{browse}',
-        browse: 'browse',
-      },
-    }
+    this.defaultLocale = locale
 
     // Default options
     const defaultOpts = {

+ 9 - 0
packages/@uppy/drag-drop/src/locale.js

@@ -0,0 +1,9 @@
+module.exports = {
+  strings: {
+    // Text to show on the droppable area.
+    // `%{browse}` is replaced with a link that opens the system file selection dialog.
+    dropHereOr: 'Drop here or %{browse}',
+    // Used as the label for the link that opens the system file selection dialog.
+    browse: 'browse',
+  },
+}

+ 4 - 5
packages/@uppy/dropbox/src/index.js

@@ -3,6 +3,8 @@ const { Provider } = require('@uppy/companion-client')
 const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
+const locale = require('./locale')
+
 module.exports = class Dropbox extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -29,11 +31,8 @@ module.exports = class Dropbox extends UIPlugin {
       pluginId: this.id,
     })
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameDropbox: 'Dropbox',
-      },
-    }
+    this.defaultLocale = locale
+
     this.i18nInit()
     this.title = this.i18n('pluginNameDropbox')
 

+ 5 - 0
packages/@uppy/dropbox/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    pluginNameDropbox: 'Dropbox',
+  },
+}

+ 4 - 5
packages/@uppy/facebook/src/index.js

@@ -3,6 +3,8 @@ const { Provider } = require('@uppy/companion-client')
 const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
+const locale = require('./locale.js')
+
 module.exports = class Facebook extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -29,11 +31,8 @@ module.exports = class Facebook extends UIPlugin {
       pluginId: this.id,
     })
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameFacebook: 'Facebook',
-      },
-    }
+    this.defaultLocale = locale
+
     this.i18nInit()
     this.title = this.i18n('pluginNameFacebook')
 

+ 5 - 0
packages/@uppy/facebook/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    pluginNameFacebook: 'Facebook',
+  },
+}

+ 3 - 8
packages/@uppy/file-input/src/index.js

@@ -2,6 +2,8 @@ const { UIPlugin } = require('@uppy/core')
 const toArray = require('@uppy/utils/lib/toArray')
 const { h } = require('preact')
 
+const locale = require('./locale')
+
 module.exports = class FileInput extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -11,14 +13,7 @@ module.exports = class FileInput extends UIPlugin {
     this.title = 'File Input'
     this.type = 'acquirer'
 
-    this.defaultLocale = {
-      strings: {
-        // The same key is used for the same purpose by @uppy/robodog's `form()` API, but our
-        // locale pack scripts can't access it in Robodog. If it is updated here, it should
-        // also be updated there!
-        chooseFiles: 'Choose files',
-      },
-    }
+    this.defaultLocale = locale
 
     // Default options
     const defaultOptions = {

+ 8 - 0
packages/@uppy/file-input/src/locale.js

@@ -0,0 +1,8 @@
+module.exports = {
+  strings: {
+    // The same key is used for the same purpose by @uppy/robodog's `form()` API, but our
+    // locale pack scripts can't access it in Robodog. If it is updated here, it should
+    // also be updated there!
+    chooseFiles: 'Choose files',
+  },
+}

+ 4 - 5
packages/@uppy/google-drive/src/index.js

@@ -3,6 +3,8 @@ const { Provider } = require('@uppy/companion-client')
 const { h } = require('preact')
 const DriveProviderViews = require('./DriveProviderViews')
 
+const locale = require('./locale')
+
 module.exports = class GoogleDrive extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -45,11 +47,8 @@ module.exports = class GoogleDrive extends UIPlugin {
       pluginId: this.id,
     })
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameGoogleDrive: 'Google Drive',
-      },
-    }
+    this.defaultLocale = locale
+
     this.i18nInit()
     this.title = this.i18n('pluginNameGoogleDrive')
 

+ 5 - 0
packages/@uppy/google-drive/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    pluginNameGoogleDrive: 'Google Drive',
+  },
+}

+ 3 - 12
packages/@uppy/image-editor/src/index.js

@@ -2,6 +2,8 @@ const { UIPlugin } = require('@uppy/core')
 const { h } = require('preact')
 const Editor = require('./Editor')
 
+const locale = require('./locale.js')
+
 module.exports = class ImageEditor extends UIPlugin {
   // eslint-disable-next-line global-require
   static VERSION = require('../package.json').version
@@ -12,18 +14,7 @@ module.exports = class ImageEditor extends UIPlugin {
     this.title = 'Image Editor'
     this.type = 'editor'
 
-    this.defaultLocale = {
-      strings: {
-        revert: 'Revert',
-        rotate: 'Rotate',
-        zoomIn: 'Zoom in',
-        zoomOut: 'Zoom out',
-        flipHorizontal: 'Flip horizontal',
-        aspectRatioSquare: 'Crop square',
-        aspectRatioLandscape: 'Crop landscape (16:9)',
-        aspectRatioPortrait: 'Crop portrait (9:16)',
-      },
-    }
+    this.defaultLocale = locale
 
     const defaultCropperOptions = {
       viewMode: 1,

+ 12 - 0
packages/@uppy/image-editor/src/locale.js

@@ -0,0 +1,12 @@
+module.exports = {
+  strings: {
+    revert: 'Revert',
+    rotate: 'Rotate',
+    zoomIn: 'Zoom in',
+    zoomOut: 'Zoom out',
+    flipHorizontal: 'Flip horizontal',
+    aspectRatioSquare: 'Crop square',
+    aspectRatioLandscape: 'Crop landscape (16:9)',
+    aspectRatioPortrait: 'Crop portrait (9:16)',
+  },
+}

+ 4 - 5
packages/@uppy/instagram/src/index.js

@@ -3,6 +3,8 @@ const { Provider } = require('@uppy/companion-client')
 const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
+const locale = require('./locale.js')
+
 module.exports = class Instagram extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -19,11 +21,8 @@ module.exports = class Instagram extends UIPlugin {
       </svg>
     )
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameInstagram: 'Instagram',
-      },
-    }
+    this.defaultLocale = locale
+
     this.i18nInit()
     this.title = this.i18n('pluginNameInstagram')
 

+ 5 - 0
packages/@uppy/instagram/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    pluginNameInstagram: 'Instagram',
+  },
+}

+ 5 - 5
packages/@uppy/locales/src/en_US.js

@@ -32,15 +32,15 @@ en_US.strings = {
   connectedToInternet: 'Connected to the Internet',
   copyLink: 'Copy link',
   copyLinkToClipboardFallback: 'Copy the URL below',
-  copyLinkToClipboardSuccess: 'Link copied to clipboard',
+  copyLinkToClipboardSuccess: 'Link copied to clipboard.',
   creatingAssembly: 'Preparing upload...',
   creatingAssemblyFailed: 'Transloadit: Could not create Assembly',
-  dashboardTitle: 'File Uploader',
-  dashboardWindowTitle: 'File Uploader Window (Press escape to close)',
+  dashboardTitle: 'Uppy Dashboard',
+  dashboardWindowTitle: 'Uppy Dashboard Window (Press escape to close)',
   dataUploadedOfTotal: '%{complete} of %{total}',
   discardRecordedFile: 'Discard recorded file',
   done: 'Done',
-  dropHereOr: 'Drop files here or %{browse}',
+  dropHereOr: 'Drop here or %{browse}',
   dropHint: 'Drop your files here',
   dropPasteBoth: 'Drop files here, %{browseFiles} or %{browseFolders}',
   dropPasteFiles: 'Drop files here or %{browseFiles}',
@@ -117,7 +117,7 @@ en_US.strings = {
     '0': 'We could not fully recover 1 file. Please re-select it and resume the upload.',
     '1': 'We could not fully recover %{smart_count} files. Please re-select them and resume the upload.',
   },
-  removeFile: 'Remove file %{file}',
+  removeFile: 'Remove file',
   reSelect: 'Re-select',
   resetFilter: 'Reset filter',
   resume: 'Resume',

+ 4 - 5
packages/@uppy/onedrive/src/index.js

@@ -3,6 +3,8 @@ const { Provider } = require('@uppy/companion-client')
 const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
+const locale = require('./locale')
+
 module.exports = class OneDrive extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -31,11 +33,8 @@ module.exports = class OneDrive extends UIPlugin {
       pluginId: this.id,
     })
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameOneDrive: 'OneDrive',
-      },
-    }
+    this.defaultLocale = locale
+
     this.i18nInit()
     this.title = this.i18n('pluginNameOneDrive')
 

+ 5 - 0
packages/@uppy/onedrive/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    pluginNameOneDrive: 'OneDrive',
+  },
+}

+ 3 - 11
packages/@uppy/screen-capture/src/index.js

@@ -4,6 +4,8 @@ const getFileTypeExtension = require('@uppy/utils/lib/getFileTypeExtension')
 const ScreenRecIcon = require('./ScreenRecIcon')
 const CaptureScreen = require('./CaptureScreen')
 
+const locale = require('./locale')
+
 // Adapted from: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia
 function getMediaDevices () {
   // check if screen capturing is supported
@@ -26,17 +28,7 @@ module.exports = class ScreenCapture extends UIPlugin {
     this.type = 'acquirer'
     this.icon = ScreenRecIcon
 
-    this.defaultLocale = {
-      strings: {
-        startCapturing: 'Begin screen capturing',
-        stopCapturing: 'Stop screen capturing',
-        submitRecordedFile: 'Submit recorded file',
-        streamActive: 'Stream active',
-        streamPassive: 'Stream passive',
-        micDisabled: 'Microphone access denied by user',
-        recording: 'Recording',
-      },
-    }
+    this.defaultLocale = locale
 
     // set default options
     // https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamConstraints

+ 11 - 0
packages/@uppy/screen-capture/src/locale.js

@@ -0,0 +1,11 @@
+module.exports = {
+  strings: {
+    startCapturing: 'Begin screen capturing',
+    stopCapturing: 'Stop screen capturing',
+    submitRecordedFile: 'Submit recorded file',
+    streamActive: 'Stream active',
+    streamPassive: 'Stream passive',
+    micDisabled: 'Microphone access denied by user',
+    recording: 'Recording',
+  },
+}

+ 3 - 33
packages/@uppy/status-bar/src/index.js

@@ -5,6 +5,8 @@ const getTextDirection = require('@uppy/utils/lib/getTextDirection')
 const statusBarStates = require('./StatusBarStates')
 const StatusBarUI = require('./StatusBar')
 
+const locale = require('./locale.js')
+
 /**
  * StatusBar: renders a status bar with upload/pause/resume/cancel/retry buttons,
  * progress percentage and time remaining.
@@ -19,39 +21,7 @@ module.exports = class StatusBar extends UIPlugin {
     this.title = 'StatusBar'
     this.type = 'progressindicator'
 
-    this.defaultLocale = {
-      strings: {
-        uploading: 'Uploading',
-        upload: 'Upload',
-        complete: 'Complete',
-        uploadFailed: 'Upload failed',
-        paused: 'Paused',
-        retry: 'Retry',
-        retryUpload: 'Retry upload',
-        cancel: 'Cancel',
-        pause: 'Pause',
-        resume: 'Resume',
-        done: 'Done',
-        filesUploadedOfTotal: {
-          0: '%{complete} of %{smart_count} file uploaded',
-          1: '%{complete} of %{smart_count} files uploaded',
-        },
-        dataUploadedOfTotal: '%{complete} of %{total}',
-        xTimeLeft: '%{time} left',
-        uploadXFiles: {
-          0: 'Upload %{smart_count} file',
-          1: 'Upload %{smart_count} files',
-        },
-        uploadXNewFiles: {
-          0: 'Upload +%{smart_count} file',
-          1: 'Upload +%{smart_count} files',
-        },
-        xMoreFilesAdded: {
-          0: '%{smart_count} more file added',
-          1: '%{smart_count} more files added',
-        },
-      },
-    }
+    this.defaultLocale = locale
 
     // set default options
     const defaultOptions = {

+ 48 - 0
packages/@uppy/status-bar/src/locale.js

@@ -0,0 +1,48 @@
+module.exports = {
+  strings: {
+    // Shown in the status bar while files are being uploaded.
+    uploading: 'Uploading',
+    // Shown in the status bar once all files have been uploaded.
+    complete: 'Complete',
+    // Shown in the status bar if an upload failed.
+    uploadFailed: 'Upload failed',
+    // Shown in the status bar while the upload is paused.
+    paused: 'Paused',
+    // Used as the label for the button that retries an upload.
+    retry: 'Retry',
+    // Used as the label for the button that cancels an upload.
+    cancel: 'Cancel',
+    // Used as the label for the button that pauses an upload.
+    pause: 'Pause',
+    // Used as the label for the button that resumes an upload.
+    resume: 'Resume',
+    // Used as the label for the button that resets the upload state after an upload
+    done: 'Done',
+    // When `showProgressDetails` is set, shows the number of files that have been fully uploaded so far.
+    filesUploadedOfTotal: {
+      0: '%{complete} of %{smart_count} file uploaded',
+      1: '%{complete} of %{smart_count} files uploaded',
+    },
+    // When `showProgressDetails` is set, shows the amount of bytes that have been uploaded so far.
+    dataUploadedOfTotal: '%{complete} of %{total}',
+    // When `showProgressDetails` is set, shows an estimation of how long the upload will take to complete.
+    xTimeLeft: '%{time} left',
+    // Used as the label for the button that starts an upload.
+    uploadXFiles: {
+      0: 'Upload %{smart_count} file',
+      1: 'Upload %{smart_count} files',
+    },
+    // Used as the label for the button that starts an upload, if another upload has been started in the past
+    // and new files were added later.
+    uploadXNewFiles: {
+      0: 'Upload +%{smart_count} file',
+      1: 'Upload +%{smart_count} files',
+    },
+    upload: 'Upload',
+    retryUpload: 'Retry upload',
+    xMoreFilesAdded: {
+      0: '%{smart_count} more file added',
+      1: '%{smart_count} more files added',
+    },
+  },
+}

+ 3 - 5
packages/@uppy/thumbnail-generator/src/index.js

@@ -4,6 +4,8 @@ const isObjectURL = require('@uppy/utils/lib/isObjectURL')
 const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
 const exifr = require('exifr/dist/mini.legacy.umd.js')
 
+const locale = require('./locale')
+
 /**
  * The Thumbnail Generator plugin
  */
@@ -21,11 +23,7 @@ module.exports = class ThumbnailGenerator extends UIPlugin {
     this.defaultThumbnailDimension = 200
     this.thumbnailType = this.opts.thumbnailType || 'image/jpeg'
 
-    this.defaultLocale = {
-      strings: {
-        generatingThumbnails: 'Generating thumbnails...',
-      },
-    }
+    this.defaultLocale = locale
 
     const defaultOptions = {
       thumbnailWidth: null,

+ 5 - 0
packages/@uppy/thumbnail-generator/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    generatingThumbnails: 'Generating thumbnails...',
+  },
+}

+ 3 - 7
packages/@uppy/transloadit/src/index.js

@@ -6,6 +6,8 @@ const Client = require('./Client')
 const AssemblyOptions = require('./AssemblyOptions')
 const AssemblyWatcher = require('./AssemblyWatcher')
 
+const locale = require('./locale')
+
 function defaultGetAssemblyOptions (file, options) {
   return {
     params: options.params,
@@ -38,13 +40,7 @@ module.exports = class Transloadit extends BasePlugin {
     this.id = this.opts.id || 'Transloadit'
     this.title = 'Transloadit'
 
-    this.defaultLocale = {
-      strings: {
-        creatingAssembly: 'Preparing upload...',
-        creatingAssemblyFailed: 'Transloadit: Could not create Assembly',
-        encoding: 'Encoding...',
-      },
-    }
+    this.defaultLocale = locale
 
     const defaultOptions = {
       service: 'https://api2.transloadit.com',

+ 11 - 0
packages/@uppy/transloadit/src/locale.js

@@ -0,0 +1,11 @@
+module.exports = {
+  strings: {
+    // Shown while Assemblies are being created for an upload.
+    creatingAssembly: 'Preparing upload...',
+    // Shown if an Assembly could not be created.
+    creatingAssemblyFailed: 'Transloadit: Could not create Assembly',
+    // Shown after uploads have succeeded, but when the Assembly is still executing.
+    // This only shows if `waitForMetadata` or `waitForEncoding` was enabled.
+    encoding: 'Encoding...',
+  },
+}

+ 3 - 8
packages/@uppy/url/src/index.js

@@ -5,6 +5,8 @@ const UrlUI = require('./UrlUI.js')
 const toArray = require('@uppy/utils/lib/toArray')
 const forEachDroppedOrPastedUrl = require('./utils/forEachDroppedOrPastedUrl')
 
+const locale = require('./locale')
+
 function UrlIcon () {
   return (
     <svg aria-hidden="true" focusable="false" width="32" height="32" viewBox="0 0 32 32">
@@ -31,14 +33,7 @@ module.exports = class Url extends UIPlugin {
     this.icon = () => <UrlIcon />
 
     // Set default options and locale
-    this.defaultLocale = {
-      strings: {
-        import: 'Import',
-        enterUrlToImport: 'Enter URL to import a file',
-        failedToFetch: 'Companion failed to fetch this URL, please make sure it’s correct',
-        enterCorrectUrl: 'Incorrect URL: Please make sure you are entering a direct link to a file',
-      },
-    }
+    this.defaultLocale = locale
 
     const defaultOptions = {}
 

+ 12 - 0
packages/@uppy/url/src/locale.js

@@ -0,0 +1,12 @@
+module.exports = {
+  strings: {
+    // Label for the "Import" button.
+    import: 'Import',
+    // Placeholder text for the URL input.
+    enterUrlToImport: 'Enter URL to import a file',
+    // Error message shown if Companion could not load a URL.
+    failedToFetch: 'Companion failed to fetch this URL, please make sure it’s correct',
+    // Error message shown if the input does not look like a URL.
+    enterCorrectUrl: 'Incorrect URL: Please make sure you are entering a direct link to a file',
+  },
+}

+ 2 - 17
packages/@uppy/webcam/src/index.js

@@ -8,6 +8,7 @@ const CameraIcon = require('./CameraIcon')
 const CameraScreen = require('./CameraScreen')
 const PermissionsScreen = require('./PermissionsScreen')
 
+const locale = require('./locale.js')
 /**
  * Normalize a MIME type or file extension into a MIME type.
  *
@@ -75,23 +76,7 @@ module.exports = class Webcam extends UIPlugin {
       </svg>
     )
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameCamera: 'Camera',
-        smile: 'Smile!',
-        takePicture: 'Take a picture',
-        startRecording: 'Begin video recording',
-        stopRecording: 'Stop video recording',
-        allowAccessTitle: 'Please allow access to your camera',
-        allowAccessDescription: 'In order to take pictures or record video with your camera, please allow camera access for this site.',
-        noCameraTitle: 'Camera Not Available',
-        noCameraDescription: 'In order to take pictures or record video, please connect a camera device',
-        recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
-        recordingLength: 'Recording length %{recording_length}',
-        submitRecordedFile: 'Submit recorded file',
-        discardRecordedFile: 'Discard recorded file',
-      },
-    }
+    this.defaultLocale = locale
 
     // set default options
     const defaultOptions = {

+ 28 - 0
packages/@uppy/webcam/src/locale.js

@@ -0,0 +1,28 @@
+module.exports = {
+  strings: {
+    pluginNameCamera: 'Camera',
+    noCameraTitle: 'Camera Not Available',
+    noCameraDescription: 'In order to take pictures or record video, please connect a camera device',
+    recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
+    submitRecordedFile: 'Submit recorded file',
+    discardRecordedFile: 'Discard recorded file',
+    // Shown before a picture is taken when the `countdown` option is set.
+    smile: 'Smile!',
+    // Used as the label for the button that takes a picture.
+    // This is not visibly rendered but is picked up by screen readers.
+    takePicture: 'Take a picture',
+    // Used as the label for the button that starts a video recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    startRecording: 'Begin video recording',
+    // Used as the label for the button that stops a video recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    stopRecording: 'Stop video recording',
+    // Used as the label for the recording length counter. See the showRecordingLength option.
+    // This is not visibly rendered but is picked up by screen readers.
+    recordingLength: 'Recording length %{recording_length}',
+    // Title on the “allow access” screen
+    allowAccessTitle: 'Please allow access to your camera',
+    // Description on the “allow access” screen
+    allowAccessDescription: 'In order to take pictures or record video with your camera, please allow camera access for this site.',
+  },
+}

+ 3 - 5
packages/@uppy/xhr-upload/src/index.js

@@ -10,6 +10,8 @@ const { RateLimitedQueue, internalRateLimitedQueue } = require('@uppy/utils/lib/
 const NetworkError = require('@uppy/utils/lib/NetworkError')
 const isNetworkError = require('@uppy/utils/lib/isNetworkError')
 
+const locale = require('./locale')
+
 function buildResponseError (xhr, err) {
   let error = err
   // No error message
@@ -53,11 +55,7 @@ module.exports = class XHRUpload extends BasePlugin {
     this.id = this.opts.id || 'XHRUpload'
     this.title = 'XHRUpload'
 
-    this.defaultLocale = {
-      strings: {
-        timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
-      },
-    }
+    this.defaultLocale = locale
 
     // Default options
     const defaultOptions = {

+ 6 - 0
packages/@uppy/xhr-upload/src/locale.js

@@ -0,0 +1,6 @@
+module.exports = {
+  strings: {
+    // Shown in the Informer if an upload is being canceled because it stalled for too long.
+    timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
+  },
+}

+ 4 - 5
packages/@uppy/zoom/src/index.js

@@ -3,6 +3,8 @@ const { Provider } = require('@uppy/companion-client')
 const { ProviderViews } = require('@uppy/provider-views')
 const { h } = require('preact')
 
+const locale = require('./locale')
+
 module.exports = class Zoom extends UIPlugin {
   static VERSION = require('../package.json').version
 
@@ -30,11 +32,8 @@ module.exports = class Zoom extends UIPlugin {
       pluginId: this.id,
     })
 
-    this.defaultLocale = {
-      strings: {
-        pluginNameZoom: 'Zoom',
-      },
-    }
+    this.defaultLocale = locale
+
     this.i18nInit()
     this.title = this.i18n('pluginNameZoom')
 

+ 5 - 0
packages/@uppy/zoom/src/locale.js

@@ -0,0 +1,5 @@
+module.exports = {
+  strings: {
+    pluginNameZoom: 'Zoom',
+  },
+}

+ 22 - 0
private/locale-pack/helpers.mjs

@@ -0,0 +1,22 @@
+import glob from 'glob'
+
+export function getPaths (globPath) {
+  return new Promise((resolve, reject) => {
+    glob(globPath, (error, paths) => {
+      if (error) reject(error)
+      else resolve(paths)
+    })
+  })
+}
+
+export function sortObjectAlphabetically (obj) {
+  return Object.fromEntries(
+    Object.entries(obj).sort(([keyA], [keyB]) => keyA.localeCompare(keyB))
+  )
+}
+
+export function omit (object, key) {
+  const copy = { ...object }
+  delete copy[key]
+  return copy
+}

+ 168 - 0
private/locale-pack/index.mjs

@@ -0,0 +1,168 @@
+/* eslint-disable no-console, prefer-arrow-callback */
+import path from 'node:path'
+import fs from 'node:fs'
+import { readFile, writeFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+
+import dedent from 'dedent'
+import stringifyObject from 'stringify-object'
+import { remark } from 'remark'
+import { headingRange } from 'mdast-util-heading-range'
+import remarkFrontmatter from 'remark-frontmatter'
+
+import remarkConfig from '../remark-lint-uppy/index.js'
+
+import { getPaths, sortObjectAlphabetically } from './helpers.mjs'
+
+const { settings: remarkSettings } = remarkConfig
+
+const root = fileURLToPath(new URL('../../', import.meta.url))
+
+const localesPath = path.join(root, 'packages', '@uppy', 'locales')
+const templatePath = path.join(localesPath, 'template.js')
+const englishLocalePath = path.join(localesPath, 'src', 'en_US.js')
+
+main()
+  .then(() => {
+    console.log(`✅ Generated '${englishLocalePath}'`)
+    console.log('✅ Generated locale docs')
+    console.log('✅ Generated types')
+  })
+  .catch((error) => {
+    console.error(error)
+    process.exit(1)
+  })
+
+function main () {
+  return getPaths(`${root}/packages/@uppy/**/src/locale.js`)
+    .then(importFiles)
+    .then(createCombinedLocale)
+    .then(({ combinedLocale, locales }) => ({
+      combinedLocale: sortObjectAlphabetically(combinedLocale),
+      locales,
+    }))
+    .then(({ combinedLocale, locales }) => {
+      return readFile(templatePath, 'utf-8')
+        .then((fileString) => populateTemplate(fileString, combinedLocale))
+        .then((file) => writeFile(englishLocalePath, file))
+        .then(() => {
+          for (const [pluginName, locale] of Object.entries(locales)) {
+            generateLocaleDocs(pluginName)
+            generateTypes(pluginName, locale)
+          }
+          return locales
+        })
+    })
+}
+
+async function importFiles (paths) {
+  const locales = {}
+
+  for (const filePath of paths) {
+    const pluginName = path.basename(path.join(filePath, '..', '..'))
+    // Note: `.default` should be removed when we move to ESM
+    const locale = (await import(filePath)).default
+
+    locales[pluginName] = locale
+  }
+
+  return locales
+}
+
+function createCombinedLocale (locales) {
+  return new Promise((resolve, reject) => {
+    const combinedLocale = {}
+    const entries = Object.entries(locales)
+
+    for (const [pluginName, locale] of entries) {
+      Object.entries(locale.strings).forEach(([key, value]) => {
+        if (key in combinedLocale && value !== combinedLocale[key]) {
+          reject(new Error(`'${key}' from ${pluginName} already exists in locale pack.`))
+        }
+        combinedLocale[key] = value
+      })
+    }
+
+    resolve({ combinedLocale, locales })
+  })
+}
+
+function populateTemplate (fileString, combinedLocale) {
+  const formattedLocale = stringifyObject(combinedLocale, {
+    indent: '  ',
+    singleQuotes: true,
+    inlineCharacterLimit: 12,
+  })
+  return fileString.replace('en_US.strings = {}', `en_US.strings = ${formattedLocale}`)
+}
+
+function generateTypes (pluginName, locale) {
+  const allowedStringTypes = Object.keys(locale.strings)
+    .map((key) => `  | '${key}'`)
+    .join('\n')
+  const pluginClassName = pluginName
+    .split('-')
+    .map((str) => str.replace(/^\w/, (c) => c.toUpperCase()))
+    .join('')
+
+  const localePath = path.join(
+    root,
+    'packages',
+    '@uppy',
+    pluginName,
+    'types',
+    'generatedLocale.d.ts'
+  )
+
+  const localeTypes = dedent`
+  /* eslint-disable */
+  import type { Locale } from '@uppy/core'
+
+  type ${pluginClassName}Locale = Locale<
+    ${allowedStringTypes}
+  >
+
+  export default ${pluginClassName}Locale
+  `
+
+  fs.writeFileSync(localePath, localeTypes)
+}
+
+function generateLocaleDocs (pluginName) {
+  const fileName = `${pluginName}.md`
+  const docPath = path.join(root, 'website', 'src', 'docs', fileName)
+  const localePath = path.join(root, 'packages', '@uppy', pluginName, 'src', 'locale.js')
+  const rangeOptions = { test: 'locale: {}', ignoreFinalDefinitions: true }
+
+  if (!fs.existsSync(docPath)) {
+    console.error(
+      `⚠️  Could not find markdown documentation file for "${pluginName}". Make sure the plugin name matches the markdown file name.`
+    )
+    return
+  }
+
+  remark()
+    .data('settings', remarkSettings)
+    .use(remarkFrontmatter)
+    .use(() => (tree) => {
+      // Replace all nodes after the locale heading until the next heading (or eof)
+      headingRange(tree, rangeOptions, (start, _, end) => [
+        start,
+        {
+          type: 'html',
+          // `module.exports` is not allowed by eslint in our docs.
+          // The script outputs an extra newline which also isn't excepted by eslint
+          value: '<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->',
+        },
+        {
+          type: 'code',
+          lang: 'js',
+          meta: null,
+          value: fs.readFileSync(localePath, 'utf-8'),
+        },
+        end,
+      ])
+    })
+    .process(fs.readFileSync(docPath))
+    .then((file) => fs.writeFileSync(docPath, String(file)))
+}

+ 20 - 0
private/locale-pack/package.json

@@ -0,0 +1,20 @@
+{
+  "private": true,
+  "name": "locale-pack",
+  "author": "Merlijn Vos <merlijn@transloadit.com>",
+  "description": "Generate locale pack, types, and documentation",
+  "main": "index.mjs",
+  "scripts": {
+    "build": "yarn node index.mjs",
+    "test": "yarn node test.mjs"
+  },
+  "dependencies": {
+    "chalk": "^4.1.2",
+    "dedent": "^0.7.0",
+    "glob": "^7.2.0",
+    "mdast-util-heading-range": "^3.1.0",
+    "remark": "^14.0.1",
+    "remark-frontmatter": "^4.0.1",
+    "stringify-object": "^4.0.0"
+  }
+}

+ 159 - 0
private/locale-pack/test.mjs

@@ -0,0 +1,159 @@
+/* eslint-disable no-console, prefer-arrow-callback */
+import path from 'node:path'
+import fs from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+import glob from 'glob'
+import chalk from 'chalk'
+
+import { getPaths, omit } from './helpers.mjs'
+
+const root = fileURLToPath(new URL('../../', import.meta.url))
+const leadingLocaleName = 'en_US'
+const mode = process.argv[2]
+const pluginLocaleDependencies = {
+  core: 'provider-views',
+}
+
+test()
+  .then(() => {
+    console.log('\n')
+    console.log('No blocking issues found')
+  })
+  .catch((error) => {
+    console.error(error)
+    process.exit(1)
+  })
+
+function test () {
+  switch (mode) {
+    case 'unused':
+      return getPaths(`${root}/packages/@uppy/**/src/locale.js`)
+        .then((paths) => paths.map((filePath) => path.basename(path.join(filePath, '..', '..'))))
+        .then(getAllFilesPerPlugin)
+        .then(unused)
+
+    case 'warnings':
+      return getPaths(`${root}/packages/@uppy/locales/src/*.js`)
+        .then(importFiles)
+        .then((locales) => ({
+          leadingLocale: locales[leadingLocaleName],
+          followerLocales: omit(locales, leadingLocaleName),
+        }))
+        .then(warnings)
+
+    default:
+      return Promise.reject(new Error(`Invalid mode "${mode}"`))
+  }
+}
+
+async function importFiles (paths) {
+  const locales = {}
+
+  for (const filePath of paths) {
+    const localeName = path.basename(filePath, '.js')
+    // Note: `.default` should be removed when we move to ESM
+    const locale = (await import(filePath)).default
+
+    locales[localeName] = locale.strings
+  }
+
+  return locales
+}
+
+function getAllFilesPerPlugin (pluginNames) {
+  const filesPerPlugin = {}
+
+  function getFiles (name) {
+    return glob
+      .sync(`${root}/packages/@uppy/${name}/lib/**/*.js`)
+      .filter((filePath) => !filePath.includes('locale.js'))
+      .map((filePath) => fs.readFileSync(filePath, 'utf-8'))
+  }
+
+  for (const name of pluginNames) {
+    filesPerPlugin[name] = getFiles(name)
+
+    if (name in pluginLocaleDependencies) {
+      filesPerPlugin[name] = filesPerPlugin[name].concat(
+        getFiles(pluginLocaleDependencies[name])
+      )
+    }
+  }
+
+  return filesPerPlugin
+}
+
+async function unused (filesPerPlugin, data) {
+  for (const [name, fileStrings] of Object.entries(filesPerPlugin)) {
+    const fileString = fileStrings.join('\n')
+    const localePath = path.join(
+      root,
+      'packages',
+      '@uppy',
+      name,
+      'src',
+      'locale.js'
+    )
+    const locale = (await import(localePath)).default
+
+    for (const key of Object.keys(locale.strings)) {
+      const regPat = new RegExp(
+        `(i18n|i18nArray)\\([^\\)]*['\`"]${key}['\`"]`,
+        'g'
+      )
+      if (!fileString.match(regPat)) {
+        return Promise.reject(new Error(`Unused locale key "${key}" in @uppy/${name}`))
+      }
+    }
+  }
+
+  return data
+}
+
+function warnings ({ leadingLocale, followerLocales }) {
+  const entries = Object.entries(followerLocales)
+  const logs = []
+
+  for (const [name, locale] of entries) {
+    const missing = Object.keys(leadingLocale).filter((key) => !(key in locale))
+    const excess = Object.keys(locale).filter((key) => !(key in leadingLocale))
+
+    logs.push('\n')
+    logs.push(`--> Keys from ${leadingLocaleName} missing in ${name}`)
+    logs.push('\n')
+
+    for (const key of missing) {
+      let value = leadingLocale[key]
+
+      if (typeof value === 'object') {
+        // For values with plural forms, just take the first one right now
+        value = value[Object.keys(value)[0]]
+      }
+
+      logs.push(
+        [
+          `${chalk.cyan(name)} locale has missing string: '${chalk.red(key)}'`,
+          `that is present in ${chalk.cyan(leadingLocaleName)}`,
+          `with value: ${chalk.yellow(value)}`,
+        ].join(' ')
+      )
+    }
+
+    logs.push('\n')
+    logs.push(`--> Keys from ${name} missing in ${leadingLocaleName}`)
+    logs.push('\n')
+
+    for (const key of excess) {
+      logs.push(
+        [
+          `${chalk.cyan(name)} locale has excess string:`,
+          `'${chalk.yellow(key)}' that is not present`,
+          `in ${chalk.cyan(leadingLocaleName)}.`,
+        ].join(' ')
+      )
+    }
+  }
+
+  console.log(logs.join('\n'))
+}

+ 7 - 33
test/endtoend/create-react-app/package-lock.json

@@ -19104,19 +19104,6 @@
         "is-typedarray": "^1.0.0"
       }
     },
-    "node_modules/typescript": {
-      "version": "4.3.5",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
-      "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
-      "peer": true,
-      "bin": {
-        "tsc": "bin/tsc",
-        "tsserver": "bin/tsserver"
-      },
-      "engines": {
-        "node": ">=4.2.0"
-      }
-    },
     "node_modules/unbox-primitive": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
@@ -24014,8 +24001,7 @@
     "acorn-jsx": {
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
-      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
-      "requires": {}
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="
     },
     "acorn-walk": {
       "version": "7.2.0",
@@ -24079,14 +24065,12 @@
     "ajv-errors": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz",
-      "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
-      "requires": {}
+      "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ=="
     },
     "ajv-keywords": {
       "version": "3.5.2",
       "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
-      "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
-      "requires": {}
+      "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="
     },
     "alphanum-sort": {
       "version": "1.0.2",
@@ -24547,8 +24531,7 @@
     "babel-plugin-named-asset-import": {
       "version": "0.3.7",
       "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.7.tgz",
-      "integrity": "sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw==",
-      "requires": {}
+      "integrity": "sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw=="
     },
     "babel-plugin-polyfill-corejs2": {
       "version": "0.2.2",
@@ -27159,8 +27142,7 @@
     "eslint-plugin-react-hooks": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz",
-      "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ==",
-      "requires": {}
+      "integrity": "sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ=="
     },
     "eslint-plugin-testing-library": {
       "version": "3.10.2",
@@ -29853,8 +29835,7 @@
     "jest-pnp-resolver": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
-      "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==",
-      "requires": {}
+      "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w=="
     },
     "jest-regex-util": {
       "version": "26.0.0",
@@ -35830,12 +35811,6 @@
         "is-typedarray": "^1.0.0"
       }
     },
-    "typescript": {
-      "version": "4.3.5",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
-      "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
-      "peer": true
-    },
     "unbox-primitive": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
@@ -37524,8 +37499,7 @@
     "ws": {
       "version": "7.5.3",
       "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz",
-      "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==",
-      "requires": {}
+      "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
     },
     "xml-name-validator": {
       "version": "3.0.0",

+ 4 - 6
website/src/docs/aws-s3.md

@@ -115,17 +115,15 @@ This option is useful when uploading to an S3-like service that doesn’t reply
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locales = {
+module.exports = {
   strings: {
-    // Shown in the StatusBar while the upload is being signed.
-    preparingUpload: 'Preparing upload...',
+    timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
   },
 }
+
 ```
 
 ## S3 Bucket configuration

+ 4 - 5
website/src/docs/box.md

@@ -127,14 +127,13 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locales = {
+module.exports = {
   strings: {
-    // TODO
+    pluginNameBox: 'Box',
   },
 }
+
 ```

+ 57 - 44
website/src/docs/uppy.md → website/src/docs/core.md

@@ -277,57 +277,70 @@ const uppy = new Uppy({
 
 ### `locale: {}`
 
-This allows you to override language strings:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const uppy = new Uppy({
-  // ...
-  locale: {
-    strings: {
-      youCanOnlyUploadX: {
-        0: 'You can only upload %{smart_count} file',
-        1: 'You can only upload %{smart_count} files',
-      },
-      youHaveToAtLeastSelectX: {
-        0: 'You have to select at least %{smart_count} file',
-        1: 'You have to select at least %{smart_count} files',
-      },
-      exceedsSize: 'This file exceeds maximum allowed size of %{size}',
-      youCanOnlyUploadFileTypes: 'You can only upload: %{types}',
-      companionError: 'Connection with Companion failed',
+module.exports = {
+  strings: {
+    addBulkFilesFailed: {
+      0: 'Failed to add %{smart_count} file due to an internal error',
+      1: 'Failed to add %{smart_count} files due to internal errors',
+    },
+    youCanOnlyUploadX: {
+      0: 'You can only upload %{smart_count} file',
+      1: 'You can only upload %{smart_count} files',
+    },
+    youHaveToAtLeastSelectX: {
+      0: 'You have to select at least %{smart_count} file',
+      1: 'You have to select at least %{smart_count} files',
+    },
+    exceedsSize: '%{file} exceeds maximum allowed size of %{size}',
+    missingRequiredMetaField: 'Missing required meta fields',
+    missingRequiredMetaFieldOnFile:
+      'Missing required meta fields in %{fileName}',
+    inferiorSize: 'This file is smaller than the allowed size of %{size}',
+    youCanOnlyUploadFileTypes: 'You can only upload: %{types}',
+    noMoreFilesAllowed: 'Cannot add more files',
+    noDuplicates:
+      "Cannot add the duplicate file '%{fileName}', it already exists",
+    companionError: 'Connection with Companion failed',
+    authAborted: 'Authentication aborted',
+    companionUnauthorizeHint:
+      'To unauthorize to your %{provider} account, please go to %{url}',
+    failedToUpload: 'Failed to upload %{file}',
+    noInternetConnection: 'No Internet connection',
+    connectedToInternet: 'Connected to the Internet',
+    // Strings for remote providers
+    noFilesFound: 'You have no files or folders here',
+    selectX: {
+      0: 'Select %{smart_count}',
+      1: 'Select %{smart_count}',
+    },
+    allFilesFromFolderNamed: 'All files from folder %{name}',
+    openFolderNamed: 'Open folder %{name}',
+    cancel: 'Cancel',
+    logOut: 'Log out',
+    filter: 'Filter',
+    resetFilter: 'Reset filter',
+    loading: 'Loading...',
+    authenticateWithTitle:
+      'Please authenticate with %{pluginName} to select files',
+    authenticateWith: 'Connect to %{pluginName}',
+    signInWithGoogle: 'Sign in with Google',
+    searchImages: 'Search for images',
+    enterTextToSearch: 'Enter text to search for images',
+    backToSearch: 'Back to Search',
+    emptyFolderAdded: 'No files were added from empty folder',
+    folderAlreadyAdded: 'The folder "%{folder}" was already added',
+    folderAdded: {
+      0: 'Added %{smart_count} file from %{folder}',
+      1: 'Added %{smart_count} files from %{folder}',
     },
   },
-})
-```
-
-Instead of overriding strings yourself, consider using [one of our language packs](https://github.com/transloadit/uppy/tree/master/packages/%40uppy/locales) (or contributing one!):
-
-```js
-import russianLocale from '@uppy/locales/lib/ru_RU'
-// ^-- OR: import russianLocale from '@uppy/locales/lib/ru_RU'
-const uppy = new Uppy({
-  locale: russianLocale,
-})
-```
-
-If you use Uppy from a CDN, [there’s an example](/examples/i18n/) showcasing how to change languages.
-
-For flexibility, you can pass a `locale` at the `Uppy`/core level, or to Plugins individually. The locale strings that you set in core take precedence.
-
-It also offers the pluralization function, which is used to figure which string will be used for the provided `smart_count` number.
-
-For example, for the Icelandic language, the pluralization function would be:
+}
 
-```js
-const uppy = new Uppy({
-  locale: {
-    pluralize: (n) => ((n % 10 !== 1 || n % 100 === 11) ? 1 : 0),
-  },
-})
 ```
 
-We are using a forked [Polyglot.js](https://github.com/airbnb/polyglot.js/blob/master/index.js#L37-L60).
-
 ### `store: defaultStore()`
 
 The Store that is used to keep track of internal state. By [default](/docs/stores/#DefaultStore), a plain object is used.

+ 88 - 94
website/src/docs/dashboard.md

@@ -333,104 +333,98 @@ Dashboard ships with the `ThumbnailGenerator` plugin that adds small resized ima
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The Dashboard also includes the [`@uppy/status-bar`](/docs/status-bar) plugin by default, which has its own strings. Strings for the Status Bar can also be specified in the Dashboard `locale.strings` option, and will be passed down. They are not all listed below—see the [`@uppy/status-bar`](/docs/status-bar) documentation pages for the full list.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const strings = {
-  // When `inline: false`, used as the screen reader label for the button that closes the modal.
-  closeModal: 'Close Modal',
-  // Used as the screen reader label for the plus (+) button that shows the “Add more files” screen
-  addMoreFiles: 'Add more files',
-  // TODO
-  addingMoreFiles: 'Adding more files',
-  // Used as the header for import panels, e.g., “Import from Google Drive”.
-  importFrom: 'Import from %{name}',
-  // When `inline: false`, used as the screen reader label for the dashboard modal.
-  dashboardWindowTitle: 'Uppy Dashboard Window (Press escape to close)',
-  // When `inline: true`, used as the screen reader label for the dashboard area.
-  dashboardTitle: 'Uppy Dashboard',
-  // Shown in the Informer when a link to a file was copied to the clipboard.
-  copyLinkToClipboardSuccess: 'Link copied to clipboard.',
-  // Used when a link cannot be copied automatically — the user has to select the text from the
-  // input element below this string.
-  copyLinkToClipboardFallback: 'Copy the URL below',
-  // Used as the hover title and screen reader label for buttons that copy a file link.
-  copyLink: 'Copy link',
-  // Used as the hover title and screen reader label for file source icons, e.g., “File source: Dropbox”.
-  fileSource: 'File source: %{name}',
-  // Used as the label for buttons that accept and close panels (remote providers or metadata editor)
-  done: 'Done',
-  // TODO
-  back: 'Back',
-  // Used as the screen reader label for buttons that remove a file.
-  removeFile: 'Remove file',
-  // Used as the screen reader label for buttons that open the metadata editor panel for a file.
-  editFile: 'Edit file',
-  // Shown in the panel header for the metadata editor. Rendered as “Editing image.png”.
-  editing: 'Editing %{file}',
-  // Text for a button shown on the file preview, used to edit file metadata
-  edit: 'Edit',
-  // Used as the screen reader label for the button that saves metadata edits and returns to the
-  // file list view.
-  finishEditingFile: 'Finish editing file',
-  // TODO
-  saveChanges: 'Save changes',
-  // Used as the label for the tab button that opens the system file selection dialog.
-  myDevice: 'My Device',
-  // Shown in the main dashboard area when no files have been selected, and one or more
-  // remote provider plugins are in use. %{browse} is replaced with a link that opens the system
-  // file selection dialog.
-  dropPasteImport: 'Drop files here, paste, %{browse} or import from',
-  // Shown in the main dashboard area when no files have been selected, and no provider
-  // plugins are in use. %{browse} is replaced with a link that opens the system
-  // file selection dialog.
-  dropPaste: 'Drop files here, paste or %{browse}',
-  // TODO
-  dropHint: 'Drop your files here',
-  // This string is clickable and opens the system file selection dialog.
-  browse: 'browse',
-  // Used as the hover text and screen reader label for file progress indicators when
-  // they have been fully uploaded.
-  uploadComplete: 'Upload complete',
-  // TODO
-  uploadPaused: 'Upload paused',
-  // Used as the hover text and screen reader label for the buttons to resume paused uploads.
-  resumeUpload: 'Resume upload',
-  // Used as the hover text and screen reader label for the buttons to pause uploads.
-  pauseUpload: 'Pause upload',
-  // Used as the hover text and screen reader label for the buttons to retry failed uploads.
-  retryUpload: 'Retry upload',
-  // Used as the hover text and screen reader label for the buttons to cancel uploads.
-  cancelUpload: 'Cancel upload',
-
-  // Used in a title, how many files are currently selected
-  xFilesSelected: {
-    0: '%{smart_count} file selected',
-    1: '%{smart_count} files selected',
-  },
-  // TODO
-  uploadingXFiles: {
-    0: 'Uploading %{smart_count} file',
-    1: 'Uploading %{smart_count} files',
-  },
-  // TODO
-  processingXFiles: {
-    0: 'Processing %{smart_count} file',
-    1: 'Processing %{smart_count} files',
+module.exports = {
+  strings: {
+    // When `inline: false`, used as the screen reader label for the button that closes the modal.
+    closeModal: 'Close Modal',
+    // Used as the screen reader label for the plus (+) button that shows the “Add more files” screen
+    addMoreFiles: 'Add more files',
+    addingMoreFiles: 'Adding more files',
+    // Used as the header for import panels, e.g., “Import from Google Drive”.
+    importFrom: 'Import from %{name}',
+    // When `inline: false`, used as the screen reader label for the dashboard modal.
+    dashboardWindowTitle: 'Uppy Dashboard Window (Press escape to close)',
+    // When `inline: true`, used as the screen reader label for the dashboard area.
+    dashboardTitle: 'Uppy Dashboard',
+    // Shown in the Informer when a link to a file was copied to the clipboard.
+    copyLinkToClipboardSuccess: 'Link copied to clipboard.',
+    // Used when a link cannot be copied automatically — the user has to select the text from the
+    // input element below this string.
+    copyLinkToClipboardFallback: 'Copy the URL below',
+    // Used as the hover title and screen reader label for buttons that copy a file link.
+    copyLink: 'Copy link',
+    back: 'Back',
+    // Used as the screen reader label for buttons that remove a file.
+    removeFile: 'Remove file',
+    // Used as the screen reader label for buttons that open the metadata editor panel for a file.
+    editFile: 'Edit file',
+    // Shown in the panel header for the metadata editor. Rendered as “Editing image.png”.
+    editing: 'Editing %{file}',
+    // Used as the screen reader label for the button that saves metadata edits and returns to the
+    // file list view.
+    finishEditingFile: 'Finish editing file',
+    saveChanges: 'Save changes',
+    // Used as the label for the tab button that opens the system file selection dialog.
+    myDevice: 'My Device',
+    dropHint: 'Drop your files here',
+    // Used as the hover text and screen reader label for file progress indicators when
+    // they have been fully uploaded.
+    uploadComplete: 'Upload complete',
+    uploadPaused: 'Upload paused',
+    // Used as the hover text and screen reader label for the buttons to resume paused uploads.
+    resumeUpload: 'Resume upload',
+    // Used as the hover text and screen reader label for the buttons to pause uploads.
+    pauseUpload: 'Pause upload',
+    // Used as the hover text and screen reader label for the buttons to retry failed uploads.
+    retryUpload: 'Retry upload',
+    // Used as the hover text and screen reader label for the buttons to cancel uploads.
+    cancelUpload: 'Cancel upload',
+    // Used in a title, how many files are currently selected
+    xFilesSelected: {
+      0: '%{smart_count} file selected',
+      1: '%{smart_count} files selected',
+    },
+    uploadingXFiles: {
+      0: 'Uploading %{smart_count} file',
+      1: 'Uploading %{smart_count} files',
+    },
+    processingXFiles: {
+      0: 'Processing %{smart_count} file',
+      1: 'Processing %{smart_count} files',
+    },
+    // The "powered by Uppy" link at the bottom of the Dashboard.
+    poweredBy: 'Powered by %{uppy}',
+    addMore: 'Add more',
+    editFileWithFilename: 'Edit file %{file}',
+    save: 'Save',
+    cancel: 'Cancel',
+    dropPasteFiles: 'Drop files here or %{browseFiles}',
+    dropPasteFolders: 'Drop files here or %{browseFolders}',
+    dropPasteBoth: 'Drop files here, %{browseFiles} or %{browseFolders}',
+    dropPasteImportFiles: 'Drop files here, %{browseFiles} or import from:',
+    dropPasteImportFolders: 'Drop files here, %{browseFolders} or import from:',
+    dropPasteImportBoth:
+      'Drop files here, %{browseFiles}, %{browseFolders} or import from:',
+    importFiles: 'Import files from:',
+    browseFiles: 'browse files',
+    browseFolders: 'browse folders',
+    recoveredXFiles: {
+      0: 'We could not fully recover 1 file. Please re-select it and resume the upload.',
+      1: 'We could not fully recover %{smart_count} files. Please re-select them and resume the upload.',
+    },
+    recoveredAllFiles: 'We restored all files. You can now resume the upload.',
+    sessionRestored: 'Session restored',
+    reSelect: 'Re-select',
+    missingRequiredMetaFields: {
+      0: 'Missing required meta field: %{fields}.',
+      1: 'Missing required meta fields: %{fields}.',
+    },
   },
-
-  // The "powered by Uppy" link at the bottom of the Dashboard.
-  poweredBy: 'Powered by %{uppy}',
-
-  // @uppy/status-bar strings:
-  uploading: 'Uploading',
-  complete: 'Complete',
-  // ...etc
 }
+
 ```
 
 ### `theme: 'light'`

+ 14 - 1
website/src/docs/dragdrop.md → website/src/docs/drag-drop.md

@@ -86,7 +86,20 @@ Optionally, specify a string of text that explains something about the upload fo
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
+
+```js
+module.exports = {
+  strings: {
+    // Text to show on the droppable area.
+    // `%{browse}` is replaced with a link that opens the system file selection dialog.
+    dropHereOr: 'Drop here or %{browse}',
+    // Used as the label for the link that opens the system file selection dialog.
+    browse: 'browse',
+  },
+}
+
+```
 
 ### `onDragOver(event)`
 

+ 4 - 5
website/src/docs/dropbox.md

@@ -127,14 +127,13 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locale = {
+module.exports = {
   strings: {
-    // TODO
+    pluginNameDropbox: 'Dropbox',
   },
 }
+
 ```

+ 4 - 5
website/src/docs/facebook.md

@@ -86,14 +86,13 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locale = {
+module.exports = {
   strings: {
-    // TODO
+    pluginNameFacebook: 'Facebook',
   },
 }
+
 ```

+ 6 - 2
website/src/docs/fileinput.md → website/src/docs/file-input.md

@@ -84,14 +84,18 @@ The `name` attribute for the `<input type="file">` element.
 
 ### `locale: {}`
 
-When `pretty` is set, specify a custom label for the button.
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locale = {
+module.exports = {
   strings: {
+    // The same key is used for the same purpose by @uppy/robodog's `form()` API, but our
+    // locale pack scripts can't access it in Robodog. If it is updated here, it should
+    // also be updated there!
     chooseFiles: 'Choose files',
   },
 }
+
 ```
 
 ## Custom file input

+ 5 - 6
website/src/docs/google-drive.md

@@ -122,14 +122,13 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locale = {
-  strings:{
-    // TODO
+module.exports = {
+  strings: {
+    pluginNameGoogleDrive: 'Google Drive',
   },
 }
+
 ```

+ 4 - 5
website/src/docs/instagram.md

@@ -92,14 +92,13 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locale = {
+module.exports = {
   strings: {
-    // TODO
+    pluginNameInstagram: 'Instagram',
   },
 }
+
 ```

+ 4 - 5
website/src/docs/onedrive.md

@@ -86,14 +86,13 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const locale = {
+module.exports = {
   strings: {
-    // TODO
+    pluginNameOneDrive: 'OneDrive',
   },
 }
+
 ```

+ 17 - 0
website/src/docs/screen-capture.md

@@ -101,3 +101,20 @@ Set the preferred mime type for video recordings, for example `'video/webm'`. If
 If no preferred video mime type is given, the ScreenCapture plugin will prefer types listed in the [`allowedFileTypes` restriction](/docs/uppy/#restrictions), if any.
 
 ### `locale: {}`
+
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
+
+```js
+module.exports = {
+  strings: {
+    startCapturing: 'Begin screen capturing',
+    stopCapturing: 'Stop screen capturing',
+    submitRecordedFile: 'Submit recorded file',
+    streamActive: 'Stream active',
+    streamPassive: 'Stream passive',
+    micDisabled: 'Microphone access denied by user',
+    recording: 'Recording',
+  },
+}
+
+```

+ 48 - 41
website/src/docs/statusbar.md → website/src/docs/status-bar.md

@@ -120,51 +120,58 @@ const doneButtonHandler = () => {
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const strings = {
-  // Shown in the status bar while files are being uploaded.
-  uploading: 'Uploading',
-  // Shown in the status bar once all files have been uploaded.
-  complete: 'Complete',
-  // Shown in the status bar if an upload failed.
-  uploadFailed: 'Upload failed',
-  // Shown in the status bar while the upload is paused.
-  paused: 'Paused',
-  // Used as the label for the button that retries an upload.
-  retry: 'Retry',
-  // Used as the label for the button that cancels an upload.
-  cancel: 'Cancel',
-  // Used as the label for the button that pauses an upload.
-  pause: 'Pause',
-  // Used as the label for the button that resumes an upload.
-  resume: 'Resume',
-  // Used as the label for the button that resets the upload state after an upload
-  done: 'Done',
-  // When `showProgressDetails` is set, shows the number of files that have been fully uploaded so far.
-  filesUploadedOfTotal: {
-    0: '%{complete} of %{smart_count} file uploaded',
-    1: '%{complete} of %{smart_count} files uploaded',
-  },
-  // When `showProgressDetails` is set, shows the amount of bytes that have been uploaded so far.
-  dataUploadedOfTotal: '%{complete} of %{total}',
-  // When `showProgressDetails` is set, shows an estimation of how long the upload will take to complete.
-  xTimeLeft: '%{time} left',
-  // Used as the label for the button that starts an upload.
-  uploadXFiles: {
-    0: 'Upload %{smart_count} file',
-    1: 'Upload %{smart_count} files',
-  },
-  // Used as the label for the button that starts an upload, if another upload has been started in the past
-  // and new files were added later.
-  uploadXNewFiles: {
-    0: 'Upload +%{smart_count} file',
-    1: 'Upload +%{smart_count} files',
+module.exports = {
+  strings: {
+    // Shown in the status bar while files are being uploaded.
+    uploading: 'Uploading',
+    // Shown in the status bar once all files have been uploaded.
+    complete: 'Complete',
+    // Shown in the status bar if an upload failed.
+    uploadFailed: 'Upload failed',
+    // Shown in the status bar while the upload is paused.
+    paused: 'Paused',
+    // Used as the label for the button that retries an upload.
+    retry: 'Retry',
+    // Used as the label for the button that cancels an upload.
+    cancel: 'Cancel',
+    // Used as the label for the button that pauses an upload.
+    pause: 'Pause',
+    // Used as the label for the button that resumes an upload.
+    resume: 'Resume',
+    // Used as the label for the button that resets the upload state after an upload
+    done: 'Done',
+    // When `showProgressDetails` is set, shows the number of files that have been fully uploaded so far.
+    filesUploadedOfTotal: {
+      0: '%{complete} of %{smart_count} file uploaded',
+      1: '%{complete} of %{smart_count} files uploaded',
+    },
+    // When `showProgressDetails` is set, shows the amount of bytes that have been uploaded so far.
+    dataUploadedOfTotal: '%{complete} of %{total}',
+    // When `showProgressDetails` is set, shows an estimation of how long the upload will take to complete.
+    xTimeLeft: '%{time} left',
+    // Used as the label for the button that starts an upload.
+    uploadXFiles: {
+      0: 'Upload %{smart_count} file',
+      1: 'Upload %{smart_count} files',
+    },
+    // Used as the label for the button that starts an upload, if another upload has been started in the past
+    // and new files were added later.
+    uploadXNewFiles: {
+      0: 'Upload +%{smart_count} file',
+      1: 'Upload +%{smart_count} files',
+    },
+    upload: 'Upload',
+    retryUpload: 'Retry upload',
+    xMoreFilesAdded: {
+      0: '%{smart_count} more file added',
+      1: '%{smart_count} more files added',
+    },
   },
 }
+
 ```
 
 [`@uppy/file-input`]: /docs/file-input

+ 12 - 11
website/src/docs/transloadit.md

@@ -275,20 +275,21 @@ Limit the amount of uploads going on at the same time. Setting this to `0` means
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const strings = {
-  // Shown while Assemblies are being created for an upload.
-  creatingAssembly: 'Preparing upload...',
-  // Shown if an Assembly could not be created.
-  creatingAssemblyFailed: 'Transloadit: Could not create Assembly',
-  // Shown after uploads have succeeded, but when the Assembly is still executing.
-  // This only shows if `waitForMetadata` or `waitForEncoding` was enabled.
-  encoding: 'Encoding...',
+module.exports = {
+  strings: {
+    // Shown while Assemblies are being created for an upload.
+    creatingAssembly: 'Preparing upload...',
+    // Shown if an Assembly could not be created.
+    creatingAssemblyFailed: 'Transloadit: Could not create Assembly',
+    // Shown after uploads have succeeded, but when the Assembly is still executing.
+    // This only shows if `waitForMetadata` or `waitForEncoding` was enabled.
+    encoding: 'Encoding...',
+  },
 }
+
 ```
 
 ## Errors

+ 13 - 12
website/src/docs/url.md

@@ -84,21 +84,22 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const strings = {
-  // Label for the "Import" button.
-  import: 'Import',
-  // Placeholder text for the URL input.
-  enterUrlToImport: 'Enter URL to import a file',
-  // Error message shown if Companion could not load a URL.
-  failedToFetch: 'Companion failed to fetch this URL, please make sure it’s correct',
-  // Error message shown if the input does not look like a URL.
-  enterCorrectUrl: 'Incorrect URL: Please make sure you are entering a direct link to a file',
+module.exports = {
+  strings: {
+    // Label for the "Import" button.
+    import: 'Import',
+    // Placeholder text for the URL input.
+    enterUrlToImport: 'Enter URL to import a file',
+    // Error message shown if Companion could not load a URL.
+    failedToFetch: 'Companion failed to fetch this URL, please make sure it’s correct',
+    // Error message shown if the input does not look like a URL.
+    enterCorrectUrl: 'Incorrect URL: Please make sure you are entering a direct link to a file',
+  },
 }
+
 ```
 
 ## Methods

+ 29 - 22
website/src/docs/webcam.md

@@ -150,29 +150,36 @@ If no preferred image mime type is given, the Webcam plugin will prefer types li
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const strings = {
-  // Shown before a picture is taken when the `countdown` option is set.
-  smile: 'Smile!',
-  // Used as the label for the button that takes a picture.
-  // This is not visibly rendered but is picked up by screen readers.
-  takePicture: 'Take a picture',
-  // Used as the label for the button that starts a video recording.
-  // This is not visibly rendered but is picked up by screen readers.
-  startRecording: 'Begin video recording',
-  // Used as the label for the button that stops a video recording.
-  // This is not visibly rendered but is picked up by screen readers.
-  stopRecording: 'Stop video recording',
-  // Used as the label for the recording length counter. See the showRecordingLength option.
-  // This is not visibly rendered but is picked up by screen readers.
-  recordingLength: 'Recording length %{recording_length}',
-  // Title on the “allow access” screen
-  allowAccessTitle: 'Please allow access to your camera',
-  // Description on the “allow access” screen
-  allowAccessDescription: 'In order to take pictures or record video with your camera, please allow camera access for this site.',
+module.exports = {
+  strings: {
+    pluginNameCamera: 'Camera',
+    noCameraTitle: 'Camera Not Available',
+    noCameraDescription: 'In order to take pictures or record video, please connect a camera device',
+    recordingStoppedMaxSize: 'Recording stopped because the file size is about to exceed the limit',
+    submitRecordedFile: 'Submit recorded file',
+    discardRecordedFile: 'Discard recorded file',
+    // Shown before a picture is taken when the `countdown` option is set.
+    smile: 'Smile!',
+    // Used as the label for the button that takes a picture.
+    // This is not visibly rendered but is picked up by screen readers.
+    takePicture: 'Take a picture',
+    // Used as the label for the button that starts a video recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    startRecording: 'Begin video recording',
+    // Used as the label for the button that stops a video recording.
+    // This is not visibly rendered but is picked up by screen readers.
+    stopRecording: 'Stop video recording',
+    // Used as the label for the recording length counter. See the showRecordingLength option.
+    // This is not visibly rendered but is picked up by screen readers.
+    recordingLength: 'Recording length %{recording_length}',
+    // Title on the “allow access” screen
+    allowAccessTitle: 'Please allow access to your camera',
+    // Description on the “allow access” screen
+    allowAccessDescription: 'In order to take pictures or record video with your camera, please allow camera access for this site.',
+  },
 }
+
 ```

+ 7 - 6
website/src/docs/xhrupload.md → website/src/docs/xhr-upload.md

@@ -231,15 +231,16 @@ Indicates whether cross-site Access-Control requests should be made using creden
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const strings = {
-  // Shown in the Informer if an upload is being canceled because it stalled for too long.
-  timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
+module.exports = {
+  strings: {
+    // Shown in the Informer if an upload is being canceled because it stalled for too long.
+    timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
+  },
 }
+
 ```
 
 ## POST Parameters / Form Fields

+ 6 - 5
website/src/docs/zoom.md

@@ -90,14 +90,15 @@ This option correlates to the [RequestCredentials value](https://developer.mozil
 
 ### `locale: {}`
 
-Localize text that is shown to the user.
-
-The default English strings are:
+<!-- eslint-disable no-restricted-globals, no-multiple-empty-lines -->
 
 ```js
-const strings = {
-  // TODO
+module.exports = {
+  strings: {
+    pluginNameZoom: 'Zoom',
+  },
 }
+
 ```
 
 ## Zoom Marketplace

+ 65 - 3
yarn.lock

@@ -21451,7 +21451,7 @@ fsevents@^1.2.7:
   languageName: node
   linkType: hard
 
-"get-own-enumerable-property-symbols@npm:^3.0.0":
+"get-own-enumerable-property-symbols@npm:^3.0.0, get-own-enumerable-property-symbols@npm:^3.0.2":
   version: 3.0.2
   resolution: "get-own-enumerable-property-symbols@npm:3.0.2"
   checksum: 8f0331f14159f939830884799f937343c8c0a2c330506094bc12cbee3665d88337fe97a4ea35c002cc2bdba0f5d9975ad7ec3abb925015cdf2a93e76d4759ede
@@ -21813,7 +21813,7 @@ fsevents@^1.2.7:
   languageName: node
   linkType: hard
 
-"glob@npm:^7.0.0, glob@npm:^7.0.3, glob@npm:^7.0.6, glob@npm:^7.1.0, glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6":
+"glob@npm:^7.0.0, glob@npm:^7.0.3, glob@npm:^7.0.6, glob@npm:^7.1.0, glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.2.0":
   version: 7.2.0
   resolution: "glob@npm:7.2.0"
   dependencies:
@@ -24625,6 +24625,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"is-obj@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "is-obj@npm:3.0.0"
+  checksum: 75e97a99ed0b0884778887f8e913791864151307774914283b068b06b57ca86f695b024aa1ba5ed04411918edef93e2bfd8f84d68c6b6aab417802cc76f5061b
+  languageName: node
+  linkType: hard
+
 "is-object@npm:^1.0.1":
   version: 1.0.2
   resolution: "is-object@npm:1.0.2"
@@ -24787,6 +24794,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"is-regexp@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "is-regexp@npm:3.0.0"
+  checksum: c0f32f93accb9408ce1fd3d47f43648b8612d28186638b3a46e835fb147842b220b2ede4bb86315e996a4d78c393b30b0b9ab9348f9be7ab27d4c2b102469c7c
+  languageName: node
+  linkType: hard
+
 "is-resolvable@npm:^1.0.0, is-resolvable@npm:^1.1.0":
   version: 1.1.0
   resolution: "is-resolvable@npm:1.1.0"
@@ -27325,6 +27339,20 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"locale-pack@workspace:private/locale-pack":
+  version: 0.0.0-use.local
+  resolution: "locale-pack@workspace:private/locale-pack"
+  dependencies:
+    chalk: ^4.1.2
+    dedent: ^0.7.0
+    glob: ^7.2.0
+    mdast-util-heading-range: ^3.1.0
+    remark: ^14.0.1
+    remark-frontmatter: ^4.0.1
+    stringify-object: ^4.0.0
+  languageName: unknown
+  linkType: soft
+
 "localtunnel@npm:^2.0.1":
   version: 2.0.2
   resolution: "localtunnel@npm:2.0.2"
@@ -28429,6 +28457,17 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"mdast-util-heading-range@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "mdast-util-heading-range@npm:3.1.0"
+  dependencies:
+    "@types/mdast": ^3.0.0
+    "@types/unist": ^2.0.0
+    mdast-util-to-string: ^3.0.0
+  checksum: c0f9892cdd36ab88b368f636270e7e846edc3c923a395b6ae49913e99c88cdff9eb5b2b131a9f20e8a78d22aff66e0523acda630721f30d8e32159ca6d35ac5d
+  languageName: node
+  linkType: hard
+
 "mdast-util-heading-style@npm:^2.0.0":
   version: 2.0.0
   resolution: "mdast-util-heading-style@npm:2.0.0"
@@ -36261,6 +36300,18 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"remark-frontmatter@npm:^4.0.1":
+  version: 4.0.1
+  resolution: "remark-frontmatter@npm:4.0.1"
+  dependencies:
+    "@types/mdast": ^3.0.0
+    mdast-util-frontmatter: ^1.0.0
+    micromark-extension-frontmatter: ^1.0.0
+    unified: ^10.0.0
+  checksum: c1c448923cd0239e9eeafb42d7129c05081c9a1bca4c8164b562cbb748e80d103bfd058597a48d54000ce3c776200ab8ccd64a9679d955423f07e4a4e77f10c3
+  languageName: node
+  linkType: hard
+
 "remark-lint-emphasis-marker@npm:^3.0.0":
   version: 3.1.0
   resolution: "remark-lint-emphasis-marker@npm:3.1.0"
@@ -36697,7 +36748,7 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
-"remark@npm:^14.0.0":
+"remark@npm:^14.0.0, remark@npm:^14.0.1":
   version: 14.0.1
   resolution: "remark@npm:14.0.1"
   dependencies:
@@ -39679,6 +39730,17 @@ resolve@^2.0.0-next.3:
   languageName: node
   linkType: hard
 
+"stringify-object@npm:^4.0.0":
+  version: 4.0.0
+  resolution: "stringify-object@npm:4.0.0"
+  dependencies:
+    get-own-enumerable-property-symbols: ^3.0.2
+    is-obj: ^3.0.0
+    is-regexp: ^3.0.0
+  checksum: 8fffae04044f0a4f7fa70e05b857750e35a54e6117cdf69cad2d1ef318ff4b0c8fec57a7731646fca0ca7c07ae723cb12edbbbc863377607aec9c83adb9ff5a3
+  languageName: node
+  linkType: hard
+
 "strip-ansi@npm:6.0.0":
   version: 6.0.0
   resolution: "strip-ansi@npm:6.0.0"