Skip to content

Commit e25e1cd

Browse files
authored
feat: add next-intl framework (#934)
* WIP * Load framework * Add VSCode setting * Basic PoC * Cleanup * Add preferred keystyle * Clean up * All permutations for keypaths
1 parent d7916a5 commit e25e1cd

File tree

16 files changed

+290
-1
lines changed

16 files changed

+290
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"rules": {
3+
"import/named": "off",
4+
"react/react-in-jsx-scope": "off"
5+
}
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/node_modules
2+
/.next/
3+
.DS_Store
4+
tsconfig.tsbuildinfo
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"i18n-ally.localesPaths": "messages",
3+
"i18n-ally.enabledFrameworks": ["next-intl"]
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Use type safe message keys with `next-intl`
2+
type Messages = typeof import('./messages/en.json')
3+
declare interface IntlMessages extends Messages {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"IndexPage": {
3+
"description": "Eine Beschreibung",
4+
"title": "next-intl Beispiel"
5+
},
6+
"Test": {
7+
"title": "Test"
8+
}
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"IndexPage": {
3+
"description": "Some description",
4+
"title": "next-intl example"
5+
},
6+
"Test": {
7+
"title": "Test"
8+
}
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
4+
// NOTE: This file should not be edited
5+
// see https://nextjs.org/docs/basic-features/typescript for more information.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "next-intl",
3+
"version": "0.0.1",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"lint": "tsc",
8+
"build": "next build",
9+
"start": "next start"
10+
},
11+
"dependencies": {
12+
"next": "^13.4.0",
13+
"next-intl": "^2.14.1",
14+
"react": "^18.2.0",
15+
"react-dom": "^18.2.0"
16+
},
17+
"devDependencies": {
18+
"@types/node": "^17.0.23",
19+
"@types/react": "^18.2.5",
20+
"typescript": "^5.0.0"
21+
}
22+
}
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { notFound } from 'next/navigation'
2+
import { NextIntlClientProvider } from 'next-intl'
3+
import { ReactNode } from 'react'
4+
5+
type Props = {
6+
children: ReactNode
7+
params: {locale: string}
8+
}
9+
10+
export default async function LocaleLayout({
11+
children,
12+
params: { locale },
13+
}: Props) {
14+
let messages
15+
try {
16+
messages = (await import(`../../../messages/${locale}.json`)).default
17+
}
18+
catch (error) {
19+
notFound()
20+
}
21+
22+
return (
23+
<html lang={locale}>
24+
<head>
25+
<title>next-intl</title>
26+
</head>
27+
<body>
28+
<NextIntlClientProvider locale={locale} messages={messages}>
29+
{children}
30+
</NextIntlClientProvider>
31+
</body>
32+
</html>
33+
)
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client'
2+
3+
import { useTranslations } from 'next-intl'
4+
5+
export default function IndexPage() {
6+
const t = useTranslations('IndexPage')
7+
8+
t('title')
9+
t.rich('title')
10+
t.raw('title')
11+
12+
return (
13+
<div>
14+
<h1>{t('title')}</h1>
15+
<p>{t('description')}</p>
16+
<Test1 />
17+
<Test2 />
18+
</div>
19+
)
20+
}
21+
22+
function Test1() {
23+
const t = useTranslations('Test')
24+
return <p>{t('title')}</p>
25+
}
26+
27+
function Test2() {
28+
const t = useTranslations()
29+
return <p>{t('Test.title')}</p>
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import createMiddleware from 'next-intl/middleware'
2+
3+
export default createMiddleware({
4+
locales: ['en', 'de'],
5+
defaultLocale: 'en',
6+
})
7+
8+
export const config = {
9+
// Skip all paths that should not be internationalized
10+
matcher: ['/((?!api|_next|.*\\..*).*)'],
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": "src",
4+
"target": "es5",
5+
"lib": [
6+
"dom",
7+
"dom.iterable",
8+
"esnext"
9+
],
10+
"allowJs": true,
11+
"skipLibCheck": true,
12+
"noEmit": true,
13+
"esModuleInterop": true,
14+
"module": "esnext",
15+
"moduleResolution": "node",
16+
"resolveJsonModule": true,
17+
"isolatedModules": true,
18+
"jsx": "preserve",
19+
"incremental": true,
20+
"plugins": [
21+
{
22+
"name": "next"
23+
}
24+
],
25+
"strict": false,
26+
"forceConsistentCasingInFileNames": true
27+
},
28+
"include": [
29+
"next-env.d.ts",
30+
"**/*.ts",
31+
"**/*.tsx",
32+
".next/types/**/*.ts"
33+
],
34+
"exclude": [
35+
"node_modules"
36+
]
37+
}

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,8 @@
832832
"lingui",
833833
"jekyll",
834834
"fluent-vue",
835-
"fluent-vue-sfc"
835+
"fluent-vue-sfc",
836+
"next-intl"
836837
]
837838
},
838839
"description": "%config.enabled_frameworks%"

