Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PoC: Translations for Next.js fallback pages #594

Open
wants to merge 1 commit into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions __tests__/getFallbackPageNamespaces.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import getFallbackPageNamespaces from '../src/getFallbackPageNamespaces'

describe('getFallbackPageNamespaces', () => {
let ctx
beforeAll(() => {
ctx = { query: {} }
})

describe('empty', () => {
test('should not return any namespace with empty pages', () => {
const input = [{ pages: {} }, '/test-page', ctx]
const output = getFallbackPageNamespaces(...input)

expect(output.length).toBe(0)
})
test('should not return any namespace with pages as undefined', () => {
const input = [{}, '/test-page', ctx]
const output = getFallbackPageNamespaces(...input)

expect(output.length).toBe(0)
})
})

describe('regular expressions', () => {
test('should return namespaces that match the rgx', () => {
const config = {
pages: {
'*': ['common'],
'/example/form': ['valid'],
'/example/form/other': ['invalid'],
'rgx:/form$': ['form'],
'rgx:/invalid$': ['invalid'],
'rgx:^/example': ['example'],
},
}
const input = [config, '/example/form']
const output = getFallbackPageNamespaces(...input)

expect(output.length).toBe(4)
expect(output[0]).toBe('common')
expect(output[1]).toBe('valid')
expect(output[2]).toBe('form')
expect(output[3]).toBe('example')
})
})

describe('as array', () => {
test('should return the page namespace', () => {
const input = [
{ pages: { '/test-page': ['test-ns'] } },
'/test-page',
ctx,
]
const output = getFallbackPageNamespaces(...input)
const expected = ['test-ns']

expect(output.length).toBe(1)
expect(output[0]).toBe(expected[0])
})

test('should return the page namespace + common', () => {
const input = [
{
pages: {
'*': ['common'],
'/test-page': ['test-ns'],
},
},
'/test-page',
ctx,
]
const output = getFallbackPageNamespaces(...input)
const expected = ['common', 'test-ns']

expect(output.length).toBe(2)
expect(output[0]).toBe(expected[0])
expect(output[1]).toBe(expected[1])
})
})

describe('as function', () => {
test('should work as a fn', () => {
ctx.query.example = '1'
const input = [
{
pages: {
'/test-page': ({ query }) => (query.example ? ['test-ns'] : []),
},
},
'/test-page',
ctx,
]
const output = getFallbackPageNamespaces(...input)
const expected = ['test-ns']

expect(output.length).toBe(1)
expect(output[0]).toBe(expected[0])
})
})
})
8 changes: 8 additions & 0 deletions examples/complex/i18n.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const fs = require('fs')

module.exports = {
locales: ['en', 'ca', 'es'],
defaultLocale: 'en',
Expand All @@ -14,4 +16,10 @@ module.exports = {
},
loadLocaleFrom: (locale, namespace) =>
import(`./src/translations/${namespace}_${locale}`).then((m) => m.default),

loadLocaleFromSync: (locale, namespace) => {
return JSON.parse(
fs.readFileSync(`./src/translations/${namespace}_${locale}.json`)
)
},
}
43 changes: 43 additions & 0 deletions examples/complex/src/pages/more-examples/fallback/[slug].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { GetStaticProps } from 'next'
import getT from 'next-translate/getT'
import useTranslation from 'next-translate/useTranslation'
import withFallbackTranslation from 'next-translate/withFallbackTranslation'
import Link from 'next/link'
import { useRouter } from 'next/router'

function DynamicRoute({ title = '' }) {
const { query } = useRouter()
const { t, lang } = useTranslation()

console.log({ query })

return (
<>
<h1>{title}</h1>
<h2>{t`more-examples:dynamic-route`}</h2>
<h3>
{query.slug} - {lang}
</h3>
<Link href="/">
<a>{t`more-examples:go-to-home`}</a>
</Link>
</>
)
}

export function getStaticPaths({ locales }: any) {
return {
paths: locales.map((locale: string) => ({
locale,
params: { slug: 'example' },
})),
fallback: true,
}
}

export const getStaticProps: GetStaticProps = async ({ locale }) => {
const t = await getT(locale, 'common')
return { props: { title: t('title') }, revalidate: 5 }
}

