зеркало из https://github.com/mozilla/fxa.git
feat(next): add l10n to localizer for next rsc
Because: * Ensure all localization strings can be localized in React Server Components. This commit: * Removes LocalizerServer * Adds LocalizerRSC to serve as a wrapper for ReactLocalization that can be used by React Server Components, as well as a few other utility methods. * Adds LocalizerRscFactory that instantiates LocalizerRSC only with bundles for a provided acceptLanguage string. * Adds LocalizerRscFactoryProvider to be used with the NestApp to instantiate and then intialize the LocalizerRscFactory. Initalization will fetch all messages from disk and populate the bundles. * Reorganize shared/l10n library to match Node style guide * Removes demo l10n code * Update purchase-details, terms-and-service components, and success and error pages to use new LocalizerRSC class for localization. * Adds temporary l10n-convert script to provide translation for existing ftl strings. Closes #FXA-8822
This commit is contained in:
Родитель
1cf03d60ad
Коммит
652c09ab50
|
@ -0,0 +1,3 @@
|
|||
## Component - PaymentConsentCheckbox
|
||||
|
||||
next-payment-confirm-with-legal-links-static-3 = I authorize { -brand-mozilla } to charge my payment method for the amount shown, according to <termsOfServiceLink>Terms of Service</termsOfServiceLink> and <privacyNoticeLink>Privacy Notice</privacyNoticeLink>, until I cancel my subscription.
|
|
@ -0,0 +1,4 @@
|
|||
next-payment-error-manage-subscription-button = Manage my subscription
|
||||
next-iap-upgrade-contact-support = You can still get this product — please contact support so we can help you.
|
||||
next-payment-error-retry-button = Try again
|
||||
next-basic-error-message = Something went wrong. Please try again later.
|
|
@ -6,17 +6,35 @@ import { headers } from 'next/headers';
|
|||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { getBundle, getLocaleFromRequest } from '@fxa/shared/l10n';
|
||||
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
|
||||
|
||||
import { getCartData } from '../../../../../../_lib/apiClient';
|
||||
import errorIcon from '../../../../../../../images/error.svg';
|
||||
import { app } from '@fxa/payments/ui/server';
|
||||
|
||||
// forces dynamic rendering
|
||||
// https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Temporary code for demo purposes only - Replaced as part of FXA-8822
|
||||
const demoSupportedLanguages = ['en-US', 'fr-FR', 'es-ES', 'de-DE'];
|
||||
const getErrorReason = (reason: string) => {
|
||||
switch (reason) {
|
||||
case 'iap_upgrade_contact_support':
|
||||
return {
|
||||
buttonFtl: 'next-payment-error-manage-subscription-button',
|
||||
buttonLabel: 'Manage my subscription',
|
||||
message:
|
||||
'You can still get this product — please contact support so we can help you.',
|
||||
messageFtl: 'next-iap-upgrade-contact-support',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
buttonFtl: 'next-payment-error-retry-button',
|
||||
buttonLabel: 'Try again',
|
||||
message: 'Something went wrong. Please try again later.',
|
||||
messageFtl: 'next-basic-error-message',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
interface CheckoutParams {
|
||||
cartId: string;
|
||||
|
@ -30,39 +48,19 @@ export default async function CheckoutError({
|
|||
}: {
|
||||
params: CheckoutParams;
|
||||
}) {
|
||||
const headersList = headers();
|
||||
const locale = getLocaleFromRequest(
|
||||
params,
|
||||
headersList.get('accept-language'),
|
||||
demoSupportedLanguages
|
||||
);
|
||||
// Temporarily defaulting to `accept-language`
|
||||
// This to be updated in FXA-9404
|
||||
//const locale = getLocaleFromRequest(
|
||||
// params,
|
||||
// headers().get('accept-language')
|
||||
//);
|
||||
const locale = headers().get('accept-language') || DEFAULT_LOCALE;
|
||||
|
||||
const cartData = getCartData(params.cartId);
|
||||
const [cart] = await Promise.all([cartData]);
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// const cartService = await app.getCartService();
|
||||
const cartDataPromise = getCartData(params.cartId);
|
||||
const l10nPromise = app.getL10n(locale);
|
||||
const [cart, l10n] = await Promise.all([cartDataPromise, l10nPromise]);
|
||||
|
||||
const l10n = await getBundle([locale]);
|
||||
|
||||
const getErrorReason = (reason: string) => {
|
||||
switch (reason) {
|
||||
case 'iap_upgrade_contact_support':
|
||||
return {
|
||||
buttonFtl: 'payment-error-manage-subscription-button',
|
||||
buttonLabel: 'Manage my subscription',
|
||||
message:
|
||||
'You can still get this product — please contact support so we can help you.',
|
||||
messageFtl: 'iap-upgrade-contact-support',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
buttonFtl: 'payment-error-retry-button',
|
||||
buttonLabel: 'Try again',
|
||||
message: 'Something went wrong. Please try again later.',
|
||||
messageFtl: 'basic-error-message',
|
||||
};
|
||||
}
|
||||
};
|
||||
const errorReason = getErrorReason(cart.errorReasonId);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -72,19 +70,14 @@ export default async function CheckoutError({
|
|||
>
|
||||
<Image src={errorIcon} alt="" className="mt-16 mb-10" />
|
||||
<p className="page-message px-7 py-0 mb-4 ">
|
||||
{l10n
|
||||
.getMessage(getErrorReason(cart.errorReasonId).messageFtl)
|
||||
?.value?.toString() || getErrorReason(cart.errorReasonId).message}
|
||||
{l10n.getString(errorReason.messageFtl, errorReason.message)}
|
||||
</p>
|
||||
|
||||
<Link
|
||||
className="page-button"
|
||||
href={`/${params.offeringId}/checkout?interval=monthly`}
|
||||
>
|
||||
{l10n
|
||||
.getMessage(getErrorReason(cart.errorReasonId).buttonFtl)
|
||||
?.value?.toString() ||
|
||||
getErrorReason(cart.errorReasonId).buttonLabel}
|
||||
{l10n.getString(errorReason.buttonFtl, errorReason.buttonLabel)}
|
||||
</Link>
|
||||
</section>
|
||||
</>
|
||||
|
|
|
@ -3,21 +3,136 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { app } from '@fxa/payments/ui/server';
|
||||
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
|
||||
import { auth, signIn, signOut } from 'apps/payments/next/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Checkout() {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
const cartService = await app.getCartService();
|
||||
const session = await auth();
|
||||
// Temporarily defaulting to `accept-language`
|
||||
// This to be updated in FXA-9404
|
||||
//const locale = getLocaleFromRequest(
|
||||
// params,
|
||||
// headers().get('accept-language')
|
||||
//);
|
||||
const locale = headers().get('accept-language') || DEFAULT_LOCALE;
|
||||
const sessionPromise = auth();
|
||||
const l10nPromise = app.getL10n(locale);
|
||||
const [session, l10n] = await Promise.all([sessionPromise, l10nPromise]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className="h-[640px] flex items-center justify-center"
|
||||
className="h-[640px] flex flex-col items-center justify-center"
|
||||
aria-label="Section under construction"
|
||||
>
|
||||
<section className="flex flex-col gap-2 mb-8">
|
||||
<div>
|
||||
<h3 className="text-xl">Temporary L10n Section</h3>
|
||||
<p className="text-sm">
|
||||
Temporary section to illustrate various translations using the
|
||||
Localizer classes
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Regular translation - no variables</h4>
|
||||
<p className="text-sm">
|
||||
{l10n.getString('app-footer-mozilla-logo-label', 'testing2')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Regular translation - with variables</h4>
|
||||
<p className="text-sm">
|
||||
{l10n.getString(
|
||||
'app-page-title-2',
|
||||
{ title: 'Test Title' },
|
||||
'testing2'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Regular translation - With Selector</h4>
|
||||
<p className="text-sm">
|
||||
{l10n.getString(
|
||||
'next-plan-price-interval-day',
|
||||
{ intervalCount: 2, amount: 20 },
|
||||
'testing2'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Regular translation - With Currency</h4>
|
||||
<p className="text-sm">
|
||||
{l10n.getString(
|
||||
'list-positive-amount',
|
||||
{
|
||||
amount: l10n.getLocalizedCurrency(502, 'usd'),
|
||||
},
|
||||
`${l10n.getLocalizedCurrencyString(502, 'usd')}`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Regular translation - With Date</h4>
|
||||
<p className="text-sm">
|
||||
{l10n.getString(
|
||||
'list-positive-amount',
|
||||
{
|
||||
amount: l10n.getLocalizedCurrency(502, 'usd'),
|
||||
},
|
||||
`${l10n.getLocalizedCurrencyString(502, 'usd')}`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Get Fragment with Fallback element</h4>
|
||||
<p className="text-sm">
|
||||
{l10n.getFragmentWithSource(
|
||||
'next-payment-legal-link-stripe-3',
|
||||
{
|
||||
elems: {
|
||||
stripePrivacyLink: (
|
||||
<a href="https://stripe.com/privacy">
|
||||
Stripe privacy policy
|
||||
</a>
|
||||
),
|
||||
},
|
||||
},
|
||||
<a href="https://stripe.com/privacy">Stripe privacy policy</a>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Get Element - With reference</h4>
|
||||
<p className="text-sm">
|
||||
{l10n.getFragmentWithSource(
|
||||
'next-payment-confirm-with-legal-links-static-3',
|
||||
{
|
||||
elems: {
|
||||
termsOfServiceLink: (
|
||||
<a href="https://stripe.com/privacy">
|
||||
Stripe privacy policy
|
||||
</a>
|
||||
),
|
||||
privacyNoticeLink: (
|
||||
<a href="https://stripe.com/privacy">
|
||||
Stripe privacy policy
|
||||
</a>
|
||||
),
|
||||
},
|
||||
},
|
||||
<>
|
||||
I authorize Mozilla to charge my payment method for the amount
|
||||
shown, according to{' '}
|
||||
<a href="https://www.example.com">Terms of Service</a> and{' '}
|
||||
<a href="https://www.example.com">Privacy Notice</a>, until I
|
||||
cancel my subscription.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{/*
|
||||
Temporary section to test NextAuth prompt/no prompt signin
|
||||
To be deleted as part of FXA-7521/FXA-7523 if not sooner where necessary
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
next-payment-confirmation-thanks-heading = Thank you!
|
||||
|
||||
# $email (String) - The user's email.
|
||||
# $product_name (String) - The name of the subscribed product.
|
||||
next-payment-confirmation-thanks-subheading = A confirmation email has been sent to { $email } with details on how to get started with { $product_name }.
|
||||
|
||||
next-payment-confirmation-order-heading = Order details
|
||||
# $invoiceNumber (String) - Invoice number of the successful payment
|
||||
next-payment-confirmation-invoice-number = Invoice #{ $invoiceNumber }
|
||||
|
||||
# $invoiceDate (Date) - Start date of the latest invoice
|
||||
next-payment-confirmation-invoice-date = { $invoiceDate }
|
||||
|
||||
next-payment-confirmation-details-heading-2 = Payment information
|
||||
# $amount (Number) - The amount billed. It will be formatted as currency.
|
||||
# $interval (String) - The interval between payments.
|
||||
next-payment-confirmation-amount = { $amount } per { $interval }
|
||||
# $last4 (Number) - Last four numbers of credit card
|
||||
next-payment-confirmation-cc-card-ending-in = Card ending in { $last4 }
|
||||
|
||||
next-payment-confirmation-download-button = Continue to download
|
|
@ -6,26 +6,17 @@ import { headers } from 'next/headers';
|
|||
import Image from 'next/image';
|
||||
|
||||
import { formatPlanPricing } from '@fxa/payments/ui';
|
||||
import {
|
||||
getBundle,
|
||||
getFormattedMsg,
|
||||
getLocaleFromRequest,
|
||||
getLocalizedCurrency,
|
||||
getLocalizedDate,
|
||||
getLocalizedDateString,
|
||||
} from '@fxa/shared/l10n';
|
||||
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
|
||||
|
||||
import {
|
||||
getCartData,
|
||||
getContentfulContent,
|
||||
} from '../../../../../../_lib/apiClient';
|
||||
import circledConfirm from '../../../../../../../images/circled-confirm.svg';
|
||||
import { app } from '@fxa/payments/ui/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Temporary code for demo purposes only - Replaced as part of FXA-8822
|
||||
const demoSupportedLanguages = ['en-US', 'fr-FR', 'es-ES', 'de-DE'];
|
||||
|
||||
type ConfirmationDetailProps = {
|
||||
title: string;
|
||||
detail1: string | Promise<string>;
|
||||
|
@ -60,26 +51,22 @@ export default async function CheckoutSuccess({
|
|||
}: {
|
||||
params: CheckoutParams;
|
||||
}) {
|
||||
const headersList = headers();
|
||||
const locale = getLocaleFromRequest(
|
||||
params,
|
||||
headersList.get('accept-language'),
|
||||
demoSupportedLanguages
|
||||
);
|
||||
// Temporarily defaulting to `accept-language`
|
||||
// This to be updated in FXA-9404
|
||||
//const locale = getLocaleFromRequest(
|
||||
// params,
|
||||
// headers().get('accept-language')
|
||||
//);
|
||||
const locale = headers().get('accept-language') || DEFAULT_LOCALE;
|
||||
|
||||
const contentfulData = getContentfulContent(params.offeringId, locale);
|
||||
const cartData = getCartData(params.cartId);
|
||||
const [contentful, cart] = await Promise.all([contentfulData, cartData]);
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// const cartService = await app.getCartService();
|
||||
const date = cart.createdAt / 1000;
|
||||
const planPrice = formatPlanPricing(
|
||||
cart.nextInvoice.totalAmount,
|
||||
cart.nextInvoice.currency,
|
||||
cart.interval
|
||||
);
|
||||
|
||||
const l10n = await getBundle([locale]);
|
||||
const contentfulDataPromise = getContentfulContent(params.offeringId, locale);
|
||||
const cartDataPromise = getCartData(params.cartId);
|
||||
const l10nPromise = app.getL10n(locale);
|
||||
const [contentful, cart, l10n] = await Promise.all([
|
||||
contentfulDataPromise,
|
||||
cartDataPromise,
|
||||
l10nPromise,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -88,73 +75,71 @@ export default async function CheckoutSuccess({
|
|||
<Image src={circledConfirm} alt="" className="w-16 h-16" />
|
||||
|
||||
<h4 className="text-xl font-normal mx-0 mt-6 mb-3">
|
||||
{l10n
|
||||
.getMessage('payment-confirmation-thanks-heading')
|
||||
?.value?.toString() || 'Thank you!'}
|
||||
{l10n.getString(
|
||||
'next-payment-confirmation-thanks-heading',
|
||||
'Thank you!'
|
||||
)}
|
||||
</h4>
|
||||
|
||||
<p className="page-message">
|
||||
{getFormattedMsg(
|
||||
l10n,
|
||||
'payment-confirmation-thanks-subheading',
|
||||
`A confirmation email has been sent to ${cart.email} with details on how to get started with ${contentful.purchaseDetails.productName}.`,
|
||||
{l10n.getString(
|
||||
'next-payment-confirmation-thanks-subheading',
|
||||
{
|
||||
email: cart.email,
|
||||
product_name: contentful.purchaseDetails.productName,
|
||||
}
|
||||
},
|
||||
`A confirmation email has been sent to ${cart.email} with details on how to get started with ${contentful.purchaseDetails.productName}.`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ConfirmationDetail
|
||||
title={
|
||||
l10n
|
||||
.getMessage('payment-confirmation-order-heading')
|
||||
?.value?.toString() || 'Order details'
|
||||
}
|
||||
detail1={getFormattedMsg(
|
||||
l10n,
|
||||
'payment-confirmation-invoice-number',
|
||||
`Invoice #${cart.invoiceNumber}`,
|
||||
title={l10n.getString(
|
||||
'next-payment-confirmation-order-heading',
|
||||
'Order details'
|
||||
)}
|
||||
detail1={l10n.getString(
|
||||
'next-payment-confirmation-invoice-number',
|
||||
{
|
||||
invoiceNumber: cart.invoiceNumber,
|
||||
}
|
||||
},
|
||||
`Invoice #${cart.invoiceNumber}`
|
||||
)}
|
||||
detail2={getFormattedMsg(
|
||||
l10n,
|
||||
'payment-confirmation-invoice-date',
|
||||
getLocalizedDateString(date),
|
||||
detail2={l10n.getString(
|
||||
'next-payment-confirmation-invoice-date',
|
||||
{
|
||||
invoiceDate: getLocalizedDate(date),
|
||||
}
|
||||
invoiceDate: l10n.getLocalizedDate(cart.createdAt / 1000),
|
||||
},
|
||||
l10n.getLocalizedDateString(cart.createdAt / 1000)
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmationDetail
|
||||
title={
|
||||
l10n
|
||||
.getMessage('payment-confirmation-details-heading-2')
|
||||
?.value?.toString() || 'Payment information'
|
||||
}
|
||||
detail1={getFormattedMsg(
|
||||
l10n,
|
||||
'payment-confirmation-amount',
|
||||
planPrice,
|
||||
title={l10n.getString(
|
||||
'next-payment-confirmation-details-heading-2',
|
||||
'Payment information'
|
||||
)}
|
||||
detail1={l10n.getString(
|
||||
'next-payment-confirmation-amount',
|
||||
{
|
||||
amount: getLocalizedCurrency(
|
||||
amount: l10n.getLocalizedCurrency(
|
||||
cart.nextInvoice.totalAmount,
|
||||
cart.nextInvoice.currency
|
||||
),
|
||||
interval: cart.interval,
|
||||
}
|
||||
},
|
||||
formatPlanPricing(
|
||||
cart.nextInvoice.totalAmount,
|
||||
cart.nextInvoice.currency,
|
||||
cart.interval
|
||||
)
|
||||
)}
|
||||
detail2={getFormattedMsg(
|
||||
l10n,
|
||||
'payment-confirmation-cc-card-ending-in',
|
||||
`Card ending in ${cart.last4}`,
|
||||
detail2={l10n.getString(
|
||||
'next-payment-confirmation-cc-card-ending-in',
|
||||
{
|
||||
last4: cart.last4,
|
||||
}
|
||||
},
|
||||
`Card ending in ${cart.last4}`
|
||||
)}
|
||||
/>
|
||||
|
||||
|
@ -163,10 +148,10 @@ export default async function CheckoutSuccess({
|
|||
href={contentful.commonContent.successActionButtonUrl}
|
||||
>
|
||||
{contentful.commonContent.successActionButtonLabel ||
|
||||
l10n
|
||||
.getMessage('payment-confirmation-download-button ')
|
||||
?.value?.toString() ||
|
||||
'Continue to download'}
|
||||
l10n.getString(
|
||||
'next-payment-confirmation-download-button',
|
||||
'Continue to download'
|
||||
)}
|
||||
</a>
|
||||
</section>
|
||||
</>
|
||||
|
|
|
@ -1,13 +1,11 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { PurchaseDetails, TermsAndPrivacy } from '@fxa/payments/ui/server';
|
||||
import { getLocaleFromRequest } from '@fxa/shared/l10n';
|
||||
|
||||
import { PurchaseDetails, TermsAndPrivacy, app } from '@fxa/payments/ui/server';
|
||||
import { getCartData, getContentfulContent } from '../../../_lib/apiClient';
|
||||
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
|
||||
|
||||
// TODO - Replace these placeholders as part of FXA-8227
|
||||
export const metadata = {
|
||||
|
@ -34,14 +32,21 @@ export default async function RootLayout({
|
|||
children: React.ReactNode;
|
||||
params: CheckoutParams;
|
||||
}) {
|
||||
const headersList = headers();
|
||||
const locale = getLocaleFromRequest(
|
||||
params,
|
||||
headersList.get('accept-language')
|
||||
);
|
||||
const contentfulData = getContentfulContent(params.offeringId, locale);
|
||||
const cartData = getCartData(params.cartId);
|
||||
const [contentful, cart] = await Promise.all([contentfulData, cartData]);
|
||||
// Temporarily defaulting to `accept-language`
|
||||
// This to be updated in FXA-9404
|
||||
//const locale = getLocaleFromRequest(
|
||||
// params,
|
||||
// headers().get('accept-language')
|
||||
//);
|
||||
const locale = headers().get('accept-language') || DEFAULT_LOCALE;
|
||||
const contentfulDataPromise = getContentfulContent(params.offeringId, locale);
|
||||
const cartDataPromise = getCartData(params.cartId);
|
||||
const l10nPromise = app.getL10n(locale);
|
||||
const [contentful, cart, l10n] = await Promise.all([
|
||||
contentfulDataPromise,
|
||||
cartDataPromise,
|
||||
l10nPromise,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -51,8 +56,8 @@ export default async function RootLayout({
|
|||
|
||||
<section className="payment-panel" aria-label="Purchase details">
|
||||
<PurchaseDetails
|
||||
l10n={l10n}
|
||||
interval={cart.interval}
|
||||
locale={locale}
|
||||
invoice={cart.nextInvoice}
|
||||
purchaseDetails={contentful.purchaseDetails}
|
||||
/>
|
||||
|
@ -61,7 +66,7 @@ export default async function RootLayout({
|
|||
<div className="page-body rounded-t-lg tablet:rounded-t-none">
|
||||
{children}
|
||||
<TermsAndPrivacy
|
||||
locale={locale}
|
||||
l10n={l10n}
|
||||
{...cart}
|
||||
{...contentful.commonContent}
|
||||
{...contentful.purchaseDetails}
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
#!/usr/bin/env node -r esbuild-register
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
/**
|
||||
* This is a temporary script to convert l10n strings to match the format that
|
||||
* will be used in Payments Next
|
||||
*
|
||||
* For unchanged strings copied from SP2.5 to SP3.0, the l10n team will attempt
|
||||
* to run a script to copy the existing translations from the SP2.5 id’s to the
|
||||
* new SP3.0 ids. To help with this effort, when copying a string ID, the
|
||||
* SubPlat team should pre-fix the string ID with ‘next-’. Example below
|
||||
*
|
||||
* SP2.5 string: coupon-promo-code-applied
|
||||
* SP3.0 string: next-coupon-promo-code-applied
|
||||
*/
|
||||
import path from 'path';
|
||||
import { stat, readFile, writeFile, readdir } from 'fs/promises';
|
||||
|
||||
// These directories will be skipped since they do not have a payments.ftl file
|
||||
// The "en" directory should also be skipped, since l10n-merge populates en/payments.ftl
|
||||
// with latest ftl files sourced from the FxA repo.
|
||||
const skipDir = [
|
||||
'en',
|
||||
'templates',
|
||||
'ar',
|
||||
'bn',
|
||||
'bs',
|
||||
'gd',
|
||||
'hy-AM',
|
||||
'km',
|
||||
'ms',
|
||||
'my',
|
||||
'ne-NP',
|
||||
];
|
||||
|
||||
function getFullPath(directory: string) {
|
||||
const filename = './payments.ftl';
|
||||
return path.resolve(directory, filename);
|
||||
}
|
||||
|
||||
async function getFile(directory: string) {
|
||||
const fullPath = getFullPath(directory);
|
||||
return readFile(fullPath, { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
async function setFile(directory: string, contents: string) {
|
||||
const fullPath = getFullPath(directory);
|
||||
return writeFile(fullPath, contents, { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function convertContents(content: string) {
|
||||
const brokenUp = content.split('\n');
|
||||
const modifiedContent: string[] = [];
|
||||
for (const line of brokenUp) {
|
||||
let newLine = line;
|
||||
if (/^[a-zA-Z]+$/.test(line.charAt(0))) {
|
||||
if (line.split('-')[0] !== 'next') {
|
||||
newLine = `next-${line}`;
|
||||
}
|
||||
}
|
||||
modifiedContent.push(newLine);
|
||||
}
|
||||
|
||||
return modifiedContent.join('\n');
|
||||
}
|
||||
|
||||
async function convertToNext(directory: string) {
|
||||
try {
|
||||
const contents = await getFile(directory);
|
||||
const modifiedContents = convertContents(contents);
|
||||
await setFile(directory, modifiedContents);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function* directoryGen(baseDir: string) {
|
||||
const files = await readdir(baseDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (skipDir.includes(file)) {
|
||||
continue;
|
||||
}
|
||||
const fullPath = path.resolve(baseDir, file);
|
||||
const fileStat = await stat(fullPath);
|
||||
if (fileStat.isDirectory()) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const baseDir = path.resolve(__dirname, '../../../public/locales');
|
||||
|
||||
for await (const dir of directoryGen(baseDir)) {
|
||||
await convertToNext(dir);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
init()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.then((result) => process.exit(result));
|
||||
}
|
|
@ -81,7 +81,7 @@
|
|||
"command": "pm2 delete apps/payments/next/pm2.config.js"
|
||||
},
|
||||
"l10n-merge": {
|
||||
"dependsOn": ["l10n-prime"],
|
||||
"dependsOn": ["l10n-convert"],
|
||||
"command": "yarn grunt --gruntfile='apps/payments/next/Gruntfile.js' merge-ftl"
|
||||
},
|
||||
"l10n-prime": {
|
||||
|
@ -94,6 +94,10 @@
|
|||
"watch-ftl": {
|
||||
"command": "yarn grunt --gruntfile='apps/payments/next/Gruntfile.js' watch-ftl"
|
||||
},
|
||||
"l10n-convert": {
|
||||
"dependsOn": ["l10n-prime"],
|
||||
"command": "node -r esbuild-register apps/payments/next/app/_lib/scripts/convert.ts"
|
||||
},
|
||||
"storybook": {
|
||||
"executor": "@nx/storybook:storybook",
|
||||
"options": {
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
# Temporary
|
||||
|
||||
This is a temporary location for translation files, just to provide limited functionality for the demo components.
|
||||
|
||||
The localization approach will be built out in future work
|
|
@ -1,39 +0,0 @@
|
|||
## Component - PlanDetails
|
||||
|
||||
next-plan-details-header = Produktdetails
|
||||
next-plan-details-list-price = Listenpreis
|
||||
next-plan-details-show-button = Details anzeigen
|
||||
next-plan-details-hide-button = Details ausblenden
|
||||
next-plan-details-total-label = Gesamt
|
||||
next-plan-details-tax = Steuern und Gebühren
|
||||
|
||||
next-terms = Nutzungsbedingungen
|
||||
next-privacy = Datenschutzhinweis
|
||||
next-terms-download = Nutzungsbedingungen herunterladen
|
||||
|
||||
payment-confirmation-thanks-heading = Vielen Dank!
|
||||
|
||||
# $email (string) - The user's email.
|
||||
# $productName (String) - The name of the subscribed product.
|
||||
payment-confirmation-thanks-subheading = An { $email } wurde eine Bestätigungs-E-Mail mit Details zu den ersten Schritten mit { $product_name } gesendet.
|
||||
|
||||
payment-confirmation-order-heading = Bestelldetails
|
||||
payment-confirmation-invoice-number = Rechnung #{ $invoiceNumber }
|
||||
# $invoiceDate (Date) - Start date of the latest invoice
|
||||
payment-confirmation-invoice-date = { $invoiceDate }
|
||||
|
||||
payment-confirmation-details-heading-2 = Zahlungsinformationen
|
||||
payment-confirmation-amount = { $amount } pro { $interval }
|
||||
payment-confirmation-cc-card-ending-in = Karte endet auf { $last4 }
|
||||
|
||||
payment-confirmation-download-button = Weiter zum Download
|
||||
|
||||
subscription-success-title = Abonnementbestätigung
|
||||
subscription-error-title = Fehler beim Bestätigen des Abonnements…
|
||||
iap-upgrade-contact-support = Sie können dieses Produkt weiterhin erhalten – wenden Sie sich bitte an den Support, damit wir Ihnen helfen können.
|
||||
payment-error-manage-subscription-button = Mein Abonnement verwalten
|
||||
basic-error-message = Etwas ist schiefgegangen. Bitte versuchen Sie es später erneut.
|
||||
payment-error-manage-subscription-button = Mein Abonnement verwalten
|
||||
payment-error-retry-button = Erneut versuchen
|
||||
|
||||
sub-guarantee = 30 Tage Geld-zurück-Garantie
|
|
@ -1,56 +0,0 @@
|
|||
## Component - PlanDetails
|
||||
|
||||
next-plan-details-header = Product details
|
||||
next-plan-details-list-price = List Price
|
||||
next-plan-details-show-button = Show details
|
||||
next-plan-details-hide-button = Hide details
|
||||
next-plan-details-total-label = Total
|
||||
next-plan-details-tax = Taxes and Fees
|
||||
|
||||
## Subscription upgrade plan details - shared by multiple components, including plan details and payment form
|
||||
## $amount (Number) - The amount billed. It will be formatted as currency.
|
||||
|
||||
# $intervalCount (Number) - The interval between payments, in days.
|
||||
plan-price-interval-daily = { $amount } daily
|
||||
# $intervalCount (Number) - The interval between payments, in weeks.
|
||||
plan-price-interval-weekly = { $amount } weekly
|
||||
# $intervalCount (Number) - The interval between payments, in months.
|
||||
plan-price-interval-monthly = { $amount } monthly
|
||||
# $intervalCount (Number) - The interval between payments every 6 months.
|
||||
plan-price-interval-6monthly = { $amount } every 6 months
|
||||
# $intervalCount (Number) - The interval between payments, in years.
|
||||
plan-price-interval-yearly = { $amount } yearly
|
||||
|
||||
list-positive-amount = { $amount }
|
||||
list-negative-amount = - { $amount }
|
||||
|
||||
next-terms = Terms of Service
|
||||
next-privacy = Privacy Notice
|
||||
next-terms-download = Download Terms
|
||||
|
||||
payment-confirmation-thanks-heading = Thank you!
|
||||
|
||||
# $email (string) - The user's email.
|
||||
# $productName (String) - The name of the subscribed product.
|
||||
payment-confirmation-thanks-subheading = A confirmation email has been sent to { $email } with details on how to get started with { $product_name }.
|
||||
|
||||
payment-confirmation-order-heading = Order details
|
||||
payment-confirmation-invoice-number = Invoice #{ $invoiceNumber }
|
||||
|
||||
# $invoiceDate (Date) - Start date of the latest invoice
|
||||
payment-confirmation-invoice-date = { $invoiceDate }
|
||||
|
||||
payment-confirmation-details-heading-2 = Payment information
|
||||
payment-confirmation-amount = { $amount } per { $interval }
|
||||
payment-confirmation-cc-card-ending-in = Card ending in { $last4 }
|
||||
|
||||
payment-confirmation-download-button = Continue to download
|
||||
|
||||
subscription-success-title = Subscription confirmation
|
||||
subscription-error-title = Error confirming subscription…
|
||||
iap-upgrade-contact-support = You can still get this product — please contact support so we can help you.
|
||||
payment-error-manage-subscription-button = Manage my subscription
|
||||
basic-error-message = Something went wrong. Please try again later.
|
||||
payment-error-retry-button = Try again
|
||||
|
||||
sub-guarantee = 30-day money-back guarantee
|
|
@ -1,82 +0,0 @@
|
|||
## Component - PlanDetails
|
||||
|
||||
next-plan-details-header = Detalles del producto
|
||||
next-plan-details-list-price = Lista de precios
|
||||
next-plan-details-show-button = Mostrar detalles
|
||||
next-plan-details-hide-button = Ocultar detalles
|
||||
next-plan-details-total-label = Total
|
||||
next-plan-details-tax = Impuestos y tasas
|
||||
|
||||
plan-price-interval-day =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } diariamente
|
||||
*[other] { $amount } cada { $intervalCount } días
|
||||
}
|
||||
.title =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } diariamente
|
||||
*[other] { $amount } cada { $intervalCount } días
|
||||
}
|
||||
# $intervalCount (Number) - The interval between payments, in weeks.
|
||||
plan-price-interval-week =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } semanalmente
|
||||
*[other] { $amount } cada { $intervalCount } semanas
|
||||
}
|
||||
.title =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } semanalmente
|
||||
*[other] { $amount } cada { $intervalCount } semanas
|
||||
}
|
||||
# $intervalCount (Number) - The interval between payments, in months.
|
||||
plan-price-interval-month =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } mensualmente
|
||||
*[other] { $amount } cada { $intervalCount } meses
|
||||
}
|
||||
.title =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } mensualmente
|
||||
*[other] { $amount } cada { $intervalCount } meses
|
||||
}
|
||||
# $intervalCount (Number) - The interval between payments, in years.
|
||||
plan-price-interval-year =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } anualmente
|
||||
*[other] { $amount } cada { $intervalCount } años
|
||||
}
|
||||
.title =
|
||||
{ $intervalCount ->
|
||||
[one] { $amount } anualmente
|
||||
*[other] { $amount } cada { $intervalCount } años
|
||||
}
|
||||
|
||||
next-terms = Términos del servicio
|
||||
next-privacy = Aviso de privacidad
|
||||
next-terms-download = Descargar términos
|
||||
|
||||
payment-confirmation-thanks-heading = ¡Gracias!
|
||||
|
||||
# $email (string) - The user's email.
|
||||
# $productName (String) - The name of the subscribed product.
|
||||
payment-confirmation-thanks-subheading = Se ha enviado un correo electrónico de confirmación a { $email } con detalles sobre cómo comenzar a usar { $product_name }.
|
||||
|
||||
payment-confirmation-order-heading = Detalles del pedido
|
||||
payment-confirmation-invoice-number = Factura #{ $invoiceNumber }
|
||||
# $invoiceDate (Date) - Start date of the latest invoice
|
||||
payment-confirmation-invoice-date = { $invoiceDate }
|
||||
|
||||
payment-confirmation-details-heading-2 = Información de pago
|
||||
payment-confirmation-amount = { $amount } por { $interval }
|
||||
payment-confirmation-cc-card-ending-in = Tarjeta que termina en { $last4 }
|
||||
|
||||
payment-confirmation-download-button = Continuar para descargar
|
||||
|
||||
subscription-success-title = Confirmación de la suscripción
|
||||
subscription-error-title = Error al confirmar la suscripción…
|
||||
iap-upgrade-contact-support = Todavía puedes obtener este producto — por favor contacta con el equipo de soporte para que podamos ayudarte.
|
||||
payment-error-manage-subscription-button = Administrar mi suscripción
|
||||
basic-error-message = Algo ha salido mal. Por favor, inténtalo de nuevo más tarde.
|
||||
payment-error-retry-button = Volver a intentarlo
|
||||
|
||||
sub-guarantee = 30 días de garantía de devolución de dinero
|
|
@ -1,55 +0,0 @@
|
|||
## Component - PlanDetails
|
||||
|
||||
next-plan-details-header = Détails du produit
|
||||
next-plan-details-list-price = Prix courant
|
||||
next-plan-details-show-button = Afficher les détails
|
||||
next-plan-details-hide-button = Masquer les détails
|
||||
next-plan-details-total-label = Total
|
||||
next-plan-details-tax = Taxes et frais
|
||||
|
||||
## Subscription upgrade plan details - shared by multiple components, including plan details and payment form
|
||||
## $amount (Number) - The amount billed. It will be formatted as currency.
|
||||
|
||||
# $intervalCount (Number) - The interval between payments, in days.
|
||||
plan-price-interval-daily = { $amount } par jour
|
||||
# $intervalCount (Number) - The interval between payments, in weeks.
|
||||
plan-price-interval-weekly = { $amount } par semaine
|
||||
# $intervalCount (Number) - The interval between payments, in months.
|
||||
plan-price-interval-monthly = { $amount } par mois
|
||||
# $intervalCount (Number) - The interval between payments every 6 months.
|
||||
plan-price-interval-6monthly = { $amount } tous les 6 mois
|
||||
# $intervalCount (Number) - The interval between payments, in years.
|
||||
plan-price-interval-yearly = { $amount } par an
|
||||
|
||||
list-positive-amount = { $amount }
|
||||
list-negative-amount = - { $amount }
|
||||
|
||||
next-terms = Conditions d’utilisation
|
||||
next-privacy = Politique de confidentialité
|
||||
next-terms-download = Télécharger les conditions
|
||||
|
||||
payment-confirmation-thanks-heading = Merci !
|
||||
|
||||
# $email (string) - The user's email.
|
||||
# $productName (String) - The name of the subscribed product.
|
||||
payment-confirmation-thanks-subheading = Un e-mail de confirmation a été envoyé à { $email } avec les détails nécessaires pour savoir comment démarrer avec { $product_name }.
|
||||
|
||||
payment-confirmation-order-heading = Détails de la commande
|
||||
payment-confirmation-invoice-number = Facture n°{ $invoiceNumber }
|
||||
# $invoiceDate (Date) - Start date of the latest invoice
|
||||
payment-confirmation-invoice-date = { $invoiceDate }
|
||||
|
||||
payment-confirmation-details-heading-2 = Informations de paiement
|
||||
payment-confirmation-amount = { $amount } par { $interval }
|
||||
payment-confirmation-cc-card-ending-in = Carte se terminant par { $last4 }
|
||||
|
||||
payment-confirmation-download-button = Continuer vers le téléchargement
|
||||
|
||||
subscription-success-title = Confirmation d’abonnement
|
||||
subscription-error-title = Erreur lors de la confirmation de l’abonnement…
|
||||
iap-upgrade-contact-support = Vous pouvez tout de même obtenir ce produit ; veuillez contacter notre équipe d’assistance afin que nous puissions vous aider.
|
||||
payment-error-manage-subscription-button = Gérer mon abonnement
|
||||
basic-error-message = Une erreur est survenue. Merci de réessayer plus tard.
|
||||
payment-error-retry-button = Veuillez réessayer
|
||||
|
||||
sub-guarantee = Garantie de remboursement de 30 jours
|
|
@ -9,7 +9,7 @@ import { AccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
|
|||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RootConfig } from './config';
|
||||
import { LocalizerServerFactory } from '@fxa/shared/l10n/server';
|
||||
import { LocalizerRscFactoryProvider } from '@fxa/shared/l10n/server';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
@ -32,7 +32,7 @@ import { LocalizerServerFactory } from '@fxa/shared/l10n/server';
|
|||
AccountDatabaseNestFactory,
|
||||
CartService,
|
||||
CartManager,
|
||||
LocalizerServerFactory,
|
||||
LocalizerRscFactoryProvider,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { CartService } from '@fxa/payments/cart';
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { LocalizerServer } from '@fxa/shared/l10n/server';
|
||||
import { LocalizerRscFactory } from '@fxa/shared/l10n/server';
|
||||
import { singleton } from '../utils/singleton';
|
||||
|
||||
class AppSingleton {
|
||||
|
@ -22,10 +22,11 @@ class AppSingleton {
|
|||
}
|
||||
}
|
||||
|
||||
async getLocalizerServer() {
|
||||
async getL10n(acceptLanguage: string) {
|
||||
// Temporary until Next.js canary lands
|
||||
await this.initialize();
|
||||
return this.app.get(LocalizerServer);
|
||||
const localizerRscFactory = this.app.get(LocalizerRscFactory);
|
||||
return localizerRscFactory.createLocalizerRsc(acceptLanguage);
|
||||
}
|
||||
|
||||
async getCartService() {
|
||||
|
|
|
@ -3,55 +3,45 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Invoice } from '@fxa/payments/cart';
|
||||
import {
|
||||
getFormattedMsg,
|
||||
getLocalizedCurrency,
|
||||
getLocalizedCurrencyString,
|
||||
} from '@fxa/shared/l10n';
|
||||
import { FluentBundle } from '@fluent/bundle';
|
||||
import Image from 'next/image';
|
||||
import { formatPlanPricing } from '../utils/helpers';
|
||||
import { LocalizerRsc } from '@fxa/shared/l10n/server';
|
||||
import '../../styles/index.css';
|
||||
import { app } from '@fxa/payments/ui/server';
|
||||
|
||||
type ListLabelItemProps = {
|
||||
labelLocalizationId: string;
|
||||
labelFallbackText: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
l10n: FluentBundle;
|
||||
l10n: LocalizerRsc;
|
||||
positiveAmount?: boolean;
|
||||
};
|
||||
|
||||
export const ListLabelItem = ({
|
||||
l10n,
|
||||
labelLocalizationId,
|
||||
labelFallbackText,
|
||||
amount,
|
||||
currency,
|
||||
l10n,
|
||||
positiveAmount = true,
|
||||
}: ListLabelItemProps) => {
|
||||
return (
|
||||
<li className="plan-details-item">
|
||||
{l10n.getMessage(labelLocalizationId)?.value?.toString() ||
|
||||
labelFallbackText}
|
||||
{l10n.getString(labelLocalizationId, labelFallbackText)}
|
||||
<div>
|
||||
{positiveAmount
|
||||
? getFormattedMsg(
|
||||
l10n,
|
||||
? l10n.getString(
|
||||
`list-positive-amount`,
|
||||
`${getLocalizedCurrencyString(amount, currency)}`,
|
||||
{
|
||||
amount: getLocalizedCurrency(amount, currency),
|
||||
}
|
||||
amount: l10n.getLocalizedCurrency(amount, currency),
|
||||
},
|
||||
`${l10n.getLocalizedCurrencyString(amount, currency)}`
|
||||
)
|
||||
: getFormattedMsg(
|
||||
l10n,
|
||||
: l10n.getString(
|
||||
`list-negative-amount`,
|
||||
`- ${getLocalizedCurrencyString(amount, currency)}`,
|
||||
{
|
||||
amount: getLocalizedCurrency(amount, currency),
|
||||
}
|
||||
amount: l10n.getLocalizedCurrency(amount, currency),
|
||||
},
|
||||
`- ${l10n.getLocalizedCurrencyString(amount, currency)}`
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
@ -59,8 +49,8 @@ export const ListLabelItem = ({
|
|||
};
|
||||
|
||||
type PurchaseDetailsProps = {
|
||||
l10n: LocalizerRsc;
|
||||
interval: string;
|
||||
locale: string;
|
||||
invoice: Invoice;
|
||||
purchaseDetails: {
|
||||
details: string[];
|
||||
|
@ -71,7 +61,7 @@ type PurchaseDetailsProps = {
|
|||
};
|
||||
|
||||
export async function PurchaseDetails(props: PurchaseDetailsProps) {
|
||||
const { purchaseDetails, invoice, interval } = props;
|
||||
const { purchaseDetails, invoice, interval, l10n } = props;
|
||||
const { currency, listAmount, discountAmount, totalAmount, taxAmounts } =
|
||||
invoice;
|
||||
const { details, subtitle, productName, webIcon } = purchaseDetails;
|
||||
|
@ -79,15 +69,6 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
|
|||
(taxAmount) => !taxAmount.inclusive
|
||||
);
|
||||
|
||||
// TODO
|
||||
// Move to instantiation on start up. Ideally getBundle's, generateBundle, is only called once at startup,
|
||||
// and then that instance is used for all requests.
|
||||
// Approach 1 (Experimental): https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||
// Approach 2 (Node global): https://github.com/vercel/next.js/blob/canary/examples/with-knex/knex/index.js#L13
|
||||
//const l10n = await getBundle([props.locale]);
|
||||
const localizer = await app.getLocalizerServer();
|
||||
const l10n = localizer.getBundle(props.locale);
|
||||
|
||||
return (
|
||||
<div className="component-card text-sm px-4 rounded-t-none tablet:rounded-t-lg">
|
||||
<div className="flex gap-4 my-0 py-4 row-divider-grey-200">
|
||||
|
@ -109,13 +90,12 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
|
|||
</h2>
|
||||
|
||||
<p className="text-grey-400 mt-1 mb-0">
|
||||
{getFormattedMsg(
|
||||
l10n,
|
||||
{l10n.getString(
|
||||
`plan-price-interval-${interval}`,
|
||||
formatPlanPricing(listAmount, currency, interval),
|
||||
{
|
||||
amount: getLocalizedCurrency(listAmount, currency),
|
||||
}
|
||||
amount: l10n.getLocalizedCurrency(listAmount, currency),
|
||||
},
|
||||
formatPlanPricing(listAmount, currency, interval)
|
||||
)}
|
||||
•
|
||||
{subtitle}
|
||||
|
@ -124,8 +104,7 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
|
|||
</div>
|
||||
|
||||
<h3 className="text-grey-600 font-semibold my-4">
|
||||
{l10n.getMessage('next-plan-details-header')?.value?.toString() ||
|
||||
`Plan Details`}
|
||||
{l10n.getString('next-plan-details-header', 'Plan Details')}
|
||||
</h3>
|
||||
|
||||
<ul className="row-divider-grey-200 text-grey-400 m-0 px-3 list-disc">
|
||||
|
@ -157,7 +136,7 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
|
|||
amount: discountAmount,
|
||||
currency,
|
||||
l10n,
|
||||
subtractValue: true,
|
||||
positiveAmount: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -175,7 +154,7 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
|
|||
)}
|
||||
|
||||
{exclusiveTaxRates.length > 1 &&
|
||||
exclusiveTaxRates.map((taxRate, idx) => (
|
||||
exclusiveTaxRates.map((taxRate) => (
|
||||
<ListLabelItem
|
||||
{...{
|
||||
labelLocalizationId: '',
|
||||
|
@ -191,22 +170,19 @@ export async function PurchaseDetails(props: PurchaseDetailsProps) {
|
|||
|
||||
<div className="plan-details-item pt-4 pb-6 font-semibold">
|
||||
<span className="text-base">
|
||||
{l10n
|
||||
.getMessage('next-plan-details-total-label')
|
||||
?.value?.toString() || `Total`}
|
||||
{l10n.getString('next-plan-details-total-label', 'Total')}
|
||||
</span>
|
||||
<span
|
||||
className="overflow-hidden text-ellipsis text-lg whitespace-nowrap"
|
||||
data-testid="total-price"
|
||||
id="total-price"
|
||||
>
|
||||
{getFormattedMsg(
|
||||
l10n,
|
||||
{l10n.getString(
|
||||
`plan-price-interval-${interval}`,
|
||||
formatPlanPricing(totalAmount, currency, interval),
|
||||
{
|
||||
amount: getLocalizedCurrency(totalAmount, currency),
|
||||
}
|
||||
amount: l10n.getLocalizedCurrency(totalAmount, currency),
|
||||
},
|
||||
formatPlanPricing(totalAmount, currency, interval)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { FluentBundle } from '@fluent/bundle';
|
||||
import { getBundle } from '@fxa/shared/l10n';
|
||||
import {
|
||||
GenericTermItem,
|
||||
GenericTermsListItem,
|
||||
|
@ -12,6 +10,7 @@ import {
|
|||
buildPaymentTerms,
|
||||
buildProductTerms,
|
||||
} from '../utils/terms-and-privacy';
|
||||
import { LocalizerRsc } from '@fxa/shared/l10n/server';
|
||||
|
||||
const CONTENT_SERVER_URL = 'https://accounts.stage.mozaws.net'; // TODO - Get from config once FXA-7503 lands
|
||||
|
||||
|
@ -20,7 +19,7 @@ type GenericTermsProps = {
|
|||
titleId: string;
|
||||
titleLocalizationId: string;
|
||||
items: GenericTermsListItem[];
|
||||
l10n: FluentBundle;
|
||||
l10n: LocalizerRsc;
|
||||
};
|
||||
|
||||
function GenericTerms({
|
||||
|
@ -37,7 +36,7 @@ function GenericTerms({
|
|||
aria-labelledby={titleId}
|
||||
>
|
||||
<h4 className="m-0 font-semibold text-grey-400" id={titleId}>
|
||||
{l10n.getMessage(titleLocalizationId)?.value?.toString() || title}
|
||||
{l10n.getString(titleLocalizationId, title)}
|
||||
</h4>
|
||||
|
||||
<ul className="flex justify-center gap-4 m-0 text-grey-500">
|
||||
|
@ -50,8 +49,7 @@ function GenericTerms({
|
|||
rel="noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
{l10n.getMessage(item.localizationId)?.value?.toString() ||
|
||||
item.text}
|
||||
{l10n.getString(item.localizationId, item.text)}
|
||||
<span className="sr-only">Opens in new window</span>
|
||||
</a>
|
||||
</li>
|
||||
|
@ -62,7 +60,7 @@ function GenericTerms({
|
|||
}
|
||||
|
||||
export interface TermsAndPrivacyProps {
|
||||
locale: string;
|
||||
l10n: LocalizerRsc;
|
||||
paymentProvider?: PaymentProvider;
|
||||
productName: string;
|
||||
termsOfServiceUrl: string;
|
||||
|
@ -72,7 +70,7 @@ export interface TermsAndPrivacyProps {
|
|||
}
|
||||
|
||||
export async function TermsAndPrivacy({
|
||||
locale,
|
||||
l10n,
|
||||
paymentProvider,
|
||||
productName,
|
||||
termsOfServiceUrl,
|
||||
|
@ -93,13 +91,6 @@ export async function TermsAndPrivacy({
|
|||
),
|
||||
];
|
||||
|
||||
// TODO
|
||||
// Move to instantiation on start up. Ideally getBundle's, generateBundle, is only called once at startup,
|
||||
// and then that instance is used for all requests.
|
||||
// Approach 1 (Experimental): https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||
// Approach 2 (Node global): https://github.com/vercel/next.js/blob/canary/examples/with-knex/knex/index.js#L13
|
||||
const l10n = await getBundle([locale]);
|
||||
|
||||
return (
|
||||
<aside className="pt-14" aria-label="Terms and Privacy Notices">
|
||||
{terms.map((term) => (
|
||||
|
|
|
@ -6,6 +6,10 @@ export default {
|
|||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'@fluent/react/esm/localization': '@fluent/react',
|
||||
'server-only': `<rootDir>/tests/__mocks__/empty.js`,
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../coverage/libs/shared/l10n',
|
||||
};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
export * from './lib/localizer/localizer.client';
|
||||
export * from './lib/localizer/localizer.client.bindings';
|
|
@ -1,16 +1,9 @@
|
|||
export {
|
||||
getLocalizedCurrency,
|
||||
getLocalizedCurrencyString,
|
||||
getLocalizedDate,
|
||||
getLocalizedDateString,
|
||||
} from './lib/formatters';
|
||||
export { getBundle, getFormattedMsg } from './lib/l10n';
|
||||
export * from './lib/determine-direction';
|
||||
export * from './lib/determine-locale';
|
||||
export * from './lib/localize-timestamp';
|
||||
export * from './lib/other-languages';
|
||||
export * from './lib/parse-accept-language';
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
export { default as supportedLanguages } from './lib/supported-languages.json';
|
||||
export * from './lib/localizer/localizer.base';
|
||||
export * from './lib/localizer/localizer.client';
|
||||
export * from './lib/localizer/localizer.client.bindings';
|
||||
export * from './lib/l10n.formatters';
|
||||
export * from './lib/l10n.constants';
|
||||
export * from './lib/l10n.utils';
|
||||
export * from './lib/l10n.types';
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { determineDirection } from './determine-direction';
|
||||
|
||||
describe('l10n/determineDirection:', () => {
|
||||
it('defaults to ltr for undefined locale', () => {
|
||||
const s: any = undefined;
|
||||
expect(determineDirection(s)).toEqual('ltr');
|
||||
});
|
||||
|
||||
it('defaults to ltr for non-sense langauge', () => {
|
||||
expect(determineDirection('wibble')).toEqual('ltr');
|
||||
});
|
||||
|
||||
it('resolves to ltr for a selection of ltr languages', () => {
|
||||
expect(determineDirection('fr')).toEqual('ltr');
|
||||
expect(determineDirection('de')).toEqual('ltr');
|
||||
expect(determineDirection('zh')).toEqual('ltr');
|
||||
});
|
||||
|
||||
// arabic is not currently supported, and strings will be displayed in English
|
||||
// direction must be LTR for English even if requested locale is arabic
|
||||
it('resolves to ltr for unspported ltr language', () => {
|
||||
expect(determineDirection('ar')).toEqual('ltr');
|
||||
});
|
||||
|
||||
it('resolves to rtl for hebrew', () => {
|
||||
expect(determineDirection('he')).toEqual('rtl');
|
||||
});
|
||||
|
||||
it('it ignores case and resovles to rtl for hebrew', () => {
|
||||
expect(determineDirection('he-il')).toEqual('rtl');
|
||||
expect(determineDirection('he-IL')).toEqual('rtl');
|
||||
});
|
||||
});
|
|
@ -1,22 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { determineLocale } from './determine-locale';
|
||||
import rtlLocales from './rtl-locales.json';
|
||||
|
||||
/**
|
||||
* Given a set of supported languages and an accept-language http header value, this resolves the direction of the language that fits best.
|
||||
* @param acceptLanguage - The accept-language http header value
|
||||
* @param supportedLanguages - optional set of supported languages. Defaults to main list held in ./supportedLanguages.json
|
||||
* @returns The best fitting locale
|
||||
*/
|
||||
export function determineDirection(
|
||||
acceptLanguage: string,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
const locale = determineLocale(acceptLanguage, supportedLanguages);
|
||||
if (rtlLocales.includes(locale)) {
|
||||
return 'rtl';
|
||||
}
|
||||
return 'ltr';
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { determineLocale, getLocaleFromRequest } from './determine-locale';
|
||||
|
||||
describe('l10n/determineLocale:', () => {
|
||||
it('finds a locale', () => {
|
||||
expect(determineLocale('en')).toEqual('en');
|
||||
});
|
||||
|
||||
it('handles undefined locale', () => {
|
||||
const s: any = undefined;
|
||||
expect(determineLocale(s)).toEqual('en');
|
||||
});
|
||||
|
||||
it('handles non-sense langauge', () => {
|
||||
expect(determineLocale('wibble')).toEqual('en');
|
||||
});
|
||||
|
||||
it('resolves region', () => {
|
||||
expect(determineLocale('en-US')).toEqual('en-US');
|
||||
});
|
||||
|
||||
it('defaults to base langauge', () => {
|
||||
expect(determineLocale('en-XY')).toEqual('en');
|
||||
});
|
||||
|
||||
it('resolves base langauge given multiple supported languages with absent region', () => {
|
||||
expect(determineLocale('es-MX')).toEqual('es-MX');
|
||||
});
|
||||
|
||||
it('ignores case', () => {
|
||||
expect(determineLocale('En-uS')).toEqual('en-US');
|
||||
});
|
||||
|
||||
it('ignores case and determines correct priority', () => {
|
||||
// Technially this shouldn't be supported, but we have some existing
|
||||
// tests that support loose case matching on region, so it will be
|
||||
// included for backwards compatibility.
|
||||
expect(determineLocale('en-US;q=0.1, es-mx; q=0.8')).toEqual('es-MX');
|
||||
});
|
||||
|
||||
it('respects q value', () => {
|
||||
expect(determineLocale('en-US;q=0.1, es-MX; q=0.8')).toEqual('es-MX');
|
||||
expect(determineLocale('en-US;q=.1, es-MX; q=.8')).toEqual('es-MX');
|
||||
});
|
||||
|
||||
it('falls back to supported locale with unsupported locale', () => {
|
||||
// en-GB has an implicit q=1, fr has q=0.9, and xyz is thrown out because it is
|
||||
// not supported. Therefore, en-GB ends up having the highest q value and
|
||||
// should be the expected result.
|
||||
expect(determineLocale('xyz, fr;q=0.9, en-GB, en;q=0.5')).toEqual('en-GB');
|
||||
});
|
||||
|
||||
it('handles q-values out of range', () => {
|
||||
// The spec says q-values must be between 0 and 1. We will still guard against bad q-values,
|
||||
// by forcing them into that range.
|
||||
expect(determineLocale('en;q=0.5, fr;q=1.1')).toEqual('fr');
|
||||
expect(determineLocale('en;q=0.5, fr;q=-.1')).toEqual('en');
|
||||
});
|
||||
|
||||
describe('getLocaleFromRequest', () => {
|
||||
it('return searchParams', () => {
|
||||
expect(getLocaleFromRequest({ locale: 'fr-FR' }, null)).toEqual('fr');
|
||||
});
|
||||
|
||||
it('return searchParams in supportedLanguages', () => {
|
||||
expect(
|
||||
getLocaleFromRequest({ locale: 'ra-ND' }, null, ['ra-ND'])
|
||||
).toEqual('ra-ND');
|
||||
});
|
||||
|
||||
it('return accept language', () => {
|
||||
expect(getLocaleFromRequest({}, 'en-US;q=0.1, es-MX;q=0.8')).toEqual(
|
||||
'es-MX'
|
||||
);
|
||||
});
|
||||
|
||||
it('return default locale', () => {
|
||||
expect(getLocaleFromRequest({}, null)).toEqual('en-US');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,42 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { DEFAULT_LOCALE } from './l10n';
|
||||
import { parseAcceptLanguage } from './parse-accept-language';
|
||||
|
||||
/**
|
||||
* Get the best fitting locale, prioritizing request search params, followed by request header AcceptLanguage and DEFAULT_LOCALE as default
|
||||
* @param params - parameters of the request
|
||||
* @param acceptLanguage - Accept language from request header
|
||||
* @returns The best fitting locale
|
||||
*/
|
||||
export function getLocaleFromRequest(
|
||||
params: { locale?: string },
|
||||
acceptLanguage: string | null,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
if (params.locale) {
|
||||
return determineLocale(params?.locale, supportedLanguages);
|
||||
}
|
||||
|
||||
if (acceptLanguage) {
|
||||
return determineLocale(acceptLanguage, supportedLanguages);
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a set of supported languages and an accept-language http header value, this resolves language that fits best.
|
||||
* @param acceptLanguage - The accept-language http header value
|
||||
* @param supportedLanguages - optional set of supported languages. Defaults to main list held in ./supportedLanguages.json
|
||||
* @returns The best fitting locale
|
||||
*/
|
||||
export function determineLocale(
|
||||
acceptLanguage: string,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
// Returns languages in order of precedence, so we can just grab the first one.
|
||||
return parseAcceptLanguage(acceptLanguage, supportedLanguages)[0];
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
/*
|
||||
* These do not exist in the l10n repo, but we want users with these locales to
|
|
@ -1,9 +1,12 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import {
|
||||
getLocalizedCurrency,
|
||||
getLocalizedCurrencyString,
|
||||
getLocalizedDate,
|
||||
getLocalizedDateString,
|
||||
} from './formatters';
|
||||
} from './l10n.formatters';
|
||||
|
||||
describe('format.ts', () => {
|
||||
describe('Currency Formatting', () => {
|
|
@ -1,3 +1,6 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { FluentDateTime, FluentNumber } from '@fluent/bundle';
|
||||
|
||||
/**
|
|
@ -1,120 +0,0 @@
|
|||
// import { negotiateLanguages } from '@fluent/langneg';
|
||||
import { FluentBundle, FluentResource, FluentVariable } from '@fluent/bundle';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// const ftl: Record<string, URL> = {
|
||||
// 'en-US': new URL('./en-US.ftl', import.meta.url),
|
||||
// fr: new URL('./fr.ftl', import.meta.url),
|
||||
// };
|
||||
|
||||
export const DEFAULT_LOCALE = 'en-US';
|
||||
// const AVAILABLE_LOCALES = {
|
||||
// 'en-US': 'English',
|
||||
// fr: 'French',
|
||||
// };
|
||||
|
||||
const RESOURCES = {
|
||||
'fr-FR': new FluentResource(`
|
||||
terms = Conditions d’utilisation\n
|
||||
privacy = Politique de confidentialité\n
|
||||
terms-download = Télécharger les conditions\n
|
||||
hello = Salut le monde !\n
|
||||
plan-details-header = Header but French !\n
|
||||
`),
|
||||
'en-US': new FluentResource(`
|
||||
terms = Terms of Service\n
|
||||
privacy = Privacy Notice\n
|
||||
terms-download = Download Terms\n
|
||||
hello = Hello, world!\n
|
||||
plan-details-header = Header but English\n
|
||||
`),
|
||||
'de-DE': new FluentResource(`
|
||||
terms = Nutzungsbedingungen
|
||||
privacy = Datenschutzhinweis
|
||||
terms-download = Nutzungsbedingungen herunterladen
|
||||
hello = Hallo Welt!\n
|
||||
plan-details-header = Header but German\n
|
||||
`),
|
||||
'es-ES': new FluentResource(`
|
||||
terms = Términos del servicio
|
||||
privacy = Aviso de privacidad
|
||||
terms-download = Descargar términos
|
||||
hello = Hola mundo!\n
|
||||
plan-details-header = Header but Spanish\n
|
||||
`),
|
||||
};
|
||||
|
||||
async function generateBundles() {
|
||||
const fetchedMessages: [string, FluentResource][] = [];
|
||||
// Temporary work to read l10n files from public/l10n
|
||||
// Better solution is required
|
||||
const dirRelativeToPublicFolder = 'l10n';
|
||||
const dir = path.resolve('./public', dirRelativeToPublicFolder);
|
||||
for (const locale in RESOURCES) {
|
||||
const ftlPath = `${dir}/${locale}.ftl`;
|
||||
const ftlFile = fs.readFileSync(ftlPath, 'utf8');
|
||||
|
||||
// const response = await fetch(String(ftl[locale]));
|
||||
// const messages = await response.text();
|
||||
const resource = new FluentResource(ftlFile);
|
||||
fetchedMessages.push([locale, resource]);
|
||||
}
|
||||
|
||||
const bundles: { locale: string; bundle: FluentBundle }[] = [];
|
||||
for (const [locale, resource] of fetchedMessages) {
|
||||
// const resource = new FluentResource(messages);
|
||||
const bundle = new FluentBundle(locale);
|
||||
bundle.addResource(resource);
|
||||
bundles.push({ locale, bundle });
|
||||
}
|
||||
|
||||
return bundles;
|
||||
}
|
||||
|
||||
export async function getBundle(languages: string[] | undefined) {
|
||||
// async function changeLocales(userLocales: Array<string>) {
|
||||
// const currentLocales = negotiateLanguages(
|
||||
// userLocales,
|
||||
// Object.keys(AVAILABLE_LOCALES),
|
||||
// { defaultLocale: DEFAULT_LOCALE }
|
||||
// );
|
||||
|
||||
const bundles = await generateBundles();
|
||||
let l10n: FluentBundle | undefined;
|
||||
for (const language in languages) {
|
||||
l10n = bundles.find(
|
||||
(bundle) =>
|
||||
bundle.locale.toLowerCase() === languages[language as any].toLowerCase()
|
||||
)?.bundle;
|
||||
|
||||
if (!!l10n) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!l10n) {
|
||||
l10n = bundles.find((bundle) => bundle.locale === DEFAULT_LOCALE)?.bundle;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return l10n!;
|
||||
}
|
||||
|
||||
// Temporary
|
||||
export function getFormattedMsg(
|
||||
l10n: FluentBundle,
|
||||
msgId: string,
|
||||
fallback: string,
|
||||
args?: Record<string, FluentVariable> | null
|
||||
) {
|
||||
const errors: Error[] = [];
|
||||
const msg = l10n.getMessage(msgId);
|
||||
if (msg?.value) {
|
||||
const formattedText = l10n.formatPattern(msg.value, args, errors);
|
||||
if (formattedText && !errors.length) {
|
||||
return formattedText;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
export type LocalizeOptions = {
|
||||
defaultLanguage: string;
|
||||
supportedLanguages?: string[];
|
||||
};
|
|
@ -0,0 +1,367 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import supportedLanguages from './supported-languages.json';
|
||||
import {
|
||||
determineDirection,
|
||||
determineLocale,
|
||||
getLocaleFromRequest,
|
||||
localizeTimestamp,
|
||||
parseAcceptLanguage,
|
||||
} from './l10n.utils';
|
||||
|
||||
describe('l10n.utils', () => {
|
||||
describe('l10n/supportedLanguages:', () => {
|
||||
it('returns an array of languages', () => {
|
||||
expect(Array.isArray(supportedLanguages)).toBe(true);
|
||||
expect(supportedLanguages.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes some common languages', () => {
|
||||
expect(supportedLanguages.indexOf('de')).toBeGreaterThanOrEqual(0);
|
||||
expect(supportedLanguages.indexOf('en')).toBeGreaterThanOrEqual(0);
|
||||
expect(supportedLanguages.indexOf('es')).toBeGreaterThanOrEqual(0);
|
||||
expect(supportedLanguages.indexOf('fr')).toBeGreaterThanOrEqual(0);
|
||||
expect(supportedLanguages.indexOf('pt')).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('l10n/parseAcceptLanguage:', () => {
|
||||
it('returns default', () => {
|
||||
expect(parseAcceptLanguage('en')).toEqual([
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty case', () => {
|
||||
expect(parseAcceptLanguage('')).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('handles unknown', () => {
|
||||
expect(parseAcceptLanguage('xyz')).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('parses single and always contains default language (en)', () => {
|
||||
expect(parseAcceptLanguage('it')).toEqual(['it', 'en']);
|
||||
});
|
||||
|
||||
it('parses several with expected output', () => {
|
||||
expect(parseAcceptLanguage('en, de, es, ru')).toEqual([
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
'de',
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'es-MX',
|
||||
'ru',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('qvalue', () => {
|
||||
it('applies correctly with an implicit and explicit value', () => {
|
||||
expect(parseAcceptLanguage('ru;q=0.3, it')).toEqual(['it', 'ru', 'en']);
|
||||
});
|
||||
|
||||
it('applies correctly with multiple explicit and implicit values', () => {
|
||||
expect(parseAcceptLanguage('de, it;q=0.8, en;q=0.5, es;q=1.0')).toEqual(
|
||||
[
|
||||
'de',
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'es-MX',
|
||||
'it',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('applies correctly with dialects', () => {
|
||||
expect(parseAcceptLanguage('de-DE, en-US;q=0.7, en;q=0.3')).toEqual([
|
||||
'de',
|
||||
'en-US',
|
||||
'en',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialect (region) options', () => {
|
||||
it('handles en-*', () => {
|
||||
expect(parseAcceptLanguage('en-CA')).toEqual([
|
||||
'en-CA',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all options and always contains default language (en)', () => {
|
||||
expect(parseAcceptLanguage('es')).toEqual([
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'es-MX',
|
||||
'en',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles region with incorrect case', () => {
|
||||
expect(parseAcceptLanguage('es-mx, ru')).toEqual([
|
||||
'es-MX',
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'ru',
|
||||
'en',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives "en" higher priority than second locale when first locale is en-*', () => {
|
||||
expect(parseAcceptLanguage('en-US, de')).toEqual([
|
||||
'en-US',
|
||||
'en',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
'de',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles alias to en-GB', () => {
|
||||
expect(parseAcceptLanguage('en-NZ')).toEqual([
|
||||
'en-GB',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles multiple languages with en-GB alias', () => {
|
||||
expect(parseAcceptLanguage('en-NZ, en-GB, en-MY')).toEqual([
|
||||
'en-GB',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to root language if dialect is missing', () => {
|
||||
expect(parseAcceptLanguage('fr-FR')).toEqual(['fr', 'en']);
|
||||
});
|
||||
|
||||
it('handles Chinese dialects properly', () => {
|
||||
expect(parseAcceptLanguage('zh-CN, zh-TW, zh-HK, zh')).toEqual([
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('l10n/localizeTimestamp:', () => {
|
||||
describe('call with supported language:', () => {
|
||||
let format: any;
|
||||
|
||||
beforeAll(() => {
|
||||
format = localizeTimestamp({
|
||||
defaultLanguage: 'en',
|
||||
supportedLanguages: ['ar', 'es', 'ru'],
|
||||
}).format;
|
||||
});
|
||||
|
||||
it('returns the empty string if called without arguments', () => {
|
||||
expect(format()).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('returns the default language if called without an Accept-Language header', () => {
|
||||
expect(format(Date.now() - 1)).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the requested language if called with an Accept-Language header', () => {
|
||||
expect(format(Date.now() - 1, 'ru,en-GB;q=0.5,en;q=0.3')).toEqual(
|
||||
'несколько секунд назад'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the requested language if called with a single language', () => {
|
||||
expect(format(Date.now() - 1, 'ru')).toEqual('несколько секунд назад');
|
||||
});
|
||||
|
||||
it('returns the requested language if called with a language variation', () => {
|
||||
expect(format(Date.now() - 1, 'es-mx, ru')).toEqual(
|
||||
'hace unos segundos'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a fallback language if called with unsupported language variations', () => {
|
||||
expect(format(Date.now() - 1, 'de, fr;q=0.8, ru;q=0.5')).toEqual(
|
||||
'несколько секунд назад'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the default language if called with an unsupported language', () => {
|
||||
expect(format(Date.now() - 1, 'qu')).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the default language if called with the default language', () => {
|
||||
expect(format(Date.now() - 1, 'en')).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the first supported language if called with the first supported language', () => {
|
||||
expect(format(Date.now() - 1, 'ar')).toEqual('منذ ثانية واحدة');
|
||||
});
|
||||
});
|
||||
|
||||
describe('call with no supported languages:', () => {
|
||||
let format: (timestamp?: number, acceptLanguageHeader?: string) => string;
|
||||
|
||||
beforeAll(() => {
|
||||
format = localizeTimestamp({
|
||||
defaultLanguage: 'en',
|
||||
supportedLanguages: [],
|
||||
}).format;
|
||||
});
|
||||
|
||||
it('returns the empty string if called without arguments', () => {
|
||||
expect(format()).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('returns the default language if called without an Accept-Language header', () => {
|
||||
expect(format(Date.now() - 1)).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the default language if called with an unsupported language', () => {
|
||||
expect(format(Date.now() - 1, 'ru')).toEqual('a few seconds ago');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('l10n/determineLocale:', () => {
|
||||
it('finds a locale', () => {
|
||||
expect(determineLocale('en')).toEqual('en');
|
||||
});
|
||||
|
||||
it('handles undefined locale', () => {
|
||||
const s: any = undefined;
|
||||
expect(determineLocale(s)).toEqual('en');
|
||||
});
|
||||
|
||||
it('handles non-sense langauge', () => {
|
||||
expect(determineLocale('wibble')).toEqual('en');
|
||||
});
|
||||
|
||||
it('resolves region', () => {
|
||||
expect(determineLocale('en-US')).toEqual('en-US');
|
||||
});
|
||||
|
||||
it('defaults to base langauge', () => {
|
||||
expect(determineLocale('en-XY')).toEqual('en');
|
||||
});
|
||||
|
||||
it('resolves base langauge given multiple supported languages with absent region', () => {
|
||||
expect(determineLocale('es-MX')).toEqual('es-MX');
|
||||
});
|
||||
|
||||
it('ignores case', () => {
|
||||
expect(determineLocale('En-uS')).toEqual('en-US');
|
||||
});
|
||||
|
||||
it('ignores case and determines correct priority', () => {
|
||||
// Technially this shouldn't be supported, but we have some existing
|
||||
// tests that support loose case matching on region, so it will be
|
||||
// included for backwards compatibility.
|
||||
expect(determineLocale('en-US;q=0.1, es-mx; q=0.8')).toEqual('es-MX');
|
||||
});
|
||||
|
||||
it('respects q value', () => {
|
||||
expect(determineLocale('en-US;q=0.1, es-MX; q=0.8')).toEqual('es-MX');
|
||||
expect(determineLocale('en-US;q=.1, es-MX; q=.8')).toEqual('es-MX');
|
||||
});
|
||||
|
||||
it('falls back to supported locale with unsupported locale', () => {
|
||||
// en-GB has an implicit q=1, fr has q=0.9, and xyz is thrown out because it is
|
||||
// not supported. Therefore, en-GB ends up having the highest q value and
|
||||
// should be the expected result.
|
||||
expect(determineLocale('xyz, fr;q=0.9, en-GB, en;q=0.5')).toEqual(
|
||||
'en-GB'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles q-values out of range', () => {
|
||||
// The spec says q-values must be between 0 and 1. We will still guard against bad q-values,
|
||||
// by forcing them into that range.
|
||||
expect(determineLocale('en;q=0.5, fr;q=1.1')).toEqual('fr');
|
||||
expect(determineLocale('en;q=0.5, fr;q=-.1')).toEqual('en');
|
||||
});
|
||||
|
||||
describe('getLocaleFromRequest', () => {
|
||||
it('return searchParams', () => {
|
||||
expect(getLocaleFromRequest({ locale: 'fr-FR' }, null)).toEqual('fr');
|
||||
});
|
||||
|
||||
it('return searchParams in supportedLanguages', () => {
|
||||
expect(
|
||||
getLocaleFromRequest({ locale: 'ra-ND' }, null, ['ra-ND'])
|
||||
).toEqual('ra-ND');
|
||||
});
|
||||
|
||||
it('return accept language', () => {
|
||||
expect(getLocaleFromRequest({}, 'en-US;q=0.1, es-MX;q=0.8')).toEqual(
|
||||
'es-MX'
|
||||
);
|
||||
});
|
||||
|
||||
it('return default locale', () => {
|
||||
expect(getLocaleFromRequest({}, null)).toEqual('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('l10n/determineDirection:', () => {
|
||||
it('defaults to ltr for undefined locale', () => {
|
||||
const s: any = undefined;
|
||||
expect(determineDirection(s)).toEqual('ltr');
|
||||
});
|
||||
|
||||
it('defaults to ltr for non-sense langauge', () => {
|
||||
expect(determineDirection('wibble')).toEqual('ltr');
|
||||
});
|
||||
|
||||
it('resolves to ltr for a selection of ltr languages', () => {
|
||||
expect(determineDirection('fr')).toEqual('ltr');
|
||||
expect(determineDirection('de')).toEqual('ltr');
|
||||
expect(determineDirection('zh')).toEqual('ltr');
|
||||
});
|
||||
|
||||
// arabic is not currently supported, and strings will be displayed in English
|
||||
// direction must be LTR for English even if requested locale is arabic
|
||||
it('resolves to ltr for unspported ltr language', () => {
|
||||
expect(determineDirection('ar')).toEqual('ltr');
|
||||
});
|
||||
|
||||
it('resolves to rtl for hebrew', () => {
|
||||
expect(determineDirection('he')).toEqual('rtl');
|
||||
});
|
||||
|
||||
it('it ignores case and resovles to rtl for hebrew', () => {
|
||||
expect(determineDirection('he-il')).toEqual('rtl');
|
||||
expect(determineDirection('he-IL')).toEqual('rtl');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,171 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { negotiateLanguages } from '@fluent/langneg';
|
||||
import availableLocales from './supported-languages.json';
|
||||
import moment from 'moment';
|
||||
import { LocalizeOptions } from './l10n.types';
|
||||
import { DEFAULT_LOCALE, EN_GB_LOCALES } from './l10n.constants';
|
||||
|
||||
/**
|
||||
* Takes an acceptLanguage value (assumed to come from an http header) and returns
|
||||
* a set of valid locales in order of precedence.
|
||||
* @param acceptLanguage - Http header accept-language value
|
||||
* @param supportedLanguages - List of supported language codes
|
||||
* @returns A list of associated supported locales. If there are no matches for the given
|
||||
* accept language, then the default locle, en, will be returned.
|
||||
*
|
||||
*/
|
||||
export function parseAcceptLanguage(
|
||||
acceptLanguage: string,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
if (!supportedLanguages) {
|
||||
supportedLanguages = availableLocales;
|
||||
}
|
||||
if (acceptLanguage == null) {
|
||||
acceptLanguage = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on criteria set forth here: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4
|
||||
* Process involves breaking string by comma, then extracting language code and numeric q value. Then
|
||||
* languages are sorted by q value. If no q-value is specified, then the q value defaults to
|
||||
* 1 as per the specificiation. Q-values are clamped between 0-1.
|
||||
*/
|
||||
const parsedLocales = acceptLanguage.split(',');
|
||||
const qValues: Record<string, number> = {};
|
||||
for (const locale of parsedLocales) {
|
||||
const localeSplit = locale.trim().split(';');
|
||||
let lang = localeSplit[0];
|
||||
const q = localeSplit[1];
|
||||
|
||||
const match = /(q=)([0-9\.-]*)/gm.exec(q) || [];
|
||||
const qvalue = parseFloat(match[2]) || 1.0;
|
||||
|
||||
if (EN_GB_LOCALES.includes(lang)) {
|
||||
lang = 'en-GB';
|
||||
}
|
||||
|
||||
// Make regions case insensitive. This might not be technically valid, but
|
||||
// trying keep things backwards compatible.
|
||||
lang = lang.toLocaleLowerCase();
|
||||
|
||||
if (!qValues[lang]) {
|
||||
qValues[lang] = Math.max(Math.min(qvalue, 1.0), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Order of locales represents priority and should correspond to q-values.
|
||||
const sortedQValues = Object.entries(qValues).sort((a, b) => b[1] - a[1]);
|
||||
const parsedLocalesByQValue = sortedQValues.map((qValue) => qValue[0]);
|
||||
|
||||
const currentLocales = negotiateLanguages(
|
||||
parsedLocalesByQValue,
|
||||
[...supportedLanguages],
|
||||
{
|
||||
defaultLocale: 'en',
|
||||
}
|
||||
);
|
||||
|
||||
return currentLocales;
|
||||
}
|
||||
|
||||
/**
|
||||
* This module contains localization utils for the server
|
||||
*/
|
||||
export function localizeTimestamp({
|
||||
defaultLanguage,
|
||||
supportedLanguages = [],
|
||||
}: LocalizeOptions) {
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
// must support at least one language.
|
||||
supportedLanguages = [defaultLanguage];
|
||||
} else {
|
||||
// default language must come first
|
||||
supportedLanguages.unshift(defaultLanguage);
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Convert a given `timestamp` to a moment 'time from now' format
|
||||
* based on a given `acceptLanguage`.
|
||||
* Docs: http://momentjs.com/docs/#/displaying/fromnow/
|
||||
*
|
||||
* @param {Number} timestamp
|
||||
* @param {String} acceptLanguageHeader
|
||||
* @returns {String} Returns a localized string based on a given timestamp.
|
||||
* Returns an empty string if no timestamp provided.
|
||||
*/
|
||||
format: function format(timestamp?: number, acceptLanguageHeader?: string) {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// set the moment locale to determined `language`.
|
||||
const locale = determineLocale(
|
||||
acceptLanguageHeader || '',
|
||||
supportedLanguages
|
||||
);
|
||||
return moment(timestamp)
|
||||
.locale(locale || defaultLanguage)
|
||||
.fromNow();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best fitting locale, prioritizing request search params, followed by request header AcceptLanguage and DEFAULT_LOCALE as default
|
||||
* @param params - Parameters of the request
|
||||
* @param acceptLanguage - Accept language from request header
|
||||
* @param supportedLanguages - Supported languages to be matched with acceptLanguage
|
||||
* @returns The best fitting locale
|
||||
*/
|
||||
export function getLocaleFromRequest(
|
||||
params: { locale?: string },
|
||||
acceptLanguage: string | null,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
if (params.locale) {
|
||||
return determineLocale(params?.locale, supportedLanguages);
|
||||
}
|
||||
|
||||
if (acceptLanguage) {
|
||||
return determineLocale(acceptLanguage, supportedLanguages);
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a set of supported languages and an accept-language http header value, this resolves language that fits best.
|
||||
* @param acceptLanguage - The accept-language http header value
|
||||
* @param supportedLanguages - optional set of supported languages. Defaults to main list held in ./supportedLanguages.json
|
||||
* @returns The best fitting locale
|
||||
*/
|
||||
export function determineLocale(
|
||||
acceptLanguage: string,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
// Returns languages in order of precedence, so we can just grab the first one.
|
||||
return parseAcceptLanguage(acceptLanguage, supportedLanguages)[0];
|
||||
}
|
||||
|
||||
import rtlLocales from './rtl-locales.json';
|
||||
|
||||
/**
|
||||
* Given a set of supported languages and an accept-language http header value, this resolves the direction of the language that fits best.
|
||||
* @param acceptLanguage - The accept-language http header value
|
||||
* @param supportedLanguages - optional set of supported languages. Defaults to main list held in ./supportedLanguages.json
|
||||
* @returns The best fitting locale
|
||||
*/
|
||||
export function determineDirection(
|
||||
acceptLanguage: string,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
const locale = determineLocale(acceptLanguage, supportedLanguages);
|
||||
if (rtlLocales.includes(locale)) {
|
||||
return 'rtl';
|
||||
}
|
||||
return 'ltr';
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { localizeTimestamp } from './localize-timestamp';
|
||||
|
||||
describe('l10n/localizeTimestamp:', () => {
|
||||
describe('call with supported language:', () => {
|
||||
let format: any;
|
||||
|
||||
beforeAll(() => {
|
||||
format = localizeTimestamp({
|
||||
defaultLanguage: 'en',
|
||||
supportedLanguages: ['ar', 'es', 'ru'],
|
||||
}).format;
|
||||
});
|
||||
|
||||
it('returns the empty string if called without arguments', () => {
|
||||
expect(format()).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('returns the default language if called without an Accept-Language header', () => {
|
||||
expect(format(Date.now() - 1)).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the requested language if called with an Accept-Language header', () => {
|
||||
expect(format(Date.now() - 1, 'ru,en-GB;q=0.5,en;q=0.3')).toEqual(
|
||||
'несколько секунд назад'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the requested language if called with a single language', () => {
|
||||
expect(format(Date.now() - 1, 'ru')).toEqual('несколько секунд назад');
|
||||
});
|
||||
|
||||
it('returns the requested language if called with a language variation', () => {
|
||||
expect(format(Date.now() - 1, 'es-mx, ru')).toEqual('hace unos segundos');
|
||||
});
|
||||
|
||||
it('returns a fallback language if called with unsupported language variations', () => {
|
||||
expect(format(Date.now() - 1, 'de, fr;q=0.8, ru;q=0.5')).toEqual(
|
||||
'несколько секунд назад'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the default language if called with an unsupported language', () => {
|
||||
expect(format(Date.now() - 1, 'qu')).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the default language if called with the default language', () => {
|
||||
expect(format(Date.now() - 1, 'en')).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the first supported language if called with the first supported language', () => {
|
||||
expect(format(Date.now() - 1, 'ar')).toEqual('منذ ثانية واحدة');
|
||||
});
|
||||
});
|
||||
|
||||
describe('call with no supported languages:', () => {
|
||||
let format: (timestamp?: number, acceptLanguageHeader?: string) => string;
|
||||
|
||||
beforeAll(() => {
|
||||
format = localizeTimestamp({
|
||||
defaultLanguage: 'en',
|
||||
supportedLanguages: [],
|
||||
}).format;
|
||||
});
|
||||
|
||||
it('returns the empty string if called without arguments', () => {
|
||||
expect(format()).toStrictEqual('');
|
||||
});
|
||||
|
||||
it('returns the default language if called without an Accept-Language header', () => {
|
||||
expect(format(Date.now() - 1)).toEqual('a few seconds ago');
|
||||
});
|
||||
|
||||
it('returns the default language if called with an unsupported language', () => {
|
||||
expect(format(Date.now() - 1, 'ru')).toEqual('a few seconds ago');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,53 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import moment from 'moment';
|
||||
import { determineLocale } from './determine-locale';
|
||||
|
||||
export type LocalizeOptions = {
|
||||
defaultLanguage: string;
|
||||
supportedLanguages?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* This module contains localization utils for the server
|
||||
*/
|
||||
export function localizeTimestamp({
|
||||
defaultLanguage,
|
||||
supportedLanguages = [],
|
||||
}: LocalizeOptions) {
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
// must support at least one language.
|
||||
supportedLanguages = [defaultLanguage];
|
||||
} else {
|
||||
// default language must come first
|
||||
supportedLanguages.unshift(defaultLanguage);
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Convert a given `timestamp` to a moment 'time from now' format
|
||||
* based on a given `acceptLanguage`.
|
||||
* Docs: http://momentjs.com/docs/#/displaying/fromnow/
|
||||
*
|
||||
* @param {Number} timestamp
|
||||
* @param {String} acceptLanguageHeader
|
||||
* @returns {String} Returns a localized string based on a given timestamp.
|
||||
* Returns an empty string if no timestamp provided.
|
||||
*/
|
||||
format: function format(timestamp?: number, acceptLanguageHeader?: string) {
|
||||
if (!timestamp) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// set the moment locale to determined `language`.
|
||||
const locale = determineLocale(
|
||||
acceptLanguageHeader || '',
|
||||
supportedLanguages
|
||||
);
|
||||
return moment(timestamp)
|
||||
.locale(locale || defaultLanguage)
|
||||
.fromNow();
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { FluentBundle, FluentResource } from '@fluent/bundle';
|
||||
import type { ILocalizerBindings } from './localizer.interfaces';
|
||||
import { DEFAULT_LOCALE } from '../l10n.constants';
|
||||
|
||||
export class LocalizerBase {
|
||||
protected readonly bindings: ILocalizerBindings;
|
||||
|
@ -7,6 +11,9 @@ export class LocalizerBase {
|
|||
this.bindings = bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @@todo - Add logic to optionally report errors in fetch
|
||||
*/
|
||||
protected async fetchMessages(currentLocales: string[]) {
|
||||
const fetchedPending: Record<string, Promise<string>> = {};
|
||||
const fetched: Record<string, string> = {};
|
||||
|
@ -28,13 +35,6 @@ export class LocalizerBase {
|
|||
if (fetchedLocale.status === 'fulfilled') {
|
||||
fetched[fetchedLocale.value.locale] = fetchedLocale.value.fetchedLocale;
|
||||
}
|
||||
|
||||
if (fetchedLocale.status === 'rejected') {
|
||||
console.error(
|
||||
'Could not fetch locale with reason: ',
|
||||
fetchedLocale.reason
|
||||
);
|
||||
}
|
||||
});
|
||||
return fetched;
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ export class LocalizerBase {
|
|||
* Returns the set of translated strings for the specified locale.
|
||||
* @param locale Locale to use, defaults to en.
|
||||
*/
|
||||
protected async fetchTranslatedMessages(locale = 'en') {
|
||||
protected async fetchTranslatedMessages(locale = DEFAULT_LOCALE) {
|
||||
const mainFtlPath = `${this.bindings.opts.translations.basePath}/${locale}/main.ftl`;
|
||||
return this.bindings.fetchResource(mainFtlPath);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import type { LocalizerOpts } from './localizer.models';
|
||||
import { ILocalizerBindings } from './localizer.interfaces';
|
||||
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { LocalizerClient } from './localizer.client';
|
||||
import { ILocalizerBindings } from './localizer.interfaces';
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { ReactLocalization } from '@fluent/react';
|
||||
import { determineLocale } from '../determine-locale';
|
||||
import { parseAcceptLanguage } from '../parse-accept-language';
|
||||
import { LocalizerBase } from './localizer.base';
|
||||
import { ILocalizerBindings } from './localizer.interfaces';
|
||||
import { determineLocale, parseAcceptLanguage } from '../l10n.utils';
|
||||
|
||||
export class LocalizerClient extends LocalizerBase {
|
||||
constructor(bindings: ILocalizerBindings) {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { LocalizerOpts } from './localizer.models';
|
||||
|
||||
export interface ILocalizerBindings {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
export type TranslationOpts = {
|
||||
basePath: string;
|
||||
};
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import 'server-only';
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { LocalizerServer } from './localizer.server';
|
||||
import { LocalizerBindingsServer } from './localizer.server.bindings';
|
||||
import { LocalizerRscFactory } from './localizer.rsc.factory';
|
||||
|
||||
export const LocalizerServerFactory: Provider<LocalizerServer> = {
|
||||
provide: LocalizerServer,
|
||||
export const LocalizerRscFactoryProvider: Provider<LocalizerRscFactory> = {
|
||||
provide: LocalizerRscFactory,
|
||||
useFactory: async () => {
|
||||
const bindings = new LocalizerBindingsServer({
|
||||
translations: { basePath: './public/locales' },
|
||||
});
|
||||
const localizer = new LocalizerServer(bindings);
|
||||
await localizer.populateBundles();
|
||||
const localizer = new LocalizerRscFactory(bindings);
|
||||
await localizer.init();
|
||||
return localizer;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LocalizerRscFactory } from './localizer.rsc.factory';
|
||||
import { ILocalizerBindings } from './localizer.interfaces';
|
||||
import supportedLanguages from '../supported-languages.json';
|
||||
import { LocalizerRsc } from './localizer.rsc';
|
||||
|
||||
describe('LocalizerRscFactory', () => {
|
||||
let localizer: LocalizerRscFactory;
|
||||
const bindings: ILocalizerBindings = {
|
||||
opts: {
|
||||
translations: {
|
||||
basePath: '',
|
||||
},
|
||||
},
|
||||
fetchResource: async (filePath) => {
|
||||
const locale = filePath.split('/')[1];
|
||||
switch (locale) {
|
||||
case 'fr':
|
||||
return 'test-id = Test Fr';
|
||||
default:
|
||||
return 'test-id = Test';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LocalizerRscFactory,
|
||||
useFactory: async () => new LocalizerRscFactory(bindings),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
localizer = module.get(LocalizerRscFactory);
|
||||
await localizer.init();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(localizer).toBeDefined();
|
||||
expect(localizer).toBeInstanceOf(LocalizerRscFactory);
|
||||
});
|
||||
|
||||
describe('fetchFluentBundles', () => {
|
||||
it('should succeed', async () => {
|
||||
expect(localizer['bundles'].size).toBe(supportedLanguages.length);
|
||||
expect(localizer['document']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLocalizerRsc', () => {
|
||||
it('should return instance of LocalizerRsc', async () => {
|
||||
const localizerRsc = await localizer.createLocalizerRsc('en');
|
||||
expect(localizerRsc).toBeInstanceOf(LocalizerRsc);
|
||||
});
|
||||
|
||||
it('should error when document or bundles are not initialized', async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LocalizerRscFactory,
|
||||
useFactory: async () => new LocalizerRscFactory(bindings),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
const emptyLocalizer = module.get(LocalizerRscFactory);
|
||||
|
||||
expect.assertions(1);
|
||||
try {
|
||||
emptyLocalizer.createLocalizerRsc('en');
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import 'server-only';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { LocalizerBase } from './localizer.base';
|
||||
import type { ILocalizerBindings } from './localizer.interfaces';
|
||||
import supportedLanguages from '../supported-languages.json';
|
||||
import { FluentBundle } from '@fluent/bundle';
|
||||
import { LocalizerRsc } from './localizer.rsc';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { parseAcceptLanguage } from '../l10n.utils';
|
||||
|
||||
@Injectable()
|
||||
export class LocalizerRscFactory extends LocalizerBase {
|
||||
private readonly bundles: Map<string, FluentBundle> = new Map();
|
||||
private document: any = null;
|
||||
constructor(bindings: ILocalizerBindings) {
|
||||
super(bindings);
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.fetchFluentBundles();
|
||||
this.document = new JSDOM().window.document;
|
||||
}
|
||||
|
||||
private async fetchFluentBundles() {
|
||||
const fetchedMessages = await this.fetchMessages(supportedLanguages);
|
||||
const bundleGenerator = this.createBundleGenerator(fetchedMessages);
|
||||
for await (const bundle of bundleGenerator(supportedLanguages)) {
|
||||
this.bundles.set(bundle.locales[0], bundle);
|
||||
}
|
||||
}
|
||||
|
||||
private parseMarkup() {
|
||||
return (str: string): Array<Node> => {
|
||||
if (!str.includes('<') && !str.includes('>')) {
|
||||
return [{ nodeName: '#text', textContent: str } as Node];
|
||||
}
|
||||
const wrapper = this.document.createElement('span');
|
||||
wrapper.innerHTML = str;
|
||||
return Array.from(wrapper.childNodes);
|
||||
};
|
||||
}
|
||||
|
||||
createLocalizerRsc(acceptLanguages: string) {
|
||||
if (!this.bundles.size || !this.document) {
|
||||
throw new Error(
|
||||
'Ensure factory is initialized before creating LocalizerRsc instances.'
|
||||
);
|
||||
}
|
||||
const supportedBundles: FluentBundle[] = [];
|
||||
const currentLocales = parseAcceptLanguage(acceptLanguages);
|
||||
currentLocales.forEach((locale) => {
|
||||
const bundle = this.bundles.get(locale);
|
||||
if (bundle) {
|
||||
supportedBundles.push(bundle);
|
||||
}
|
||||
});
|
||||
|
||||
return new LocalizerRsc(supportedBundles, this.parseMarkup());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import * as formatters from '../l10n.formatters';
|
||||
import { LocalizerRsc } from './localizer.rsc';
|
||||
import { ReactLocalization } from '@fluent/react';
|
||||
|
||||
jest.mock('@fluent/react', () => {
|
||||
return {
|
||||
ReactLocalization: jest.fn().mockImplementation(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('LocalizerRsc', () => {
|
||||
let localizerRsc: LocalizerRsc;
|
||||
let spyCreateFragment: jest.SpyInstance;
|
||||
const mockReactLocalization = {
|
||||
getElement: jest.fn(),
|
||||
getString: jest.fn(),
|
||||
getBundle: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
(ReactLocalization as jest.Mock).mockImplementation(() => {
|
||||
return mockReactLocalization;
|
||||
});
|
||||
spyCreateFragment = jest.spyOn(
|
||||
LocalizerRsc.prototype as any,
|
||||
'createFragment'
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockReactLocalization.getString.mockClear();
|
||||
mockReactLocalization.getElement.mockClear();
|
||||
mockReactLocalization.getBundle.mockClear();
|
||||
spyCreateFragment.mockClear();
|
||||
|
||||
localizerRsc = new LocalizerRsc([] as any);
|
||||
});
|
||||
|
||||
describe('getFragmentWithSource', () => {
|
||||
it('should call getElement with createElement fragment', () => {
|
||||
mockReactLocalization.getBundle = jest.fn().mockReturnValue(true);
|
||||
localizerRsc.getFragmentWithSource('id', {}, {} as any);
|
||||
expect(mockReactLocalization.getElement).toBeCalled();
|
||||
expect(spyCreateFragment).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call getElement with fallback', () => {
|
||||
mockReactLocalization.getBundle = jest.fn().mockReturnValue(false);
|
||||
localizerRsc.getFragmentWithSource('id', {}, {} as any);
|
||||
expect(mockReactLocalization.getElement).toBeCalled();
|
||||
expect(spyCreateFragment).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFragment', () => {
|
||||
it('should call getElement', () => {
|
||||
localizerRsc.getFragment('id', {});
|
||||
expect(mockReactLocalization.getElement).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getString', () => {
|
||||
const id = 'localizer-id';
|
||||
const fallback = 'fallback text';
|
||||
it('should call getString with no vars', () => {
|
||||
localizerRsc.getString(id, fallback);
|
||||
expect(mockReactLocalization.getString).toBeCalledWith(
|
||||
id,
|
||||
undefined,
|
||||
fallback
|
||||
);
|
||||
});
|
||||
|
||||
it('should call getString with vars', () => {
|
||||
const vars = { test: 'var' };
|
||||
localizerRsc.getString(id, vars, fallback);
|
||||
expect(mockReactLocalization.getString).toBeCalledWith(
|
||||
id,
|
||||
vars,
|
||||
fallback
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatters', () => {
|
||||
const amountInCents = 50;
|
||||
const currency = 'usd';
|
||||
const unixSeconds = Date.now() / 1000;
|
||||
it('should call getLocalizedCurrency', () => {
|
||||
const spy = jest.spyOn(formatters, 'getLocalizedCurrency');
|
||||
localizerRsc.getLocalizedCurrency(amountInCents, currency);
|
||||
expect(spy).toBeCalledWith(amountInCents, currency);
|
||||
});
|
||||
|
||||
it('should call getLocalizedCurrencyString', () => {
|
||||
const spy = jest.spyOn(formatters, 'getLocalizedCurrencyString');
|
||||
localizerRsc.getLocalizedCurrencyString(amountInCents, currency);
|
||||
expect(spy).toBeCalledWith(amountInCents, currency);
|
||||
});
|
||||
|
||||
it('should call getLocalizedDate', () => {
|
||||
const spy = jest.spyOn(formatters, 'getLocalizedDate');
|
||||
localizerRsc.getLocalizedDate(unixSeconds);
|
||||
expect(spy).toBeCalledWith(unixSeconds, false);
|
||||
});
|
||||
|
||||
it('should call getLocalizedDateString', () => {
|
||||
const spy = jest.spyOn(formatters, 'getLocalizedDateString');
|
||||
localizerRsc.getLocalizedDateString(unixSeconds);
|
||||
expect(spy).toBeCalledWith(unixSeconds, false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,120 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import 'server-only';
|
||||
import {
|
||||
FluentBundle,
|
||||
FluentDateTime,
|
||||
FluentNumber,
|
||||
FluentVariable,
|
||||
} from '@fluent/bundle';
|
||||
import type { MarkupParser } from '@fluent/react';
|
||||
// @fluent/react's default export bundles all code in a single scope, so just
|
||||
// importing <ReactLocalization> from there will run createContext,
|
||||
// which is not allowed in server components. To avoid that, we import directly
|
||||
// from the included ES module code. There is the risk that @fluent/react
|
||||
// updates break that.
|
||||
// Src + TY: https://github.com/mozilla/blurts-server/blob/main/src/app/functions/server/l10n.ts
|
||||
import { ReactLocalization } from '@fluent/react/esm/localization';
|
||||
import { Fragment, ReactElement, createElement } from 'react';
|
||||
import {
|
||||
getLocalizedCurrency,
|
||||
getLocalizedCurrencyString,
|
||||
getLocalizedDate,
|
||||
getLocalizedDateString,
|
||||
} from '../l10n.formatters';
|
||||
|
||||
/**
|
||||
* This class is largely a wrapper around the ReactLocalization class, to adapt it's functionality
|
||||
* to function as desired in React Server Components.
|
||||
*
|
||||
* A few formatters, from l10n.formatters, are also wrapped by this class to work around some
|
||||
* unexpected behavior during Next.js hot refreshes.
|
||||
*/
|
||||
export class LocalizerRsc {
|
||||
private l10n: ReactLocalization;
|
||||
constructor(
|
||||
bundles: Iterable<FluentBundle>,
|
||||
parseMarkup?: MarkupParser | null,
|
||||
reportError?: (error: Error) => void
|
||||
) {
|
||||
this.l10n = new ReactLocalization(bundles, parseMarkup, reportError);
|
||||
}
|
||||
|
||||
private createFragment(id: string) {
|
||||
return createElement(Fragment, null, id);
|
||||
}
|
||||
/**
|
||||
* Localize react element with a fallback element.
|
||||
* Caution - Ensure the correct fallback value is provided
|
||||
* NB! - Fallback needs to match same format as expected else Hydration error might occur
|
||||
*/
|
||||
getFragmentWithSource(
|
||||
id: string,
|
||||
args: {
|
||||
vars?: Record<string, FluentVariable>;
|
||||
elems?: Record<string, ReactElement>;
|
||||
attrs?: Record<string, boolean>;
|
||||
},
|
||||
fallback: ReactElement
|
||||
) {
|
||||
// If bundle for id is not found, use the fallback as sourceElement for getElement
|
||||
const sourceElement = this.l10n.getBundle(id)
|
||||
? this.createFragment(id)
|
||||
: fallback;
|
||||
|
||||
return this.l10n.getElement(sourceElement, id, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize react element without a fallback element. If id is invalid, or localization failed, a React fragment with
|
||||
* the value of "id" as text content will be returned.
|
||||
*/
|
||||
getFragment(
|
||||
id: string,
|
||||
args: {
|
||||
vars?: Record<string, FluentVariable>;
|
||||
elems?: Record<string, ReactElement>;
|
||||
attrs?: Record<string, boolean>;
|
||||
}
|
||||
) {
|
||||
return this.l10n.getElement(this.createFragment(id), id, args);
|
||||
}
|
||||
|
||||
getString(id: string, fallback: string): string;
|
||||
getString(
|
||||
id: string,
|
||||
vars: Record<string, FluentVariable> | null,
|
||||
fallback: string
|
||||
): string;
|
||||
getString(
|
||||
id: string,
|
||||
varsOrFallback: Record<string, FluentVariable> | null | string,
|
||||
fallback?: string
|
||||
): string {
|
||||
if (typeof varsOrFallback === 'string') {
|
||||
return this.l10n.getString(id, undefined, varsOrFallback);
|
||||
} else {
|
||||
return this.l10n.getString(id, varsOrFallback, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
getLocalizedCurrency(
|
||||
amountInCents: number | null,
|
||||
currency: string
|
||||
): FluentNumber {
|
||||
return getLocalizedCurrency(amountInCents, currency);
|
||||
}
|
||||
|
||||
getLocalizedCurrencyString(amountInCents: number | null, currency: string) {
|
||||
return getLocalizedCurrencyString(amountInCents, currency);
|
||||
}
|
||||
|
||||
getLocalizedDate(unixSeconds: number, numericDate = false): FluentDateTime {
|
||||
return getLocalizedDate(unixSeconds, numericDate);
|
||||
}
|
||||
|
||||
getLocalizedDateString(unixSeconds: number, numericDate = false): string {
|
||||
return getLocalizedDateString(unixSeconds, numericDate);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,7 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import 'server-only';
|
||||
import { promises as fsPromises, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { LocalizerOpts } from './localizer.models';
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LocalizerServer } from './localizer.server';
|
||||
import { ILocalizerBindings } from './localizer.interfaces';
|
||||
import supportedLanguages from '../supported-languages.json';
|
||||
|
||||
describe('LocalizerServer', () => {
|
||||
let localizer: LocalizerServer;
|
||||
const bindings: ILocalizerBindings = {
|
||||
opts: {
|
||||
translations: {
|
||||
basePath: '',
|
||||
},
|
||||
},
|
||||
fetchResource: async (filePath) => {
|
||||
const locale = filePath.split('/')[1];
|
||||
switch (locale) {
|
||||
case 'fr':
|
||||
return 'test-id = Test Fr';
|
||||
default:
|
||||
return 'test-id = Test';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LocalizerServer,
|
||||
useFactory: async () => new LocalizerServer(bindings),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
localizer = module.get(LocalizerServer);
|
||||
await localizer.populateBundles();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(localizer).toBeDefined();
|
||||
expect(localizer).toBeInstanceOf(LocalizerServer);
|
||||
});
|
||||
|
||||
it('testing', async () => {
|
||||
const bundle = localizer.getBundle('fr');
|
||||
expect(bundle.locales).toEqual(['fr']);
|
||||
expect(bundle.getMessage('test-id')?.value).toBe('Test Fr');
|
||||
});
|
||||
|
||||
describe('populateBundles', () => {
|
||||
it('should succeed', async () => {
|
||||
expect(localizer['bundles'].size).toBe(supportedLanguages.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBundle', () => {
|
||||
it('should return bundle for locale', () => {
|
||||
const bundle = localizer.getBundle('fr');
|
||||
expect(bundle.locales).toEqual(['fr']);
|
||||
expect(bundle.getMessage('test-id')?.value).toBe('Test Fr');
|
||||
});
|
||||
|
||||
it('should return empty bundle for missing locale', () => {
|
||||
const bundle = localizer.getBundle('nope');
|
||||
expect(bundle.locales).toEqual(['nope']);
|
||||
expect(bundle._messages.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,33 +0,0 @@
|
|||
import { FluentBundle } from '@fluent/bundle';
|
||||
import { LocalizerBase } from './localizer.base';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import supportedLanguages from '../supported-languages.json';
|
||||
import type { ILocalizerBindings } from './localizer.interfaces';
|
||||
|
||||
@Injectable()
|
||||
export class LocalizerServer extends LocalizerBase {
|
||||
private readonly bundles: Map<string, FluentBundle> = new Map();
|
||||
constructor(bindings: ILocalizerBindings) {
|
||||
super(bindings);
|
||||
}
|
||||
|
||||
async populateBundles() {
|
||||
const fetchedMessages = await this.fetchMessages(supportedLanguages);
|
||||
const bundleGenerator = this.createBundleGenerator(fetchedMessages);
|
||||
for await (const bundle of bundleGenerator(supportedLanguages)) {
|
||||
this.bundles.set(bundle.locales[0], bundle);
|
||||
}
|
||||
}
|
||||
|
||||
getBundle(locale: string) {
|
||||
const bundle = this.bundles.get(locale);
|
||||
if (!bundle) {
|
||||
// If no bundle is found, return an empty bundle
|
||||
return new FluentBundle(locale, {
|
||||
useIsolating: false,
|
||||
});
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
}
|
|
@ -1,149 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { parseAcceptLanguage } from './parse-accept-language';
|
||||
|
||||
describe('l10n/parseAcceptLanguage:', () => {
|
||||
it('returns default', () => {
|
||||
expect(parseAcceptLanguage('en')).toEqual([
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty case', () => {
|
||||
expect(parseAcceptLanguage('')).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('handles unknown', () => {
|
||||
expect(parseAcceptLanguage('xyz')).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('parses single and always contains default language (en)', () => {
|
||||
expect(parseAcceptLanguage('it')).toEqual(['it', 'en']);
|
||||
});
|
||||
|
||||
it('parses several with expected output', () => {
|
||||
expect(parseAcceptLanguage('en, de, es, ru')).toEqual([
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
'de',
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'es-MX',
|
||||
'ru',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('qvalue', () => {
|
||||
it('applies correctly with an implicit and explicit value', () => {
|
||||
expect(parseAcceptLanguage('ru;q=0.3, it')).toEqual(['it', 'ru', 'en']);
|
||||
});
|
||||
|
||||
it('applies correctly with multiple explicit and implicit values', () => {
|
||||
expect(parseAcceptLanguage('de, it;q=0.8, en;q=0.5, es;q=1.0')).toEqual([
|
||||
'de',
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'es-MX',
|
||||
'it',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('applies correctly with dialects', () => {
|
||||
expect(parseAcceptLanguage('de-DE, en-US;q=0.7, en;q=0.3')).toEqual([
|
||||
'de',
|
||||
'en-US',
|
||||
'en',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dialect (region) options', () => {
|
||||
it('handles en-*', () => {
|
||||
expect(parseAcceptLanguage('en-CA')).toEqual([
|
||||
'en-CA',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-GB',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all options and always contains default language (en)', () => {
|
||||
expect(parseAcceptLanguage('es')).toEqual([
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'es-MX',
|
||||
'en',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles region with incorrect case', () => {
|
||||
expect(parseAcceptLanguage('es-mx, ru')).toEqual([
|
||||
'es-MX',
|
||||
'es',
|
||||
'es-ES',
|
||||
'es-AR',
|
||||
'es-CL',
|
||||
'ru',
|
||||
'en',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives "en" higher priority than second locale when first locale is en-*', () => {
|
||||
expect(parseAcceptLanguage('en-US, de')).toEqual([
|
||||
'en-US',
|
||||
'en',
|
||||
'en-GB',
|
||||
'en-CA',
|
||||
'de',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles alias to en-GB', () => {
|
||||
expect(parseAcceptLanguage('en-NZ')).toEqual([
|
||||
'en-GB',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles multiple languages with en-GB alias', () => {
|
||||
expect(parseAcceptLanguage('en-NZ, en-GB, en-MY')).toEqual([
|
||||
'en-GB',
|
||||
'en',
|
||||
'en-US',
|
||||
'en-CA',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to root language if dialect is missing', () => {
|
||||
expect(parseAcceptLanguage('fr-FR')).toEqual(['fr', 'en']);
|
||||
});
|
||||
|
||||
it('handles Chinese dialects properly', () => {
|
||||
expect(parseAcceptLanguage('zh-CN, zh-TW, zh-HK, zh')).toEqual([
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,70 +0,0 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
import { negotiateLanguages } from '@fluent/langneg';
|
||||
import availableLocales from './supported-languages.json';
|
||||
import { EN_GB_LOCALES } from './other-languages';
|
||||
|
||||
/**
|
||||
* Takes an acceptLanguage value (assumed to come from an http header) and returns
|
||||
* a set of valid locales in order of precedence.
|
||||
* @param acceptLanguage - Http header accept-language value
|
||||
* @param supportedLanguages - List of supported language codes
|
||||
* @returns A list of associated supported locales. If there are no matches for the given
|
||||
* accept language, then the default locle, en, will be returned.
|
||||
*
|
||||
*/
|
||||
export function parseAcceptLanguage(
|
||||
acceptLanguage: string,
|
||||
supportedLanguages?: string[]
|
||||
) {
|
||||
if (!supportedLanguages) {
|
||||
supportedLanguages = availableLocales;
|
||||
}
|
||||
if (acceptLanguage == null) {
|
||||
acceptLanguage = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on criteria set forth here: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4
|
||||
* Process involves breaking string by comma, then extracting language code and numeric q value. Then
|
||||
* languages are sorted by q value. If no q-value is specified, then the q value defaults to
|
||||
* 1 as per the specificiation. Q-values are clamped between 0-1.
|
||||
*/
|
||||
const parsedLocales = acceptLanguage.split(',');
|
||||
const qValues: Record<string, number> = {};
|
||||
for (const locale of parsedLocales) {
|
||||
const localeSplit = locale.trim().split(';');
|
||||
let lang = localeSplit[0];
|
||||
const q = localeSplit[1];
|
||||
|
||||
const match = /(q=)([0-9\.-]*)/gm.exec(q) || [];
|
||||
const qvalue = parseFloat(match[2]) || 1.0;
|
||||
|
||||
if (EN_GB_LOCALES.includes(lang)) {
|
||||
lang = 'en-GB';
|
||||
}
|
||||
|
||||
// Make regions case insensitive. This might not be technically valid, but
|
||||
// trying keep things backwards compatible.
|
||||
lang = lang.toLocaleLowerCase();
|
||||
|
||||
if (!qValues[lang]) {
|
||||
qValues[lang] = Math.max(Math.min(qvalue, 1.0), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Order of locales represents priority and should correspond to q-values.
|
||||
const sortedQValues = Object.entries(qValues).sort((a, b) => b[1] - a[1]);
|
||||
const parsedLocalesByQValue = sortedQValues.map((qValue) => qValue[0]);
|
||||
|
||||
const currentLocales = negotiateLanguages(
|
||||
parsedLocalesByQValue,
|
||||
[...supportedLanguages],
|
||||
{
|
||||
defaultLocale: 'en',
|
||||
}
|
||||
);
|
||||
|
||||
return currentLocales;
|
||||
}
|
|
@ -1,3 +1,7 @@
|
|||
export * from './lib/localizer/localizer.server';
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
export * from './lib/localizer/localizer.server.bindings';
|
||||
export * from './lib/localizer/localizer.provider';
|
||||
export * from './lib/localizer/localizer.rsc.factory';
|
||||
export * from './lib/localizer/localizer.rsc';
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/**
|
||||
* This is a work around along with the moduleNameMapper in jest.config.ts
|
||||
* ```
|
||||
* // Disable server-only
|
||||
* 'server-only': `<rootDir>/__mocks__/empty.js`,
|
||||
* ```
|
||||
*
|
||||
* Saw this as a recommended work around in Pages Router docs
|
||||
* https://nextjs.org/docs/pages/building-your-application/testing/jest
|
||||
*
|
||||
* I also found a few discussions saying this issue has alreayd been resolved
|
||||
* but couldn't get it working. Worth checking back later, and potentially
|
||||
* removing this file.
|
||||
*/
|
|
@ -187,6 +187,8 @@
|
|||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"babel-jest": "^29.7.0",
|
||||
"esbuild": "^0.17.15",
|
||||
"esbuild-register": "^3.5.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-next": "14.1.4",
|
||||
"eslint-plugin-fxa": "workspace:*",
|
||||
|
@ -212,6 +214,7 @@
|
|||
"postcss": "^8.4.31",
|
||||
"react-test-renderer": "^18.2.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"server-only": "^0.0.1",
|
||||
"stylelint": "^16.2.1",
|
||||
"stylelint-config-prettier": "^9.0.3",
|
||||
"stylelint-config-recommended-scss": "^14.0.0",
|
||||
|
|
|
@ -49,6 +49,7 @@
|
|||
"@fxa/shared/error": ["libs/shared/error/src/index.ts"],
|
||||
"@fxa/shared/l10n": ["libs/shared/l10n/src/index.ts"],
|
||||
"@fxa/shared/l10n/server": ["libs/shared/l10n/src/server.ts"],
|
||||
"@fxa/shared/l10n/client": ["libs/shared/l10n/src/client.ts"],
|
||||
"@fxa/shared/log": ["libs/shared/log/src/index.ts"],
|
||||
"@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index.ts"],
|
||||
},
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
declare module 'server-only';
|
10
yarn.lock
10
yarn.lock
|
@ -39013,6 +39013,8 @@ fsevents@~2.1.1:
|
|||
class-transformer: ^0.5.1
|
||||
class-validator: ^0.14.1
|
||||
diffparser: ^2.0.1
|
||||
esbuild: ^0.17.15
|
||||
esbuild-register: ^3.5.0
|
||||
eslint: ^7.32.0
|
||||
eslint-config-next: 14.1.4
|
||||
eslint-plugin-fxa: "workspace:*"
|
||||
|
@ -39065,6 +39067,7 @@ fsevents@~2.1.1:
|
|||
reflect-metadata: ^0.2.1
|
||||
rxjs: ^7.8.1
|
||||
semver: ^7.6.0
|
||||
server-only: ^0.0.1
|
||||
stylelint: ^16.2.1
|
||||
stylelint-config-prettier: ^9.0.3
|
||||
stylelint-config-recommended-scss: ^14.0.0
|
||||
|
@ -59534,6 +59537,13 @@ resolve@1.1.7:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"server-only@npm:^0.0.1":
|
||||
version: 0.0.1
|
||||
resolution: "server-only@npm:0.0.1"
|
||||
checksum: c432348956641ea3f460af8dc3765f3a1bdbcf7a1e0205b0756d868e6e6fe8934cdee6bff68401a1dd49ba4a831c75916517a877446d54b334f7de36fa273e53
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-blocking@npm:^2.0.0, set-blocking@npm:~2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "set-blocking@npm:2.0.0"
|
||||
|
|
Загрузка…
Ссылка в новой задаче