src/frameworks/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import FluentVueFramework from './fluent-vue'
66
import ReactFramework from './react-intl'
77
import I18nextFramework from './i18next'
88
import ReactI18nextFramework from './react-i18next'
9+
import NextIntlFramework from './next-intl'
910
import ShopifyI18nextFramework from './i18next-shopify'
1011
import VSCodeFramework from './vscode'
1112
import NgxTranslateFramework from './ngx-translate'
@@ -49,6 +50,7 @@ export const frameworks: Framework[] = [
4950
new I18nextFramework(),
5051
new ShopifyI18nextFramework(),
5152
new ReactI18nextFramework(),
53+
new NextIntlFramework(),
5254
new I18nTagFramework(),
5355
new FluentVueFramework(),
5456
new PhpJoomlaFramework(),

src/frameworks/next-intl.ts

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { TextDocument } from 'vscode'
2+
import { Framework, ScopeRange } from './base'
3+
import { LanguageId } from '~/utils'
4+
import { RewriteKeySource, RewriteKeyContext, KeyStyle } from '~/core'
5+
6+
class NextIntlFramework extends Framework {
7+
id = 'next-intl'
8+
display = 'next-intl'
9+
namespaceDelimiter = '.'
10+
perferredKeystyle?: KeyStyle = 'nested'
11+
12+
namespaceDelimiters = ['.']
13+
namespaceDelimitersRegex = /[\.]/g
14+
15+
detection = {
16+
packageJSON: [
17+
'next-intl',
18+
],
19+
}
20+
21+
languageIds: LanguageId[] = [
22+
'javascript',
23+
'typescript',
24+
'javascriptreact',
25+
'typescriptreact',
26+
'ejs',
27+
]
28+
29+
usageMatchRegex = [
30+
// Basic usage
31+
'[^\\w\\d]t\\([\'"`]({key})[\'"`]',
32+
33+
// Rich text
34+
'[^\\w\\d]t\.rich\\([\'"`]({key})[\'"`]',
35+
36+
// Raw text
37+
'[^\\w\\d]t\.raw\\([\'"`]({key})[\'"`]',
38+
]
39+
40+
refactorTemplates(keypath: string) {
41+
// Ideally we'd automatically consider the namespace here. Since this
42+
// doesn't seem to be possible though, we'll generate all permutations for
43+
// the `keypath`. E.g. `one.two.three` will generate `three`, `two.three`,
44+
// `one.two.three`.
45+
46+
const keypaths = keypath.split('.').map((cur, index, parts) => {
47+
return parts.slice(parts.length - index - 1).join('.')
48+
})
49+
return [
50+
...keypaths.map(cur =>
51+
`{t('${cur}')}`,
52+
),
53+
...keypaths.map(cur =>
54+
`t('${cur}')`,
55+
),
56+
]
57+
}
58+
59+
rewriteKeys(key: string, source: RewriteKeySource, context: RewriteKeyContext = {}) {
60+
const dottedKey = key.split(this.namespaceDelimitersRegex).join('.')
61+
62+
// When the namespace is explicitly set, ignore the current namespace scope
63+
if (
64+
this.namespaceDelimiters.some(delimiter => key.includes(delimiter))
65+
&& context.namespace
66+
&& dottedKey.startsWith(context.namespace.split(this.namespaceDelimitersRegex).join('.'))
67+
) {
68+
// +1 for the an extra `.`
69+
key = key.slice(context.namespace.length + 1)
70+
}
71+
72+
return dottedKey
73+
}
74+
75+
getScopeRange(document: TextDocument): ScopeRange[] | undefined {
76+
if (!this.languageIds.includes(document.languageId as any))
77+
return
78+
79+
const ranges: ScopeRange[] = []
80+
const text = document.getText()
81+
82+
// Find matches of `useTranslations`, later occurences will override
83+
// previous ones (this allows for multiple components with different
84+
// namespaces in the same file).
85+
const regex = /useTranslations\(\s*(['"`](.*?)['"`])?/g
86+
let prevGlobalScope = false
87+
for (const match of text.matchAll(regex)) {
88+
if (typeof match.index !== 'number')
89+
continue
90+
91+
const namespace = match[2]
92+
93+
// End previous scope
94+
if (prevGlobalScope)
95+
ranges[ranges.length - 1].end = match.index
96+
97+
// Start a new scope if a namespace is provided
98+
if (namespace) {
99+
prevGlobalScope = true
100+
ranges.push({
101+
start: match.index,
102+
end: text.length,
103+
namespace,
104+
})
105+
}
106+
}
107+
108+
return ranges
109+
}
110+
}
111+
112+
export default NextIntlFramework

0 commit comments

Comments
 (0)