Skip to content

Commit

Permalink
feat: add next-intl framework (lokalise#934)
Browse files Browse the repository at this point in the history
* WIP

* Load framework

* Add VSCode setting

* Basic PoC

* Cleanup

* Add preferred keystyle

* Clean up

* All permutations for keypaths
  • Loading branch information
amannn authored and huacnlee committed Aug 28, 2023
1 parent 81bc139 commit f778d8b
Show file tree
Hide file tree
Showing 16 changed files with 290 additions and 1 deletion.
6 changes: 6 additions & 0 deletions examples/by-frameworks/next-intl/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"rules": {
"import/named": "off",
"react/react-in-jsx-scope": "off"
}
}
4 changes: 4 additions & 0 deletions examples/by-frameworks/next-intl/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/node_modules
/.next/
.DS_Store
tsconfig.tsbuildinfo
4 changes: 4 additions & 0 deletions examples/by-frameworks/next-intl/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"i18n-ally.localesPaths": "messages",
"i18n-ally.enabledFrameworks": ["next-intl"]
}
3 changes: 3 additions & 0 deletions examples/by-frameworks/next-intl/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Use type safe message keys with `next-intl`
type Messages = typeof import('./messages/en.json')
declare interface IntlMessages extends Messages {}
9 changes: 9 additions & 0 deletions examples/by-frameworks/next-intl/messages/de.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"IndexPage": {
"description": "Eine Beschreibung",
"title": "next-intl Beispiel"
},
"Test": {
"title": "Test"
}
}
9 changes: 9 additions & 0 deletions examples/by-frameworks/next-intl/messages/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"IndexPage": {
"description": "Some description",
"title": "next-intl example"
},
"Test": {
"title": "Test"
}
}
5 changes: 5 additions & 0 deletions examples/by-frameworks/next-intl/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
22 changes: 22 additions & 0 deletions examples/by-frameworks/next-intl/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "next-intl",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev",
"lint": "tsc",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^13.4.0",
"next-intl": "^2.14.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^17.0.23",
"@types/react": "^18.2.5",
"typescript": "^5.0.0"
}
}
Binary file not shown.
34 changes: 34 additions & 0 deletions examples/by-frameworks/next-intl/src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { notFound } from 'next/navigation'
import { NextIntlClientProvider } from 'next-intl'
import { ReactNode } from 'react'

type Props = {
children: ReactNode
params: {locale: string}
}

