commit ff30289ec79ae9ad1aa5fd20c1a496af8f5ea574 parent a4a3aa5eaa355c43f66e250c02829c33974407e0 Author: Sebastian <sebasjm@gmail.com> Date: Mon, 22 Feb 2021 19:08:56 -0300 refactor i18n to support weblate Diffstat:
49 files changed, 1420 insertions(+), 501 deletions(-)
diff --git a/packages/frontend/.storybook/main.js b/packages/frontend/.storybook/main.js @@ -29,5 +29,26 @@ module.exports = { "@storybook/preset-scss", // "@storybook/addon-a11y", "@storybook/addon-essentials" //docs, control, actions, viewpot, toolbar, background - ] + ], + webpackFinal: async (config, { configType }) => { + // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION' + // You can change the configuration based on that. + // 'PRODUCTION' is used when building the static version of storybook. + + // Make whatever fine-grained changes you need + config.module.rules.push({ + test: [/\.pot?$/, /\.mo$/], + loader: require.resolve('messageformat-po-loader'), + options: { + biDiSupport: false, + defaultCharset: null, + defaultLocale: 'en', + forceContext: false, + pluralFunction: null, + verbose: false + } + }); + // Return the altered config + return config; + }, } \ No newline at end of file diff --git a/packages/frontend/.storybook/preview.js b/packages/frontend/.storybook/preview.js @@ -1,8 +1,8 @@ import "../src/scss/main.scss" -import { IntlProvider } from 'preact-i18n'; -import { h } from "preact"; -import { translations } from '../src/i18n' +import { MessageProvider } from "preact-messages"; import { ConfigContext } from '../src/context/backend' +import * as messages from '../src/messages' +import { h } from 'preact'; const mockConfig = { backendURL: 'http://demo.taler.net', @@ -31,9 +31,9 @@ export const globalTypes = { export const decorators = [ (Story, { globals }) => { - return <IntlProvider definition={translations[globals.locale]} mark> + return <MessageProvider locale={globals.locale} onError="warn" messages={messages[globals.locale]} > <Story /> - </IntlProvider> + </MessageProvider> }, (Story) => <ConfigContext.Provider value={mockConfig}> <Story /> </ConfigContext.Provider> ]; diff --git a/packages/frontend/package.json b/packages/frontend/package.json @@ -31,9 +31,10 @@ "dependencies": { "axios": "^0.21.1", "date-fns": "^2.17.0", + "messageformat": "^2.3.0", "preact": "^10.3.1", - "preact-i18n": "2.3.1-preactx", "preact-router": "^3.2.1", + "preact-messages": "workspace:*", "swr": "^0.4.1", "yup": "^0.32.8" }, @@ -51,7 +52,6 @@ "@testing-library/preact-hooks": "^1.1.0", "@types/enzyme": "^3.10.5", "@types/jest": "^26.0.8", - "@types/preact-i18n": "^2.3.0", "@typescript-eslint/eslint-plugin": "^4.15.1", "@typescript-eslint/parser": "^4.15.1", "ava": "^3.15.0", @@ -69,6 +69,7 @@ "eslint-config-preact": "^1.1.1", "jest": "^26.2.2", "jest-preset-preact": "^4.0.2", + "messageformat-po-loader": "^0.3.0", "node-sass": "^5.0.0", "preact-cli": "^3.0.5", "preact-render-to-string": "^5.1.4", diff --git a/packages/frontend/preact.config.js b/packages/frontend/preact.config.js @@ -26,10 +26,22 @@ export default { webpack(config, env, helpers, options) { config.node.process = 'mock' - // config.plugins.push( - // new DefinePlugin({ - // // 'process.env.BACKEND_ENDPOINT': JSON.stringify(parsed['BACKEND_ENDPOINT']), - // }), - // ); + config.resolve.extensions.push('.po'); + config.module.rules.push({ + enforce: 'pre', + test: /\.po$/, + use: [{ + loader: 'messageformat-po-loader', + options: { + biDiSupport: false, + defaultCharset: null, + defaultLocale: 'en', + forceContext: false, + pluralFunction: null, + verbose: false + } + }], + }); + } } diff --git a/packages/frontend/src/components/auth/index.tsx b/packages/frontend/src/components/auth/index.tsx @@ -20,7 +20,7 @@ */ import { h, VNode } from "preact"; -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; import { useContext, useState } from "preact/hooks"; import { BackendContext } from "../../context/backend"; import { Notification } from "../../declaration"; @@ -44,8 +44,8 @@ export function LoginModal({ onConfirm, withMessage }: Props): VNode { <div class="columns is-vcentered"> <div class="column is-12"> <div> - <p><Text id={`notification.${withMessage.messageId}.title`} /> </p> - <Text id={`notification.${withMessage.messageId}.description`} fields={withMessage.params} /> + <p>{withMessage.message}</p> + {withMessage.description} </div> </div> </div> diff --git a/packages/frontend/src/components/modal/index.tsx b/packages/frontend/src/components/modal/index.tsx @@ -20,7 +20,7 @@ */ import { h, VNode } from "preact"; -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; interface Props { active?: boolean; @@ -36,15 +36,15 @@ export function ConfirmModal({ active, description, onCancel, onConfirm, childre <div class="modal-background " onClick={onCancel} /> <div class="modal-card"> <header class="modal-card-head"> - <p class="modal-card-title"> <Text id="confirm_modal.title" /> { !description ? null : <Text id={`confirm_modal.${description}`} /> }</p> + <p class="modal-card-title"> <Message id="confirm_modal.title" /> { !description ? null : <Message id={`confirm_modal.${description}`} /> }</p> <button class="delete " aria-label="close" onClick={onCancel} /> </header> <section class="modal-card-body"> {children} </section> <footer class="modal-card-foot"> - <button class="button " onClick={onCancel} ><Text id="text.cancel" /></button> - <button class={danger ? "button is-danger " : "button is-info "} onClick={onConfirm} ><Text id="text.confirm" /></button> + <button class="button " onClick={onCancel} ><Message id="Cancel" /></button> + <button class={danger ? "button is-danger " : "button is-info "} onClick={onConfirm} ><Message id="Confirm" /></button> </footer> </div> <button class="modal-close is-large " aria-label="close" onClick={onCancel} /> diff --git a/packages/frontend/src/components/navbar/index.tsx b/packages/frontend/src/components/navbar/index.tsx @@ -20,9 +20,8 @@ */ import { h, VNode } from 'preact'; -import { translations } from '../../i18n' -// TODO: Fix compilation problem -// import * as logo from '../../assets/logo.jpeg'; +import * as messages from '../../messages' +import logo from '../../assets/logo.jpeg'; interface Props { lang: string; @@ -34,7 +33,7 @@ export function NavigationBar({ lang, setLang, onLogout }: Props): VNode { return (<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation"> <div class="navbar-brand"> <a class="navbar-item" href="https://taler.net"> - <img src="https://taler.net/static/images/logo-2020.jpg" style={{ height: 50, maxHeight: 50 }} /> + <img src={logo} style={{ height: 50, maxHeight: 50 }} /> </a> <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" data-target="navbarBasicExample"> @@ -51,7 +50,7 @@ export function NavigationBar({ lang, setLang, onLogout }: Props): VNode { <div class="control has-icons-left"> <div class="select"> <select onChange={(e): void => setLang(e.currentTarget.value)}> - {Object.keys(translations).map(l => <option selected={lang === l} value={l}>{l}</option>)} + {Object.keys(messages).map(l => <option selected={lang === l} value={l}>{l}</option>)} </select> </div> <div class="icon is-small is-left"> diff --git a/packages/frontend/src/components/notifications/Notifications.stories.tsx b/packages/frontend/src/components/notifications/Notifications.stories.tsx @@ -34,21 +34,24 @@ export default { export const Info = (a: any) => <Notifications {...a} />; Info.args = { notifications: [{ - messageId: 'unauthorized', + message: 'Title', + description: 'Some large description', type: 'INFO', }] } export const Warn = (a: any) => <Notifications {...a} />; Warn.args = { notifications: [{ - messageId: 'unauthorized', + message: 'Title', + description: 'Some large description', type: 'WARN', }] } export const Error = (a: any) => <Notifications {...a} />; Error.args = { notifications: [{ - messageId: 'unauthorized', + message: 'Title', + description: 'Some large description', type: 'ERROR', }] } diff --git a/packages/frontend/src/components/notifications/index.tsx b/packages/frontend/src/components/notifications/index.tsx @@ -14,13 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** - * - * @author Sebastian Javier Marchano (sebasjm) - */ +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ import { h, VNode } from "preact"; -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; import { MessageType, Notification } from "../../declaration"; interface Props { @@ -42,12 +42,12 @@ export function Notifications({ notifications, removeNotification }: Props): VNo return <div class="toast"> {notifications.map(n => <article class={messageStyle(n.type)}> <div class="message-header"> - <p><Text id={`notification.${n.messageId}.title`} /> </p> - <button class="delete" onClick={()=> removeNotification && removeNotification(n)} /> - </div> - <div class="message-body"> - <Text id={`notification.${n.messageId}.description`} fields={n.params} /> + <p>{n.message}</p> + <button class="delete" onClick={() => removeNotification && removeNotification(n)} /> </div> + {n.description && <div class="message-body"> + {n.description} + </div>} </article>)} </div> } \ No newline at end of file diff --git a/packages/frontend/src/components/yup/YupField.tsx b/packages/frontend/src/components/yup/YupField.tsx @@ -20,7 +20,7 @@ */ import { h, VNode } from "preact"; -import { Text, useText } from "preact-i18n"; +import { Message, useMessage } from "preact-messages"; import { StateUpdater, useContext, useState } from "preact/hooks"; import { intervalToDuration, formatDuration } from 'date-fns' import { BackendContext, ConfigContext } from '../../context/backend'; @@ -96,7 +96,7 @@ function YupObjectInput({ name, info, value, errors, onChange }: PropsObject): V return <div class="card"> <header class="card-header"> <p class="card-header-title"> - <Text id={`fields.instance.${name}.label`} /> + <Message id={`fields.instance.${name}.label`} /> </p> <button class="card-header-icon" aria-label="more options" onClick={(): void => setActive(!active)}> <span class="icon"> @@ -118,16 +118,14 @@ function YupObjectInput({ name, info, value, errors, onChange }: PropsObject): V } function YupInput({ name, readonly, value, errors, onChange }: PropsInputInternal): VNode { - const dict = useText({ - placeholder: `fields.instance.${name}.placeholder`, - tooltip: `fields.instance.${name}.tooltip`, - }) + const placeholder = useMessage(`fields.instance.${name}.placeholder`) + const tooltip = useMessage(`fields.instance.${name}.tooltip`) return <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label"> - <Text id={`fields.instance.${name}.label`} /> - {dict.tooltip && <span class="icon" data-tooltip={dict.tooltip}> + <Message id={`fields.instance.${name}.label`} /> + {tooltip && <span class="icon" data-tooltip={tooltip}> <i class="mdi mdi-information" /> </span>} </label> @@ -136,13 +134,13 @@ function YupInput({ name, readonly, value, errors, onChange }: PropsInputInterna <div class="field"> <p class="control"> <input class={errors[name] ? "input is-danger" : "input"} type="text" - placeholder={dict.placeholder} readonly={readonly} + placeholder={placeholder} readonly={readonly} name={name} value={value} onChange={(e): void => onChange(e.currentTarget.value)} /> - <Text id={`fields.instance.${name}.help`} /> + <Message id={`fields.instance.${name}.help`} > </Message> </p> {errors[name] ? <p class="help is-danger"> - <Text id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message}</Text> + <Message id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message} </Message> </p> : null} </div> </div> @@ -150,18 +148,17 @@ function YupInput({ name, readonly, value, errors, onChange }: PropsInputInterna } function YupInputArray({ name, readonly, value, errors, onChange }: PropsInputInternal): VNode { - const dict = useText({ - placeholder: `fields.instance.${name}.placeholder`, - tooltip: `fields.instance.${name}.tooltip`, - }) + const placeholder = useMessage(`fields.instance.${name}.placeholder`) + const tooltip = useMessage(`fields.instance.${name}.tooltip`) + const array = value as unknown as string[] || [] const [currentValue, setCurrentValue] = useState('') return <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label"> - <Text id={`fields.instance.${name}.label`} /> - {dict.tooltip && <span class="icon" data-tooltip={dict.tooltip}> + <Message id={`fields.instance.${name}.label`} /> + {tooltip && <span class="icon" data-tooltip={tooltip}> <i class="mdi mdi-information" /> </span>} </label> @@ -177,14 +174,14 @@ function YupInputArray({ name, readonly, value, errors, onChange }: PropsInputIn </p> <p class="control"> <input class={errors[name] ? "input is-danger" : "input"} type="text" - placeholder={dict.placeholder} readonly={readonly} + placeholder={placeholder} readonly={readonly} name={name} value={currentValue} onChange={(e): void => setCurrentValue(e.currentTarget.value)} /> - <Text id={`fields.instance.${name}.help`} /> + <Message id={`fields.instance.${name}.help`} > </Message> </p> </div> {errors[name] ? <p class="help is-danger"> - <Text id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message}</Text> + <Message id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message}</Message> </p> : null} {array.map(v => <div class="tags has-addons"> <span class="tag is-medium is-info">{v}</span> @@ -201,16 +198,14 @@ function YupInputArray({ name, readonly, value, errors, onChange }: PropsInputIn } function YupInputWithAddon({ name, readonly, value, errors, onChange, addon, atTheEnd }: PropsInputInternal & { addon: string; atTheEnd?: boolean }): VNode { - const dict = useText({ - placeholder: `fields.instance.${name}.placeholder`, - tooltip: `fields.instance.${name}.tooltip`, - }) + const placeholder = useMessage(`fields.instance.${name}.placeholder`) + const tooltip = useMessage(`fields.instance.${name}.tooltip`) return <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label"> - <Text id={`fields.instance.${name}.label`} /> - {dict.tooltip && <span class="icon" data-tooltip={dict.tooltip}> + <Message id={`fields.instance.${name}.label`} /> + {tooltip && <span class="icon" data-tooltip={tooltip}> <i class="mdi mdi-information" /> </span>} </label> @@ -223,34 +218,32 @@ function YupInputWithAddon({ name, readonly, value, errors, onChange, addon, atT </div>} <p class="control is-expanded"> <input class={errors[name] ? "input is-danger" : "input"} type="text" - placeholder={dict.placeholder} readonly={readonly} + placeholder={placeholder} readonly={readonly} name={name} value={value} onChange={(e): void => onChange(e.currentTarget.value)} /> - <Text id={`fields.instance.${name}.help`} /> + <Message id={`fields.instance.${name}.help`} > </Message> </p> {atTheEnd && <div class="control"> <a class="button is-static">{addon}</a> </div>} </div> - {errors[name] ? <p class="help is-danger"><Text id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message}</Text></p> : null} + {errors[name] ? <p class="help is-danger"><Message id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message}</Message></p> : null} </div> </div> </div> } function YupInputSecured({ name, readonly, value, errors, onChange }: PropsInputInternal): VNode { - const dict = useText({ - placeholder: `fields.instance.${name}.placeholder`, - tooltip: `fields.instance.${name}.tooltip`, - }) + const placeholder = useMessage(`fields.instance.${name}.placeholder`, {}) + const tooltip = useMessage(`fields.instance.${name}.tooltip`, {}) const [active, setActive] = useState(false) return <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label"> - <Text id={`fields.instance.${name}.label`} /> - {dict.tooltip && <span class="icon" data-tooltip={dict.tooltip}> + <Message id={`fields.instance.${name}.label`} /> + {tooltip && <span class="icon" data-tooltip={tooltip}> <i class="mdi mdi-information" /> </span>} </label> @@ -264,14 +257,14 @@ function YupInputSecured({ name, readonly, value, errors, onChange }: PropsInput </label> <p class="control"> <input class="input" type="text" - placeholder={dict.placeholder} readonly={readonly || !active} + placeholder={placeholder} readonly={readonly || !active} disabled={readonly || !active} name={name} value={value} onChange={(e): void => onChange(e.currentTarget.value)} /> - <Text id={`fields.instance.${name}.help`} /> + <Message id={`fields.instance.${name}.help`}> </Message> </p> </div> - {errors[name] ? <p class="help is-danger"><Text id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message}</Text></p> : null} + {errors[name] ? <p class="help is-danger"><Message id={`validation.${errors[name].type}`} fields={errors[name].params}>{errors[name].message}</Message></p> : null} </div> </div> </div> diff --git a/packages/frontend/src/custom.d.ts b/packages/frontend/src/custom.d.ts @@ -0,0 +1,8 @@ +declare module '*.po' { + const content: any; + export default content; +} +declare module "*.jpeg" { + const content: any; + export default content; +} diff --git a/packages/frontend/src/declaration.d.ts b/packages/frontend/src/declaration.d.ts @@ -19,26 +19,14 @@ * @author Sebastian Javier Marchano (sebasjm) */ -declare module "*.css" { - const mapping: Record<string, string>; - export default mapping; -} -declare module "*.jpeg" { - const mapping: Record<string, string>; - export default mapping; -} - -declare module "*.scss" { - const mapping: Record<string, string>; - export default mapping; -} interface KeyValue { [key: string]: string; } interface Notification { - messageId: string; + message: string; + description?: string; type: MessageType; params?: any; } diff --git a/packages/frontend/src/hooks/backend.ts b/packages/frontend/src/hooks/backend.ts @@ -24,17 +24,24 @@ import axios from 'axios' import { MerchantBackend } from '../declaration'; import { useContext } from 'preact/hooks'; import { BackendContext, InstanceContext } from '../context/backend'; -import { useBackendInstanceToken } from '.'; -type HttpResponse<T> = HttpResponseOk<T> | HttpResponseError<T>; +type HttpResponse<T> = HttpResponseOk<T> | HttpResponseError; interface HttpResponseOk<T> { data: T; } -interface HttpResponseError<T> { + +export interface SwrError { + info: any, + status: number, + message: string, + backend: string, + hasToken: boolean, +} +interface HttpResponseError { data: undefined; unauthorized: boolean; - error: Error; + error?: SwrError; } @@ -46,6 +53,8 @@ interface RequestOptions { data?: any; } + + async function request(url: string, options: RequestOptions = {}): Promise<any> { const headers = options.token ? { Authorization: `${options.token}` } : undefined @@ -61,7 +70,7 @@ async function request(url: string, options: RequestOptions = {}): Promise<any> } catch (e) { const info = e.response?.data const status = e.response?.status - throw { info, status, error: e, backend: url, hasToken: !!options.token } + throw { info, status, message: e.message, backend: url, hasToken: !!options.token } } } @@ -123,7 +132,7 @@ export function useBackendInstanceMutateAPI(): BackendInstaceMutateAPI { export function useBackendInstances(): HttpResponse<MerchantBackend.Instances.InstancesResponse> { const { url, token } = useContext(BackendContext) - const { data, error } = useSWR<MerchantBackend.Instances.InstancesResponse>(['/private/instances', token, url], fetcher) + const { data, error } = useSWR<MerchantBackend.Instances.InstancesResponse, SwrError>(['/private/instances', token, url], fetcher) return { data, unauthorized: error?.status === 401, error } } @@ -131,14 +140,14 @@ export function useBackendInstances(): HttpResponse<MerchantBackend.Instances.In export function useBackendInstance(): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> { const { url } = useContext(BackendContext); const { id, token } = useContext(InstanceContext); - const { data, error } = useSWR<MerchantBackend.Instances.QueryInstancesResponse>([`/private/instances/${id}`, token, url], fetcher) + const { data, error } = useSWR<MerchantBackend.Instances.QueryInstancesResponse, SwrError>([`/private/instances/${id}`, token, url], fetcher) return { data, unauthorized: error?.status === 401, error } } export function useBackendConfig(): HttpResponse<MerchantBackend.VersionResponse> { const { url, token } = useContext(BackendContext) - const { data, error } = useSWR<MerchantBackend.VersionResponse>(['/config', token, url], fetcher, { + const { data, error } = useSWR<MerchantBackend.VersionResponse, SwrError>(['/config', token, url], fetcher, { shouldRetryOnError: false }) diff --git a/packages/frontend/src/hooks/index.ts b/packages/frontend/src/hooks/index.ts @@ -23,7 +23,7 @@ import { StateUpdater, useEffect, useState } from "preact/hooks"; import { mutate } from 'swr'; export function useBackendURL(): [string, StateUpdater<string>] { - return useNotNullLocalStorage('backend-url', window.location.origin) + return useNotNullLocalStorage('backend-url', typeof window !== 'undefined' ? window.location.origin : '') } export function useBackendDefaultToken(): [string | undefined, StateUpdater<string | undefined>] { return useLocalStorage('backend-token') diff --git a/packages/frontend/src/i18n/index.ts b/packages/frontend/src/i18n/index.ts @@ -1,285 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - - /** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -export const translations = { - es: { - confirm_modal: { - title: 'confirmar accion', - create_instance: 'crear instancia', - delete_instance: 'borrar instancia', - update_instance: 'actualizar instancia', - }, - notification: { - unauthorized: { - title: 'acceso no autorizado', - description: 'el servidor a denegado el acceso' - }, - create_error: { - title: 'error creando', - description: 'la creación no se efectuó correctamente. el servidor dice: {{message}}' - }, - create_success: { - title: 'creación correcta', - description: 'la creación se efectuó correctamente' - }, - update_error: { - title: 'error actualizando', - description: 'la actualizacion no se efectuó correctamente. el servidor dice: {{message}}' - }, - update_success: { - title: 'actualización correcta', - description: 'la actualizacion se efectuó correctamente' - }, - delete_error: { - title: 'error eliminando', - description: 'la eliminación no se efectuó correctamente. el servidor dice: {{message}}' - }, - delete_success: { - title: 'eliminación correcta', - description: 'la eliminación se efectuó correctamente' - }, - }, - cancel: 'cancelar', - confirm: 'confirmar', - fields: { - instance: { - id: { - label: 'Id', - }, - merchant_pub: { - label: 'Clave pública' - }, - payment_targets: { - label: 'Dirección de pago', - }, - name: { - label: 'Nombre', - }, - payto_uris: { - label: 'PaytTO URI', - placeholder: 'valores separados por coma', - help: 'example: payto://<authority>/<path>/<name>', - }, - default_max_deposit_fee: { - label: 'Máximo pago por depósito', - }, - default_max_wire_fee: { - label: 'Máximo pago por transferencia bancaria', - }, - default_wire_fee_amortization: { - label: 'Amortización de pago', - }, - default_pay_delay: { - label: 'Tiempo de espera de pago' - }, - default_wire_transfer_delay: { - label: 'Tiempo de espera de transferencia bancaria' - }, - }, - }, - validation: { - required: '{{label}} es obligatorio', - typeError: '{{label}}', - payto: 'la dirección de pago no es valida', - }, - text: { - instances: 'Instancias', - merchant: 'Merchant', - list_of_configured_instances: 'Lista de instancias configuradas', - instance: { - empty_list: 'No hay instancias configuradas, puede crear una usando el boton + ', - } - } - }, - en: { - confirm_modal: { - title: 'confirm action', - create_instance: 'create instance', - delete_instance: 'delete instance', - update_instance: 'update instance', - }, - notification: { - unauthorized: { - title: 'Could not access the backend', - description: 'backend has denied access, try using another token' - }, - error: { - title: 'Error query the backend', - description: 'Got message: "{{error.message}}" from: {{backend}} (hasToken: {{hasToken}})' - }, - no_server: { - title: 'Could not access the backend', - description: `There was a problem trying to reach the backend. \n Got message: "{{error.message}}" from: {{backend}} (hasToken: {{hasToken}})` - }, - create_error: { - title: 'create error', - description: 'the create process went wrong, server says: {{info.hint}}' - }, - create_success: { - title: 'create success', - description: 'the create process completed' - }, - update_error: { - title: 'update error', - description: 'the update process went wrong, server says: {{info.hint}}' - }, - update_success: { - title: 'update success', - description: 'the update process completed' - }, - delete_error: { - title: 'delete error', - description: 'the delete process went wrong, server says: {{info.hint}}' - }, - delete_success: { - title: 'delete success', - description: 'the delete process completed' - }, - }, - fields: { - instance: { - id: { - label: 'Id', - }, - auth_token: { - label: 'Auth Token', - }, - merchant_pub: { - label: 'Public Key' - }, - payment_targets: { - label: 'Payment targets', - }, - name: { - label: 'Business Name', - tooltip: 'the name of the merchant instance' - }, - payto_uris: { - label: 'Bank accounts', - tooltip: 'Bank account URI', - help: 'payto://x-taler-bank/bank.taler:5882/blogger', - }, - default_max_deposit_fee: { - label: 'Max deposit fee', - }, - default_max_wire_fee: { - label: 'Max wire fee', - }, - default_wire_fee_amortization: { - label: 'Max fee amortization', - }, - default_pay_delay: { - label: 'Pay delay', - tooltip: 'value expressed in seconds', - }, - default_wire_transfer_delay: { - label: 'Wire transfer delay', - tooltip: 'value expressed in seconds', - }, - address: { - label: 'Address', - country: { - label: 'Country', - }, - country_subdivision: { - label: 'Country subdivision', - }, - town: { - label: 'Town', - }, - district: { - label: 'District', - }, - town_location: { - label: 'Town Location', - }, - post_code: { - label: 'Post code', - }, - street: { - label: 'Street', - }, - building_name: { - label: 'Building name', - }, - building_number: { - label: 'Building number', - }, - address_lines: { - label: 'Address lines', - } - }, - jurisdiction: { - label: 'Jurisdiction', - country: { - label: 'Country', - }, - country_subdivision: { - label: 'Country subdivision', - }, - town: { - label: 'Town', - }, - district: { - label: 'District', - }, - town_location: { - label: 'Town Location', - }, - post_code: { - label: 'Post code', - }, - street: { - label: 'Street', - }, - building_name: { - label: 'Building name', - }, - building_number: { - label: 'Building number', - }, - address_lines: { - label: 'Address lines', - } - } - } - }, - validation: { - required: '{{label}} is required', - typeError: '{{label}}', - payto: 'the pay address is not valid', - }, - text: { - instances: 'Instances', - merchant: 'Merchant', - list_of_configured_instances: 'List of configured instances', - create_new_instance: 'Create new instance', - - cancel: 'cancel', - confirm: 'confirm', - delete: 'delete', - update: 'update', - instance: { - empty_list: 'No instance configured yet, setup one pressing the + button', - } - }, - }, -} -\ No newline at end of file diff --git a/packages/frontend/src/index.tsx b/packages/frontend/src/index.tsx @@ -22,19 +22,19 @@ import "./scss/main.scss" import { h, VNode } from 'preact'; -import { StateUpdater, useCallback, useContext, useEffect, useState } from "preact/hooks"; +import { useCallback, useContext, useEffect, useState } from "preact/hooks"; import { Route, Router, route } from 'preact-router'; -import { IntlProvider } from 'preact-i18n'; +import { MessageError, MessageProvider, useMessageTemplate } from 'preact-messages'; import { Notification } from "./declaration"; import { Sidebar } from './components/sidebar'; import { NavigationBar } from './components/navbar'; import { Notifications } from './components/notifications'; -import { translations } from './i18n'; +import * as messages from './messages' import { useBackendURL, useBackendDefaultToken, useLang, useBackendInstanceToken } from './hooks'; import { useNotifications } from "./hooks/notifications"; import { BackendContext, ConfigContext, InstanceContext } from './context/backend'; -import { useBackendConfig } from "./hooks/backend"; +import { SwrError, useBackendConfig } from "./hooks/backend"; import NotFoundPage from './routes/notfound'; import Login from './routes/login'; @@ -68,12 +68,13 @@ function AppRouting(): VNode { const { notifications, pushNotification, removeNotification } = useNotifications() const { lang, setLang, changeBackend, updateToken } = useContext(BackendContext) const backendConfig = useBackendConfig(); + const i18n = useMessageTemplate('') const LoginWithError = () => <Login withMessage={{ - messageId: 'no_server', + message: i18n`Couldnt access the server`, type: 'ERROR', - params: !backendConfig.data ? backendConfig.error : {} + description: !backendConfig.data && backendConfig.error ? i18n`Got message: ${backendConfig.error.message} from: ${backendConfig.error.backend} (hasToken: ${backendConfig.error.hasToken})` : undefined, }} onConfirm={(url: string, token?: string) => { changeBackend(url) @@ -108,6 +109,7 @@ function AppReady({ pushNotification,addTokenCleaner }: { pushNotification: (n: changeBackend(url) if (token) updateToken(token) } + const i18n = useMessageTemplate('') return <Router> <Route path={RootPages.root} component={Redirect} to={RootPages.instances} /> @@ -124,14 +126,14 @@ function AppReady({ pushNotification,addTokenCleaner }: { pushNotification: (n: }} onUnauthorized={() => <Login - withMessage={{ messageId: 'unauthorized', type: 'ERROR', }} + withMessage={{ message: i18n`unauthorized`, type: 'ERROR', }} onConfirm={updateLoginStatus} />} - onError={(error: Error) => { - pushNotification({ messageId: 'error', params: error, type: 'ERROR' }) + onError={(error: SwrError) => { + pushNotification({ message: i18n`error`, params: error, type: 'ERROR' }) return <div /> - }} + }} /> <Route path={RootPages.new} @@ -139,12 +141,12 @@ function AppReady({ pushNotification,addTokenCleaner }: { pushNotification: (n: onBack={() => route(RootPages.instances)} onConfirm={() => { - pushNotification({ messageId: 'create_success', type: 'SUCCESS' }) + pushNotification({ message: i18n`create_success`, type: 'SUCCESS' }) route(RootPages.instances) }} onError={(error: any) => { - pushNotification({ messageId: 'create_error', type: 'ERROR', params: error }) + pushNotification({ message: i18n`create_error`, type: 'ERROR', params: error }) }} /> @@ -155,6 +157,10 @@ function AppReady({ pushNotification,addTokenCleaner }: { pushNotification: (n: </Router> } +function hasKey<O>(obj: O, key: string | number | symbol): key is keyof O { + return key in obj +} + function useBackendContextState() { const [lang, setLang] = useLang() const [url, changeBackend] = useBackendURL(); @@ -163,13 +169,19 @@ function useBackendContextState() { return { url, token, changeBackend, updateToken, lang, setLang } } +function onTranslationError(error: MessageError) { + if (typeof window === "undefined") return; + (window as any)['missing_locale'] = ([] as string[]).concat((window as any)['missing_locale']).concat(error.path.join()) +} + export default function Application(): VNode { const state = useBackendContextState() + return ( <BackendContext.Provider value={state}> - <IntlProvider definition={(translations as any)[state.lang] || translations.en}> + <MessageProvider locale={state.lang} onError={onTranslationError} messages={hasKey(messages, state.lang) ? messages[state.lang] : messages.en} pathSep={null as any} > <AppRouting /> - </IntlProvider > + </MessageProvider > </BackendContext.Provider> ); } @@ -179,12 +191,13 @@ interface SubPagesProps { addTokenCleaner: any; } + function SubPages({ id, pushNotification, addTokenCleaner }: SubPagesProps): VNode { const [token, updateToken] = useBackendInstanceToken(id); const { changeBackend } = useContext(BackendContext) - const cleaner = useCallback(() =>{updateToken(undefined)},[id]) - + const i18n = useMessageTemplate('') + useEffect(() => { addTokenCleaner(cleaner) }, [addTokenCleaner, cleaner]) @@ -200,7 +213,7 @@ function SubPages({ id, pushNotification, addTokenCleaner }: SubPagesProps): VNo component={Details} onUnauthorized={() => <Login - withMessage={{ messageId: 'unauthorized', type: 'ERROR', }} + withMessage={{ message: i18n`unauthorized`, type: 'ERROR', }} onConfirm={updateLoginStatus} />} @@ -208,8 +221,8 @@ function SubPages({ id, pushNotification, addTokenCleaner }: SubPagesProps): VNo route(`/instance/${id}/update`) }} - onLoadError={(e: Error) => { - pushNotification({ messageId: 'update_load_error', type: 'ERROR', params: e }) + onLoadError={(e: SwrError) => { + pushNotification({ message: i18n`update_load_error`, type: 'ERROR', params: e }) route(`/instance/${id}/`) return <div /> }} @@ -218,11 +231,11 @@ function SubPages({ id, pushNotification, addTokenCleaner }: SubPagesProps): VNo <Route path={InstancePages.update} component={Update} onUnauthorized={() => <Login - withMessage={{ messageId: 'unauthorized', type: 'ERROR', }} + withMessage={{ message: i18n`unauthorized`, type: 'ERROR', }} onConfirm={updateLoginStatus} />} - onLoadError={(e: Error) => { - pushNotification({ messageId: 'update_load_error', type: 'ERROR', params: e }) + onLoadError={(e: SwrError) => { + pushNotification({ message: i18n`update_load_error`, type: 'ERROR', params: e }) route(`/instance/${id}/`) return <div /> }} @@ -230,11 +243,11 @@ function SubPages({ id, pushNotification, addTokenCleaner }: SubPagesProps): VNo route(`/instance/${id}/`) }} onConfirm={() => { - pushNotification({ messageId: 'create_success', type: 'SUCCESS' }) + pushNotification({ message: i18n`create_success`, type: 'SUCCESS' }) route(`/instance/${id}/`) }} onUpdateError={(e: Error) => { - pushNotification({ messageId: 'update_error', type: 'ERROR', params: e }) + pushNotification({ message: i18n`update_error`, type: 'ERROR', params: e }) }} /> diff --git a/packages/frontend/src/messages/en.po b/packages/frontend/src/messages/en.po @@ -0,0 +1,155 @@ +# Examples from http://pology.nedohodnik.net/doc/user/en_US/ch-poformat.html +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: en\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +msgid "Time: %1 second" +msgid_plural "Time: %1 seconds" +msgstr[0] "Czas: %1 sekunda" +msgstr[1] "Czas: %1 sekundy" +msgstr[2] "Czas: %1 sekund" + +msgid "Hi" +msgstr "Hello" + +msgid "List of configured instances" +msgstr "List of configured instances" + +msgid "There is no instances yet, add more pressing the + sign" +msgstr "There is no instances yet, add more pressing the + sign" + +# msgctxt "fields.instance.name" +# msgid "placeholder" +# msgstr "" + +# |msgctxt "fields" +# |msgctxt "instance" +# msgctxt "fields.instance.id.label" + +msgid "fields.instance.id.label" +msgstr "Id" + +msgid "fields.instance.name.label" +msgstr "Name" + +msgid "fields.instance.merchant.pub.label" +msgstr "Public key" + +msgid "fields.instance.payment.targets.label" +msgstr "Payment targets" + +msgid "fields.instance.auth_token.label" +msgstr "Auth token" + +msgid "fields.instance.auth_token.tooltip" +msgstr "Use this token to secure an instance with a password" + +msgid "fields.instance.payto_uris.label" +msgstr "Account address" + +msgid "fields.instance.payto_uris.help" +msgstr "payto://x-taler-bank/bank.taler:5882/blogger" + +msgid "fields.instance.default_max_deposit_fee.label" +msgstr "Max deposit fee label" + +msgid "fields.instance.default_max_wire_fee.label" +msgstr "Max wire fee label" + +msgid "fields.instance.default_wire_fee_amortization.label" +msgstr "Wire fee Amortization" + +msgid "fields.instance.address.label" +msgstr "Address" + +msgid "fields.instance.address.country.label" +msgstr "Country" + +msgid "fields.instance.address.country_subdivision.label" +msgstr "Country Subdivision" + +msgid "fields.instance.address.district.label" +msgstr "District" + +msgid "fields.instance.address.town.label" +msgstr "Town" + +msgid "fields.instance.address.town_location.label" +msgstr "Town Location" + +msgid "fields.instance.address.post_code.label" +msgstr "Post code" + +msgid "fields.instance.address.street.label" +msgstr "Street" + +msgid "fields.instance.address.building_name.label" +msgstr "Building Name" + +msgid "fields.instance.address.building_number.label" +msgstr "Building Number" + +msgid "fields.instance.address.address_lines.label" +msgstr "Adress Line" + +msgid "fields.instance.jurisdiction.label" +msgstr "Jurisdiction" + +msgid "fields.instance.jurisdiction.country.label" +msgstr "Country" + +msgid "fields.instance.jurisdiction.country_subdivision.label" +msgstr "Country Subdivision" + +msgid "fields.instance.jurisdiction.district.label" +msgstr "District" + +msgid "fields.instance.jurisdiction.town.label" +msgstr "Town" + +msgid "fields.instance.jurisdiction.town_location.label" +msgstr "Town Location" + +msgid "fields.instance.jurisdiction.post_code.label" +msgstr "Post code" + +msgid "fields.instance.jurisdiction.street.label" +msgstr "Street" + +msgid "fields.instance.jurisdiction.building_name.label" +msgstr "Building Name" + +msgid "fields.instance.jurisdiction.building_number.label" +msgstr "Building Number" + +msgid "fields.instance.jurisdiction.address_lines.label" +msgstr "Adress Line" + +msgid "fields.instance.default_pay_delay.label" +msgstr "Pay delay" + +msgid "fields.instance.default_wire_transfer_delay.label" +msgstr "Wire transfer delay" + +msgid "Couldnt access the server" +msgstr "Couldnt access the server" + +msgid "Got message: %s from: %s (hasToken: %s)" +msgstr "Recibimos el mensaje: %s desde: %s (con token: %s)" + +msgid "Merchant" +msgstr "Merchant" + +msgid "Instances" +msgstr "Instances" + +msgid "Update this instance" +msgstr "Update this instance" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Confirm" +msgstr "Confirm" +\ No newline at end of file diff --git a/packages/frontend/src/messages/es.po b/packages/frontend/src/messages/es.po @@ -0,0 +1,22 @@ +# Examples from http://pology.nedohodnik.net/doc/user/en_US/ch-poformat.html +msgid "" +msgstr "" +"Content-Type: text/plain; charset=UTF-8\n" +"Language: pl\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +msgid "Time: %1 second" +msgid_plural "Time: %1 seconds" +msgstr[0] "Czas: %1 sekunda" +msgstr[1] "Czas: %1 sekundy" +msgstr[2] "Czas: %1 sekund" + +msgid "Hi" +msgstr "Hola" + +msgid "List of configured instances" +msgstr "Lista de instancias configuradas" + +msgid "There is no instances yet, add more pressing the + sign" +msgstr "No hay instancias todavía, agregá mas presionando el signo +" + diff --git a/packages/frontend/src/messages/index.ts b/packages/frontend/src/messages/index.ts @@ -0,0 +1,3 @@ +export * as en from './en.po' +export * as es from './es.po' + diff --git a/packages/frontend/src/routes/instances/create/CreatePage.tsx b/packages/frontend/src/routes/instances/create/CreatePage.tsx @@ -25,7 +25,7 @@ import { MerchantBackend } from "../../../declaration"; import * as yup from 'yup'; import { YupField } from "../../../components/yup/YupField" import { InstanceCreateSchema as schema } from '../../../schemas' -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; interface Props { onCreate: (d: MerchantBackend.Instances.InstanceConfigurationMessage) => void; @@ -67,8 +67,8 @@ export function CreatePage({ onCreate, isLoading, onBack }: Props): VNode { <div class="level-left"> <div class="level-item"> <ul> - <li><Text id="text.merchant" /></li> - <li><Text id="text.instances" /></li> + <li><Message id="Merchant" /></li> + <li><Message id="Instances" /></li> </ul> </div> </div> @@ -81,7 +81,7 @@ export function CreatePage({ onCreate, isLoading, onBack }: Props): VNode { <div class="level-left"> <div class="level-item"> <h1 class="title"> - <Text id="text.create_new_instances" /> + <Message id="Create new instances" /> </h1> </div> </div> @@ -102,8 +102,8 @@ export function CreatePage({ onCreate, isLoading, onBack }: Props): VNode { valueHandler={valueHandler} info={schema.fields[f].describe()} />)} <div class="buttons is-right"> - <button class="button" onClick={onBack} ><Text id="text.cancel" /></button> - <button class="button is-success" onClick={submit} ><Text id="text.confirm" /></button> + <button class="button" onClick={onBack} ><Message id="Cancel" /></button> + <button class="button is-success" onClick={submit} ><Message id="Confirm" /></button> </div> </div> <div class="column" /> diff --git a/packages/frontend/src/routes/instances/details/DetailPage.tsx b/packages/frontend/src/routes/instances/details/DetailPage.tsx @@ -24,7 +24,7 @@ import { useState } from "preact/hooks"; import { MerchantBackend } from "../../../declaration"; import { YupField } from "../../../components/yup/YupField" import { InstanceUpdateSchema as schema } from '../../../schemas' -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; interface Props { onUpdate: () => void; @@ -59,8 +59,8 @@ export function DetailPage({ onUpdate, isLoading, selected, onDelete }: Props): <div class="level-left"> <div class="level-item"> <ul> - <li><Text id="text.merchant" /></li> - <li><Text id="text.instances" /></li> + <li><Message id="Merchant" /></li> + <li><Message id="Instances" /></li> </ul> </div> </div> @@ -73,7 +73,7 @@ export function DetailPage({ onUpdate, isLoading, selected, onDelete }: Props): <div class="level-left"> <div class="level-item"> <h1 class="title"> - <Text id="text.create_new_instances" /> + <Message id="Instance details" /> </h1> </div> </div> @@ -94,8 +94,8 @@ export function DetailPage({ onUpdate, isLoading, selected, onDelete }: Props): valueHandler={valueHandler} info={schema.fields[f].describe()} />)} <div class="buttons is-right"> - <button class="button is-danger" onClick={() => onDelete()} ><Text id="text.delete" /></button> - <button class="button is-success" onClick={() => onUpdate()} ><Text id="text.update" /></button> + <button class="button is-danger" onClick={() => onDelete()} ><Message id="delete" /></button> + <button class="button is-success" onClick={() => onUpdate()} ><Message id="update" /></button> </div> </div> <div class="column" /> diff --git a/packages/frontend/src/routes/instances/details/index.tsx b/packages/frontend/src/routes/instances/details/index.tsx @@ -2,13 +2,13 @@ import { Fragment, h, VNode } from "preact"; import { useContext, useState } from "preact/hooks"; import { InstanceContext } from "../../../context/backend"; import { Notification } from "../../../declaration"; -import { useBackendInstance, useBackendInstanceMutateAPI } from "../../../hooks/backend"; +import { useBackendInstance, useBackendInstanceMutateAPI, SwrError } from "../../../hooks/backend"; import { DeleteModal } from "../list/DeleteModal"; import { DetailPage } from "./DetailPage"; interface Props { onUnauthorized: () => VNode; - onLoadError: (e: Error) => VNode; + onLoadError: (e: SwrError) => VNode; onUpdate: () => void; pushNotification: (n: Notification) => void; } @@ -41,9 +41,9 @@ export default function Detail({ onUpdate, onLoadError, onUnauthorized, pushNoti onConfirm={async (): Promise<void> => { try { await deleteInstance() - pushNotification({ messageId: 'delete_success', type: 'SUCCESS' }) + pushNotification({ message: 'delete_success', type: 'SUCCESS' }) } catch (error) { - pushNotification({ messageId: 'delete_error', type: 'ERROR', params: error }) + pushNotification({ message: 'delete_error', type: 'ERROR', params: error }) } setDeleting(false) }} diff --git a/packages/frontend/src/routes/instances/list/CardTable.tsx b/packages/frontend/src/routes/instances/list/CardTable.tsx @@ -20,7 +20,7 @@ */ import { h, VNode } from "preact"; -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; import { useEffect, useState } from "preact/hooks"; import { MerchantBackend } from "../../../declaration"; import { EmptyTable } from "./EmptyTable"; @@ -70,7 +70,7 @@ export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: return <div class="card has-table"> <header class="card-header"> - <p class="card-header-title"><span class="icon"><i class="mdi mdi-account-multiple" /></span><Text id="text.instances" /></p> + <p class="card-header-title"><span class="icon"><i class="mdi mdi-account-multiple" /></span><Message id="Instances" /></p> <div class="card-header-icon" aria-label="more options"> diff --git a/packages/frontend/src/routes/instances/list/EmptyTable.tsx b/packages/frontend/src/routes/instances/list/EmptyTable.tsx @@ -20,13 +20,14 @@ */ import { h, VNode } from "preact"; -import { Text } from "preact-i18n"; +import { useMessageTemplate } from "preact-messages"; +import { Message } from "preact-messages"; export function EmptyTable(): VNode { return <div class="content has-text-grey has-text-centered"> <p> <span class="icon is-large"><i class="mdi mdi-emoticon-sad mdi-48px" /></span> </p> - <p><Text id="text.instance.empty_list" /></p> + <p><Message id="There is no instances yet, add more pressing the + sign" /></p> </div> } diff --git a/packages/frontend/src/routes/instances/list/Table.tsx b/packages/frontend/src/routes/instances/list/Table.tsx @@ -20,7 +20,7 @@ */ import { h, VNode } from "preact" -import { Text } from "preact-i18n" +import { Message } from "preact-messages" import { StateUpdater } from "preact/hooks" import { MerchantBackend } from "../../../declaration" @@ -47,10 +47,10 @@ export function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, <span class="check" /> </label> </th> - <th><Text id="fields.instance.id.label" /></th> - <th><Text id="fields.instance.name.label" /></th> - <th><Text id="fields.instance.merchant_pub.label" /></th> - <th><Text id="fields.instance.payment_targets.label" /></th> + <th><Message id="fields_instance_id_label" /></th> + <th><Message id="fields_instance_name_label" /></th> + <th><Message id="fields_instance_merchant_pub_label" /></th> + <th><Message id="fields_instance_payment_targets_label" /></th> <th /> </tr> </thead> diff --git a/packages/frontend/src/routes/instances/list/View.tsx b/packages/frontend/src/routes/instances/list/View.tsx @@ -22,7 +22,7 @@ import { h, VNode } from "preact"; import { MerchantBackend } from "../../../declaration"; import { CardTable } from './CardTable'; -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; interface Props { instances: MerchantBackend.Instances.Instance[]; @@ -42,8 +42,8 @@ export function View({ instances, isLoading, onCreate, onDelete, onUpdate, selec <div class="level-left"> <div class="level-item"> <ul> - <li><Text id="text.merchant" /></li> - <li><Text id="text.instances" /></li> + <li><Message id="Merchant" /></li> + <li><Message id="Instances" /></li> </ul> </div> </div> @@ -56,7 +56,7 @@ export function View({ instances, isLoading, onCreate, onDelete, onUpdate, selec <div class="level-left"> <div class="level-item"> <h1 class="title"> - <Text id="text.list_of_configured_instances" /> + <Message id="List of configured instances" /> </h1> </div> </div> diff --git a/packages/frontend/src/routes/instances/list/index.tsx b/packages/frontend/src/routes/instances/list/index.tsx @@ -21,14 +21,14 @@ import { Fragment, h, VNode } from 'preact'; import { View } from './View'; -import { useBackendInstances, useBackendInstanceMutateAPI } from '../../../hooks/backend'; +import { useBackendInstances, useBackendInstanceMutateAPI, SwrError } from '../../../hooks/backend'; import { useState } from 'preact/hooks'; import { MerchantBackend, Notification } from '../../../declaration'; import { DeleteModal } from './DeleteModal'; interface Props { pushNotification: (n: Notification) => void; onUnauthorized: () => VNode; - onError: (e: Error) => VNode; + onError: (e: SwrError) => VNode; onCreate: () => void; onUpdate: (id: string) => void; } @@ -60,9 +60,9 @@ export default function Instances({ pushNotification, onUnauthorized, onError, o onConfirm={async (): Promise<void> => { try { await deleteInstance() - pushNotification({ messageId: 'delete_success', type: 'SUCCESS' }) + pushNotification({ message: 'delete_success', type: 'SUCCESS' }) } catch (e) { - pushNotification({ messageId: 'delete_error', type: 'ERROR', params: error }) + pushNotification({ message: 'delete_error', type: 'ERROR', params: error }) } setDeleting(null) }} diff --git a/packages/frontend/src/routes/instances/update/UpdatePage.tsx b/packages/frontend/src/routes/instances/update/UpdatePage.tsx @@ -25,7 +25,7 @@ import { MerchantBackend } from "../../../declaration"; import * as yup from 'yup'; import { YupField } from "../../../components/yup/YupField" import { InstanceUpdateSchema as schema } from '../../../schemas' -import { Text } from "preact-i18n"; +import { Message } from "preact-messages"; interface Props { onUpdate: (d: MerchantBackend.Instances.InstanceReconfigurationMessage) => void; @@ -72,8 +72,8 @@ export function UpdatePage({ onUpdate, isLoading, selected, onBack }: Props): VN <div class="level-left"> <div class="level-item"> <ul> - <li><Text id="text.merchant" /></li> - <li><Text id="text.instances" /></li> + <li><Message id="Merchant" /></li> + <li><Message id="Instances" /></li> </ul> </div> </div> @@ -86,7 +86,7 @@ export function UpdatePage({ onUpdate, isLoading, selected, onBack }: Props): VN <div class="level-left"> <div class="level-item"> <h1 class="title"> - <Text id="text.create_new_instances" /> + <Message id="Update this instance" /> </h1> </div> </div> @@ -107,8 +107,8 @@ export function UpdatePage({ onUpdate, isLoading, selected, onBack }: Props): VN valueHandler={valueHandler} info={schema.fields[f].describe()} />)} <div class="buttons is-right"> - <button class="button" onClick={onBack} ><Text id="text.cancel" /></button> - <button class="button is-success" onClick={submit} ><Text id="text.confirm" /></button> + <button class="button" onClick={onBack} ><Message id="Cancel" /></button> + <button class="button is-success" onClick={submit} ><Message id="Confirm" /></button> </div> </div> <div class="column" /> diff --git a/packages/frontend/src/routes/instances/update/index.tsx b/packages/frontend/src/routes/instances/update/index.tsx @@ -1,6 +1,6 @@ import { h, VNode } from "preact"; import { MerchantBackend } from "../../../declaration"; -import { useBackendInstance, useBackendInstanceMutateAPI } from "../../../hooks/backend"; +import { SwrError, useBackendInstance, useBackendInstanceMutateAPI } from "../../../hooks/backend"; import { UpdatePage } from "./UpdatePage"; interface Props { @@ -9,7 +9,7 @@ interface Props { pushNotification: (n: Notification) => void; onUnauthorized: () => VNode; - onLoadError: (e: Error) => VNode; + onLoadError: (e: SwrError) => VNode; onUpdateError: (e: Error) => void; } diff --git a/packages/frontend/src/schemas/index.ts b/packages/frontend/src/schemas/index.ts @@ -69,27 +69,27 @@ const InstanceSchema = yup.object().shape({ .required(), address: yup.object().shape({ country: yup.string().optional(), - country_subdivision: yup.string().optional(), - district: yup.string().optional(), - town: yup.string(), - town_location: yup.string().optional(), - post_code: yup.string().optional(), - street: yup.string().optional(), - building_name: yup.string().optional(), - building_number: yup.string().optional(), address_lines: yup.array().of(yup.string()).max(7).optional(), + building_number: yup.string().optional(), + building_name: yup.string().optional(), + street: yup.string().optional(), + post_code: yup.string().optional(), + town_location: yup.string().optional(), + town: yup.string(), + district: yup.string().optional(), + country_subdivision: yup.string().optional(), }).meta({type:'group'}), jurisdiction: yup.object().shape({ country: yup.string().optional(), - country_subdivision: yup.string().optional(), - district: yup.string().optional(), - town: yup.string(), - town_location: yup.string().optional(), - post_code: yup.string().optional(), - street: yup.string().optional(), - building_name: yup.string().optional(), - building_number: yup.string().optional(), address_lines: yup.array().of(yup.string()).max(7).optional(), + building_number: yup.string().optional(), + building_name: yup.string().optional(), + street: yup.string().optional(), + post_code: yup.string().optional(), + town_location: yup.string().optional(), + town: yup.string(), + district: yup.string().optional(), + country_subdivision: yup.string().optional(), }).meta({type:'group'}), default_pay_delay: yup.object() .shape({ d_ms: yup.number() }) diff --git a/packages/frontend/tests/hooks/notification.test.ts b/packages/frontend/tests/hooks/notification.test.ts @@ -34,7 +34,7 @@ test('notification should disapear after timeout', () => { act(() => { result.current?.pushNotification({ - messageId: 'some_id', + message: 'some_id', type: 'INFO' }); }); diff --git a/packages/preact-message/.gitignore b/packages/preact-message/.gitignore @@ -0,0 +1,2 @@ +/example/dist/ +/lib/ diff --git a/packages/preact-message/CHANGELOG.md b/packages/preact-message/CHANGELOG.md @@ -0,0 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 1.0.0-beta.1 (2020-11-29) + + +### Features + +* Add @messageformat/react (was react-message-context) ([#292](https://github.com/messageformat/messageformat/issues/292)) ([9089f0a](https://github.com/messageformat/messageformat/commit/9089f0ad52f21f8ab6c356fd4f51bb140dc36855)) + + +# 0.6.2 and earlier + +For earlier changes, see https://github.com/eemeli/react-message-context/releases diff --git a/packages/preact-message/LICENSE b/packages/preact-message/LICENSE @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/preact-message/README.md b/packages/preact-message/README.md @@ -0,0 +1,126 @@ +# @messageformat/react + +An efficient React front-end for message formatting libraries. +Designed in particular for use with [messageformat], but will work with any messages. +Provides the best possible API for a front-end developer, without making the back end any more difficult than it needs to be either. +Should add at most about 1kB to your compiled & minified bundle size. + +This package was previously named [react-message-context](https://www.npmjs.com/package/react-message-context). + +[messageformat]: https://messageformat.github.io + +## Installation + +``` +npm install @messageformat/react +``` + +The library has React 16.8 or later as a peer dependency. +It is published as an **ES module** only, which should work directly with almost all tools and environments that support modern development targeting browser environments. +For tools such as Jest that define their own import methods, you may need to add something like `transformIgnorePatterns: ['node_modules/(?!@messageformat/react)']` to your configuration. + +## [API Documentation] + +- [`<MessageProvider messages [locale] [onError] [pathSep]>`](http://messageformat.github.io/messageformat/api/react.messageprovider/) +- [`<Message id [locale] [props] [...msgProps]>`](http://messageformat.github.io/messageformat/api/react.message/) +- [`useLocales()`](http://messageformat.github.io/messageformat/api/react.uselocales/) +- [`useMessage(id, [params], [locale])`](http://messageformat.github.io/messageformat/api/react.usemessage/) +- [`useMessageGetter(rootId, [{ baseParams, locale }])`](http://messageformat.github.io/messageformat/api/react.usemessagegetter/) + +## Usage Examples + +In addition to the examples included below and in the [API documentation], see the [example] for a simple, but fully functional example of using this library along with [@messageformat/core] and [@messageformat/loader] to handle localized messages, with dynamic loading of non-default locales. + +[api documentation]: http://messageformat.github.io/messageformat/api/react/ +[example]: https://github.com/messageformat/messageformat/tree/master/packages/react/example +[@messageformat/core]: https://www.npmjs.com/package/@messageformat/core +[@messageformat/loader]: https://www.npmjs.com/package/@messageformat/loader + +--- + +Within a `MessageProvider`, access to the messages is possible using either the `Message` component, or via custom hooks such as `useMessageGetter`: + +```js +import React from 'preact'; +import { + Message, + MessageProvider, + useMessageGetter +} from '@messageformat/react'; + +const messages = { + message: 'Your message is important', + answers: { + sixByNine: ({ base }) => (6 * 9).toString(base), + universe: 42 + } +}; + +function Equality() { + const getAnswer = useMessageGetter('answers'); + const foo = getAnswer('sixByNine', { base: 13 }); + const bar = getAnswer('universe'); + return `${foo} and ${bar} are equal`; +} + +export const Example = () => ( + <MessageProvider messages={messages}> + <ul> + <li> + <Message id="message" /> + </li> + <li> + <Equality /> + </li> + </ul> + </MessageProvider> +); + +// Will render as: +// - Your message is important +// - 42 and 42 are equal +``` + +--- + +Using MessageProviders within each other allows for multiple locales and namespaces: + +```jsx +import React from 'preact'; +import { Message, MessageProvider } from '@messageformat/react'; + +export const Example = () => ( + <MessageProvider locale="en" messages={{ foo: 'FOO', qux: 'QUX' }}> + <MessageProvider locale="fi" messages={{ foo: 'FÖÖ', bar: 'BÄR' }}> + <ul> + <li> + <Message id="foo" /> + </li> + <li> + <Message id="foo" locale="en" /> + </li> + <li> + <Message id="bar" /> + </li> + <li> + <Message id="bar" locale="en" /> + </li> + <li> + <Message id="qux" /> + </li> + <li> + <Message id="quux">xyzzy</Message> + </li> + </ul> + </MessageProvider> + </MessageProvider> +); + +// Will render as: +// - FÖÖ +// - FOO +// - BÄR +// - bar (uses fallback to key) +// - QUX (uses fallback to "en" locale) +// - xyzzy (uses fallback to child node) +``` diff --git a/packages/preact-message/package.json b/packages/preact-message/package.json @@ -0,0 +1,39 @@ +{ + "name": "preact-messages", + "version": "1.0.0-beta.1", + "description": "PReact hooks and other bindings for messages", + "keywords": [ + "i18n", + "preact", + "context", + "messages", + "messageformat", + "provider" + ], + "contributors": [ + "Eemeli Aro <eemeli@gmail.com>" + ], + "license": "MIT", + "homepage": "http://messageformat.github.io/messageformat/api/react/", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js", + "./package.json": "./package.json" + }, + "files": [ + "lib/" + ], + "sideEffects": false, + "scripts": { + "build": "tsc", + "extract-api": "api-extractor run --local --verbose" + }, + "peerDependencies": { + "preact": ">=10.3.0" + }, + "dependencies": { + "preact": "^10.5.12", + "typescript": "^4.1.5" + } +} diff --git a/packages/preact-message/src/MessageProvider.ts b/packages/preact-message/src/MessageProvider.ts @@ -0,0 +1,183 @@ +import { createElement } from 'preact'; +import { useContext, useMemo } from 'preact/hooks'; +import { MessageContext, MessageObject, defaultValue } from './message-context'; +// import { MessageProviderProps, getPathSep, getLocales, getMessages, getOnError } from './message-provider'; +import { MessageError, ErrorCode, errorMessages } from './message-error'; + +/** + * `<MessageProvider messages [locale] [merge] [onError] [pathSep]>` + * + * Makes the messages available for its descendants via a React Context. + * To support multiple locales and/or namespaces, MessageProviders may be used within each other, merging each provider's messages with those of its parents. + * The locale preference order is also set similarly, from nearest to furthest. + * + * @public + * + * @example + * ```js + * import React from 'preact' + * import { Message, MessageProvider } from '@messageformat/react' + * + * const messages = { example: { key: 'Your message here' } } + * const extended = { other: { key: 'Another message' } } + * + * const Example = () => ( + * <span> + * <Message id={['example', 'key']} /> + * {' | '} + * <Message id="other/key" /> + * </span> + * ) // 'Your message here | Another message' + * + * export const App = () => ( + * <MessageProvider messages={messages} pathSep="/"> + * <MessageProvider messages={extended}> + * <Example /> + * </MessageProvider> + * </MessageProvider> + * ) + * ``` + */ + +export function MessageProvider(props: MessageProviderProps) { + const { + children, + context: propContext, + debug, + locale = '', + merge, + messages, + onError, + pathSep + } = props; + let parent = useContext(MessageContext); + if (propContext) + parent = propContext; + else if (propContext === null) + parent = defaultValue; + const value: MessageContext = useMemo(() => { + const ps = getPathSep(parent, pathSep); + return { + locales: getLocales(parent, locale), + merge: merge || parent.merge, + messages: getMessages(parent, locale, messages), + onError: getOnError(parent, ps, onError, debug), + pathSep: ps + }; + }, [parent, locale, merge, messages, pathSep]); + return createElement(MessageContext.Provider, { value } as any, children); +} + + + +/** @public */ +export interface MessageProviderProps { + children: any; + + /** + * A hierarchical object containing the messages as boolean, number, string or function values. + */ + messages: MessageObject; + context?: MessageContext; + + /** @deprecated Use onError instead */ + debug?: 'error' | 'warn' | ((msg: string) => any); + + /** + * A key for the locale of the given messages. + * If uset, will inherit the locale from the parent context, or ultimately use en empty string. + */ + locale?: string; + + /** + * By default, top-level namespaces defined in a child `MessageProvider` overwrite those defined in a parent. + * Set this to {@link https://lodash.com/docs/#merge | _.merge} or some other function with the same arguments as + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign | Object.assign} to allow for deep merges. + */ + merge?: MessageContext['merge']; + + /** + * What to do on errors; most often called if a message is not found. + * + * - `"silent"`: Ignore the error; use the message's id as the replacement message. + * + * - `"error"`: Throw the error. + * + * - `"warn"` (default): Print a warning in the console and use the message's id as the replacement message. + * + * - `(error) => any`: A custom function that is called with an `Error` object with `code: string` and `path: string[]` fields set. + * The return falue is used as the replacement message. + */ + onError?: 'error' | 'silent' | 'warn' | ((error: MessageError) => any); + + /** + * By default, `.` in a `<Message id>` splits the path into parts, such that e.g. `'a.b'` is equivalent to `['a', 'b']`. + * Use this option to customize or disable this behaviour (by setting it to `null`). + */ + pathSep?: string; +} + +export function getOnError( + parent: MessageContext, + pathSep: string | null, + onError: MessageProviderProps['onError'], + debug: MessageProviderProps['debug'] +) { + const asId = (path: string[]) => path.join(pathSep || ','); + function msgError(path: string[], code: ErrorCode) { + throw new MessageError(path, code, asId); + } + function msgWarning(path: string[], code: ErrorCode) { + console.warn(errorMessages[code], path); + return asId(path); + } + + if (onError === undefined) { + // debug is deprecated, will be removed later + if (typeof debug === 'function') + return (path: string[], code: ErrorCode) => + debug(`${errorMessages[code]}: ${asId(path)}`); + onError = debug; + } + + switch (onError) { + case 'silent': + return asId; + case 'error': + return msgError; + case 'warn': + return msgWarning; + default: + if (typeof onError === 'function') { + const _onError = onError; + return (path: string[], code: ErrorCode) => + _onError(new MessageError(path, code, asId)); + } + return parent.onError || msgWarning; + } +} + +export function getLocales({ locales }: MessageContext, locale: string) { + const fallback = locales.filter(fb => fb !== locale); + return [locale].concat(fallback); +} + +export function getMessages( + { merge, messages }: MessageContext, + locale: string, + lcMessages: MessageObject +) { + const res = Object.assign({}, messages); + const prev = res[locale]; + res[locale] = + prev && typeof prev === 'object' ? merge({}, prev, lcMessages) : lcMessages; + return res; +} + +export function getPathSep(context: MessageContext, pathSep?: string | null) { + return pathSep === null || typeof pathSep === 'string' + ? pathSep + : context.pathSep; +} + + diff --git a/packages/preact-message/src/declarations.d.ts b/packages/preact-message/src/declarations.d.ts @@ -0,0 +1,3 @@ +export module "" { + +} +\ No newline at end of file diff --git a/packages/preact-message/src/get-message.ts b/packages/preact-message/src/get-message.ts @@ -0,0 +1,87 @@ +import { + MessageContext, + MessageObject, + MessageValue +} from './message-context.js'; + +function getIn(messages: MessageValue | MessageObject, path: string[]) { + if (messages) { + for (let i = 0; i < path.length; ++i) { + if (typeof messages !== 'object') return undefined; + messages = messages[path[i]]; + if (messages === undefined) return undefined; + } + } + return messages; +} + +export function getPath(id?: string | string[], pathSep?: string | null) { + if (!id) return []; + if (Array.isArray(id)) return id; + return pathSep ? id.split(pathSep) : [id]; +} + +/** + * Given a `MessageContext` instance, fetches an entry from the messages object of the current or given locale. + * The returned value will be `undefined` if not found, or otherwise exactly as set in the `MessageProvider` props. + * + * @public + * @param id - The key or key path of the message or message object. + * If empty or `[]`, matches the root of the messages object + * @param locale - If set, overrides the current locale precedence as set by parent MessageProviders. + */ +export function getMessage( + context: MessageContext, + id?: string | string[], + locale?: string | string[] +) { + const { locales, messages, onError, pathSep } = context; + const lca = + locale == null ? locales : Array.isArray(locale) ? locale : [locale]; + const path = getPath(id, pathSep); + for (let i = 0; i < lca.length; ++i) { + const lc = lca[i]; + const msg = getIn(messages[lc], path); + if (msg !== undefined) return msg; + } + return onError ? onError(path, 'ENOMSG') : undefined; +} + +/** + * @param id - Message identifier; extends the path set by `rootId` + * @param params - Parameters for a function message + */ +export interface MessageGetterOptions { + baseParams?: any; + locale?: string | string[]; +} + +/** + * Given a `MessageContext` instance, returns a message getter function, which may have a preset root id path, locale, and/or base parameters for message functions. + * + * The returned function takes two parameters `(msgId, msgParams)`, which will extend any values set by the hook's arguments. + * + * @public + * @param context - The `MessageContext` instance + * @param rootId - The key or key path of the message or message object. + * If empty or `[]`, matches the root of the messages object + * @param options - If `baseParams` is set, message function parameters will be assumed to always be an object, with these values initially set. + * `locale` overrides the current locale precedence as set by parent MessageProviders. + */ +export function getMessageGetter( + context: MessageContext, + rootId?: string | string[], + { baseParams, locale }: MessageGetterOptions = {} +) { + const { pathSep } = context; + const pathPrefix = getPath(rootId, pathSep); + return function message(id?: string | string[], params?: any) { + const path = pathPrefix.concat(getPath(id, pathSep)); + const msg = getMessage(context, path, locale); + if (typeof msg !== 'function') return msg; + const msgParams = baseParams + ? Object.assign({}, baseParams, params) + : params; + return msg(msgParams); + }; +} diff --git a/packages/preact-message/src/index.ts b/packages/preact-message/src/index.ts @@ -0,0 +1,32 @@ +/** + * An efficient React front-end for message formatting + * + * @packageDocumentation + * @remarks + * Designed in particular for use with {@link https://messageformat.github.io | messageformat}, but will work with any messages. + * Provides the best possible API for a front-end developer, without making the back end any more difficult than it needs to be either. + * Should add at most about 1kB to your compiled & minified bundle size. + * + * @example + * ```js + * import { + * MessageContext, + * MessageProvider, + * Message, + * getMessage, + * getMessageGetter, + * useLocales, + * useMessage, + * useMessageGetter + * } from '@messageformat/react' + * ``` + */ +export { getMessage, getMessageGetter } from './get-message'; +export { Message, MessageProps } from './message'; +export { MessageContext, MessageObject, MessageValue } from './message-context'; +export { MessageError } from './message-error'; +export { MessageProvider } from './MessageProvider'; +export { useLocales } from './use-locales'; +export { useMessage } from './use-message'; +export { useMessageGetter } from './use-message-getter'; +export { useMessageTemplate } from './use-message-template'; diff --git a/packages/preact-message/src/message-context.ts b/packages/preact-message/src/message-context.ts @@ -0,0 +1,72 @@ +// @ts-ignore - https://github.com/microsoft/rushstack/issues/1050 +import { createContext } from 'preact'; +import { ErrorCode } from './message-error'; + +/** @internal */ +export type MessageValue = string | number | boolean | ((props: any) => any); + +/** @internal */ +export interface MessageObject { + [key: string]: MessageValue | MessageObject; +} + +/** @public */ +export interface MessageContext { + locales: string[]; + merge: (target: MessageObject, ...sources: MessageObject[]) => MessageObject; + messages: MessageObject; + + /** Always defined in MessageProvider children */ + onError?: (path: string[], code: ErrorCode) => any; + pathSep: string | null; +} + +export const defaultValue: MessageContext = { + locales: [], + merge: Object.assign, + messages: {}, + pathSep: '.' +}; + +/** + * The context object used internally by the library. + * Probably only useful with `Class.contextType` or for building custom hooks. + * + * @public + * + * @example + * ```js + * import React, { Component } from 'preact' + * import { + * getMessage, + * getMessageGetter, + * MessageContext, + * MessageProvider + * } from '@messageformat/react' + * + * const messages = { + * example: { key: 'Your message here' }, + * other: { key: 'Another message' } + * } + * + * class Example extends Component { + * render() { + * const message = getMessage(this.context, 'example.key') + * const otherMsg = getMessageGetter(this.context, 'other') + * return ( + * <span> + * {message} | {otherMsg('key')} + * </span> + * ) // 'Your message here | Another message' + * } + * } + * Example.contextType = MessageContext + * + * export const App = () => ( + * <MessageProvider messages={messages}> + * <Example /> + * </MessageProvider> + * ) + * ``` + */ +export const MessageContext = createContext(defaultValue); diff --git a/packages/preact-message/src/message-error.ts b/packages/preact-message/src/message-error.ts @@ -0,0 +1,22 @@ +export const errorMessages = { + EBADMSG: 'Message with unexpected object value', + ENOMSG: 'Message not found' +}; + +export type ErrorCode = keyof typeof errorMessages; + +/** @internal */ +export class MessageError extends Error { + code: ErrorCode; + path: string[]; + + constructor( + path: string[], + code: ErrorCode, + asId: (path: string[]) => string + ) { + super(`${errorMessages[code]}: ${asId(path)}`); + this.code = code; + this.path = path; + } +} diff --git a/packages/preact-message/src/message.ts b/packages/preact-message/src/message.ts @@ -0,0 +1,89 @@ +import { useContext } from 'preact/hooks'; +import { getMessage, getPath } from './get-message'; +import { MessageContext } from './message-context'; + +/** @public */ +export interface MessageProps { + /** + * If a function, will be called with the found message. + * In this case, `params` will be ignored and `id` is optional. + * If some other type of non-empty renderable node, it will be used as a fallback value if the message is not found. + */ + children?: any; + + /** The key or key path of the message. */ + id?: string | string[]; + + /** If set, overrides the `locale` of the nearest MessageProvider. */ + locale?: string | string[]; + + /** + * Parameters to pass to function messages as their first and only argument. + * `params` will override `msgParams`, to allow for data keys such as `key` and `locale`. + */ + params?: any; + + /** + * Parameters to pass to function messages as their first and only argument. + * Overriden by `params`, to allow for data keys such as `key` and `locale`. + */ + [msgParamKey: string]: any; +} + +// Just using { foo, ...bar } adds a polyfill with a boilerplate copyright +// statement that would add 50% to the minified size of the whole library. +function rest(props: { [key: string]: any }, exclude: string[]) { + const t: typeof props = {}; + for (const k of Object.keys(props)) if (!exclude.includes(k)) t[k] = props[k]; + return t; +} + +/** + * `<Message id [locale] [params] [...msgParams]>` + * + * The value of a message. + * May also be used with a render prop: `<Message id={id}>{msg => {...}}</Message>`. + * + * @public + * + * @example + * ```js + * import React from 'preact' + * import { Message, MessageProvider } from '@messageformat/react' + * + * const messages = { example: { key: ({ thing }) => `Your ${thing} here` } } + * + * const Example = () => ( + * <span> + * <Message id="example.key" thing="message" /> + * </span> + * ) // 'Your message here' + * + * export const App = () => ( + * <MessageProvider messages={messages}> + * <Example /> + * </MessageProvider> + * ) + * ``` + */ +export function Message(props: MessageProps) { + const { children, id, locale, params } = props; + const msgParams = rest(props, ['children', 'id', 'locale', 'params']); + let context = useContext(MessageContext); + let fallback = false; + if (children && typeof children !== 'function') + context = Object.assign({}, context, { onError: () => (fallback = true) }); + const msg = getMessage(context, id, locale); + if (fallback) return children; + if (typeof children === 'function') return children(msg); + switch (typeof msg) { + case 'function': + return msg(Object.assign(msgParams, params)); + case 'boolean': + return String(msg); + case 'object': + if (msg && !Array.isArray(msg)) + return context.onError ? context.onError(getPath(id), 'EBADMSG') : null; + } + return msg || null; +} diff --git a/packages/preact-message/src/use-locales.ts b/packages/preact-message/src/use-locales.ts @@ -0,0 +1,44 @@ +import { useContext } from 'preact/hooks'; +import { MessageContext } from './message-context'; + +/** + * A custom React hook providing the current locales as an array of string identifiers, with earlier entries taking precedence over latter ones. + * Undefined locales are identified by an empty string `''`. + * + * @public + * + * @example + * ```js + * import React from 'preact' + * import { MessageProvider, useLocales } from '@messageformat/react' + * + * <MessageProvider locale="en" messages={ { foo: 'FOO' } }> + * {() => useLocales().join(',') // 'en' + * } + * <MessageProvider locale="fi" messages={ { foo: 'FÖÖ' } }> + * {() => useLocales().join(',') // 'fi,en' + * } + * </MessageProvider> + * </MessageProvider> + * ``` + * + * @example + * ```js + * import React, { Component } from 'preact' + * import { MessageContext, MessageProvider, useLocales } from '@messageformat/react' + * + * // Within a class component, locales are available via the context object + * class Foo extends Component { + * static contextType = MessageContext + * declare context: React.ContextType<typeof MessageContext> // TS + * render() { + * const { locales } = this.context + * return locales.join(',') + * } + * } + * ``` + */ +export function useLocales() { + const { locales } = useContext(MessageContext); + return locales.slice(); +} diff --git a/packages/preact-message/src/use-message-getter.ts b/packages/preact-message/src/use-message-getter.ts @@ -0,0 +1,47 @@ +import { useContext } from 'preact/hooks'; +import { getMessageGetter, MessageGetterOptions } from './get-message'; +import { MessageContext } from './message-context'; + +/** + * A custom [React hook] providing a message getter function, which may have a preset root id path, locale, and/or base parameters for message functions. + * + * The returned function takes two parameters `(msgId, msgParams)`, which will extend any values set by the hook's arguments. + * + * @public + * @param rootId - The key or key path of the message or message object. + * If empty or `[]`, matches the root of the messages object + * @param options - If `baseParams` is set, message function parameters will be assumed to always be an object, with these values initially set. + * `locale` overrides the current locale precedence as set by parent MessageProviders. + * + * @example + * ```js + * import React from 'preact' + * import { MessageProvider, useMessageGetter } from '@messageformat/react' + * + * const messages = { + * example: { + * funMsg: ({ thing }) => `Your ${thing} here`, + * thing: 'message' + * } + * } + * + * function Example() { + * const getMsg = useMessageGetter('example') + * const thing = getMsg('thing') // 'message' + * return getMsg('funMsg', { thing }) // 'Your message here' + * } + * + * export const App = () => ( + * <MessageProvider messages={messages}> + * <Example /> + * </MessageProvider> + * ) + * ``` + */ +export function useMessageGetter( + rootId: string | string[], + opt?: MessageGetterOptions +) { + const context = useContext(MessageContext); + return getMessageGetter(context, rootId, opt); +} diff --git a/packages/preact-message/src/use-message-template.ts b/packages/preact-message/src/use-message-template.ts @@ -0,0 +1,31 @@ +import { useContext } from 'preact/hooks'; +import { MessageGetterOptions, getPath, getMessage } from './get-message'; +import { MessageContext } from './message-context'; + + +export function useMessageTemplate( + rootId?: string | string[], + opt?: MessageGetterOptions +) { + const context = useContext(MessageContext); + return getMessageGetter(context, rootId, opt); +} + +export function getMessageGetter( + context: MessageContext, + rootId?: string | string[], + { baseParams, locale }: MessageGetterOptions = {} +) { + const { pathSep } = context; + const pathPrefix = getPath(rootId, pathSep); + return function message(id?: TemplateStringsArray, ...params: any) { + const path = pathPrefix.concat(getPath(id?.join('%s'), pathSep)); + const msg = getMessage(context, path, locale); + if (typeof msg !== 'function') return msg; + const msgParams = baseParams + ? Object.assign({}, baseParams, params) + : params; + return msg(msgParams); + }; +} + diff --git a/packages/preact-message/src/use-message.ts b/packages/preact-message/src/use-message.ts @@ -0,0 +1,58 @@ +import { useContext } from 'preact/hooks'; +import { getMessage } from './get-message'; +import { MessageContext } from './message-context'; + +/** + * A custom React hook providing an entry from the messages object of the current or given locale. + * The returned value will be `undefined` if not found. + * + * If the identified message value is a function, the returned value will be the result of calling it with a single argument `params`, or `{}` if empty. + * Otherwise the value set in the `MessageProvider` props will be returned directly. + * + * @public + * @param id - The key or key path of the message or message object. + * If empty or `[]`, matches the root of the messages object + * @param params - Argument to use if the identified message is a function + * @param locale - If set, overrides the current locale precedence as set by parent MessageProviders. + * + * @example + * ```js + * import React from 'preact' + * import { MessageProvider, useLocales, useMessage } from '@messageformat/react' + * + * const en = { example: { key: 'Your message here' } } + * const fi = { example: { key: 'Lisää viestisi tähän' } } + * + * // Intl.ListFormat may require a polyfill, such as intl-list-format + * function Example() { + * const locales = useLocales() // ['fi', 'en'] + * const lfOpt = { style: 'long', type: 'conjunction' } + * const lf = new Intl.ListFormat(locales, lfOpt) + * const lcMsg = lf.format(locales.map(lc => JSON.stringify(lc))) // '"fi" ja "en"' + * const keyMsg = useMessage('example.key') // 'Lisää viestisi tähän' + * return ( + * <article> + * <h1>{lcMsg}</h1> + * <p>{keyMsg}</p> + * </article> + * ) + * } + * + * export const App = () => ( + * <MessageProvider locale="en" messages={en}> + * <MessageProvider locale="fi" messages={fi}> + * <Example /> + * </MessageProvider> + * </MessageProvider> + * ) + * ``` + */ +export function useMessage( + id: string | string[], + params?: any, + locale?: string | string[] +) { + const context = useContext(MessageContext); + const msg = getMessage(context, id, locale); + return typeof msg === 'function' ? msg(params == null ? {} : params) : msg; +} diff --git a/packages/preact-message/tsconfig.json b/packages/preact-message/tsconfig.json @@ -0,0 +1,60 @@ +{ + "compilerOptions": { + /* Basic Options */ + "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ + "module": "ESNext", /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation: */ + "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + "jsxFactory": "h", /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */ + "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./lib/", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "removeComments": true, /* Do not emit comments to output. */ + "noEmit": false, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + + /* Module Resolution Options */ + "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + "esModuleInterop": true, /* */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + + /* Source Map Options */ + // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + + /* Advanced Options */ + "skipLibCheck": true /* Skip type checking of declaration files. */ + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -5,8 +5,9 @@ importers: dependencies: axios: 0.21.1 date-fns: 2.17.0 + messageformat: 2.3.0 preact: 10.5.12 - preact-i18n: 2.3.1-preactx_preact@10.5.12 + preact-messages: link:../preact-message preact-router: 3.2.1_preact@10.5.12 swr: 0.4.2 yup: 0.32.9 @@ -24,7 +25,6 @@ importers: '@testing-library/preact-hooks': 1.1.0_368c9f1500877413beac8052be555e33 '@types/enzyme': 3.10.8 '@types/jest': 26.0.20 - '@types/preact-i18n': 2.3.0 '@typescript-eslint/eslint-plugin': 4.15.1_dd080f2a8fb4d0ac76cfb4c7062ee728 '@typescript-eslint/parser': 4.15.1_eslint@7.20.0+typescript@4.1.5 ava: 3.15.0 @@ -42,6 +42,7 @@ importers: eslint-config-preact: 1.1.3_eslint@7.20.0+typescript@4.1.5 jest: 26.6.3 jest-preset-preact: 4.0.2_120c6743da4bd73ebdbf5629f89f97bc + messageformat-po-loader: 0.3.0_messageformat@2.3.0 node-sass: 5.0.0 preact-cli: 3.0.5_2abf32adaded329872bb8e69d10f8425 preact-render-to-string: 5.1.12_preact@10.5.12 @@ -64,7 +65,6 @@ importers: '@testing-library/preact-hooks': ^1.1.0 '@types/enzyme': ^3.10.5 '@types/jest': ^26.0.8 - '@types/preact-i18n': ^2.3.0 '@typescript-eslint/eslint-plugin': ^4.15.1 '@typescript-eslint/parser': ^4.15.1 ava: ^3.15.0 @@ -84,10 +84,12 @@ importers: eslint-config-preact: ^1.1.1 jest: ^26.2.2 jest-preset-preact: ^4.0.2 + messageformat: ^2.3.0 + messageformat-po-loader: ^0.3.0 node-sass: ^5.0.0 preact: ^10.3.1 preact-cli: ^3.0.5 - preact-i18n: 2.3.1-preactx + preact-messages: workspace:* preact-render-to-string: ^5.1.4 preact-router: ^3.2.1 rimraf: ^3.0.2 @@ -97,6 +99,13 @@ importers: typedoc: ^0.20.25 typescript: ^4.1.3 yup: ^0.32.8 + packages/preact-message: + dependencies: + preact: 10.5.12 + typescript: 4.1.5 + specifiers: + preact: ^10.5.12 + typescript: ^4.1.5 lockfileVersion: 5.2 packages: /@babel/code-frame/7.12.11: @@ -3121,12 +3130,6 @@ packages: dev: true resolution: integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== - /@types/preact-i18n/2.3.0: - dependencies: - preact: 10.5.12 - dev: true - resolution: - integrity: sha512-qDgb5QbPnWJ141y+fca5R3MBQis5h7ITnSB9WQiHj5WH41Q5g9Wc4rCnqYERfqSBSC0ac4cE1JAlFisiAUIiLw== /@types/prettier/2.2.1: dev: true resolution: @@ -6524,10 +6527,6 @@ packages: dev: true resolution: integrity: sha1-44Mx8IRLukm5qctxx3FYWqsbxlo= - /dlv/1.1.3: - dev: false - resolution: - integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== /dns-equal/1.0.0: dev: true resolution: @@ -6857,6 +6856,12 @@ packages: node: '>= 0.8' resolution: integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + /encoding/0.1.13: + dependencies: + iconv-lite: 0.6.2 + dev: true + resolution: + integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== /end-of-stream/1.4.4: dependencies: once: 1.4.0 @@ -8075,6 +8080,21 @@ packages: dev: true resolution: integrity: sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + /gettext-parser/1.4.0: + dependencies: + encoding: 0.1.13 + safe-buffer: 5.2.1 + dev: true + resolution: + integrity: sha512-sedZYLHlHeBop/gZ1jdg59hlUEcpcZJofLq2JFwJT1zTqAU3l2wFv6IsuwFHGqbiT9DWzMUW4/em2+hspnmMMA== + /gettext-to-messageformat/0.3.1: + dependencies: + gettext-parser: 1.4.0 + dev: true + engines: + node: '>=6.0' + resolution: + integrity: sha512-UyqIL3Ul4NryU95Wome/qtlcuVIqgEWVIFw0zi7Lv14ACLXfaVDCbrjZ7o+3BZ7u+4NS1mP/2O1eXZoHCoas8g== /github-slugger/1.3.0: dependencies: emoji-regex: 6.1.1 @@ -8827,6 +8847,14 @@ packages: node: '>=0.10.0' resolution: integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + /iconv-lite/0.6.2: + dependencies: + safer-buffer: 2.1.2 + dev: true + engines: + node: '>=0.10.0' + resolution: + integrity: sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== /icss-utils/4.1.1: dependencies: postcss: 7.0.35 @@ -10811,6 +10839,13 @@ packages: node: '>=8' resolution: integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + /make-plural/4.3.0: + dev: false + hasBin: true + optionalDependencies: + minimist: 1.2.5 + resolution: + integrity: sha512-xTYd4JVHpSCW+aqDof6w/MebaMVNTVYBZhbB/vi513xXdiPT92JMVCo0Jq8W2UZnzYRFeVbQiQ+I25l13JuKvA== /makeerror/1.0.11: dependencies: tmpl: 1.0.4 @@ -11026,6 +11061,34 @@ packages: node: '>= 8' resolution: integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + /messageformat-formatters/2.0.1: + dev: false + resolution: + integrity: sha512-E/lQRXhtHwGuiQjI7qxkLp8AHbMD5r2217XNe/SREbBlSawe0lOqsFb7rflZJmlQFSULNLIqlcjjsCPlB3m3Mg== + /messageformat-parser/4.1.3: + dev: false + resolution: + integrity: sha512-2fU3XDCanRqeOCkn7R5zW5VQHWf+T3hH65SzuqRvjatBK7r4uyFa5mEX+k6F9Bd04LVM5G4/BHBTUJsOdW7uyg== + /messageformat-po-loader/0.3.0_messageformat@2.3.0: + dependencies: + gettext-to-messageformat: 0.3.1 + loader-utils: 1.4.0 + messageformat: 2.3.0 + dev: true + engines: + node: '>=6.0' + peerDependencies: + messageformat: 1.x | 2.x + resolution: + integrity: sha512-thu/A7hNl/iBHsRXUdmiy/nEFJZku3bsBMXL53HgHm+I0JaVU9lSpwuQAe7huCO4INGxgZtDoPAEpeb1ZeI5lg== + /messageformat/2.3.0: + dependencies: + make-plural: 4.3.0 + messageformat-formatters: 2.0.1 + messageformat-parser: 4.1.3 + dev: false + resolution: + integrity: sha512-uTzvsv0lTeQxYI2y1NPa1lItL5VRI8Gb93Y2K2ue5gBPyrbJxfDi/EYWxh2PKv5yO42AJeeqblS9MJSh/IEk4w== /methods/1.1.2: dev: true engines: @@ -11154,7 +11217,6 @@ packages: resolution: integrity: sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== /minimist/1.2.5: - dev: true resolution: integrity: sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== /minipass-collect/1.0.2: @@ -12823,24 +12885,6 @@ packages: preact-render-to-string: '*' resolution: integrity: sha512-Oc9HOjwX/3Zk1eXkmP7TMmtqbaROl7F0RWZ2Ni5Q/grmx3yBLJmarkUcOSKabkI/Usw2dU3RVju32Q3Pvy5qIw== - /preact-i18n/2.3.1-preactx_preact@10.5.12: - dependencies: - dlv: 1.1.3 - preact: 10.5.12 - preact-markup: 2.1.1_preact@10.5.12 - dev: false - peerDependencies: - preact: '>=10' - resolution: - integrity: sha512-i/QGG3BQOWh4nFPXTnhazHGOq2STYMa9/0h6oiUkV+p/c5IDd0luPhRlXkAnEgGRZX3PjAEgx/tzWPQne61wuQ== - /preact-markup/2.1.1_preact@10.5.12: - dependencies: - preact: 10.5.12 - dev: false - peerDependencies: - preact: '>=10' - resolution: - integrity: sha512-8JL2p36mzK8XkspOyhBxUSPjYwMxDM0L5BWBZWxsZMVW8WsGQrYQDgVuDKkRspt2hwrle+Cxr/053hpc9BJwfw== /preact-render-to-string/5.1.12_preact@10.5.12: dependencies: preact: 10.5.12 @@ -12859,6 +12903,7 @@ packages: resolution: integrity: sha512-KEN2VN1DxUlTwzW5IFkF13YIA2OdQ2OvgJTkQREF+AA2NrHRLaGbB68EjS4IeZOa1shvQ1FvEm3bSLta4sXBhg== /preact/10.5.12: + dev: false resolution: integrity: sha512-r6siDkuD36oszwlCkcqDJCAKBQxGoeEGytw2DGMD5A/GGdu5Tymw+N2OBXwvOLxg6d1FeY8MgMV3cc5aVQo4Cg== /prelude-ls/1.1.2: @@ -15755,7 +15800,6 @@ packages: resolution: integrity: sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w== /typescript/4.1.5: - dev: true engines: node: '>=4.2.0' hasBin: true