From f8db6c3f02b64785a1b420dd0e9b4759bf1a0a44 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Tue, 27 Mar 2018 12:30:23 -0700 Subject: [PATCH] Support extension translation The new `maybeFormatMessage` function detects whether its argument looks like a message descriptor object and, if so, will call `formatMessage` on it. This is now used for all user-visible text fields in extensions. Also, messages may use "select" to check the target type with a message like this: '{targetType, select, stage {text for stage} sprite {text for sprite} other {text for other}'. Note that the "other" clause is required by `formatMessage`. --- src/engine/runtime.js | 33 +++++++++++++++++----- src/extension-support/define-messages.js | 18 ++++++++++++ src/extension-support/extension-manager.js | 16 +++++++++-- src/util/maybe-format-message.js | 18 ++++++++++++ 4 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 src/extension-support/define-messages.js create mode 100644 src/util/maybe-format-message.js diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 2e14ab1dd..7cd85aaea 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -6,9 +6,11 @@ const ArgumentType = require('../extension-support/argument-type'); const Blocks = require('./blocks'); const BlockType = require('../extension-support/block-type'); const Sequencer = require('./sequencer'); +const TargetType = require('../extension-support/target-type'); const Thread = require('./thread'); const Profiler = require('./profiler'); const log = require('../util/log'); +const maybeFormatMessage = require('../util/maybe-format-message'); // Virtual I/O devices. const Clock = require('../io/clock'); @@ -495,6 +497,19 @@ class Runtime extends EventEmitter { return `${extensionId}.menu.${escapeHtml(menuName)}`; } + /** + * Create a context ("args") object for use with `formatMessage` on messages which might be target-specific. + * @param {Target} [target] - the target to use as context. If a target is not provided, default to the current + * editing target or the stage. + */ + makeMessageContextForTarget (target) { + const context = {}; + target = target || this.getEditingTarget() || this.getTargetForStage(); + if (target) { + context.targetType = (target.isStage ? TargetType.STAGE : TargetType.SPRITE); + } + } + /** * Register the primitives provided by an extension. * @param {ExtensionMetadata} extensionInfo - information about the extension (id, blocks, etc.) @@ -503,7 +518,7 @@ class Runtime extends EventEmitter { _registerExtensionPrimitives (extensionInfo) { const categoryInfo = { id: extensionInfo.id, - name: extensionInfo.name, + name: maybeFormatMessage(extensionInfo.name), blockIconURI: extensionInfo.blockIconURI, menuIconURI: extensionInfo.menuIconURI, color1: '#FF6680', @@ -591,14 +606,16 @@ class Runtime extends EventEmitter { if (typeof menuItems === 'function') { options = menuItems; } else { + const extensionMessageContext = this.makeMessageContextForTarget(); options = menuItems.map(item => { - switch (typeof item) { + const formattedItem = maybeFormatMessage(item, extensionMessageContext); + switch (typeof formattedItem) { case 'string': - return [item, item]; + return [formattedItem, formattedItem]; case 'object': - return [item.text, item.value]; + return [maybeFormatMessage(item.text, extensionMessageContext), item.value]; default: - throw new Error(`Can't interpret menu item: ${item}`); + throw new Error(`Can't interpret menu item: ${JSON.stringify(item)}`); } }); } @@ -713,12 +730,14 @@ class Runtime extends EventEmitter { let inBranchNum = 0; // how many branches have we placed into the JSON so far? let outLineNum = 0; // used for scratch-blocks `message${outLineNum}` and `args${outLineNum}` const convertPlaceholders = this._convertPlaceholders.bind(this, context); + const extensionMessageContext = this.makeMessageContextForTarget(); // alternate between a block "arm" with text on it and an open slot for a substack while (inTextNum < blockText.length || inBranchNum < blockInfo.branchCount) { if (inTextNum < blockText.length) { context.outLineNum = outLineNum; - const convertedText = blockText[inTextNum].replace(/\[(.+?)]/g, convertPlaceholders); + const lineText = maybeFormatMessage(blockText[inTextNum], extensionMessageContext); + const convertedText = lineText.replace(/\[(.+?)]/g, convertPlaceholders); if (blockJSON[`message${outLineNum}`]) { blockJSON[`message${outLineNum}`] += convertedText; } else { @@ -784,7 +803,7 @@ class Runtime extends EventEmitter { const argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; const defaultValue = (typeof argInfo.defaultValue === 'undefined' ? '' : - escapeHtml(argInfo.defaultValue.toString())); + escapeHtml(maybeFormatMessage(argInfo.defaultValue, this.makeMessageContextForTarget()).toString())); if (argTypeInfo.check) { argJSON.check = argTypeInfo.check; diff --git a/src/extension-support/define-messages.js b/src/extension-support/define-messages.js new file mode 100644 index 000000000..0ca9b4b5b --- /dev/null +++ b/src/extension-support/define-messages.js @@ -0,0 +1,18 @@ +/** + * @typedef {object} MessageDescriptor + * @property {string} id - the translator-friendly unique ID of this message. + * @property {string} default - the message text in the default language (English). + * @property {string} [description] - a description of this message to help translators understand the context. + */ + +/** + * This is a hook for extracting messages from extension source files. + * This function simply returns the message descriptor map object that's passed in. + * @param {object.} messages - the messages to be defined + * @return {object.} - the input, unprocessed + */ +const defineMessages = function (messages) { + return messages; +}; + +module.exports = defineMessages; diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index c6b846f35..6afbf23df 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -1,5 +1,6 @@ const dispatch = require('../dispatch/central-dispatch'); const log = require('../util/log'); +const maybeFormatMessage = require('../util/maybe-format-message'); const BlockType = require('./block-type'); @@ -286,12 +287,23 @@ class ExtensionManager { _getExtensionMenuItems (extensionObject, menuName) { // Fetch the items appropriate for the target currently being edited. This assumes that menus only // collect items when opened by the user while editing a particular target. - const editingTarget = this.runtime.getEditingTarget(); + const editingTarget = this.runtime.getEditingTarget() || this.runtime.getTargetForStage(); const editingTargetID = editingTarget ? editingTarget.id : null; + const extensionMessageContext = this.runtime.makeMessageContextForTarget(editingTarget); // TODO: Fix this to use dispatch.call when extensions are running in workers. const menuFunc = extensionObject[menuName]; - const menuItems = menuFunc.call(extensionObject, editingTargetID); + const menuItems = menuFunc.call(extensionObject, editingTargetID).map( + item => { + item = maybeFormatMessage(item, extensionMessageContext); + if (typeof item === 'object') { + return [ + maybeFormatMessage(item.text, extensionMessageContext), + item.value + ]; + } + return item; + }); if (!menuItems || menuItems.length < 1) { throw new Error(`Extension menu returned no items: ${menuName}`); diff --git a/src/util/maybe-format-message.js b/src/util/maybe-format-message.js new file mode 100644 index 000000000..6fb653daf --- /dev/null +++ b/src/util/maybe-format-message.js @@ -0,0 +1,18 @@ +const formatMessage = require('format-message'); + +/** + * Check if `maybeMessage` looks like a message object, and if so pass it to `formatMessage`. + * Otherwise, return `maybeMessage` as-is. + * @param {*} maybeMessage - something that might be a message descriptor object. + * @param {object} [args] - the arguments to pass to `formatMessage` if it gets called. + * @param {string} [locale] - the locale to pass to `formatMessage` if it gets called. + * @return {string|*} - the formatted message OR the original `maybeMessage` input. + */ +const maybeFormatMessage = function (maybeMessage, args, locale) { + if (maybeMessage.id && maybeMessage.default) { + return formatMessage(maybeMessage, args, locale); + } + return maybeMessage; +}; + +module.exports = maybeFormatMessage;