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`.
This commit is contained in:
Christopher Willis-Ford 2018-03-27 12:30:23 -07:00
parent defdd42c47
commit f8db6c3f02
4 changed files with 76 additions and 9 deletions

View file

@ -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;

View file

@ -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.<MessageDescriptor>} messages - the messages to be defined
* @return {object.<MessageDescriptor>} - the input, unprocessed
*/
const defineMessages = function (messages) {
return messages;
};
module.exports = defineMessages;

View file

@ -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}`);

View file

@ -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;