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:
Reino Muhl 2024-03-21 17:00:19 -04:00
Родитель 1cf03d60ad
Коммит 652c09ab50
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: C86660FCF998897A
56 изменённых файлов: 1427 добавлений и 1232 удалений

Просмотреть файл

@ -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 ids 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 dutilisation
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 dabonnement
subscription-error-title = Erreur lors de la confirmation de labonnement…
iap-upgrade-contact-support = Vous pouvez tout de même obtenir ce produit ; veuillez contacter notre équipe dassistance 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)
)}
&nbsp;&bull;&nbsp;
{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 dutilisation\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"],
},

1
types/server-only/index.d.ts поставляемый Normal file
Просмотреть файл

@ -0,0 +1 @@
declare module 'server-only';

Просмотреть файл

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