export default async function LocaleLayout({
children,
params: { locale },
}: Props) {
let messages
try {
messages = (await import(`../../../messages/${locale}.json`)).default
}
catch (error) {
notFound()
}

return (
<html lang={locale}>
<head>
<title>next-intl</title>
</head>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
30 changes: 30 additions & 0 deletions examples/by-frameworks/next-intl/src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client'

import { useTranslations } from 'next-intl'

export default function IndexPage() {
const t = useTranslations('IndexPage')

t('title')
t.rich('title')
t.raw('title')

return (
<div>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
<Test1 />
<Test2 />
</div>
)
}

function Test1() {
const t = useTranslations('Test')
return <p>{t('title')}</p>
}

function Test2() {
const t = useTranslations()
return <p>{t('Test.title')}</p>
}
11 changes: 11 additions & 0 deletions examples/by-frameworks/next-intl/src/middleware.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import createMiddleware from 'next-intl/middleware'

export default createMiddleware({
locales: ['en', 'de'],
defaultLocale: 'en',
})

export const config = {
// Skip all paths that should not be internationalized
matcher: ['/((?!api|_next|.*\\..*).*)'],
}
37 changes: 37 additions & 0 deletions examples/by-frameworks/next-intl/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"strict": false,
"forceConsistentCasingInFileNames": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,8 @@
"lingui",
"jekyll",
"fluent-vue",
"fluent-vue-sfc"
"fluent-vue-sfc",
"next-intl"
]
},
"description": "%config.enabled_frameworks%"
Expand Down
2 changes: 2 additions & 0 deletions src/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import FluentVueFramework from './fluent-vue'
import ReactFramework from './react-intl'
import I18nextFramework from './i18next'
import ReactI18nextFramework from './react-i18next'
import NextIntlFramework from './next-intl'
import ShopifyI18nextFramework from './i18next-shopify'
import VSCodeFramework from './vscode'
import NgxTranslateFramework from './ngx-translate'
Expand Down Expand Up @@ -49,6 +50,7 @@ export const frameworks: Framework[] = [
new I18nextFramework(),
new ShopifyI18nextFramework(),
new ReactI18nextFramework(),
new NextIntlFramework(),
new I18nTagFramework(),
new FluentVueFramework(),
new PhpJoomlaFramework(),
Expand Down
112 changes: 112 additions & 0 deletions src/frameworks/next-intl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { TextDocument } from 'vscode'
import { Framework, ScopeRange } from './base'
import { LanguageId } from '~/utils'
import { RewriteKeySource, RewriteKeyContext, KeyStyle } from '~/core'

class NextIntlFramework extends Framework {
id = 'next-intl'
display = 'next-intl'
namespaceDelimiter = '.'
perferredKeystyle?: KeyStyle = 'nested'

namespaceDelimiters = ['.']
namespaceDelimitersRegex = /[\.]/g

detection = {
packageJSON: [
'next-intl',
],
}

languageIds: LanguageId[] = [
'javascript',
'typescript',
'javascriptreact',
'typescriptreact',
'ejs',
]

usageMatchRegex = [
// Basic usage
'[^\\w\\d]t\\([\'"`]({key})[\'"`]',

// Rich text
'[^\\w\\d]t\.rich\\([\'"`]({key})[\'"`]',

// Raw text
'[^\\w\\d]t\.raw\\([\'"`]({key})[\'"`]',
]

refactorTemplates(keypath: string) {
// Ideally we'd automatically consider the namespace here. Since this
// doesn't seem to be possible though, we'll generate all permutations for
// the `keypath`. E.g. `one.two.three` will generate `three`, `two.three`,
// `one.two.three`.

const keypaths = keypath.split('.').map((cur, index, parts) => {
return parts.slice(parts.length - index - 1).join('.')
})
return [
...keypaths.map(cur =>
`{t('${cur}')}`,
),
...keypaths.map(cur =>
`t('${cur}')`,
),
]
}

rewriteKeys(key: string, source: RewriteKeySource, context: RewriteKeyContext = {}) {
const dottedKey = key.split(this.namespaceDelimitersRegex).join('.')

// When the namespace is explicitly set, ignore the current namespace scope
if (
this.namespaceDelimiters.some(delimiter => key.includes(delimiter))
&& context.namespace
&& dottedKey.startsWith(context.namespace.split(this.namespaceDelimitersRegex).join('.'))
) {
// +1 for the an extra `.`
key = key.slice(context.namespace.length + 1)
}

return dottedKey
}

getScopeRange(document: TextDocument): ScopeRange[] | undefined {
if (!this.languageIds.includes(document.languageId as any))
return

const ranges: ScopeRange[] = []
const text = document.getText()

// Find matches of `useTranslations`, later occurences will override
// previous ones (this allows for multiple components with different
// namespaces in the same file).
const regex = /useTranslations\(\s*(['"`](.*?)['"`])?/g
let prevGlobalScope = false
for (const match of text.matchAll(regex)) {
if (typeof match.index !== 'number')
continue

const namespace = match[2]

// End previous scope
if (prevGlobalScope)
ranges[ranges.length - 1].end = match.index

// Start a new scope if a namespace is provided
if (namespace) {
prevGlobalScope = true
ranges.push({
start: match.index,
end: text.length,
namespace,
})
}
}

return ranges
}
}

export default NextIntlFramework

0 comments on commit f778d8b

Please sign in to comment.