Skip to content
Draft
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
135 changes: 132 additions & 3 deletions packages/app/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,133 @@ const DATE_SYNTAX_RESTRICTIONS = [
},
];

const LOCAL_I18N_PLUGIN = {
rules: {
'no-jsx-text-outside-trans': {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow user-facing JSX text outside of Trans components in migrated files.',
},
schema: [
{
type: 'object',
properties: {},
additionalProperties: false,
},
],
messages: {
wrapInTrans:
'Wrap user-facing JSX text in <Trans> so it can be translated.',
},
},
create(context) {
const sourceCode = context.sourceCode;
let hasTransImport = false;
let hasUntranslatedText = false;

const hasWords = value => /[\p{L}\p{N}]/u.test(value);

const isInsideTrans = node => {
let current = node.parent;

while (current) {
if (
current.type === 'JSXElement' &&
current.openingElement.name.type === 'JSXIdentifier' &&
current.openingElement.name.name === 'Trans'
) {
return true;
}

current = current.parent;
}

return false;
};

const getTransImportFix = fixer => {
if (hasTransImport) {
return null;
}

const body = sourceCode.ast.body;
const lastImport = body.findLast(
node => node.type === 'ImportDeclaration',
);

if (lastImport) {
return fixer.insertTextAfter(
lastImport,
"\nimport { Trans } from 'next-i18next/pages';",
);
}

return fixer.insertTextBefore(
body[0] ?? sourceCode.ast,
"import { Trans } from 'next-i18next/pages';\n\n",
);
};

return {
ImportDeclaration(node) {
if (
node.source.value === 'next-i18next/pages' ||
node.source.value === 'react-i18next'
) {
hasTransImport ||= node.specifiers.some(
specifier =>
specifier.type === 'ImportSpecifier' &&
specifier.imported.type === 'Identifier' &&
specifier.imported.name === 'Trans',
);
}
},
'Program:exit'(node) {
if (!hasUntranslatedText || hasTransImport) {
return;
}

context.report({
node,
message:
'Import Trans so JSX text can be wrapped for translation.',
fix: getTransImportFix,
});
},
JSXText(node) {
if (!hasWords(node.value) || isInsideTrans(node)) {
return;
}

hasUntranslatedText = true;

context.report({
node,
loc: sourceCode.getLocFromIndex(
node.range[0] + node.value.search(/[\p{L}\p{N}]/u),
),
messageId: 'wrapInTrans',
fix(fixer) {
const leadingWhitespace = node.value.match(/^\s*/u)?.[0] ?? '';
const trailingWhitespace = node.value.match(/\s*$/u)?.[0] ?? '';
const text = node.value.trim();

return fixer.replaceText(
node,
`${leadingWhitespace}<Trans>${text}</Trans>${trailingWhitespace}`,
);
},
});
},
};
},
},
},
};

