diff --git a/locales/pulsar.en.cson b/locales/pulsar.en.cson new file mode 100644 index 0000000000..9fe490ac32 --- /dev/null +++ b/locales/pulsar.en.cson @@ -0,0 +1,13 @@ +'pulsar': { + 'config': { + 'excludeVcsIgnoredPaths': { + 'title': 'Exclude VCS Ignored Paths' + } + } + 'menu': { + 'settings-view:view-installed-packages': 'Open Package Manager' + } + 'context-menu': { + 'core:undo': 'Undo' + } +} diff --git a/menus/win32.cson b/menus/win32.cson index 17ae6df142..9e87ac37dc 100644 --- a/menus/win32.cson +++ b/menus/win32.cson @@ -178,7 +178,7 @@ { label: '&Packages' submenu: [ - { label: 'Open Package Manager', command: 'settings-view:view-installed-packages' } + { label: '%pulsar.menu.settings-view:view-installed-packages%', command: 'settings-view:view-installed-packages' } { type: 'separator' } ] } @@ -209,7 +209,7 @@ 'context-menu': 'atom-text-editor, .overlayer': [ - {label: 'Undo', command: 'core:undo'} + {label: '%pulsar.context-menu.core:undo%', command: 'core:undo'} {label: 'Redo', command: 'core:redo'} {type: 'separator'} {label: 'Cut', command: 'core:cut'} diff --git a/package.json b/package.json index 17339a8718..683ec6039d 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "grim": "2.0.3", "image-view": "file:packages/image-view", "incompatible-packages": "file:packages/incompatible-packages", + "intl-messageformat": "^10.5.14", "jasmine-json": "~0.0", "jasmine-reporters": "1.1.0", "jasmine-tagged": "^1.1.4", diff --git a/packages/command-palette/lib/command-palette-view.js b/packages/command-palette/lib/command-palette-view.js index 40703d1487..bc8dabf7ab 100644 --- a/packages/command-palette/lib/command-palette-view.js +++ b/packages/command-palette/lib/command-palette-view.js @@ -10,7 +10,7 @@ export default class CommandPaletteView { initiallyVisibleItemCount: initiallyVisibleItemCount, // just for being able to disable visible-on-render in spec items: [], filter: this.filter, - emptyMessage: 'No matches found', + emptyMessage: atom.i18n.t("command-palette.commandPaletteView.emptyMessage"), elementForItem: (item, {index, selected, visible}) => { if (!visible) { return document.createElement("li") @@ -232,7 +232,7 @@ export default class CommandPaletteView { }) const introEl = document.createElement('strong') - introEl.textContent = 'matching tags: ' + introEl.textContent = atom.i18n.t("command-palette.commandPaletteView.matchingTags"); tagsEl.appendChild(introEl) matchingTags.map(t => this.createTag(t, query)).forEach((tagEl, i) => { diff --git a/packages/command-palette/locales/command-palette.en.cson b/packages/command-palette/locales/command-palette.en.cson new file mode 100644 index 0000000000..5637eda166 --- /dev/null +++ b/packages/command-palette/locales/command-palette.en.cson @@ -0,0 +1,4 @@ +'commandPaletteView': { + 'emptyMessage': 'No matches found' + 'matchingTags': 'matching tags: ' +} diff --git a/packages/settings-view/lib/rich-description.js b/packages/settings-view/lib/rich-description.js index a71f43cb63..2bbb153e17 100644 --- a/packages/settings-view/lib/rich-description.js +++ b/packages/settings-view/lib/rich-description.js @@ -6,6 +6,12 @@ module.exports = { if (schema && schema.description) { description = schema.description } + + // Localize + if (atom.i18n.isAutoTranslateLabel(description)) { + description = atom.i18n.translateLabel(description); + } + return atom.ui.markdown.render( description, { diff --git a/packages/settings-view/lib/rich-title.js b/packages/settings-view/lib/rich-title.js index 7b50e59866..72f39c86ec 100644 --- a/packages/settings-view/lib/rich-title.js +++ b/packages/settings-view/lib/rich-title.js @@ -6,7 +6,11 @@ module.exports = { name = '' } const schema = atom.config.getSchema(keyPath) - const title = schema != null ? schema.title : null + let title = schema != null ? schema.title : null + // Localize + if (typeof title === "string" && atom.i18n.isAutoTranslateLabel(title)) { + title = atom.i18n.translateLabel(title); + } return title || _.uncamelcase(name).split('.').map(_.capitalize).join(' ') } } diff --git a/spec/context-menu-manager-spec.js b/spec/context-menu-manager-spec.js index e0246ee018..140b6dd4e8 100644 --- a/spec/context-menu-manager-spec.js +++ b/spec/context-menu-manager-spec.js @@ -5,7 +5,7 @@ describe('ContextMenuManager', function() { beforeEach(function() { const { resourcePath } = atom.getLoadSettings(); - contextMenu = new ContextMenuManager({ keymapManager: atom.keymaps }); + contextMenu = new ContextMenuManager({ keymapManager: atom.keymaps, i18n: atom.i18n }); contextMenu.initialize({ resourcePath }); parent = document.createElement('div'); @@ -518,5 +518,58 @@ describe('ContextMenuManager', function() { } ]); }); + + it('translates labels when a LocaleLabel is present', function() { + const I18n = require("../src/i18n.js"); + atom.i18n.localeFallbackList = I18n.LocaleNegotiation( + "es-MX", + [ "zh-Hant" ] + ); + atom.i18n.addStrings({ + example: { + stringKey: "Hello Pulsar", + otherStringKey: "Goodbye Pulsar" + } + }, "en"); + + contextMenu.add({ + '.parent': [ + { + label: '%example.stringKey%', + submenu: [ + { + label: 'My Command', + command: 'test:my-command', + after: ['test:my-other-command'] + }, + { + label: '%example.otherStringKey%', + command: 'test:my-other-command' + } + ] + } + ] + }); + const dispatchedEvent = { target: parent }; + expect(contextMenu.templateForEvent(dispatchedEvent)).toEqual([ + { + label: 'Hello Pulsar', + id: `Parent`, + submenu: [ + { + label: 'My Other Command', + id: 'My Other Command', + command: 'test:my-other-command' + }, + { + label: 'Goodbye Pulsar', + id: 'My Command', + command: 'test:my-command', + after: ['test:my-other-command'] + } + ] + } + ]); + }); }); }); diff --git a/spec/i18n-spec.js b/spec/i18n-spec.js new file mode 100644 index 0000000000..1e09e3f2af --- /dev/null +++ b/spec/i18n-spec.js @@ -0,0 +1,241 @@ +const I18n = require("../src/i18n.js"); + +describe("I18n", () => { + let i18n; + + beforeEach(() => { + const { resourcePath } = atom.getLoadSettings(); + i18n = new I18n({ + config: atom.config + }); + i18n.initialize({ resourcePath }); + }); + + describe("Handles Locales logic", () => { + it("Locale Lookup Filtering Fallback Pattern Array: Follows RFC4647", () => { + const primaryLocale = "es-MX"; + const priorityListLocale = [ + "zh-Hant-CN-x-private1-private2", + "en-US" + ]; + const fallbackSet = I18n.LocaleNegotiation(primaryLocale, priorityListLocale); + + expect(Array.from(fallbackSet)).toEqual([ + "es-MX", + "es", + "zh-Hant-CN-x-private1-private2", + "zh-Hant-CN-x-private1", + "zh-Hant-CN", + "zh-Hant", + "zh", + "en-US", + "en" + ]); + }); + + it("Locale Lookup Filtering Fallback Pattern Array: Excludes duplicates from hardcoded fallback", () => { + const primaryLocale = "en-US"; + const priorityListLocale = [ "es-MX" ]; + const fallbackSet = I18n.LocaleNegotiation(primaryLocale, priorityListLocale); + + expect(Array.from(fallbackSet)).toEqual([ + "en-US", + "en", + "es-MX", + "es" + ]); + }); + + it("Accurately determines if Locale Should be included when it should", () => { + const primaryLocale = "en-US"; + const priorityListLocale = [ "es-MX" ]; + const questionedLocale = "en"; + const shouldBeIncluded = I18n.ShouldIncludeLocale(questionedLocale, primaryLocale, priorityListLocale); + expect(shouldBeIncluded).toEqual(true); + }); + + it("Accurately determines if Locale Should be included when it shoudln't", () => { + const primaryLocale = "zh-Hant"; + const priorityListLocale = [ "es-MX" ]; + const questionedLocale = "ja-JP"; + const shouldBeIncluded = I18n.ShouldIncludeLocale(questionedLocale, primaryLocale, priorityListLocale); + expect(shouldBeIncluded).toEqual(false); + }); + + it("Accurately determines if Locale should be included when the locale is the hardcoded fallback", () => { + const primaryLocale = "zh-Hant"; + const priorityListLocale = [ "es-MX" ]; + const questionedLocale = "en"; + const shouldBeIncluded = I18n.ShouldIncludeLocale(questionedLocale, primaryLocale, priorityListLocale); + expect(shouldBeIncluded).toEqual(true); + }); + + it("Can determine if basic locales match", () => { + const want = "en-US"; + const have = "en-US"; + expect(I18n.DoLocalesMatch(want, have)).toEqual(true); + }); + + it("Can determine if wildcard locales match", () => { + const want = "en-*"; + const have = "en-US"; + expect(I18n.DoLocalesMatch(want, have)).toEqual(true); + }); + + it("Can determine if locales do not match", () => { + const want = "en-US"; + const have = "ja-JP"; + expect(I18n.DoLocalesMatch(want, have)).toEqual(false); + }); + }); + + describe("Crafts strings object correctly", () => { + it("Properly adds locale key", () => { + i18n.addStrings({ + example: { + stringKey: "String Value" + } + }, "en-US"); + + expect(i18n.strings.example.stringKey).toEqual({ + "en-US": "String Value" + }); + }); + + it("Handles deep objects", () => { + i18n.addStrings({ + example: { + deepExampleKey: { + stringKey: "String Value" + } + } + }, "en-US"); + + expect(i18n.strings.example.deepExampleKey.stringKey).toEqual({ + "en-US": "String Value" + }); + }); + + it("Adds new locales to existing objects", () => { + i18n.addStrings({ + example: { + stringKey: "Hello Pulsar" + } + }, "en-US"); + i18n.addStrings({ + example: { + stringKey: "Hola Pulsar" + } + }, "es-MX"); + + expect(i18n.strings.example.stringKey).toEqual({ + "en-US": "Hello Pulsar", + "es-MX": "Hola Pulsar" + }); + }); + + it("Adds new locale to object", () => { + i18n.addStrings({ + example: { + stringKey: "Hello Pulsar" + } + }, "en-US"); + + i18n.addString("example.stringKey", "Hola Pulsar", "es-MX"); + + expect(i18n.strings.example.stringKey).toEqual({ + "en-US": "Hello Pulsar", + "es-MX": "Hola Pulsar" + }); + }); + }); + + describe("Is able to translate properly", () => { + + beforeEach(() => { + const primary = "es-MX"; + const priorityListLocale = [ "zh-Hant" ]; + i18n.localeFallbackList = I18n.LocaleNegotiation(primary, priorityListLocale); + }); + + it("Returns the proper string based on user setting", () => { + i18n.addStrings({ + example: { + stringKey: "Hello Pulsar" + } + }, "en"); + + i18n.addStrings({ + example: { + stringKey: "Hola Pulsar" + } + }, "es-MX"); + + expect(i18n.t("example.stringKey")).toEqual("Hola Pulsar"); + }); + + it("Handles ICU MessageFormat Replacements", () => { + i18n.addStrings({ + example: { + stringKey: "Hello {value}" + } + }, "en"); + + expect(i18n.t("example.stringKey", { value: "confused-Techie" })).toEqual( + "Hello confused-Techie" + ); + }); + + it("Handles namespace translations", () => { + i18n.addStrings({ + example: { + stringKey: "Hello Pulsar" + } + }, "en"); + + const t = i18n.getT("example"); + + expect(t.t("stringKey")).toEqual("Hello Pulsar"); + }); + }); + + describe("Translates LocaleLabels", () => { + it("Identifies a LocaleLabel", () => { + const str1 = "%this-is-a-locale-label%"; + const str2 = "this-is-not-a-locale-label"; + const str3 = "%nor-is-this"; + const str4 = "%or-%this"; + + expect(i18n.isAutoTranslateLabel(str1)).toEqual(true); + expect(i18n.isAutoTranslateLabel(str2)).toEqual(false); + expect(i18n.isAutoTranslateLabel(str3)).toEqual(false); + expect(i18n.isAutoTranslateLabel(str4)).toEqual(false); + }); + + it("Successfully translates a LocaleLabel", () => { + const primary = "es-MX"; + const priorityListLocale = [ "zh-Hant" ]; + i18n.localeFallbackList = I18n.LocaleNegotiation(primary, priorityListLocale); + + i18n.addStrings({ + example: { + stringKey: "Hello Pulsar" + } + }, "en"); + + const localeLabel = "%example.stringKey%"; + + expect(i18n.translateLabel(localeLabel)).toEqual("Hello Pulsar"); + }); + + it("Falls back to the original label if unable to translate", () => { + const primary = "es-MX"; + const priorityListLocale = [ "zh-Hant" ]; + i18n.localeFallbackList = I18n.LocaleNegotiation(primary, priorityListLocale); + + const localeLabel = "%example.stringKey%"; + + expect(i18n.translateLabel(localeLabel)).toEqual("%example.stringKey%"); + }); + }); +}); diff --git a/spec/menu-manager-spec.js b/spec/menu-manager-spec.js index 3b78e42b60..bb4afd5999 100644 --- a/spec/menu-manager-spec.js +++ b/spec/menu-manager-spec.js @@ -7,7 +7,8 @@ describe('MenuManager', function() { beforeEach(function() { menu = new MenuManager({ keymapManager: atom.keymaps, - packageManager: atom.packages + packageManager: atom.packages, + i18n: atom.i18n }); spyOn(menu, 'sendToBrowserProcess'); // Do not modify Atom's actual menus menu.initialize({ resourcePath: atom.getLoadSettings().resourcePath }); @@ -103,6 +104,33 @@ describe('MenuManager', function() { submenu: [{ label: 'B', id: 'B', command: 'b' }] }); }); + + it('translates LocaleLabels', function() { + const I18n = require("../src/i18n.js"); + atom.i18n.localeFallbackList = I18n.LocaleNegotiation( + "es-MX", + [ "zh-Hant" ] + ); + atom.i18n.addStrings({ + example: { + stringKey: "Hello Pulsar", + otherStringKey: "Goodbye Pulsar" + } + }, "en"); + + const disposable = menu.add([ + { label: '%example.stringKey%', submenu: [{ label: '%example.otherStringKey%', command: 'b' }] } + ]); + expect(menu.template).toEqual([ + { + label: 'Hello Pulsar', + id: 'A', + submenu: [{ label: 'Goodbye Pulsar', id: 'B', command: 'b' }] + } + ]); + disposable.dispose(); + expect(menu.template).toEqual([]); + }); }); describe('::update()', function() { diff --git a/src/atom-environment.js b/src/atom-environment.js index acdd901edc..2c731aeb64 100644 --- a/src/atom-environment.js +++ b/src/atom-environment.js @@ -47,6 +47,7 @@ const TextEditorRegistry = require('./text-editor-registry'); const StartupTime = require('./startup-time'); const { getReleaseChannel } = require('./get-app-details.js'); const UI = require('./ui.js'); +const I18n = require("./i18n.js"); const packagejson = require("../package.json"); const stat = util.promisify(fs.stat); @@ -126,6 +127,11 @@ class AtomEnvironment { /** @type {StyleManager} */ this.styles = new StyleManager(); + /** @type {I18n} */ + this.i18n = new I18n({ + config: this.config + }); + /** @type {PackageManager} */ this.packages = new PackageManager({ config: this.config, @@ -152,11 +158,12 @@ class AtomEnvironment { /** @type {MenuManager} */ this.menu = new MenuManager({ keymapManager: this.keymaps, - packageManager: this.packages + packageManager: this.packages, + i18n: this.i18n }); /** @type {ContextMenuManager} */ - this.contextMenu = new ContextMenuManager({ keymapManager: this.keymaps }); + this.contextMenu = new ContextMenuManager({ keymapManager: this.keymaps, i18n: this.i18n }); this.packages.setMenuManager(this.menu); this.packages.setContextMenuManager(this.contextMenu); @@ -275,6 +282,10 @@ class AtomEnvironment { this.project.replace(projectSpecification); } + this.i18n.initialize({ + resourcePath: resourcePath + }); + this.menu.initialize({ resourcePath }); this.contextMenu.initialize({ resourcePath, devMode }); diff --git a/src/config-schema.js b/src/config-schema.js index d1a2ed2fe4..e346e81f5b 100644 --- a/src/config-schema.js +++ b/src/config-schema.js @@ -25,7 +25,7 @@ const configSchema = { excludeVcsIgnoredPaths: { type: 'boolean', default: true, - title: 'Exclude VCS Ignored Paths', + title: '%pulsar.config.excludeVcsIgnoredPaths.title%', description: "Files and directories ignored by the current project's VCS will be ignored by some packages, such as the fuzzy finder and find and replace. For example, projects using Git have these paths defined in the .gitignore file. Individual packages might have additional config settings for ignoring VCS ignored files and folders." }, @@ -410,6 +410,28 @@ const configSchema = { default: false, title: 'Allow Window Transparency', description: `Allows editor windows to be see-through. When this setting is enabled, UI themes and user stylesheets can use background colors with an alpha channel to make editor windows translucent. Takes effect after a restart of Pulsar.` + }, + language: { + type: "object", + description: "Language and Locale options. Requires a restart of Pulsar to take effect.", + properties: { + primary: { + type: "string", + order: 1, + default: "en-US", + description: "The primary language/locale you prefer." + }, + priorityList: { + type: "array", + order: 2, + description: "List of alternative languages to load. Highest priority to lowest. Use the most specific locale selectors possible.", + default: [], + items: { + type: "string", + // TODO consider enum options, maybe? + } + } + } } } }, diff --git a/src/context-menu-manager.js b/src/context-menu-manager.js index 10aad2877d..e58dd4b870 100644 --- a/src/context-menu-manager.js +++ b/src/context-menu-manager.js @@ -45,8 +45,9 @@ if (buildMetadata != null && buildMetadata._atomMenu != null && buildMetadata._a // The format for use in {::add} is the same minus the `context-menu` key. See // {::add} for more information. module.exports = class ContextMenuManager { - constructor({keymapManager}) { + constructor({keymapManager, i18n}) { this.keymapManager = keymapManager; + this.i18n = i18n; this.definitions = { '.overlayer': [] // TODO: Remove once color picker package stops touching private data }; @@ -130,7 +131,7 @@ module.exports = class ContextMenuManager { if (throwOnInvalidSelector) { validateSelector(selector); } - const itemSet = new ContextMenuItemSet(selector, items); + const itemSet = new ContextMenuItemSet(selector, items, this.i18n); addedItemSets.push(itemSet); this.itemSets.push(itemSet); } @@ -273,10 +274,21 @@ module.exports = class ContextMenuManager { }; var ContextMenuItemSet = class ContextMenuItemSet { - constructor(selector1, items1) { + constructor(selector1, items1, i18n) { this.selector = selector1; this.items = items1; this.specificity = calculateSpecificity(this.selector); + + this.i18n = i18n; + this.localize(); + } + + localize() { + for (const item of this.items) { + if (this.i18n.isAutoTranslateLabel(item.label)) { + item.label = this.i18n.translateLabel(item.label); + } + } } }; diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000000..744bd1533a --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,231 @@ +const path = require("path"); +const fs = require("fs-plus"); +const CSON = require("season"); +const keyPathHelpers = require("key-path-helpers"); +const IntlMessageFormat = require("intl-messageformat").default; + +// Truncate trailing subtag, as well as single letter or digit subtags +// which introduce private-use sequences, and extensions, and are not valid +// locale selectors alone. +const LOCALE_LOOKUP_FILTER_TRUNCATE = /(-\w){0,1}-[\w*]*?$/g; + +const AUTO_TRANSLATE_LABEL = /^%.+%$/; + +function generateLocaleFallback(list, lang) { + // Breakdown a lang provided into the fallback options + let locale = lang; + while(locale.length > 0) { + if (!locale.includes("-")) { + // If we have no more subtags to remove + locale = ""; + break; + } + locale = locale.replace(LOCALE_LOOKUP_FILTER_TRUNCATE, ""); + list.add(locale); + } + return; +} + +module.exports = +class I18n { + // Provides LocaleNegotiation in accordance to RFC4647 Lookup Filtering Fallback Pattern + // Provided a priorityList, primary language, and default language. + static LocaleNegotiation( + primary, + priorityList, + fallback = "en" // Hardcoding to ensure we can always fallback to something + ) { + // First create the lookup list + const lookupList = new Set(); + + if (typeof primary === "string") { + // The first entry should be the primary language + lookupList.add(primary); + // Provide fallback options from the primary language + generateLocaleFallback(lookupList, primary); + } + + if (Array.isArray(priorityList)) { + for (const lang of priorityList) { + // Since the first entry should be fully formed, we will add it directly + lookupList.add(lang); + // Then we breakdown the lang provided into the fallback options + generateLocaleFallback(lookupList, lang); + } + } + + // After adding all items in the priority list, lets add the default + lookupList.add(fallback); + + return lookupList; + } + + // Determines if the provided locale should be loaded. Based on the user's + // current settings. + static ShouldIncludeLocale(locale, primary, priorityList, fallback) { + const localeList = I18n.LocaleNegotiation(primary, priorityList, fallback); + + for (const localeListItem of localeList) { + if (I18n.DoLocalesMatch(localeListItem, locale)) { + return true; + } + } + + return false; + } + + // Takes a wanted locale, and the locale you have, to determine if they match + static DoLocalesMatch(want, have) { + if (want == have) { + return true; + } + if (want.endsWith("-*") && have.includes("-")) { + // Attempt to match with wildcard + let wantArr = want.split("-"); + let haveArr = have.split("-"); + for (let i = 0; i < wantArr.length; i++) { + if (wantArr[i] == "*") { + // wants contains a wildcard match, doesn't matter what the have is + return true; + } else if (wantArr[i] != haveArr[i]) { + return false; + } // else they equal, and we let the loop continue to check the next place + } + } else { + // As we don't do any fallback behavior here, we can safely say these do + // not match. + return false; + } + } + + constructor({ config }) { + this.config = config; + this.strings = {}; + this.localeFallbackList = null; + } + + // Helps along with initial setup + initialize({ resourcePath }) { + this.localeFallbackList = I18n.LocaleNegotiation( + this.config.get("core.language.primary"), + this.config.get("core.language.priorityList") + ); + + // Load Pulsar Core Locales + const localesPath = path.join(resourcePath, "locales"); + const localesPaths = fs.listSync(localesPath, ["cson", "json"]); + + for (const localePath of localesPaths) { + const localeFilePath = localePath.split("."); + // `pulsar.en-US.json` => `en-US` + const locale = localeFilePath[localeFilePath.length - 2] ?? ""; + if (I18n.ShouldIncludeLocale(locale)) { + this.addStrings(CSON.readFileSync(localePath) || {}, locale); + } + } + } + + shouldIncludeLocale(locale) { + return I18n.ShouldIncludeLocale(locale); + } + + addStrings(newObj, locale, stringObj = this.strings) { + if (typeof newObj === "object") { + for (const key in newObj) { + if (typeof stringObj[key] !== "object") { + // We only want to initialize an empty object, if this doesn't + // already exist on the string tree + stringObj[key] = {}; + } + stringObj[key] = this.addStrings(newObj[key], locale, stringObj[key]); + } + } else if (typeof newObj === "string") { + // We have hit the final entry in the object, and it is that of a string + // Meaning this value is the translated string itself, so we will add it + // within a final key of the locale + stringObj[locale] = newObj; + } + return stringObj; + } + + addString(keyPath, str, locale) { + keyPathHelpers.setValueAtKeyPath(this.strings, `${keyPath}.${locale}`, str); + } + + t(keyPath, opts) { + return this.translate(keyPath, opts); + } + + translate(keyPath, opts = {}) { + const stringLocales = keyPathHelpers.getValueAtKeyPath(this.strings, keyPath); + + if (typeof stringLocales !== "object") { + // If the keypath requested doesn't exist, return null + return null; + } + + let bestLocale; + + if (this.localeFallbackList == null) { + this.localeFallbackList = I18n.LocaleNegotiation( + this.config.get("core.language.primary"), + this.config.get("core.language.priorityList") + ); + } + + // Find the first match for a locale available within the string + localeFallbackListLoop: + for (const localeListItem of this.localeFallbackList) { + for (const possibleLocale in stringLocales) { + if (I18n.DoLocalesMatch(localeListItem, possibleLocale)) { + bestLocale = possibleLocale; + break localeFallbackListLoop; + } + } + } + + if (!stringLocales[bestLocale]) { + // If we couldn't find any way to read the string, return null + return null; + } + + const msg = new IntlMessageFormat(stringLocales[bestLocale], bestLocale); + + return msg.format(opts); + } + + getT(namespace) { + return this.getTranslate(namespace); + } + + getTranslate(namespace) { + return new NamespaceI18n(namespace, this); + } + + // === Helper Methods + isAutoTranslateLabel(value) { + return AUTO_TRANSLATE_LABEL.test(value); + } + + // Used in the menu and context-menu when auto-translating labels + translateLabel(label) { + // Since failing to translate menus could crash Pulsar + // We must ensure to fallback to the raw label value + return this.translate(label.replace(/%/g, "")) ?? label; + } +} + +class NamespaceI18n { + constructor(namespace, i18n) { + this.namespace = namespace; + this.i18n = i18n; + } + + t(keyPath, opts) { + return this.translate(keyPath, opts); + } + + translate(keyPath, opts) { + return this.i18n.translate(`${this.namespace}.${keyPath}`, opts); + } +} diff --git a/src/menu-manager.js b/src/menu-manager.js index c67e338976..f2e649d65e 100644 --- a/src/menu-manager.js +++ b/src/menu-manager.js @@ -60,10 +60,11 @@ if (buildMetadata) { // // See {::add} for more info about adding menu's directly. module.exports = MenuManager = class MenuManager { - constructor({resourcePath, keymapManager, packageManager}) { + constructor({resourcePath, keymapManager, packageManager, i18n}) { this.resourcePath = resourcePath; this.keymapManager = keymapManager; this.packageManager = packageManager; + this.i18n = i18n; this.initialized = false; this.pendingUpdateOperation = null; this.template = []; @@ -106,6 +107,19 @@ module.exports = MenuManager = class MenuManager { if (item.label == null) { continue; // TODO: Should we emit a warning here? } + + // Localize item label + if (this.i18n.isAutoTranslateLabel(item.label)) { + item.label = this.i18n.translateLabel(item.label); + } + if (Array.isArray(item.submenu)) { + for (let y = 0; y < item.submenu.length; y++) { + if (this.i18n.isAutoTranslateLabel(item.submenu[y].label)) { + item.submenu[y].label = this.i18n.translateLabel(item.submenu[y].label); + } + } + } + this.merge(this.template, item); } this.update(); diff --git a/src/package.js b/src/package.js index 1831e919d9..370b1bee2c 100644 --- a/src/package.js +++ b/src/package.js @@ -95,6 +95,7 @@ module.exports = class Package { preload() { this.loadKeymaps(); this.loadMenus(); + this.loadLocales(); this.registerDeserializerMethods(); this.activateCoreStartupServices(); this.registerURIHandler(); @@ -130,6 +131,7 @@ module.exports = class Package { this.loadKeymaps(); this.loadMenus(); + this.loadLocales(); this.loadStylesheets(); this.registerDeserializerMethods(); this.activateCoreStartupServices(); @@ -531,6 +533,25 @@ module.exports = class Package { } } + loadLocales() { + const localesDirPath = path.join(this.path, "locales"); + const localesPaths = fs.listSync(localesDirPath, ["cson", "json"]); + + for (const localePath of localesPaths) { + const localeFilePath = localePath.split("."); + // `package-name.en-US.json` => `en-US` + const locale = localeFilePath[localeFilePath.length - 2] ?? ""; + if (atom.i18n.shouldIncludeLocale(locale)) { + const localeFile = CSON.readFileSync(localePath); + if (localeFile) { + const localeObj = {}; + localeObj[this.name] = localeFile; + atom.i18n.addStrings(localeObj, locale); + } + } + } + } + getKeymapPaths() { const keymapsDirPath = path.join(this.path, 'keymaps'); if (this.metadata.keymaps) { diff --git a/yarn.lock b/yarn.lock index 4505c010e9..b83d750410 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1431,6 +1431,45 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@formatjs/ecma402-abstract@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz#39197ab90b1c78b7342b129a56a7acdb8f512e17" + integrity sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g== + dependencies: + "@formatjs/intl-localematcher" "0.5.4" + tslib "^2.4.0" + +"@formatjs/fast-memoize@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.0.tgz#33bd616d2e486c3e8ef4e68c99648c196887802b" + integrity sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA== + dependencies: + tslib "^2.4.0" + +"@formatjs/icu-messageformat-parser@2.7.8": + version "2.7.8" + resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.7.8.tgz#f6d7643001e9bb5930d812f1f9a9856f30fa0343" + integrity sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA== + dependencies: + "@formatjs/ecma402-abstract" "2.0.0" + "@formatjs/icu-skeleton-parser" "1.8.2" + tslib "^2.4.0" + +"@formatjs/icu-skeleton-parser@1.8.2": + version "1.8.2" + resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.2.tgz#2252c949ae84ee66930e726130ea66731a123c9f" + integrity sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q== + dependencies: + "@formatjs/ecma402-abstract" "2.0.0" + tslib "^2.4.0" + +"@formatjs/intl-localematcher@0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz#caa71f2e40d93e37d58be35cfffe57865f2b366f" + integrity sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g== + dependencies: + tslib "^2.4.0" + "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -5453,6 +5492,16 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== +intl-messageformat@^10.5.14: + version "10.5.14" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.5.14.tgz#e5bb373f8a37b88fbe647d7b941f3ab2a37ed00a" + integrity sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w== + dependencies: + "@formatjs/ecma402-abstract" "2.0.0" + "@formatjs/fast-memoize" "2.2.0" + "@formatjs/icu-messageformat-parser" "2.7.8" + tslib "^2.4.0" + invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" @@ -9472,6 +9521,11 @@ tslib@^2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== +tslib@^2.4.0: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"