From f778d8b9241177a6cbd0a91078e1797b9daefefc Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Sun, 27 Aug 2023 22:16:09 +0200 Subject: [PATCH] feat: add `next-intl` framework (#934) * WIP * Load framework * Add VSCode setting * Basic PoC * Cleanup * Add preferred keystyle * Clean up * All permutations for keypaths --- examples/by-frameworks/next-intl/.eslintrc | 6 + examples/by-frameworks/next-intl/.gitignore | 4 + .../next-intl/.vscode/settings.json | 4 + examples/by-frameworks/next-intl/global.d.ts | 3 + .../by-frameworks/next-intl/messages/de.json | 9 ++ .../by-frameworks/next-intl/messages/en.json | 9 ++ .../by-frameworks/next-intl/next-env.d.ts | 5 + examples/by-frameworks/next-intl/package.json | 22 ++++ .../next-intl/public/favicon.ico | Bin 0 -> 15086 bytes .../next-intl/src/app/[locale]/layout.tsx | 34 ++++++ .../next-intl/src/app/[locale]/page.tsx | 30 +++++ .../next-intl/src/middleware.tsx | 11 ++ .../by-frameworks/next-intl/tsconfig.json | 37 ++++++ package.json | 3 +- src/frameworks/index.ts | 2 + src/frameworks/next-intl.ts | 112 ++++++++++++++++++ 16 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 examples/by-frameworks/next-intl/.eslintrc create mode 100644 examples/by-frameworks/next-intl/.gitignore create mode 100644 examples/by-frameworks/next-intl/.vscode/settings.json create mode 100644 examples/by-frameworks/next-intl/global.d.ts create mode 100644 examples/by-frameworks/next-intl/messages/de.json create mode 100644 examples/by-frameworks/next-intl/messages/en.json create mode 100644 examples/by-frameworks/next-intl/next-env.d.ts create mode 100644 examples/by-frameworks/next-intl/package.json create mode 100644 examples/by-frameworks/next-intl/public/favicon.ico create mode 100644 examples/by-frameworks/next-intl/src/app/[locale]/layout.tsx create mode 100644 examples/by-frameworks/next-intl/src/app/[locale]/page.tsx create mode 100644 examples/by-frameworks/next-intl/src/middleware.tsx create mode 100644 examples/by-frameworks/next-intl/tsconfig.json create mode 100644 src/frameworks/next-intl.ts diff --git a/examples/by-frameworks/next-intl/.eslintrc b/examples/by-frameworks/next-intl/.eslintrc new file mode 100644 index 00000000..7629f73f --- /dev/null +++ b/examples/by-frameworks/next-intl/.eslintrc @@ -0,0 +1,6 @@ +{ + "rules": { + "import/named": "off", + "react/react-in-jsx-scope": "off" + } +} \ No newline at end of file diff --git a/examples/by-frameworks/next-intl/.gitignore b/examples/by-frameworks/next-intl/.gitignore new file mode 100644 index 00000000..04239e7d --- /dev/null +++ b/examples/by-frameworks/next-intl/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/.next/ +.DS_Store +tsconfig.tsbuildinfo diff --git a/examples/by-frameworks/next-intl/.vscode/settings.json b/examples/by-frameworks/next-intl/.vscode/settings.json new file mode 100644 index 00000000..7bd4e6fd --- /dev/null +++ b/examples/by-frameworks/next-intl/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "i18n-ally.localesPaths": "messages", + "i18n-ally.enabledFrameworks": ["next-intl"] +} diff --git a/examples/by-frameworks/next-intl/global.d.ts b/examples/by-frameworks/next-intl/global.d.ts new file mode 100644 index 00000000..e069a70d --- /dev/null +++ b/examples/by-frameworks/next-intl/global.d.ts @@ -0,0 +1,3 @@ +// Use type safe message keys with `next-intl` +type Messages = typeof import('./messages/en.json') +declare interface IntlMessages extends Messages {} diff --git a/examples/by-frameworks/next-intl/messages/de.json b/examples/by-frameworks/next-intl/messages/de.json new file mode 100644 index 00000000..1e3138ca --- /dev/null +++ b/examples/by-frameworks/next-intl/messages/de.json @@ -0,0 +1,9 @@ +{ + "IndexPage": { + "description": "Eine Beschreibung", + "title": "next-intl Beispiel" + }, + "Test": { + "title": "Test" + } +} diff --git a/examples/by-frameworks/next-intl/messages/en.json b/examples/by-frameworks/next-intl/messages/en.json new file mode 100644 index 00000000..648f7ccb --- /dev/null +++ b/examples/by-frameworks/next-intl/messages/en.json @@ -0,0 +1,9 @@ +{ + "IndexPage": { + "description": "Some description", + "title": "next-intl example" + }, + "Test": { + "title": "Test" + } +} diff --git a/examples/by-frameworks/next-intl/next-env.d.ts b/examples/by-frameworks/next-intl/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/examples/by-frameworks/next-intl/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/by-frameworks/next-intl/package.json b/examples/by-frameworks/next-intl/package.json new file mode 100644 index 00000000..5109dff1 --- /dev/null +++ b/examples/by-frameworks/next-intl/package.json @@ -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" + } +} diff --git a/examples/by-frameworks/next-intl/public/favicon.ico b/examples/by-frameworks/next-intl/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4ddd8fff7086c240ed21438ca7d96f1b8dae0bc0 GIT binary patch literal 15086 zcmdU$O=uNY6vrnYO%#n9m0G$<)C8)FRuGb41&a%z2%-zOrYkKQ7LVcV8tZ8{r_I(J}2Xwd3WZ$*OwePxifd}IsgB? z@7{AiX5x7@UY)mPi>I{JyV~w~9iHd4w)*$0Jnt9fty0SKPwPGJD;0Q8Wn|)gA|*d3 zZJyV>ZcgVi%JR2KUOJ_pNiRu%l#WaPk^bu|*W~exGL*#z+iFOiKxv{cq~A-QNoSnvZWVz$%y2 z$kB(=jC597D#v2>V1NZC*rkY~fpzbN^>mc9}aM-le$W`3Rbh-!*zlSeaS_(t57xidzf?h9!_wJ6XMpeG;F(2?%@^} zTZz5QCkusdm&t8;0X=2;v z=4NyA=FO7%Pfbmky?gg2#`ihHeJl-sYxshdXBjsq7mY z8_mUw7gO^e8yhp7ot>$$ea>(%X}z;`>sB*5I-05ur%#_Y#Iv;R=W{QKf7*DOB3>4% zC)`u_AKPw8<7vuxS%^Q}Q;%QEpEOB4Eg@cp>j3vu@iWMsYu9*MV!RCVhdXmXI6u|B zOFS(xUWR$WJ@NV>s=meIX&K^WkiT6&bc&Dj`YnpP&5w(xWr&wS{&xM=By8vPv*j6; zM#a-I#mgXnIKv%r-oL19&_}`Ra6BzzybR*m{R{jzNz1x_B%V%BPv_?4GR8~GAFgn= z!d{p?v3*{0N<1AJ8Zs|lyeMUT8O4LM2F@IU!kI)_e}g@H_L#xJL9=w}(kLEj^5Mu? zPa!Gm`4#bW_3G7WI7ZpTonKL(#c+POu291V^0w=Gv7X=BY8=qTLOVb2w&WaA-W4pg z_ZPfVNg|8+{gJIFb*3*>V?2NfcG7m2$vk0h`@i3x4*QtFGv;}uQrr+*nI9_oTi_dv zxOXgioLZQ(?e_y}6iZ{#ZwxL^&=9>|`hvL_vv zayCHQqFj^5GrQ-*#;)IWnNEy;<($6sycVvrp4Uj4(FrcKN zvwpkUcIKIsPTVT}NSgmnYgOv+w20N4r9IMbr9Vq2rL(?rO&-rELs@J}6~-g|aZs9- zPD^QmIHgQhiVe2-NF(pcs9(zWSaEo9&#=V@zLH3X3Xe$n+2j1XJi`aR@aZBuRp=+F zi|-_5@QLp@fnz1GEl+$$wFAG=4^msUQYjeW{!ptt`bniQ!+ya6(@K{v%63*JUtzrP zJy>8OjL&bR;WD}Vy1F`(R_p8QS z3)aiW1e0>+L40 z-*@!(_U7itD4r4d!TA^bRO^q1h6b~2*|K1rT(A3mOt7s~bNyvGRCE1lIqcL$qL`%e zF71v_e7gwkz@DKR`wz>H{y;x@CUvP_p)%OwBfQ>LmXi=0*h5xh{~07$vHiE{t1C-pXV!RjLzkh$YPT0;{WAHuhnKNhJu)lWgTA}x{_V=CVx2>(su;#M@ zA9w|M`q$Xc<8RBbW@GJ!#C#`=ZNB}iEh&4Pk&zMe=+Pt7-riman>=juzhBUvYv%ET z2M + + next-intl + + + + {children} + + + + ) +} diff --git a/examples/by-frameworks/next-intl/src/app/[locale]/page.tsx b/examples/by-frameworks/next-intl/src/app/[locale]/page.tsx new file mode 100644 index 00000000..2b76a084 --- /dev/null +++ b/examples/by-frameworks/next-intl/src/app/[locale]/page.tsx @@ -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 ( +
+

{t('title')}

+

{t('description')}

+ + +
+ ) +} + +function Test1() { + const t = useTranslations('Test') + return

{t('title')}

+} + +function Test2() { + const t = useTranslations() + return

{t('Test.title')}

+} diff --git a/examples/by-frameworks/next-intl/src/middleware.tsx b/examples/by-frameworks/next-intl/src/middleware.tsx new file mode 100644 index 00000000..23f8881a --- /dev/null +++ b/examples/by-frameworks/next-intl/src/middleware.tsx @@ -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|.*\\..*).*)'], +} diff --git a/examples/by-frameworks/next-intl/tsconfig.json b/examples/by-frameworks/next-intl/tsconfig.json new file mode 100644 index 00000000..2132fd18 --- /dev/null +++ b/examples/by-frameworks/next-intl/tsconfig.json @@ -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" + ] +} diff --git a/package.json b/package.json index ad476362..e03a2832 100644 --- a/package.json +++ b/package.json @@ -833,7 +833,8 @@ "lingui", "jekyll", "fluent-vue", - "fluent-vue-sfc" + "fluent-vue-sfc", + "next-intl" ] }, "description": "%config.enabled_frameworks%" diff --git a/src/frameworks/index.ts b/src/frameworks/index.ts index cb3eb37b..316dc0b3 100644 --- a/src/frameworks/index.ts +++ b/src/frameworks/index.ts @@ -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' @@ -49,6 +50,7 @@ export const frameworks: Framework[] = [ new I18nextFramework(), new ShopifyI18nextFramework(), new ReactI18nextFramework(), + new NextIntlFramework(), new I18nTagFramework(), new FluentVueFramework(), new PhpJoomlaFramework(), diff --git a/src/frameworks/next-intl.ts b/src/frameworks/next-intl.ts new file mode 100644 index 00000000..879bb39e --- /dev/null +++ b/src/frameworks/next-intl.ts @@ -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