export default [
js.configs.recommended,
...tseslint.configs.recommended,
Expand Down Expand Up @@ -113,6 +240,7 @@ export default [
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'@next/next': nextPlugin,
'local-i18n': LOCAL_I18N_PLUGIN,
'react-hooks': reactHooksPlugin,
'simple-import-sort': simpleImportSort,
'react-hook-form': fixupPluginRules(reactHookFormPlugin), // not compatible with eslint 9 yet
Expand All @@ -123,12 +251,12 @@ export default [
...nextPlugin.configs['core-web-vitals'].rules,
...reactHooksPlugin.configs.recommended.rules,
...eslintReactPlugin.configs['recommended-type-checked'].rules,

// Non-default react-hooks rules
'react-hooks/set-state-in-render': 'error',
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/exhaustive-deps': 'error',

// Disable rules from @eslint-react that have equivalent rules enabled in eslint-plugin-react-hooks
'@eslint-react/rules-of-hooks': 'off',
'@eslint-react/component-hook-factories': 'off',
Expand All @@ -143,8 +271,9 @@ export default [
'@eslint-react/no-nested-lazy-component-declarations': 'off',
'@eslint-react/unsupported-syntax': 'off',
'@eslint-react/use-memo': 'off',

'react-hook-form/no-use-watch': 'error',
'local-i18n/no-jsx-text-outside-trans': 'error',
'@eslint-react/no-unstable-default-props': 'error',
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
Expand Down
12 changes: 12 additions & 0 deletions packages/app/next-i18next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @type {import('next-i18next/pages').UserConfig} */
const nextI18NextConfig = {
i18n: {
defaultLocale: 'en',
locales: ['en', 'ja'],
},
defaultNS: 'common',
keySeparator: false,
nsSeparator: false,
};

export default nextI18NextConfig;
5 changes: 5 additions & 0 deletions packages/app/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

import nextI18NextConfig from './next-i18next.config.mjs';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

Expand All @@ -25,6 +27,9 @@ const nextConfig = {
...(process.env.NEXT_DIST_DIR ? { distDir: process.env.NEXT_DIST_DIR } : {}),
reactCompiler: true,
basePath: basePath,
...(process.env.NEXT_PUBLIC_CLICKHOUSE_BUILD
? {}
: { i18n: nextI18NextConfig.i18n }),
env: {
// Ensures bundler-time replacements for client/server code that references this env var
NEXT_PUBLIC_APP_VERSION: version,
Expand Down
5 changes: 5 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet",
"ci:unit": "jest --ci --coverage",
"dev:unit": "jest --watchAll",
"i18n:extract": "node scripts/extract-i18n.mjs",
"i18n:ja:build": "node scripts/combine-ja-locale.mjs",
"test:e2e": "node scripts/run-e2e.js",
"test:e2e:ci": "../../scripts/test-e2e-ci.sh",
"storybook": "storybook dev -p 6006",
Expand Down Expand Up @@ -62,13 +64,15 @@
"flat": "^5.0.2",
"fuse.js": "^6.6.2",
"http-proxy-middleware": "^3.0.5",
"i18next": "^26.0.7",
"immer": "^9.0.21",
"jotai": "^2.5.1",
"ky": "^0.30.0",
"ky-universal": "^0.10.1",
"lodash": "^4.17.23",
"ms": "^2.1.3",
"next": "^16.1.7",
"next-i18next": "^16.0.5",
"next-query-params": "^4.3.1",
"next-runtime-env": "1",
"next-seo": "^4.28.1",
Expand All @@ -83,6 +87,7 @@
"react-grid-layout": "^1.3.4",
"react-hook-form": "^7.43.8",
"react-hotkeys-hook": "^4.3.7",
"react-i18next": "^17.0.4",
"react-json-tree": "^0.20.0",
"react-markdown": "^10.1.0",
"react-resizable": "^3.0.4",
Expand Down
21 changes: 20 additions & 1 deletion packages/app/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import type { NextPage } from 'next';
import type { AppProps } from 'next/app';
import Head from 'next/head';
import { appWithTranslation } from 'next-i18next/pages';
import { NextAdapter } from 'next-query-params';
import { env } from 'next-runtime-env';
import randomUUID from 'crypto-randomuuid';
Expand Down Expand Up @@ -35,6 +36,10 @@ import {
useUserPreferences,
} from '@/useUserPreferences';

import nextI18NextConfig from '../next-i18next.config.mjs';
import enCommon from '../public/locales/en/common.json';
import jaCommon from '../public/locales/ja/common.json';

import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
Expand All @@ -61,6 +66,18 @@ const queryClient = new QueryClient({
}),
});

const i18nConfig = {
...nextI18NextConfig,
resources: {
en: {
common: enCommon,
},
ja: {
common: jaCommon,
},
},
};

export type NextPageWithLayout<P = object, IP = P> = NextPage<P, IP> & {
getLayout?: (page: React.ReactElement) => React.ReactNode;
};
Expand Down Expand Up @@ -127,7 +144,7 @@ function AppContent({
);
}

export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// port to react query ? (needs to wrap with QueryClientProvider)
useEffect(() => {
if (IS_LOCAL_MODE) {
Expand Down Expand Up @@ -200,3 +217,5 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
</React.Fragment>
);
}

export default appWithTranslation(MyApp, i18nConfig);
3 changes: 2 additions & 1 deletion packages/app/pages/trace/[traceId].tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { Trans } from 'next-i18next/pages';
import { Center, Text } from '@mantine/core';

import { withAppNav } from '@/layout';
Expand Down Expand Up @@ -30,7 +31,7 @@ export function TraceRedirectPage() {
return (
<Center h="100vh">
<Text size="sm" c="dimmed">
Redirecting to search...
<Trans>Redirecting to search...</Trans>
</Text>
</Center>
);
Expand Down
Loading