export default withFallbackTranslation(DynamicRoute)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"scripts": {
"build": "yarn clean && cross-env NODE_ENV=production && yarn tsc",
"clean": "yarn clean:build && yarn clean:examples",
"clean:build": "rm -rf lib plugin appWith* Dynamic* I18n* index _context loadNa* setLang* Trans useT* withT* getP* getC* *.d.ts getT transC* wrapT* types",
"clean:build": "rm -rf lib plugin appWith* Dynamic* I18n* index _context loadNa* loadFa* setLang* Trans useT* withT* getFa* getP* getC* *.d.ts getT transC* wrapT* withFall* types",
"clean:examples": "rm -rf examples/**/.next && rm -rf examples/**/node_modules && rm -rf examples/**/yarn.lock",
"example": "yarn example:complex",
"example:basic": "yarn build && cd examples/basic && yarn && yarn dev",
Expand Down
51 changes: 48 additions & 3 deletions src/appWithI18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,58 @@ export default function appWithI18n(
function AppWithTranslations(props: Props) {
const { defaultLocale } = config

var ns = {}
var pageProps

if (typeof window === 'undefined') {
if (
props.router &&
props.router.isFallback &&
props.Component &&
typeof props.Component.__PAGE_NEXT_NAMESPACES === 'function'
) {
ns =
props.Component.__PAGE_NEXT_NAMESPACES({
locale: props.router.locale,
pathname: props.router.pathname,
}) || {}

pageProps = { ...ns, ...props.pageProps }
}
} else {
if (
props.Component &&
typeof props.Component.__PAGE_NEXT_NAMESPACES === 'function'
) {
ns = props.Component.__PAGE_NEXT_NAMESPACES() || {}

pageProps = { ...ns, ...props.pageProps }
}
}

if (pageProps == null) {
pageProps = props.pageProps
}

var newProps: any = {
...props,
pageProps,
}

return (
<I18nProvider
lang={props.pageProps?.__lang || props.__lang || defaultLocale}
namespaces={props.pageProps?.__namespaces || props.__namespaces}
lang={pageProps?.__lang || props.__lang || defaultLocale}
namespaces={pageProps?.__namespaces || props.__namespaces}
config={config}
>
<AppToTranslate {...props} />
<AppToTranslate {...newProps} />
<script
id="__NEXT_NAMESPACES_DATA__"
type="application/json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(ns || {}),
}}
/>
</I18nProvider>
)
}
Expand Down
35 changes: 35 additions & 0 deletions src/getFallbackPageNamespaces.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { I18nConfig, PageValue } from '.'

// @todo Replace to [].flat() in the future
function flat(a: string[][]): string[] {
return a.reduce((b, c) => b.concat(c), [])
}

/**
* Get fallback page namespaces
*
* @param {object} config
* @param {string} page
*/
export default function getFallbackPageNamespaces(
{ pages = {} }: I18nConfig,
page: string,
ctx: object
): string[] {
const rgx = 'rgx:'
const getNs = (ns: PageValue): string[] =>
typeof ns === 'function' ? ns(ctx) : ns || []

// Namespaces promises using regex
const rgxs = Object.keys(pages).reduce((arr: string[][], p) => {
if (
p.substring(0, rgx.length) === rgx &&
new RegExp(p.replace(rgx, '')).test(page)
) {
arr.push(getNs(pages[p]))
}
return arr
}, [])

return [...getNs(pages['*']), ...getNs(pages[page]), ...flat(rgxs)]
}
6 changes: 6 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,16 @@ export type LocaleLoader = (
namespace: string
) => Promise<I18nDictionary>

export type LocaleLoaderSync = (
language: string | undefined,
namespace: string
) => I18nDictionary

export interface I18nConfig {
defaultLocale?: string
locales?: string[]
loadLocaleFrom?: LocaleLoader
loadLocaleFromSync?: LocaleLoaderSync
pages?: Record<string, PageValue>
logger?: I18nLogger
staticsHoc?: Function
Expand Down
63 changes: 63 additions & 0 deletions src/loadFallbackPageNamespaces.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { LoaderConfig, LocaleLoaderSync } from '.'
import getConfig from './getConfig'
import getFallbackPageNamespaces from './getFallbackPageNamespaces'

export default function loadFallbackPageNamespaces(
config: LoaderConfig = {}
): {
__lang: string
__namespaces?: Record<string, object>
} {
const conf = { ...getConfig(), ...config }
const __lang: string =
conf.locale || conf.router?.locale || conf.defaultLocale || ''

if (!conf.pathname) {
console.warn(
'🚨 [next-translate] You forgot to pass the "pathname" inside "loadNamespaces" configuration'
)
return { __lang }
}

if (!conf.loaderName && conf.loader !== false) {
console.warn(
'🚨 [next-translate] You can remove the "loadNamespaces" helper, unless you set "loader: false" in your i18n config file.'
)
}

const page = removeTrailingSlash(conf.pathname.replace(/\/index$/, '')) || '/'
const namespaces = getFallbackPageNamespaces(conf, page, conf)
const defaultLoader: LocaleLoaderSync = () => ({})
const pageNamespaces = namespaces.map((ns) =>
typeof conf.loadLocaleFromSync === 'function'
? conf.loadLocaleFromSync(__lang, ns)
: defaultLoader(__lang, ns)
)

if (conf.logBuild !== false && typeof window === 'undefined') {
const color = (c: string) => `\x1b[36m${c}\x1b[0m`
console.log(
color('next-translate'),
`- compiled page:`,
color(page),
'- locale:',
color(__lang),
'- namespaces:',
color(namespaces.join(', ')),
'- used loader:',
color(conf.loaderName || '-')
)
}

return {
__lang,
__namespaces: namespaces.reduce((obj: Record<string, object>, ns, i) => {
obj[ns] = pageNamespaces[i]
return obj
}, {}),
}
}

function removeTrailingSlash(path = '') {
return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path
}
7 changes: 7 additions & 0 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export default function nextTranslate(nextConfig: any = {}) {
'@next-translate-root': path.resolve(dir),
}

// translating Next.js fallback pages requires the use of `fs` module
if (!options.isServer) {
config.node = {
fs: 'empty',
}
}

// we give the opportunity for people to use next-translate without altering
// any document, allowing them to manually add the necessary helpers on each
// page to load the namespaces.
Expand Down
Loading