From 95a27cdd5b3c1f62c5813645b382e0ceea10abc6 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 26 Jul 2024 20:22:04 -0400 Subject: [PATCH 1/3] Initial implementation of Locale framework, with updates to asyncLoad to handle json files, and simplifications for components/bin/copy config data --- components/bin/copy | 29 ++- components/mjs/core/core.js | 12 +- components/mjs/core/locale.js | 4 + .../mjs/input/mml/extensions/mml3/config.json | 4 +- .../mjs/input/tex/extensions/bbox/config.json | 5 + components/mjs/node-main/config.json | 2 +- components/mjs/node-main/node-main-setup.mjs | 1 + components/mjs/node-main/node-main.cjs | 1 + components/mjs/node-main/node-main.js | 32 +-- components/mjs/sre/config.json | 2 +- package.json | 5 +- ts/components/startup.ts | 12 +- ts/input/tex/bbox/BboxConfiguration.ts | 147 +++++++------ ts/input/tex/bbox/locales/en.json | 3 + ts/input/tex/require/RequireConfiguration.ts | 6 +- ts/util/AsyncLoad.ts | 23 ++ ts/util/Locale.ts | 208 ++++++++++++++++++ ts/util/asyncLoad/esm.ts | 3 +- ts/util/asyncLoad/node.ts | 3 +- ts/util/asyncLoad/system.ts | 3 +- 20 files changed, 402 insertions(+), 103 deletions(-) create mode 100644 components/mjs/core/locale.js create mode 100644 ts/input/tex/bbox/locales/en.json create mode 100644 ts/util/Locale.ts diff --git a/components/bin/copy b/components/bin/copy index a1429ddda..b8d7a206a 100755 --- a/components/bin/copy +++ b/components/bin/copy @@ -50,13 +50,6 @@ const config = require(json).copy; if (!config) process.exit(); process.chdir(path.dirname(json)); -/** - * Redirect the bundle, if not the default - */ -if (bundle !== 'bundle') { - config.to = config.to.replace(/\/bundle(\/|$)/, '/' + bundle + '$1'); -} - /** * Get the directory for node modules (either the parent of the MathJax directory, * or the MathJax node_modules directory, if it exists). @@ -64,6 +57,13 @@ if (bundle !== 'bundle') { const parent = path.resolve(__dirname, '..', '..'); const nodeDir = (dir => (fs.existsSync(dir) ? dir : path.resolve(parent, '..')))(path.join(parent, 'node_modules')); +/** + * Other top-level directories + */ +const tsDir = path.resolve(parent, 'ts'); +const jsDir = path.resolve(parent, target); +const bundleDir = path.resolve(parent, bundle); + /** * Copy a file or directory tree * @@ -87,12 +87,21 @@ function copyFile(from, to, name, space) { } } +function resolvePaths(name) { + return path.resolve( + process.cwd(), + name.replace(/^\[node\]/, nodeDir) + .replace(/^\[ts\]/, tsDir) + .replace(/^\[js\]/, jsDir) + .replace(/^\[bundle\]/, bundleDir) + ); +} + /** * Copy the given files */ -const wd = process.cwd(); -const to = path.resolve(wd, config.to); -const from = path.resolve(wd, config.from.replace(/\[node\]/, nodeDir)); +const to = resolvePaths(config.to); +const from = resolvePaths(config.from); for (const name of config.copy) { copyFile(from, to, name, ''); } diff --git a/components/mjs/core/core.js b/components/mjs/core/core.js index 55bc14ed5..f6b3ab7cf 100644 --- a/components/mjs/core/core.js +++ b/components/mjs/core/core.js @@ -1,7 +1,9 @@ +import './locale.js'; import './lib/core.js'; import {HTMLHandler} from '#js/handlers/html/HTMLHandler.js'; import {browserAdaptor} from '#js/adaptors/browserAdaptor.js'; +import {Package} from '#js/components/package.js'; if (MathJax.startup) { MathJax.startup.registerConstructor('HTMLHandler', HTMLHandler); @@ -10,5 +12,13 @@ if (MathJax.startup) { MathJax.startup.useAdaptor('browserAdaptor'); } if (MathJax.loader) { - MathJax._.mathjax.mathjax.asyncLoad = (name => MathJax.loader.load(name)); + MathJax._.mathjax.mathjax.asyncLoad = (name => { + if (name.match(/\.json$/)) { + if (name.charAt(0) === '[') { + name = Package.resolvePath(name); + } + return fetch(name).then(response => response.json()); + } + return MathJax.loader.load(name); + }); } diff --git a/components/mjs/core/locale.js b/components/mjs/core/locale.js new file mode 100644 index 000000000..6009400c7 --- /dev/null +++ b/components/mjs/core/locale.js @@ -0,0 +1,4 @@ +import {Locale} from '#js/util/Locale.js'; + +Locale.isComponent = true; + diff --git a/components/mjs/input/mml/extensions/mml3/config.json b/components/mjs/input/mml/extensions/mml3/config.json index 4a0f06a9a..8afb7362f 100644 --- a/components/mjs/input/mml/extensions/mml3/config.json +++ b/components/mjs/input/mml/extensions/mml3/config.json @@ -6,8 +6,8 @@ "excludeSubdirs": true }, "copy": { - "to": "../../../../../../bundle/input/mml/extensions", - "from": "../../../../../../ts/input/mathml/mml3", + "to": "[bundle]/input/mml/extensions", + "from": "[ts]/input/mathml/mml3", "copy": [ "mml3.sef.json" ] diff --git a/components/mjs/input/tex/extensions/bbox/config.json b/components/mjs/input/tex/extensions/bbox/config.json index 3c233eb2b..992885a51 100644 --- a/components/mjs/input/tex/extensions/bbox/config.json +++ b/components/mjs/input/tex/extensions/bbox/config.json @@ -4,6 +4,11 @@ "component": "input/tex/extensions/bbox", "targets": ["input/tex/bbox"] }, + "copy": { + "to": "[bundle]/input/tex/extensions/bbox", + "from": "[ts]/input/tex/bbox", + "copy": ["locales"] + }, "webpack": { "name": "input/tex/extensions/bbox", "libs": [ diff --git a/components/mjs/node-main/config.json b/components/mjs/node-main/config.json index 28cd82bb6..b9131328b 100644 --- a/components/mjs/node-main/config.json +++ b/components/mjs/node-main/config.json @@ -1,6 +1,6 @@ { "copy": { - "to": "../../../bundle", + "to": "[bundle]", "from": ".", "copy": [ "node-main.mjs", diff --git a/components/mjs/node-main/node-main-setup.mjs b/components/mjs/node-main/node-main-setup.mjs index f143f4233..4f0bf71e4 100644 --- a/components/mjs/node-main/node-main-setup.mjs +++ b/components/mjs/node-main/node-main-setup.mjs @@ -5,3 +5,4 @@ const path = require("path"); if (!global.MathJax) global.MathJax = {}; global.MathJax.__dirname = path.dirname(new URL(import.meta.url).pathname); +global.MathJax.__js = 'mjs'; \ No newline at end of file diff --git a/components/mjs/node-main/node-main.cjs b/components/mjs/node-main/node-main.cjs index 8978bb318..37d28e27f 100644 --- a/components/mjs/node-main/node-main.cjs +++ b/components/mjs/node-main/node-main.cjs @@ -1,5 +1,6 @@ if (!global.MathJax) global.MathJax = {}; global.MathJax.__dirname = __dirname; +global.MathJax.__js = 'cjs'; module.exports = require('./node-main.js'); diff --git a/components/mjs/node-main/node-main.js b/components/mjs/node-main/node-main.js index 83aae0afa..5033354b1 100644 --- a/components/mjs/node-main/node-main.js +++ b/components/mjs/node-main/node-main.js @@ -21,14 +21,15 @@ import '../startup/init.js'; import {Loader, CONFIG} from '#js/components/loader.js'; -import {Package} from '#js/components/package.js'; -import {combineDefaults, combineConfig} from '#js/components/global.js'; +import {MathJax, combineDefaults, combineConfig} from '#js/components/global.js'; +import {resolvePath} from '#js/util/AsyncLoad.js'; import '../core/core.js'; import '../adaptors/liteDOM/liteDOM.js'; import {source} from '../source.js'; const path = eval('require("path")'); // get path from node, not webpack -const dir = global.MathJax.config.__dirname; // set up by node-main.mjs or node-main.cjs +const fs = eval('require("fs").promises'); +const dir = MathJax.config.__dirname; // set up by node-main.mjs or node-main.cjs /* * Set up the initial configuration @@ -45,25 +46,28 @@ combineDefaults(MathJax.config, 'output', {font: 'mathjax-modern'}); */ Loader.preLoad('loader', 'startup', 'core', 'adaptors/liteDOM'); +/* + * Set the paths. + */ if (path.basename(dir) === 'node-main') { CONFIG.paths.esm = CONFIG.paths.mathjax; CONFIG.paths.sre = '[esm]/sre/mathmaps'; - CONFIG.paths.mathjax = path.dirname(dir); + CONFIG.paths.mathjax = path.resolve(dir, '..', '..', '..', MathJax.config.__js); combineDefaults(CONFIG, 'source', source); - // - // Set the asynchronous loader to use the js directory, so we can load - // other files like entity definitions - // - const ROOT = path.resolve(dir, '..', '..', '..', path.basename(path.dirname(dir))); - const REQUIRE = MathJax.config.loader.require; - MathJax._.mathjax.mathjax.asyncLoad = function (name) { - return REQUIRE(name.charAt(0) === '.' ? path.resolve(ROOT, name) : - name.charAt(0) === '[' ? Package.resolvePath(name) : name); - }; } else { CONFIG.paths.mathjax = dir; } +/* + * Set the asynchronous loader to handle json files + */ +MathJax._.mathjax.mathjax.asyncLoad = function (name) { + const file = resolvePath(name, (name) => path.resolve(CONFIG.paths.mathjax, name)); + return file.match(/\.json$/) ? + fs.readFile(file).then((json) => JSON.parse(json)) : + MathJax.config.loader.require(file); +}; + /* * The initialization function. Use as: * diff --git a/components/mjs/sre/config.json b/components/mjs/sre/config.json index 5cca64ca2..ba603b8d9 100644 --- a/components/mjs/sre/config.json +++ b/components/mjs/sre/config.json @@ -1,6 +1,6 @@ { "copy": { - "to": "../../../bundle/sre", + "to": "[bundle]/sre", "from": "[node]/speech-rule-engine/lib", "copy": [ "mathmaps" diff --git a/package.json b/package.json index fdef16b2f..cfcd42dca 100644 --- a/package.json +++ b/package.json @@ -70,9 +70,10 @@ "clean:lib": "clean() { pnpm -s log:single \"Cleaning $1 component libs\"; pnpm rimraf -g components/$1'/**/lib'; }; clean", "clean:mod": "clean() { pnpm -s log:comp \"Cleaning $1 module\"; pnpm -s clean:dir $1 && pnpm -s clean:lib $1; }; clean", "=============================================================================== copy": "", - "copy:assets": "pnpm -s log:comp 'Copying assets'; copy() { pnpm -s copy:mj2 $1 && pnpm -s copy:mml3 $1; }; copy", + "copy:assets": "pnpm -s log:comp 'Copying assets'; copy() { pnpm -s copy:locales $1 && pnpm -s copy:mj2 $1 && pnpm -s copy:mml3 $1; }; copy", + "copy:locales": "pnpm -s log:single 'Copying TeX extension locales'; copy() { pnpm copyfiles -u 3 'ts/input/tex/*/locales/*.json' $1/input/tex/extensions; }; copy", "copy:mj2": "copy() { pnpm -s log:single 'Copying legacy code AsciiMath'; pnpm copyfiles -u 1 'ts/input/asciimath/legacy/**/*' $1; }; copy", - "copy:mml3": "copy() { pnpm -s log:single 'Copying legacy code MathML3'; pnpm copyfiles -u 1 ts/input/mathml/mml3/mml3.sef.json $1; }; copy", + "copy:mml3": "copy() { pnpm -s log:single 'Copying MathML3 extension json'; pnpm copyfiles -u 1 ts/input/mathml/mml3/mml3.sef.json $1; }; copy", "copy:pkg": "copy() { pnpm -s log:single \"Copying package.json to $1\"; pnpm copyfiles -u 2 components/bin/package.json $1; }; copy", "=============================================================================== log": "", "log:comp": "log() { echo \\\\033[32m$1\\\\033[0m; }; log", diff --git a/ts/components/startup.ts b/ts/components/startup.ts index a8307bd77..f3847c45c 100644 --- a/ts/components/startup.ts +++ b/ts/components/startup.ts @@ -36,6 +36,7 @@ import {CommonOutputJax} from '../output/common.js'; import {DOMAdaptor} from '../core/DOMAdaptor.js'; import {PrioritizedList} from '../util/PrioritizedList.js'; import {OptionList, OPTIONS} from '../util/Options.js'; +import {Locale} from '../util/Locale.js'; import {TeX} from '../input/tex.js'; @@ -107,6 +108,7 @@ export interface MathJaxObject extends MJObject { toMML(node: MmlNode): string; defaultReady(): void; defaultPageReady(): Promise; + setLocale(): Promise; getComponents(): void; makeMethods(): void; makeTypesetMethods(): void; @@ -293,7 +295,8 @@ export namespace Startup { export function defaultReady() { getComponents(); makeMethods(); - pagePromise + setLocale() + .then(() => pagePromise) .then(() => CONFIG.pageReady()) // usually the initial typesetting call .then(() => promiseResolve()) .catch((err) => promiseReject(err)); @@ -315,6 +318,13 @@ export namespace Startup { .then(() => promiseResolve()); } + /** + * Set the locale and load any needed locale data files. + */ + export function setLocale() { + return Locale.setLocale(MathJax.config.locale || 'en'); + } + /** * Perform the typesetting with handling of retries */ diff --git a/ts/input/tex/bbox/BboxConfiguration.ts b/ts/input/tex/bbox/BboxConfiguration.ts index 783a54e6f..79757e491 100644 --- a/ts/input/tex/bbox/BboxConfiguration.ts +++ b/ts/input/tex/bbox/BboxConfiguration.ts @@ -28,82 +28,101 @@ import TexParser from '../TexParser.js'; import {CommandMap} from '../TokenMap.js'; import {ParseMethod} from '../Types.js'; import TexError from '../TexError.js'; +import {Locale} from '../../../util/Locale.js'; -// Namespace -const BboxMethods: {[key: string]: ParseMethod} = { +/** + * The component name + */ +export const COMPONENT = '[tex]/bbox'; /** - * Implements MathJax Bbox macro to pad and colorize background boxes. - * @param {TexParser} parser The current tex parser. - * @param {string} name The name of the calling macro. + * Register the locales */ -BBox(parser: TexParser, name: string) { - const bbox = parser.GetBrackets(name, ''); - let math = parser.ParseArg(name); - const parts = bbox.split(/,/); - let def, background, style; - for (let i = 0, m = parts.length; i < m; i++) { - const part = parts[i].trim(); - const match = part.match(/^(\.\d+|\d+(\.\d*)?)(pt|em|ex|mu|px|in|cm|mm)$/); - if (match) { - // @test Bbox-Padding - if (def) { - // @test Bbox-Padding-Error - throw new TexError('MultipleBBoxProperty', '%1 specified twice in %2', 'Padding', name); - } - const pad = BBoxPadding(match[1] + match[3]); - if (pad) { +Locale.registerLocaleFiles(COMPONENT, './input/tex/bbox', ['en']); + +/** + * Throw a TexError for this component (eventually, TexError will handle the message directly). + */ +function bboxError(id: string, message: string, ...args: string[]) { + const error = new TexError('', ''); + error.message = Locale.message(COMPONENT, id, message, ...args); + throw error; +} + + +// Namespace +const BboxMethods: {[key: string]: ParseMethod} = { + + /** + * Implements MathJax Bbox macro to pad and colorize background boxes. + * @param {TexParser} parser The current tex parser. + * @param {string} name The name of the calling macro. + */ + BBox(parser: TexParser, name: string) { + const bbox = parser.GetBrackets(name, ''); + let math = parser.ParseArg(name); + const parts = bbox.split(/,/); + let def, background, style; + for (let i = 0, m = parts.length; i < m; i++) { + const part = parts[i].trim(); + const match = part.match(/^(\.\d+|\d+(\.\d*)?)(pt|em|ex|mu|px|in|cm|mm)$/); + if (match) { // @test Bbox-Padding - def = { - height: '+' + pad, - depth: '+' + pad, - lspace: pad, - width: '+' + (2 * parseInt(match[1], 10)) + match[3] - }; + if (def) { + // @test Bbox-Padding-Error + bboxError('MultipleBBoxProperty', '%1 specified twice in %2', 'Padding', name); + } + const pad = BBoxPadding(match[1] + match[3]); + if (pad) { + // @test Bbox-Padding + def = { + height: '+' + pad, + depth: '+' + pad, + lspace: pad, + width: '+' + (2 * parseInt(match[1], 10)) + match[3] + }; + } + } else if (part.match(/^([a-z0-9]+|\#[0-9a-f]{6}|\#[0-9a-f]{3})$/i)) { + // @test Bbox-Background + if (background) { + // @test Bbox-Background-Error + bboxError('MultipleBBoxProperty', '%1 specified twice in %2', 'Background', name); + } + background = part; + } else if (part.match(/^[-a-z]+:/i)) { + // @test Bbox-Frame + if (style) { + // @test Bbox-Frame-Error + bboxError('MultipleBBoxProperty', '%1 specified twice in %2', 'Style', name); + } + style = BBoxStyle(part); + } else if (part !== '') { + // @test Bbox-General-Error + bboxError( + 'InvalidBBoxProperty', + '"%1" doesn\'t look like a color, a padding dimension, or a style', + part); } - } else if (part.match(/^([a-z0-9]+|\#[0-9a-f]{6}|\#[0-9a-f]{3})$/i)) { - // @test Bbox-Background + } + if (def) { + // @test Bbox-Padding + math = parser.create('node', 'mpadded', [math], def); + } + if (background || style) { + def = {}; if (background) { - // @test Bbox-Background-Error - throw new TexError('MultipleBBoxProperty', '%1 specified twice in %2', - 'Background', name); + // @test Bbox-Background + Object.assign(def, {mathbackground: background}); } - background = part; - } else if (part.match(/^[-a-z]+:/i)) { - // @test Bbox-Frame if (style) { - // @test Bbox-Frame-Error - throw new TexError('MultipleBBoxProperty', '%1 specified twice in %2', - 'Style', name); + // @test Bbox-Frame + Object.assign(def, {style: style}); } - style = BBoxStyle(part); - } else if (part !== '') { - // @test Bbox-General-Error - throw new TexError( - 'InvalidBBoxProperty', - '"%1" doesn\'t look like a color, a padding dimension, or a style', - part); + math = parser.create('node', 'mstyle', [math], def); } + parser.Push(math); } - if (def) { - // @test Bbox-Padding - math = parser.create('node', 'mpadded', [math], def); - } - if (background || style) { - def = {}; - if (background) { - // @test Bbox-Background - Object.assign(def, {mathbackground: background}); - } - if (style) { - // @test Bbox-Frame - Object.assign(def, {style: style}); - } - math = parser.create('node', 'mstyle', [math], def); - } - parser.Push(math); -}, } @@ -116,10 +135,8 @@ let BBoxPadding = function(pad: string) { return pad; }; - new CommandMap('bbox', {bbox: BboxMethods.BBox}); - export const BboxConfiguration = Configuration.create( 'bbox', {[ConfigurationType.HANDLER]: {[HandlerType.MACRO]: ['bbox']}} ); diff --git a/ts/input/tex/bbox/locales/en.json b/ts/input/tex/bbox/locales/en.json new file mode 100644 index 000000000..4ec3bf9d6 --- /dev/null +++ b/ts/input/tex/bbox/locales/en.json @@ -0,0 +1,3 @@ +{ + "MultipleBBoxProperty": "In %2, property %1 is specified twice" +} diff --git a/ts/input/tex/require/RequireConfiguration.ts b/ts/input/tex/require/RequireConfiguration.ts index 5d6815f8f..3106bdb9c 100644 --- a/ts/input/tex/require/RequireConfiguration.ts +++ b/ts/input/tex/require/RequireConfiguration.ts @@ -22,8 +22,7 @@ * @author dpvc@mathjax.org (Davide P. Cervone) */ -import { - HandlerType, ConfigurationType} from '../HandlerTypes.js'; +import {HandlerType, ConfigurationType} from '../HandlerTypes.js'; import {Configuration, ParserConfiguration, ConfigurationHandler} from '../Configuration.js'; import TexParser from '../TexParser.js'; import {CommandMap} from '../TokenMap.js'; @@ -37,6 +36,7 @@ import {Loader, CONFIG as LOADERCONFIG} from '../../../components/loader.js'; import {mathjax} from '../../../mathjax.js'; import {expandable} from '../../../util/Options.js'; import {MenuMathDocument} from '../../../ui/menu/MenuHandler.js'; +import {Locale} from '../../../util/Locale.js'; /** * The MathJax configuration block (for looking up user-defined package options) @@ -151,7 +151,7 @@ export function RequireLoad(parser: TexParser, name: string) { throw new TexError('BadRequire', 'Extension "%1" is not allowed to be loaded', extension); } if (!Package.packages.has(extension)) { - mathjax.retryAfter(Loader.load(extension)); + mathjax.retryAfter(Loader.load(extension).then(() => Locale.setLocale())); } const require = LOADERCONFIG[extension]?.rendererExtensions; (MathJax.startup.document as MenuMathDocument)?.menu?.addRequiredExtensions?.(require); diff --git a/ts/util/AsyncLoad.ts b/ts/util/AsyncLoad.ts index 470eb4414..07fea3879 100644 --- a/ts/util/AsyncLoad.ts +++ b/ts/util/AsyncLoad.ts @@ -42,3 +42,26 @@ export function asyncLoad(name: string): Promise { } }); } + +/** + * Used to look up Package object, if it is in use + */ +declare const MathJax: any; + +/** + * Resolve a file name to a full path or URL + * + * @param {string} name The file name to resolve + * @param {(string)=>string} relative Function to get absolute path from relative one + * @param {(string)=>string} absolute Function to fix up absolute path + * @return {string} The full path name + */ +export function resolvePath( + name: string, + relative: (name: string) => string, + absolute: (name: string) => string = (name) => name +): string { + const Package = MathJax?._?.components?.package?.Package; + return name.charAt(0) === '[' && Package ? Package.resolvePath(name) : + name.charAt(0) === '.' ? relative(name) : absolute(name); +} diff --git a/ts/util/Locale.ts b/ts/util/Locale.ts new file mode 100644 index 000000000..1b09edd8d --- /dev/null +++ b/ts/util/Locale.ts @@ -0,0 +1,208 @@ +/************************************************************* + * + * Copyright (c) 2024 The MathJax Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Implements the locale framework + * + * @author dpvc@mathjax.org (Davide Cervone) + */ + +import {asyncLoad} from './AsyncLoad.js'; + +/** + * The various object map types + */ +export type messageData = {[id: string]: string}; +export type localeData = {[locale: string]: messageData}; +export type componentData = {[component: string]: localeData}; +export type namedData = {[name: string | number]: string}; + +/** + * The Locale class for handling localized messages + */ +export class Locale { + + /** + * The current locale + */ + public static current: string = 'en'; + + /** + * True when the core component has been loaded (and so the Package path resolution is available + */ + public static isComponent: boolean = false; + + /** + * The localized message strings, per component and locale + */ + protected static data: componentData = {}; + + /** + * The locale files to load for each locale (as registered by the components) + */ + protected static files: {[locale: string]: {component: string, file: string}[]} = {en: []}; + + /** + * Registers a given component's locale files + * + * @param{string} component The component's name (e.g., [tex]/bbox) + * @param{string} prefix The directory where the locales are located + * @param{string[]} locales The list of localizations for this component (e.g., ['en', 'fr', 'de']) + */ + public static registerLocaleFiles(component: string, prefix: string, locales: string[]) { + if (this.isComponent) { + prefix = component; + } + for (const locale of locales) { + if (!this.files[locale]) { + this.files[locale] = []; + } + this.files[locale].push({component, file: `${prefix}/locales/${locale}.json`}); + } + } + + /** + * Register a set of messages for a given component and locale (called when the localization + * files are loaded). + * + * @param{string} component The component's name (e.g., [tex]/bbox) + * @param{string} locale The locale for the messages + * @param{messageData} data The messages indexed byu their IDs + */ + public static registerMessages(component: string, locale: string, data: messageData) { + if (!this.data[component]) { + this.data[component] = {}; + } + const cdata = this.data[component]; + if (!cdata[locale]) { + cdata[locale] = {}; + } + Object.assign(cdata[locale], data); + } + + /** + * Get a message string and insert any arguments. The arguments can be positional, or a + * mapping of names to values. E.g. + * + * Locale.message('[my]/test', 'Hello', 'Hello %{name}!', {name: 'World'})); + * Locale.message('[my]/test', 'FooBar', '%1 bar', 'Foo')); + * + * @param{string} component The component whose message is requested + * @param{string} id The id of the message + * @param{string} message The English message for in case the localized version is missing + * @param{string|namedDat} data The first argument or the object of names arguments + * @param{srting[]} ...args Any additional string arguments (if data is a string) + * @return{string} The localized message with arguments substituted in + */ + public static message( + component: string, + id: string, + message: string, + data: string | namedData = {}, + ...args: string[] + ): string { + message = this.lookupMessage(component, id, message); + if (typeof data === 'string') { + data = {1: data}; + for (let i = 0; i < args.length; i++) { + data[i + 2] = args[i]; + } + } + data['%'] ='%'; + return this.substituteArguments(message, data); + } + + /** + * Find a localized message string, or use the default if not available + * + * @param{string} component The component for this message + * @param{string} id The id of the message + * @param{string} message The default message for if a localized one is not available + * @return{string} The message string to use + */ + public static lookupMessage(component: string, id: string, message: string): string { + return this.data[component]?.[this.current]?.[id] || message; + } + + /** + * Substitue arguments into a message string + * + * @param{string} message The original message string + * @param{namedData} data The mapping of markers to values + * @return{string} The final string with substitutions made + */ + protected static substituteArguments(message: string, data: namedData): string { + const parts = message.split(/%(%|\d+|[a-z]+|\{.*?\})/); + for (let i = 1; i < parts.length; i += 2) { + const id = parts[i].replace(/^\{(.*)\}$/, '$1'); + parts[i] = data[id] || ''; + } + return parts.join(''); + } + + /** + * Throw an error with a given string substituting the given parameters + * + * @param{string} component The component whose message is requested + * @param{string} id The id of the message + * @param{string} message The English message for in case the localized version is missing + * @param{string|namedDat} data The first argument or the object of names arguments + * @param{srting[]} ...args Any additional string arguments (if data is a string) + */ + public static error( + component: string, + id: string, + message: string, + data: string | namedData, + ...args: string[] + ) { + throw Error(this.message(component, id, message, data, ...args)); + } + + /** + * Set the locale to the given one (or use the current one), and load + * any needed files (or newly registered files for the current locale). + * + * @param{string} locale The local to use (or use the current one) + * @return{Promise} A promise that resolves when the locale files have been loaded + */ + public static async setLocale(locale: string = this.current): Promise { + const files = this.files[locale]; + if (!files) { + this.error('core', 'UnknownLocale', 'Local "%1" is not defined', locale); + } + const promises = []; + while (files.length) { + const {component, file} = files.shift(); + promises.push(this.getLocaleData(component, this.current, file)); + } + return Promise.all(promises); + } + + /** + * Load a localization file and register its contents + * + * @param{string} component The component whose localization is being loaded + * @param{string} locale The locale being loaded + * @param{string} file The file to load for that localization + * @return{Promise} A promise that resolves when the file is loaded and registered + */ + protected static async getLocaleData(component: string, locale: string, file: string): Promise { + return asyncLoad(file).then((data: messageData) => this.registerMessages(component, locale, data)); + } + +} diff --git a/ts/util/asyncLoad/esm.ts b/ts/util/asyncLoad/esm.ts index 2c819d6fd..cfaa74e66 100644 --- a/ts/util/asyncLoad/esm.ts +++ b/ts/util/asyncLoad/esm.ts @@ -22,12 +22,13 @@ */ import {mathjax} from '../../mathjax.js'; +import {resolvePath} from '../AsyncLoad.js'; let root = new URL(import.meta.url).href.replace(/\/util\/asyncLoad\/esm.js$/, '/'); if (!mathjax.asyncLoad) { mathjax.asyncLoad = async (name: string) => { - const file = (name.charAt(0) === '.' ? new URL(name, root).pathname : name); + const file = resolvePath(name, (name) => new URL(name, root).pathname); return import(file).then((result) => result?.default || result); }; } diff --git a/ts/util/asyncLoad/node.ts b/ts/util/asyncLoad/node.ts index c9b8d3696..e49ff2099 100644 --- a/ts/util/asyncLoad/node.ts +++ b/ts/util/asyncLoad/node.ts @@ -22,6 +22,7 @@ */ import {mathjax} from '../../mathjax.js'; +import {resolvePath} from '../AsyncLoad.js'; import * as path from 'path'; import {src} from '#source/source.cjs'; @@ -31,7 +32,7 @@ let root = path.resolve(src, '..', '..', 'cjs'); if (!mathjax.asyncLoad && typeof require !== 'undefined') { mathjax.asyncLoad = (name: string) => { - return require(name.charAt(0) === '.' ? path.resolve(root, name) : name); + return require(resolvePath(name, (name) => path.resolve(root, name))); }; mathjax.asyncIsSynchronous = true; } diff --git a/ts/util/asyncLoad/system.ts b/ts/util/asyncLoad/system.ts index 19db5dc0c..0f2423aa8 100644 --- a/ts/util/asyncLoad/system.ts +++ b/ts/util/asyncLoad/system.ts @@ -22,6 +22,7 @@ */ import {mathjax} from '../../mathjax.js'; +import {resolvePath} from '../AsyncLoad.js'; declare var System: {import: (name: string, url?: string) => any}; declare var __dirname: string; @@ -30,7 +31,7 @@ let root = 'file://' + __dirname.replace(/\/[^\/]*\/[^\/]*$/, '/'); if (!mathjax.asyncLoad && typeof System !== 'undefined' && System.import) { mathjax.asyncLoad = (name: string) => { - const file = (name.charAt(0) === '.' ? new URL(name, root) : new URL(name, 'file://')).href; + const file = resolvePath(name, (name) => new URL(name, root).href, (name) => new URL(name, 'file://').href); return System.import(file).then((result: any) => result?.default || result); }; } From e1c5da839b4832a6508b47078098015aa201720e Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Fri, 26 Jul 2024 20:35:43 -0400 Subject: [PATCH 2/3] Only use Pacakge if MathJax is defined --- ts/util/AsyncLoad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts/util/AsyncLoad.ts b/ts/util/AsyncLoad.ts index 07fea3879..41dea6297 100644 --- a/ts/util/AsyncLoad.ts +++ b/ts/util/AsyncLoad.ts @@ -61,7 +61,7 @@ export function resolvePath( relative: (name: string) => string, absolute: (name: string) => string = (name) => name ): string { - const Package = MathJax?._?.components?.package?.Package; + const Package = typeof MathJax === 'undefined' ? null : MathJax?._?.components?.package?.Package; return name.charAt(0) === '[' && Package ? Package.resolvePath(name) : name.charAt(0) === '.' ? relative(name) : absolute(name); } From a58d1a5d7d60a5b64be8f5618bf654ec4f202628 Mon Sep 17 00:00:00 2001 From: "Davide P. Cervone" Date: Thu, 22 Aug 2024 15:31:39 -0400 Subject: [PATCH 3/3] Don't require the list of localizations, just try to load them. Also, remove English default messages and use the en loclaization for them. These were requested by Volker's review. --- ts/input/tex/bbox/BboxConfiguration.ts | 20 +++---- ts/input/tex/bbox/locales/en.json | 3 +- ts/util/Locale.ts | 77 +++++++++++++++----------- 3 files changed, 58 insertions(+), 42 deletions(-) diff --git a/ts/input/tex/bbox/BboxConfiguration.ts b/ts/input/tex/bbox/BboxConfiguration.ts index 79757e491..ad1897e4c 100644 --- a/ts/input/tex/bbox/BboxConfiguration.ts +++ b/ts/input/tex/bbox/BboxConfiguration.ts @@ -39,14 +39,17 @@ export const COMPONENT = '[tex]/bbox'; /** * Register the locales */ -Locale.registerLocaleFiles(COMPONENT, './input/tex/bbox', ['en']); +Locale.registerLocaleFiles(COMPONENT); /** * Throw a TexError for this component (eventually, TexError will handle the message directly). + * + * @param {string} id The ID of the error message + * @param {string[]} args The values to substitute into the message */ -function bboxError(id: string, message: string, ...args: string[]) { +function bboxError(id: string, ...args: string[]) { const error = new TexError('', ''); - error.message = Locale.message(COMPONENT, id, message, ...args); + error.message = Locale.message(COMPONENT, id, ...args); throw error; } @@ -71,7 +74,7 @@ const BboxMethods: {[key: string]: ParseMethod} = { // @test Bbox-Padding if (def) { // @test Bbox-Padding-Error - bboxError('MultipleBBoxProperty', '%1 specified twice in %2', 'Padding', name); + bboxError('MultipleBBoxProperty', 'Padding', name); } const pad = BBoxPadding(match[1] + match[3]); if (pad) { @@ -87,22 +90,19 @@ const BboxMethods: {[key: string]: ParseMethod} = { // @test Bbox-Background if (background) { // @test Bbox-Background-Error - bboxError('MultipleBBoxProperty', '%1 specified twice in %2', 'Background', name); + bboxError('MultipleBBoxProperty', 'Background', name); } background = part; } else if (part.match(/^[-a-z]+:/i)) { // @test Bbox-Frame if (style) { // @test Bbox-Frame-Error - bboxError('MultipleBBoxProperty', '%1 specified twice in %2', 'Style', name); + bboxError('MultipleBBoxProperty', 'Style', name); } style = BBoxStyle(part); } else if (part !== '') { // @test Bbox-General-Error - bboxError( - 'InvalidBBoxProperty', - '"%1" doesn\'t look like a color, a padding dimension, or a style', - part); + bboxError('InvalidBBoxProperty', part); } } if (def) { diff --git a/ts/input/tex/bbox/locales/en.json b/ts/input/tex/bbox/locales/en.json index 4ec3bf9d6..e06b8dfba 100644 --- a/ts/input/tex/bbox/locales/en.json +++ b/ts/input/tex/bbox/locales/en.json @@ -1,3 +1,4 @@ { - "MultipleBBoxProperty": "In %2, property %1 is specified twice" + "MultipleBBoxProperty": "%1 specified twice in %2", + "InvalidBBoxProperty": "'%1' doesn't look like a color, a padding dimension, or a style" } diff --git a/ts/util/Locale.ts b/ts/util/Locale.ts index 1b09edd8d..e22540e45 100644 --- a/ts/util/Locale.ts +++ b/ts/util/Locale.ts @@ -42,7 +42,12 @@ export class Locale { public static current: string = 'en'; /** - * True when the core component has been loaded (and so the Package path resolution is available + * The default locale for when a message has no current localization + */ + public static default: string = 'en'; + + /** + * True when the core component has been loaded (and so the Package path resolution is available) */ public static isComponent: boolean = false; @@ -54,25 +59,19 @@ export class Locale { /** * The locale files to load for each locale (as registered by the components) */ - protected static files: {[locale: string]: {component: string, file: string}[]} = {en: []}; + protected static locations: {[component: string]: [string, Set]} = {}; /** - * Registers a given component's locale files + * Registers a given component's locale directory * * @param{string} component The component's name (e.g., [tex]/bbox) * @param{string} prefix The directory where the locales are located - * @param{string[]} locales The list of localizations for this component (e.g., ['en', 'fr', 'de']) */ - public static registerLocaleFiles(component: string, prefix: string, locales: string[]) { - if (this.isComponent) { - prefix = component; - } - for (const locale of locales) { - if (!this.files[locale]) { - this.files[locale] = []; - } - this.files[locale].push({component, file: `${prefix}/locales/${locale}.json`}); - } + public static registerLocaleFiles(component: string, prefix: string = component) { + this.locations[component] = [ + `${this.isComponent ? component : prefix}/locales`, + new Set() + ]; } /** @@ -103,7 +102,6 @@ export class Locale { * * @param{string} component The component whose message is requested * @param{string} id The id of the message - * @param{string} message The English message for in case the localized version is missing * @param{string|namedDat} data The first argument or the object of names arguments * @param{srting[]} ...args Any additional string arguments (if data is a string) * @return{string} The localized message with arguments substituted in @@ -111,11 +109,10 @@ export class Locale { public static message( component: string, id: string, - message: string, data: string | namedData = {}, ...args: string[] ): string { - message = this.lookupMessage(component, id, message); + const message = this.lookupMessage(component, id); if (typeof data === 'string') { data = {1: data}; for (let i = 0; i < args.length; i++) { @@ -131,11 +128,12 @@ export class Locale { * * @param{string} component The component for this message * @param{string} id The id of the message - * @param{string} message The default message for if a localized one is not available * @return{string} The message string to use */ - public static lookupMessage(component: string, id: string, message: string): string { - return this.data[component]?.[this.current]?.[id] || message; + public static lookupMessage(component: string, id: string): string { + return this.data[component]?.[this.current]?.[id] || + this.data[component]?.[this.default]?.[id] || + `No localized or default version for message with id '${id}'`; } /** @@ -159,18 +157,16 @@ export class Locale { * * @param{string} component The component whose message is requested * @param{string} id The id of the message - * @param{string} message The English message for in case the localized version is missing * @param{string|namedDat} data The first argument or the object of names arguments * @param{srting[]} ...args Any additional string arguments (if data is a string) */ public static error( component: string, id: string, - message: string, data: string | namedData, ...args: string[] ) { - throw Error(this.message(component, id, message, data, ...args)); + throw Error(this.message(component, id, data, ...args)); } /** @@ -181,14 +177,12 @@ export class Locale { * @return{Promise} A promise that resolves when the locale files have been loaded */ public static async setLocale(locale: string = this.current): Promise { - const files = this.files[locale]; - if (!files) { - this.error('core', 'UnknownLocale', 'Local "%1" is not defined', locale); - } const promises = []; - while (files.length) { - const {component, file} = files.shift(); - promises.push(this.getLocaleData(component, this.current, file)); + for (const [component, [directory, loaded]] of Object.entries(this.locations)) { + if (!loaded.has(locale)) { + loaded.add(locale); + promises.push(this.getLocaleData(component, this.current, `${directory}/${locale}.json`)); + } } return Promise.all(promises); } @@ -202,7 +196,28 @@ export class Locale { * @return{Promise} A promise that resolves when the file is loaded and registered */ protected static async getLocaleData(component: string, locale: string, file: string): Promise { - return asyncLoad(file).then((data: messageData) => this.registerMessages(component, locale, data)); + return asyncLoad(file) + .then((data: messageData) => this.registerMessages(component, locale, data)) + .catch((error) => this.localeError(component, locale, error)); + } + + /** + * Report an error thrown when loading a component's locale file + * + * @param{string} component The component whose localization is being loaded + * @param{string} locale The locale being loaded + * @param{Error} error The Error object causing the issue + */ + protected static localeError(component: string, locale: string, error: Error) { + const message = this.message( + 'core', + 'LocaleJsonError', + "MathJax(%1): Can't load '%2': %3", + component, + `${locale}.json`, + error.message + ); + console.log(message); } }