Skip to content

Commit

Permalink
feat: add basePath support and new getLocalizedUrl API
Browse files Browse the repository at this point in the history
`basePath` support was never fully tested and found non-functional. To ensure it will stay functional, Cypress tests now automatically test `basePath` when running the new `npm run tests` command.

The new `getLocalizedUrl` API will allow access to server side localized URLs which can be especially useful when using Next.js' API routes. Cases like transactional emails will now be able to benefit from localized URLs.

BREAKING CHANGE: `useLocalizedUrl` was moved from `next-multilingual/link` to `next-multilingual/url`
  • Loading branch information
nbouvrette committed Apr 25, 2022
1 parent 45631e0 commit fb7f9cf
Show file tree
Hide file tree
Showing 28 changed files with 717 additions and 253 deletions.
58 changes: 51 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,13 @@ module.exports = {
},
webpack(config, { isServer }) {
if (isServer) {
config.resolve.alias['next-multilingual/link$'] = require.resolve(
'next-multilingual/link/ssr'
);
config.resolve.alias['next-multilingual/head$'] = require.resolve(
'next-multilingual/head/ssr'
);
config.resolve.alias['next-multilingual/link$'] = require.resolve(
'next-multilingual/link/ssr'
);
config.resolve.alias['next-multilingual/url$'] = require.resolve('next-multilingual/url/ssr');
}
return config;
},
Expand Down Expand Up @@ -201,7 +202,7 @@ class MyDocument extends Document {
export default MyDocument;
```

This serves only 1 purpose: display the correct server-side locale in the `<html>` tag. Since we are using a "fake" default locale, it's important to keep the correct SSR markup, especially when resolving a dynamic locale on `/`. The `normalizeLocale` is not mandatory but a recommended ISO 3166 convention. Since Next.js uses the locales as URL prefixes, they are lower-cased in the configuration and can be re-normalized as needed.
This serves only 1 purpose: display the correct server side locale in the `<html>` tag. Since we are using a "fake" default locale, it's important to keep the correct SSR markup, especially when resolving a dynamic locale on `/`. The `normalizeLocale` is not mandatory but a recommended ISO 3166 convention. Since Next.js uses the locales as URL prefixes, they are lower-cased in the configuration and can be re-normalized as needed.

### Configure all your pages to use SEO friendly markup

Expand Down Expand Up @@ -452,13 +453,13 @@ Each of these links will be automatically localized when the `slug` key is speci

As the data for this mapping is not immediately available during rendering, `next-multilingual/link/ssr` will take care of the server side rendering (SSR). By using `next-multilingual/config`'s `getConfig`, the Webpack configuration will be added automatically. If you are using the advanced `Config` method, this explains why the special Webpack configuration is required in the example provided prior.

### Adding links to other components
### Using localized URLs in other components

Not all links are using the `<Link>` component and this is also why Next.js has the `router.push` method that can be used by many other use cases. `next-multilingual` can support these use cases with the `useLocalizedUrl` hook that will return a localized URL, usable by any components. Here is an example on how it can be leveraged:
Not all localized URLs are using the `<Link>` component and this is also why Next.js has the `router.push` method that can be used by many other use cases. `next-multilingual` can support these use cases with the `useLocalizedUrl` hook that will return a localized URL, usable by any components. Here is an example on how it can be leveraged:

```tsx
import { NextPage } from 'next';
import { useLocalizedUrl } from 'next-multilingual/link';
import { useLocalizedUrl } from 'next-multilingual/url';
import { useMessages } from 'next-multilingual/messages';
import router from 'next/router';

Expand All @@ -471,6 +472,49 @@ const Tests: NextPage = () => {
export default Tests;
```

### Server side localized URLs

There could be cases where you need to use localized URLs on the server side and hooks (`useLocalizedUrl`) cannot be used. Imagine using Next.js' API to send transactional emails and wanting to leverage `next-multilingual`'s localized URLs without having to hardcode them in a configuration. This is where `getLocalizedUrl` comes in. `getLocalizedUrl` is only usable on the server side which is why it is imported directly from `next-multilingual/url/ssr`. Here is an example of how it can be used:

```ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { isLocale } from 'next-multilingual';
import { getLocalizedUrl } from 'next-multilingual/url/ssr';
import { getMessages } from 'next-multilingual/messages';

import { sendEmail } from '../send-email/';

/**
* The "/api/send-email" handler.
*/
export default async function handler(
request: NextApiRequest,
response: NextApiResponse
): Promise<void> {
const locale = request.headers['accept-language'];
let emailAddress = '';

try {
emailAddress = JSON.parse(request.body).emailAddress;
} catch (error) {
response.status(400);
return;
}

if (locale === undefined || !isLocale(locale) || !emailAddress.length) {
response.status(400);
return;
}

const messages = getMessages(locale);
sendEmail(
emailAddress,
messages.format('welcome', { loginUrl: getLocalizedUrl('/login', locale, true) })
);
response.status(200);
}
```

### Creating components

Creating components is the same as pages but they live outside the `pages` directory. Also, the `slug` key (if used) will not have any impact on URLs. We have a few [example components](./example/components) that should be self-explanatory but here is an example of a `Footer.tsx` component:
Expand Down
3 changes: 3 additions & 0 deletions cypress/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ export const ACTUAL_DEFAULT_LOCALE = ACTUAL_LOCALES[0];

/** The origin used by the Next.js application. */
export const ORIGIN = Cypress.env('isProd') ? Cypress.env('prodBaseUrl') : Cypress.config().baseUrl;

/** The base path of the Next.js application. (set manually when testing `basePath`) */
export const BASE_PATH = Cypress.env('basePath') !== undefined ? Cypress.env('basePath') : '';
8 changes: 5 additions & 3 deletions cypress/integration/anchor-links.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ACTUAL_LOCALES, LOCALE_NAMES } from '../constants';
import { ACTUAL_LOCALES, BASE_PATH, LOCALE_NAMES } from '../constants';

export const ANCHOR_LINKS_TESTS_URLS = {
'en-US': '/tests/anchor-links',
Expand All @@ -17,8 +17,10 @@ export const LONG_PAGE_TESTS_URLS = {

describe('An anchor link', () => {
ACTUAL_LOCALES.forEach((locale) => {
const anchorLinksTestsUrl = `/${locale.toLowerCase()}${ANCHOR_LINKS_TESTS_URLS[locale]}`;
const longPageTestsUrl = `/${locale.toLowerCase()}${LONG_PAGE_TESTS_URLS[locale]}`;
const anchorLinksTestsUrl = `${BASE_PATH}/${locale.toLowerCase()}${
ANCHOR_LINKS_TESTS_URLS[locale]
}`;
const longPageTestsUrl = `${BASE_PATH}/${locale.toLowerCase()}${LONG_PAGE_TESTS_URLS[locale]}`;
const linkWithFragment = `${longPageTestsUrl}#${ANCHOR_LINKS_TESTS_FRAGMENTS[locale]}`;

it(`will have the correct SSR markup when using an anchor link for '${LOCALE_NAMES[locale]}'`, () => {
Expand Down
29 changes: 20 additions & 9 deletions cypress/integration/dynamic-routes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ACTUAL_DEFAULT_LOCALE, ACTUAL_LOCALES, LOCALE_NAMES, ORIGIN } from '../constants';
import {
ACTUAL_DEFAULT_LOCALE, ACTUAL_LOCALES, BASE_PATH, LOCALE_NAMES, ORIGIN
} from '../constants';

export const DYNAMIC_ROUTE_URLS = {
'en-US': '/tests/dynamic-routes',
Expand All @@ -9,7 +11,9 @@ describe('A dynamic route', () => {
ACTUAL_LOCALES.forEach((locale) => {
const localeName = LOCALE_NAMES[locale];

const dynamicRouteIndexUrl = `/${locale.toLowerCase()}${DYNAMIC_ROUTE_URLS[locale]}`;
const dynamicRouteIndexUrl = `${BASE_PATH}/${locale.toLowerCase()}${
DYNAMIC_ROUTE_URLS[locale]
}`;

let source: string;
let parameterValue: string;
Expand All @@ -36,7 +40,7 @@ describe('A dynamic route', () => {
expect(inputMarkup).to.match(inputValueRegExp);
parameterValue = inputMarkup.match(inputValueRegExp).groups['parameterValue'];
dynamicRouteUrl = `${dynamicRouteIndexUrl}/${parameterValue}`;
canonicalDynamicRouteUrl = `/${ACTUAL_DEFAULT_LOCALE.toLowerCase()}${
canonicalDynamicRouteUrl = `${BASE_PATH}/${ACTUAL_DEFAULT_LOCALE.toLowerCase()}${
DYNAMIC_ROUTE_URLS[ACTUAL_DEFAULT_LOCALE]
}/${parameterValue}`;
expect(source).to.match(linkMarkupRegExp);
Expand Down Expand Up @@ -71,8 +75,8 @@ describe('A dynamic route', () => {

// Localized <Link> click() (client-side)
it(`has the correct URL when clicking (client-side) on a <Link> component for '${localeName}'`, () => {
cy.get(`#link-with-parameter`)
.click({ timeout: 10000 })
cy.get(`#link-with-parameter`, { timeout: 15000 })
.click()
.then(() => {
cy.url().should('eq', `${Cypress.config().baseUrl}${dynamicRouteUrl}`);
});
Expand Down Expand Up @@ -108,7 +112,7 @@ describe('A dynamic route', () => {
// Localized Alternate <Head> link (SSR)
it(`has the correct 'alternate' <Head> links (SSR) markup for '${localeName}'`, () => {
ACTUAL_LOCALES.forEach((locale) => {
const alternateLinkHref = `/${locale.toLowerCase()}${
const alternateLinkHref = `${BASE_PATH}/${locale.toLowerCase()}${
DYNAMIC_ROUTE_URLS[locale]
}/${parameterValue}`;
const alternateLinkMarkup = `<link rel="alternate" href="${ORIGIN}${alternateLinkHref}" hrefLang="${locale}"/>`;
Expand Down Expand Up @@ -140,7 +144,9 @@ describe('A dynamic route', () => {
.should('have.attr', 'href')
.then((href) => {
expect(href).eq(
`${ORIGIN}/${locale.toLowerCase()}${DYNAMIC_ROUTE_URLS[locale]}/${parameterValue}`
`${ORIGIN}${BASE_PATH}/${locale.toLowerCase()}${
DYNAMIC_ROUTE_URLS[locale]
}/${parameterValue}`
);
});
});
Expand All @@ -157,7 +163,9 @@ describe('A dynamic route', () => {
.should('have.attr', 'href')
.then((href) => {
expect(href).eq(
`/${otherLocale.toLowerCase()}${DYNAMIC_ROUTE_URLS[otherLocale]}/${parameterValue}`
`${BASE_PATH}/${otherLocale.toLowerCase()}${
DYNAMIC_ROUTE_URLS[otherLocale]
}/${parameterValue}`
);
});
});
Expand All @@ -171,7 +179,10 @@ describe('A dynamic route', () => {
cy.get(`#language-picker`).trigger('mouseout');
cy.get(`#go-back a`)
.should('have.attr', 'href')
.should('eq', `/${otherLocale.toLowerCase()}${DYNAMIC_ROUTE_URLS[otherLocale]}`);
.should(
'eq',
`${BASE_PATH}/${otherLocale.toLowerCase()}${DYNAMIC_ROUTE_URLS[otherLocale]}`
);
});
});
});
42 changes: 23 additions & 19 deletions cypress/integration/homepage.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
ACTUAL_DEFAULT_LOCALE, ACTUAL_LOCALES, DEFAULT_LOCALE, LOCALE_NAMES, LOCALES, ORIGIN
ACTUAL_DEFAULT_LOCALE, ACTUAL_LOCALES, BASE_PATH, DEFAULT_LOCALE, LOCALE_NAMES, LOCALES, ORIGIN
} from '../constants';

export const ABOUT_US_URLS = {
Expand Down Expand Up @@ -28,8 +28,8 @@ export const PLURAL_MESSAGES = {
};

export const API_RESPONSES = {
'en-US': 'Hello from the API.',
'fr-CA': "L'API dit bonjour.",
'en-US': `API response: the URL of the "Contact Us" page is: ${ORIGIN}${BASE_PATH}/en-us/contact-us`,
'fr-CA': `Réponse de l'API: l'URL de la page "Nous joindre" est: ${ORIGIN}${BASE_PATH}/fr-ca/nous-joindre`,
};

describe('The homepage', () => {
Expand All @@ -39,7 +39,7 @@ describe('The homepage', () => {
it(`returns SSR html that contains '${htmlTagMarkup}' (actual default locale) when a client locale is invalid`, () => {
cy.request({
method: 'GET',
url: '/',
url: `${BASE_PATH}/`,
headers: {
'Accept-Language': invalidLocale,
Cookie: 'L=',
Expand All @@ -52,7 +52,7 @@ describe('The homepage', () => {
// Check that the content renders using the default locale on the client side.
it(`dynamically renders content with the actual default locale when a client locale is invalid`, () => {
cy.visit({
url: '/',
url: `${BASE_PATH}/`,
headers: {
'Accept-Language': invalidLocale,
Cookie: 'L=',
Expand All @@ -73,7 +73,7 @@ describe('The homepage', () => {
it(`returns SSR html that contains '${htmlTagMarkup}' for '${localeName}'`, () => {
cy.request({
method: 'GET',
url: '/',
url: `${BASE_PATH}/`,
headers: {
'Accept-Language': locale,
Cookie: 'L=',
Expand All @@ -85,29 +85,31 @@ describe('The homepage', () => {
});

// Check that the canonical link points on the default locale on the SSR markup.
const canonicalLinkMarkup = `<link rel="canonical" href="${ORIGIN}/${ACTUAL_DEFAULT_LOCALE.toLowerCase()}"/>`;
const canonicalLinkMarkup = `<link rel="canonical" href="${ORIGIN}${BASE_PATH}/${ACTUAL_DEFAULT_LOCALE.toLowerCase()}"/>`;
it(`returns SSR html that contains '${canonicalLinkMarkup}' for '${localeName}'`, () => {
expect(source).to.contain(canonicalLinkMarkup);
});

// Check that all alternate links or all locales are present on the SSR markup.
it(`returns SSR html that contains all alternate links for '${localeName}'`, () => {
ACTUAL_LOCALES.forEach((locale) => {
const alternateLinkMarkup = `<link rel="alternate" href="${ORIGIN}/${locale.toLowerCase()}" hrefLang="${locale}"/>`;
const alternateLinkMarkup = `<link rel="alternate" href="${ORIGIN}${BASE_PATH}/${locale.toLowerCase()}" hrefLang="${locale}"/>`;
expect(source).to.contain(alternateLinkMarkup);
});
});

// Test the localized SSR URL for the "about us" page.
const aboutUsAnchorMarkup = `<a href="/${locale.toLowerCase()}${ABOUT_US_URLS[locale]}">`;
const aboutUsAnchorMarkup = `<a href="${BASE_PATH}/${locale.toLowerCase()}${
ABOUT_US_URLS[locale]
}">`;
it(`returns SSR html that contains '${aboutUsAnchorMarkup}' for '${localeName}'`, () => {
expect(source).to.contain(aboutUsAnchorMarkup);
});

// Check that the content renders dynamically on the client side.
it(`dynamically renders content with the correct 'Accept-Language' header for '${localeName}'`, () => {
cy.visit({
url: '/',
url: `${BASE_PATH}/`,
headers: {
'Accept-Language': LANGUAGE_DIRECTIVES[locale],
Cookie: 'L=',
Expand All @@ -121,7 +123,7 @@ describe('The homepage', () => {
cy.get(`head link[rel=canonical]`)
.should('have.attr', 'href')
.then((href) => {
expect(href).eq(`${ORIGIN}/${ACTUAL_DEFAULT_LOCALE.toLowerCase()}`);
expect(href).eq(`${ORIGIN}${BASE_PATH}/${ACTUAL_DEFAULT_LOCALE.toLowerCase()}`);
});
});

Expand All @@ -131,14 +133,16 @@ describe('The homepage', () => {
cy.get(`head link[rel=alternate][hreflang=${locale}]`)
.should('have.attr', 'href')
.then((href) => {
expect(href).eq(`${ORIGIN}/${locale.toLowerCase()}`);
expect(href).eq(`${ORIGIN}${BASE_PATH}/${locale.toLowerCase()}`);
});
});
});

// Test the localized client side URL for the "about us" page.
it(`dynamically renders client side html that contains localized links for '${localeName}'`, () => {
cy.get(`body nav a[href='/${locale.toLowerCase()}${ABOUT_US_URLS[locale]}']`).should('exist');
cy.get(
`body nav a[href='${BASE_PATH}/${locale.toLowerCase()}${ABOUT_US_URLS[locale]}']`
).should('exist');
});

// Test the localized "shared messages".
Expand All @@ -159,9 +163,9 @@ describe('The homepage', () => {

// Check that the API responses also behaves as expected.
it(`dynamically fetches API content with the correct 'Accept-Language' header for '${localeName}'`, () => {
cy.intercept('/api/hello').as('getApi');
cy.intercept(`${BASE_PATH}/api/hello`).as('getApi');
cy.visit({
url: '/',
url: `${BASE_PATH}/`,
headers: {
'Accept-Language': locale,
Cookie: 'L=',
Expand All @@ -174,16 +178,16 @@ describe('The homepage', () => {

// Persist the locale preference when navigating to a localized pages.
it(`persists locale preferences when navigating to the localized page for '${localeName}'`, () => {
cy.visit(`/${locale.toLowerCase()}`);
cy.visit(`${BASE_PATH}/${locale.toLowerCase()}`);
cy.wait(1000);
cy.visit('/');
cy.visit(`${BASE_PATH}/`);
cy.get('#header').contains(HEADERS[locale]);
});

// Persist the locale preference when changing language.
it(`persists locale preferences when clicking on language picker links for '${localeName}'`, () => {
cy.visit({
url: '/',
url: `${BASE_PATH}/`,
headers: {
'Accept-Language': locale,
Cookie: 'L=',
Expand All @@ -198,7 +202,7 @@ describe('The homepage', () => {
visitedLocales.push(linkLocale);
cy.wrap(languagePickerLink).click({ force: true, timeout: 10000 });
cy.get('#header').contains(HEADERS[linkLocale]);
cy.visit('/');
cy.visit(`${BASE_PATH}/`);
cy.get('#header').contains(HEADERS[linkLocale]);
return;
}
Expand Down
15 changes: 10 additions & 5 deletions cypress/integration/jsx-injection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { KeyValueObject } from '../../src/messages/properties';
import { ACTUAL_DEFAULT_LOCALE, ACTUAL_LOCALES, LOCALE_NAMES } from '../constants';
import { ACTUAL_DEFAULT_LOCALE, ACTUAL_LOCALES, BASE_PATH, LOCALE_NAMES } from '../constants';

export const JSX_TESTS_URLS = {
'en-US': '/tests/jsx-injection',
Expand Down Expand Up @@ -35,7 +35,7 @@ const emptyReact18HtmlComment = '<!-- -->';

describe('The JSX test page', () => {
ACTUAL_LOCALES.forEach((locale) => {
const jsxTestsUrl = `/${locale.toLowerCase()}${JSX_TESTS_URLS[locale]}`;
const jsxTestsUrl = `${BASE_PATH}/${locale.toLowerCase()}${JSX_TESTS_URLS[locale]}`;

let source;
let messages: KeyValueObject;
Expand Down Expand Up @@ -68,7 +68,7 @@ describe('The JSX test page', () => {
baseTest1Markup = messages.baseTest1
.replace(
'<link>',
`${emptyReact18HtmlComment}<a href="/${locale.toLowerCase()}${
`${emptyReact18HtmlComment}<a href="${BASE_PATH}/${locale.toLowerCase()}${
CONTACT_US_URLS[locale]
}">`
)
Expand All @@ -84,7 +84,9 @@ describe('The JSX test page', () => {
baseTest2Markup = messages.baseTest2
.replace(
'<link>',
`${emptyReact18HtmlComment}<a href="/${locale.toLowerCase()}${CONTACT_US_URLS[locale]}">`
`${emptyReact18HtmlComment}<a href="${BASE_PATH}/${locale.toLowerCase()}${
CONTACT_US_URLS[locale]
}">`
)
.replace('</link>', '</a>')
.replace('<strong>', `${emptyReact18HtmlComment}<strong>`)
Expand Down Expand Up @@ -151,7 +153,10 @@ describe('The JSX test page', () => {

styleAndEventsMarkup =
messages.styleAndEvents
.replace('<link>', `<a href="/${locale.toLowerCase()}${CONTACT_US_URLS[locale]}">`)
.replace(
'<link>',
`<a href="${BASE_PATH}/${locale.toLowerCase()}${CONTACT_US_URLS[locale]}">`
)
.replace('</link>', '</a>')
.replace('<strong>', `${emptyReact18HtmlComment}<strong class="${strongClass}">`)
.replace('<a href', `${emptyReact18HtmlComment}<a class="${aClass}" href`) +
Expand Down
Loading

0 comments on commit fb7f9cf

Please sign in to comment.