From 8e16c19f616ea700bd84f67a7e991cae43797f22 Mon Sep 17 00:00:00 2001 From: jeremydolle Date: Wed, 25 Feb 2026 18:06:30 +0100 Subject: [PATCH 01/10] save --- .../08 - Components/06 - SafeScreen.md | 2 +- .../{eslint.config.mjs => eslint.config.js} | 91 +++-- .../file-composition/domain/domain.api.mjs | 33 ++ .../domain/domain.query-option.mjs | 42 ++ .../file-composition/domain/domain.schema.mjs | 46 +++ template/eslint/file-composition/hook.mjs | 68 ++++ template/eslint/file-composition/index.mjs | 17 + template/eslint/folder-structure.mjs | 358 ++++++++++++++++++ template/eslint/independent-modules.mjs | 311 +++++++++++++++ template/index.js | 2 +- template/jest.setup.js | 2 - template/projectStructure.cache.json | 14 + template/src/App.tsx | 23 +- .../mocks/get-assets-context.ts} | 2 +- .../mocks}/libs/index.ts | 0 .../mocks}/libs/react-native-reanimated.ts | 0 .../libs/react-native-safe-area-context.ts | 0 template/src/__tests__/setup.ts | 2 + .../test-wrappers.tsx} | 5 +- .../atoms/IconByVariant/IconByVariant.tsx | 62 --- .../atoms/Skeleton/Skeleton.test.tsx | 6 +- .../components/atoms/Skeleton/Skeleton.tsx | 6 +- .../asset-by-variant.tsx} | 9 +- .../atoms/icon-by-variant/icon-by-variant.tsx | 68 ++++ template/src/components/atoms/index.ts | 6 +- .../default-error.tsx} | 6 +- template/src/components/molecules/index.ts | 2 +- .../error-boundary.tsx} | 8 +- template/src/components/organisms/index.ts | 2 +- template/src/components/providers/index.ts | 1 + .../theme-provider/theme-provider.test.tsx} | 4 +- .../theme-provider/theme-provider.tsx} | 51 ++- template/src/components/templates/index.ts | 2 +- .../safe-screen.tsx} | 10 +- template/src/hooks/domain/index.ts | 1 - template/src/hooks/domain/user/schema.ts | 8 - template/src/hooks/domain/user/useUser.ts | 30 -- template/src/hooks/domain/user/userService.ts | 10 - template/src/hooks/index.ts | 3 +- template/src/hooks/language/schema.ts | 13 - template/src/hooks/language/useI18n.ts | 19 - .../use-theme/use-theme.ts} | 6 +- template/src/navigators/index.ts | 1 + .../Application.tsx => navigators/root.tsx} | 8 +- template/src/reactotron.config.ts | 8 +- template/src/screens/Example/Example.test.tsx | 8 +- template/src/screens/Example/Example.tsx | 24 +- template/src/screens/Startup/Startup.tsx | 7 +- template/src/screens/index.ts | 4 +- .../src/services/domains/user/user.api.ts | 10 + .../domains/user/user.query-options.ts | 16 + .../src/services/domains/user/user.schema.ts | 8 + template/src/services/http-client.ts | 22 ++ .../i18n}/i18next.d.ts | 15 +- .../index.ts => services/i18n/instance.ts} | 16 +- template/src/services/instance.ts | 10 - .../src/{ => services}/navigation/paths.ts | 0 .../src/{ => services}/navigation/types.ts | 3 +- template/src/services/storage.ts | 3 + .../theme-generation/generate-config.ts} | 8 +- .../theme-generation}/types/backgrounds.ts | 1 - .../theme-generation}/types/borders.ts | 1 - .../theme-generation}/types/colors.ts | 0 .../theme-generation}/types/common.ts | 0 .../theme-generation}/types/config.ts | 5 +- .../theme-generation}/types/fonts.ts | 1 - .../theme-generation}/types/gutters.ts | 1 - .../theme-generation}/types/theme.ts | 5 +- template/src/theme/_config.ts | 2 + ...AssetsContext.ts => get-assets-context.ts} | 0 template/src/theme/backgrounds.ts | 5 +- template/src/theme/borders.ts | 9 +- template/src/theme/components.ts | 1 + template/src/theme/fonts.ts | 11 +- template/src/theme/gutters.ts | 6 +- template/src/theme/index.ts | 2 - 76 files changed, 1256 insertions(+), 316 deletions(-) rename template/{eslint.config.mjs => eslint.config.js} (64%) create mode 100644 template/eslint/file-composition/domain/domain.api.mjs create mode 100644 template/eslint/file-composition/domain/domain.query-option.mjs create mode 100644 template/eslint/file-composition/domain/domain.schema.mjs create mode 100644 template/eslint/file-composition/hook.mjs create mode 100644 template/eslint/file-composition/index.mjs create mode 100644 template/eslint/folder-structure.mjs create mode 100644 template/eslint/independent-modules.mjs delete mode 100644 template/jest.setup.js create mode 100644 template/projectStructure.cache.json rename template/src/{tests/__mocks__/getAssetsContext.ts => __tests__/mocks/get-assets-context.ts} (89%) rename template/src/{tests/__mocks__ => __tests__/mocks}/libs/index.ts (100%) rename template/src/{tests/__mocks__ => __tests__/mocks}/libs/react-native-reanimated.ts (100%) rename template/src/{tests/__mocks__ => __tests__/mocks}/libs/react-native-safe-area-context.ts (100%) create mode 100644 template/src/__tests__/setup.ts rename template/src/{tests/TestAppWrapper.tsx => __tests__/test-wrappers.tsx} (86%) delete mode 100644 template/src/components/atoms/IconByVariant/IconByVariant.tsx rename template/src/components/atoms/{AssetByVariant/AssetByVariant.tsx => asset-by-variant/asset-by-variant.tsx} (85%) create mode 100644 template/src/components/atoms/icon-by-variant/icon-by-variant.tsx rename template/src/components/molecules/{DefaultError/DefaultError.tsx => default-error/default-error.tsx} (91%) rename template/src/components/organisms/{ErrorBoundary/ErrorBoundary.tsx => error-boundary/error-boundary.tsx} (75%) create mode 100644 template/src/components/providers/index.ts rename template/src/{theme/ThemeProvider/ThemeProvider.test.tsx => components/providers/theme-provider/theme-provider.test.tsx} (95%) rename template/src/{theme/ThemeProvider/ThemeProvider.tsx => components/providers/theme-provider/theme-provider.tsx} (75%) rename template/src/components/templates/{SafeScreen/SafeScreen.tsx => safe-screen/safe-screen.tsx} (82%) delete mode 100644 template/src/hooks/domain/index.ts delete mode 100644 template/src/hooks/domain/user/schema.ts delete mode 100644 template/src/hooks/domain/user/useUser.ts delete mode 100644 template/src/hooks/domain/user/userService.ts delete mode 100644 template/src/hooks/language/schema.ts delete mode 100644 template/src/hooks/language/useI18n.ts rename template/src/{theme/hooks/useTheme.ts => hooks/use-theme/use-theme.ts} (64%) create mode 100644 template/src/navigators/index.ts rename template/src/{navigation/Application.tsx => navigators/root.tsx} (83%) create mode 100644 template/src/services/domains/user/user.api.ts create mode 100644 template/src/services/domains/user/user.query-options.ts create mode 100644 template/src/services/domains/user/user.schema.ts create mode 100644 template/src/services/http-client.ts rename template/src/{translations => services/i18n}/i18next.d.ts (62%) rename template/src/{translations/index.ts => services/i18n/instance.ts} (61%) delete mode 100644 template/src/services/instance.ts rename template/src/{ => services}/navigation/paths.ts (100%) rename template/src/{ => services}/navigation/types.ts (83%) create mode 100644 template/src/services/storage.ts rename template/src/{theme/ThemeProvider/generateConfig.ts => services/theme-generation/generate-config.ts} (93%) rename template/src/{theme => services/theme-generation}/types/backgrounds.ts (99%) rename template/src/{theme => services/theme-generation}/types/borders.ts (99%) rename template/src/{theme => services/theme-generation}/types/colors.ts (100%) rename template/src/{theme => services/theme-generation}/types/common.ts (100%) rename template/src/{theme => services/theme-generation}/types/config.ts (94%) rename template/src/{theme => services/theme-generation}/types/fonts.ts (99%) rename template/src/{theme => services/theme-generation}/types/gutters.ts (99%) rename template/src/{theme => services/theme-generation}/types/theme.ts (93%) rename template/src/theme/assets/{getAssetsContext.ts => get-assets-context.ts} (100%) delete mode 100644 template/src/theme/index.ts diff --git a/documentation/docs/04-Guides/08 - Components/06 - SafeScreen.md b/documentation/docs/04-Guides/08 - Components/06 - SafeScreen.md index 3b9bea42b..511597383 100644 --- a/documentation/docs/04-Guides/08 - Components/06 - SafeScreen.md +++ b/documentation/docs/04-Guides/08 - Components/06 - SafeScreen.md @@ -11,7 +11,7 @@ The template `SafeScreen` component is a helper component that allows you to dis ### Usage ```jsx -import { useI18n, useUser } from '@/hooks'; +import { useTranslation, useUser } from '@/hooks'; import { SafeScreen } from '@/components/templates'; diff --git a/template/eslint.config.mjs b/template/eslint.config.js similarity index 64% rename from template/eslint.config.mjs rename to template/eslint.config.js index 292c09cf5..d4434aa8c 100644 --- a/template/eslint.config.mjs +++ b/template/eslint.config.js @@ -1,34 +1,56 @@ +// @ts-check +import eslint from '@eslint/js'; import eslintConfigPrettier from 'eslint-config-prettier'; -import importPlugin from 'eslint-plugin-import'; import jest from 'eslint-plugin-jest'; import perfectionist from 'eslint-plugin-perfectionist'; +import { + projectStructureParser, + projectStructurePlugin, +} from 'eslint-plugin-project-structure'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; -import testingLibrary from 'eslint-plugin-testing-library'; +import reactYouMightNotNeedAnEffect from 'eslint-plugin-react-you-might-not-need-an-effect'; import unicorn from 'eslint-plugin-unicorn'; import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +import { fileCompositionConfig } from './eslint/file-composition/index.mjs'; +import { folderStructureConfig } from './eslint/folder-structure.mjs'; +import { independentModulesConfig } from './eslint/independent-modules.mjs'; const ERROR = 2; const OFF = 0; -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; - -export default defineConfig( - tseslint.configs.recommended, +export default defineConfig([ + { + files: ['**/*.{js,jsx,ts,tsx}'], + ignores: ['project-structure.cache.json'], + languageOptions: { parser: projectStructureParser }, + plugins: { + 'project-structure': projectStructurePlugin, + }, + rules: { + 'project-structure/file-composition': [ERROR, fileCompositionConfig], + 'project-structure/folder-structure': [ERROR, folderStructureConfig], + 'project-structure/independent-modules': [ + ERROR, + independentModulesConfig, + ], + }, + }, + { + ignores: ['coverage/**', 'dist/**'], + }, eslint.configs.recommended, + tseslint.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked, unicorn.configs.all, perfectionist.configs['recommended-alphabetical'], - importPlugin.flatConfigs.react, - importPlugin.flatConfigs['react-native'], - importPlugin.flatConfigs.typescript, - react.configs.flat.all, react.configs.flat['jsx-runtime'], reactRefresh.configs.recommended, - testingLibrary.configs['flat/react'], + reactYouMightNotNeedAnEffect.configs.strict, eslintConfigPrettier, // last { languageOptions: { @@ -39,9 +61,11 @@ export default defineConfig( projectService: true, tsconfigRootDir: import.meta.dirname, }, + // parser: tseslint.parser, }, settings: { 'import/resolver': { + extensions: ['.js', '.mjs', '.ts', '.tsx'], node: true, typescript: true, }, @@ -55,19 +79,31 @@ export default defineConfig( }, }, { - ...reactHooks.configs.recommended, - plugins: { - 'react-hooks': reactHooks, - }, + ...reactHooks.configs.flat['recommended-latest'], rules: { + 'import-x/order': OFF, // handled by perfectionist + // 'react-you-might-not-need-an-effect/no-adjust-state-on-prop-change': + // ERROR, + // 'react-you-might-not-need-an-effect/no-chain-state-updates': ERROR, + // 'react-you-might-not-need-an-effect/no-derived-state': ERROR, + // 'react-you-might-not-need-an-effect/no-empty-effect': ERROR, + // 'react-you-might-not-need-an-effect/no-event-handler': ERROR, + // 'react-you-might-not-need-an-effect/no-initialize-state': ERROR, + // 'react-you-might-not-need-an-effect/no-manage-parent': ERROR, + // 'react-you-might-not-need-an-effect/no-pass-data-to-parent': ERROR, + // 'react-you-might-not-need-an-effect/no-pass-live-state-to-parent': ERROR, + // 'react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change': + // ERROR, ...reactHooks.configs.recommended.rules, '@typescript-eslint/consistent-type-definitions': [ERROR, 'type'], '@typescript-eslint/dot-notation': [ERROR, { allowKeywords: true }], '@typescript-eslint/no-empty-function': OFF, '@typescript-eslint/no-useless-default-assignment': OFF, + '@typescript-eslint/only-throw-error': [OFF], '@typescript-eslint/restrict-template-expressions': OFF, 'import/no-unresolved': OFF, // handled by TypeScript 'no-console': [ERROR, { allow: ['warn', 'error'] }], + 'no-duplicate-imports': ERROR, 'no-magic-numbers': [ ERROR, { ignore: [-1, 0, 1, 2, 3, 4, 5, 6], ignoreArrayIndexes: true }, @@ -107,11 +143,10 @@ export default defineConfig( ], groups: [ 'side-effect', - ['type', 'type-internal'], + ['type'], ['builtin', 'external'], - ['theme', 'hooks', 'navigation', 'translations'], + ['hooks', 'navigation', 'translations'], ['components', 'screens'], - ['test'], 'internal', 'unknown', ], @@ -119,13 +154,12 @@ export default defineConfig( type: 'alphabetical', }, ], - 'react-refresh/only-export-components': OFF, 'react/forbid-component-props': OFF, 'react/jsx-filename-extension': [ERROR, { extensions: ['.tsx', '.jsx'] }], 'react/jsx-max-depth': [ERROR, { max: 10 }], 'react/jsx-no-bind': OFF, - 'react/jsx-no-literals': OFF, + 'react/jsx-no-literals': ERROR, 'react/jsx-props-no-spreading': OFF, 'react/jsx-sort-props': OFF, // Handled by perfectionist 'react/no-multi-comp': OFF, @@ -137,7 +171,16 @@ export default defineConfig( functions: 'defaultArguments', }, ], - 'unicorn/filename-case': OFF, + // Disable perfectionist/sort-objects for createFileRoute calls to prioritize @tanstack/router/create-route-property-order + 'perfectionist/sort-objects': [ + ERROR, + { + useConfigurationIf: { + callingFunctionNamePattern: '^createFileRoute$', + }, + }, + ], + 'sort-imports': OFF, // handled by perfectionist 'unicorn/no-keyword-prefix': OFF, 'unicorn/no-useless-undefined': OFF, 'unicorn/prefer-top-level-await': 0, // not valid on RN for the moment @@ -147,8 +190,10 @@ export default defineConfig( allowList: { env: true, Param: true, + Params: true, props: true, Props: true, + utils: true, }, }, ], @@ -180,4 +225,4 @@ export default defineConfig( { ignores: ['plugins/**'], }, -); +]); diff --git a/template/eslint/file-composition/domain/domain.api.mjs b/template/eslint/file-composition/domain/domain.api.mjs new file mode 100644 index 000000000..87e38caaa --- /dev/null +++ b/template/eslint/file-composition/domain/domain.api.mjs @@ -0,0 +1,33 @@ +// @ts-check + +// src +// ├── services/ +// │ ├── domains/ +// │ │ ├── domain-name/ +// │ │ │ ├── domain-name.(api).ts <-- HERE +// │ │ │ ├── domain-name.(query-options).ts +// │ │ │ ├── domain-name.(schema).ts +// │ │ │ └── index.ts + +/** + * @type {ReturnType} + */ +export default { + filesRules: [ + { + allowOnlySpecifiedSelectors: { + nestedSelectors: false, + }, + filePattern: 'src/services/domains/**/*.api.ts', + rules: [ + { + filenamePartsToRemove: '.api', + format: '{FileName}Apis', + positionIndex: -1, + scope: 'fileExport', + selector: ['variable'], + }, + ], + }, + ], +}; diff --git a/template/eslint/file-composition/domain/domain.query-option.mjs b/template/eslint/file-composition/domain/domain.query-option.mjs new file mode 100644 index 000000000..79d287883 --- /dev/null +++ b/template/eslint/file-composition/domain/domain.query-option.mjs @@ -0,0 +1,42 @@ +// @ts-check + +// src +// ├── services/ +// │ ├── domains/ +// │ │ ├── domain-name/ +// │ │ │ ├── domain-name.(api).ts +// │ │ │ ├── domain-name.(query-options).ts <-- HERE +// │ │ │ ├── domain-name.(schema).ts +// │ │ │ └── index.ts +/** + * @type {ReturnType} + */ +export default { + filesRules: [ + { + allowOnlySpecifiedSelectors: { + nestedSelectors: false, + }, + filePattern: 'src/services/domains/**/*.query-options.ts', + rules: [ + { + filenamePartsToRemove: '.query-options', + format: '{FileName}QueryKeys', + positionIndex: 0, + scope: 'fileExport', + selector: ['variable'], + }, + { + format: '{camelCase}(Query|Mutation)Options', + scope: 'fileExport', + selector: ['arrowFunction', 'function'], + }, + { + format: ['{camelCase}', '{SNAKE_CASE}'], + scope: 'fileRoot', + selector: ['variable'], + }, + ], + }, + ], +}; diff --git a/template/eslint/file-composition/domain/domain.schema.mjs b/template/eslint/file-composition/domain/domain.schema.mjs new file mode 100644 index 000000000..dc89bf79e --- /dev/null +++ b/template/eslint/file-composition/domain/domain.schema.mjs @@ -0,0 +1,46 @@ +// @ts-check + +// src +// ├── services/ +// │ ├── domains/ +// │ │ ├── domain-name/ +// │ │ │ ├── domain-name.(api).ts +// │ │ │ ├── domain-name.(query-options).ts +// │ │ │ ├── domain-name.(schema).ts <-- HERE +// │ │ │ └── index.ts +/** + * @type {ReturnType} + */ +export default { + filesRules: [ + { + allowOnlySpecifiedSelectors: { + fileRoot: false, + nestedSelectors: false, + }, + filePattern: 'src/services/domains/**/*.schema.ts', + rules: [ + { + format: '{PascalCase}', + scope: 'fileExport', + selector: ['enum', 'type'], + }, + { + format: '{SNAKE_CASE}', + scope: 'fileExport', + selector: ['variable'], + }, + { + format: '{PascalCase}Schema', + scope: 'fileExport', + selector: ['variableExpression'], + }, + { + format: 'build{PascalCase}Schema', + scope: ['fileExport', 'fileRoot'], + selector: ['arrowFunction'], + }, + ], + }, + ], +}; diff --git a/template/eslint/file-composition/hook.mjs b/template/eslint/file-composition/hook.mjs new file mode 100644 index 000000000..7bcd6d84b --- /dev/null +++ b/template/eslint/file-composition/hook.mjs @@ -0,0 +1,68 @@ +// @ts-check + +// ├── hooks/ +// │ ├── use-hook-name/ +// │ | ├── use-hook-name.ts <-- HERE +// │ | └── (use-hook-name.test.tsx) +// │ └── index.ts + +/** + * @type {ReturnType} + */ +export default { + filesRules: [ + { + filePattern: 'src/hooks/use-*/use-*.test.ts', + }, + { + allowOnlySpecifiedSelectors: { + nestedSelectors: false, + }, + filePattern: ['src/hooks/use-*/use-*.ts'], + rules: [ + // Can create inner types and enums + { + format: '{PascalCase}', + positionIndex: { index: 0, sorting: 'none' }, + scope: 'fileRoot', + selector: ['type', 'enum'], + }, + // Context hook + { + format: '{PascalCase}Context', + positionIndex: { index: 1, sorting: 'none' }, + scope: 'fileExport', + selector: ['variableExpression'], + }, + // can create inner const variables + { + format: '{SNAKE_CASE}', + positionIndex: { index: 1, sorting: 'none' }, + scope: 'fileRoot', + selector: ['variable', 'variableExpression'], + }, + // can create inner const variables + { + format: '{camelCase}', + positionIndex: { index: 1, sorting: 'none' }, + scope: 'fileRoot', + selector: ['arrowFunction'], + }, + // Always create the type of the params of the hook + { + format: '{FileName}Params', + positionIndex: 2, + scope: 'fileRoot', + selector: ['type'], + }, + // The hook itself + { + format: '{fileName}', + positionIndex: -1, + scope: 'fileExport', + selector: ['arrowFunction'], + }, + ], + }, + ], +}; diff --git a/template/eslint/file-composition/index.mjs b/template/eslint/file-composition/index.mjs new file mode 100644 index 000000000..518092b79 --- /dev/null +++ b/template/eslint/file-composition/index.mjs @@ -0,0 +1,17 @@ +// @ts-check + +import { createFileComposition } from 'eslint-plugin-project-structure'; + +import DomainApiConfig from './domain/domain.api.mjs'; +import DomainQueryOptionConfig from './domain/domain.query-option.mjs'; +import DomainSchemaConfig from './domain/domain.schema.mjs'; +import HookConfig from './hook.mjs'; + +export const fileCompositionConfig = createFileComposition({ + filesRules: [ + ...DomainApiConfig.filesRules, + ...DomainQueryOptionConfig.filesRules, + ...DomainSchemaConfig.filesRules, + ...HookConfig.filesRules, + ], +}); diff --git a/template/eslint/folder-structure.mjs b/template/eslint/folder-structure.mjs new file mode 100644 index 000000000..e79f48a2d --- /dev/null +++ b/template/eslint/folder-structure.mjs @@ -0,0 +1,358 @@ +// @ts-check + +import { createFolderStructure } from 'eslint-plugin-project-structure'; + +// src +// ├── __tests__/ +// │ ├── mocks/ +// | | ├── libs/ +// | | | ├── lib-name.ts +// | | └── *.ts +// │ ├── setup.ts +// │ └── test-wrappers.tsx +// ├── components/ +// │ ├── atoms/ +// │ | ├── component-folder/ +// | | | ├── component-folder.tsx +// | | | └── (component-folder.test.tsx) +// │ | ├── ... +// │ | └── index.ts +// │ ├── molecules/ // same structure as atoms/ +// │ ├── organisms/ // same structure as atoms/ +// │ ├── templates/ // same structure as atoms/ +// │ └── providers/ // same structure as atoms/ +// ├── hooks/ +// │ ├── use-hook-name/ +// │ | ├── use-hook-name.ts +// │ | └── (use-hook-name.test.ts) +// │ └── index.ts +// ├── navigators/ +// | ├── (navigator-folder)/ +// | | | ├── navigator-folder.tsx +// | | | └── (navigator-folder.test.tsx) +// │ └── kebab-case.tsx +// ├── screens/ +// | ├── screen-folder/ +// | | | ├── screen-folder.tsx +// | | | └── (screen-folder.test.tsx) +// │ └── index.ts +// ├── services/ +// │ ├── domains/ +// │ │ ├── domain-name/ +// │ │ │ ├── (domain-name.api.ts) +// │ │ │ ├── (domain-name.query-options.ts) +// │ │ │ ├── (domain-name.schema.ts) +// │ │ │ └── index.ts +// │ ├── navigation/ +// │ │ ├── paths.ts +// │ │ └── types.ts +// │ ├── theme-generation/ +// | | ├── types/ +// | | ├── background.ts +// | | ├── borders.ts +// | | ├── colors.ts +// | | ├── commons.ts +// | | ├── config.ts +// | | ├── fonts.ts +// | | ├── gutters.ts +// | | ├── theme.ts +// │ │ └── generate-config.ts +// │ ├── i18n/ +// │ │ ├── instance.ts +// │ │ └── i18next.d.ts +// │ ├── /*(.test)?.ts +// │ ├── http-client.ts +// │ ├── storage.ts +// │ └── *.ts +// ├── theme/ +// | ├── assets/ +// | | ├── icons/ +// | | | └── kebab-case.{svg} +// | | └── images/ +// │ │ │ ├── dark/ +// │ │ | | └── kebab-case.{webp} +// | | | └── kebab-case.{webp} +// | | ├── context.d.ts +// | | └── get-assets-context.ts +// | ├── _config.ts +// | ├── background.ts +// | ├── borders.ts +// | ├── components.ts +// | ├── fonts.ts +// | ├── gutters.ts +// | ├── layout.ts +// ├── translations/ +// | └── *.json +// ├── app.tsx +// └── reactotron.config.ts + +export const folderStructureConfig = createFolderStructure({ + ignorePatterns: [ + 'coverage/**', + 'eslint/**', + 'node_modules/**', + './*.config.js', + ], + rules: { + // ├── __tests__/ + tests: { + name: '__tests__', + // child + children: [ + { + children: [ + { children: [{ name: '*.ts' }], name: 'libs' }, + { name: '*.ts' }, + ], + name: 'mocks', + }, + { name: 'setup.ts' }, + { name: 'test-wrappers.tsx' }, + ], + }, + // ├── components/ + components: { + name: 'components', + // child + children: [ + { + name: 'atoms', + // child + children: [{ name: 'index.ts' }, { ruleId: 'component' }], + enforceExistence: 'index.ts', + }, + { + name: 'molecules', + // child + children: [{ name: 'index.ts' }, { ruleId: 'component' }], + enforceExistence: 'index.ts', + }, + { + name: 'organisms', + // child + children: [{ name: 'index.ts' }, { ruleId: 'component' }], + enforceExistence: 'index.ts', + }, + { + name: 'templates', + // child + children: [{ name: 'index.ts' }, { ruleId: 'component' }], + enforceExistence: 'index.ts', + }, + { + name: 'providers', + // child + children: [{ name: 'index.ts' }, { ruleId: 'component' }], + enforceExistence: 'index.ts', + }, + ], + }, + // ├── components/* + component: { + name: '{kebab-case}', + // child + children: [ + { name: '{folder-name}(-{kebab-case})?.tsx' }, + { name: '{folder-name}(-{kebab-case})?.stories.tsx' }, + { name: '{folder-name}(-{kebab-case})?.test.tsx' }, + ], + }, + componentStrict: { + name: '{kebab-case}', + // child + children: [ + { + name: '{folder-name}(-{kebab-case})?.tsx', + // force existence + enforceExistence: ['{node-name}.stories.tsx', '{node-name}.test.tsx'], + }, + { name: '{folder-name}(-{kebab-case})?.stories.tsx' }, + { name: '{folder-name}(-{kebab-case})?.test.tsx' }, + ], + }, + // ├── hooks/ + hooks: { + name: 'hooks', + // child + children: [{ ruleId: 'hook' }, { name: 'index.ts' }], + }, + // ├── hooks/* + hook: { + name: 'use-{kebab-case}', + // child + children: [ + { name: '{folder-name}.ts' }, + { name: '{folder-name}.test.ts' }, + ], + }, + navigators: { + name: 'navigators', + // child + children: [ + { name: 'index.ts' }, + { name: '*.tsx' }, + { + children: [ + { name: '{folder-name}.tsx' }, + { name: '{folder-name}.test.tsx' }, + ], + name: '{kebab-case}', + }, + ], + }, + screens: { + name: 'screens', + // child + children: [ + { name: 'index.ts' }, + { + children: [ + { name: '{folder-name}.tsx' }, + { name: '{folder-name}.test.tsx' }, + ], + name: '{kebab-case}', + }, + ], + }, + // ├── services/ + services: { + name: 'services', + // child + children: [ + { ruleId: 'domains' }, + { ruleId: 'navigation' }, + { ruleId: 'i18n' }, + { ruleId: 'themeGen' }, + { name: '{kebab-case}.ts' }, + { + name: '{kebab-case}', + // child + children: [ + { name: '{folder-name}.ts' }, + { name: '{folder-name}.test.ts' }, + ], + }, + ], + }, + // ├── services/domains + domains: { + name: 'domains', + // child + children: [{ ruleId: 'domain' }, { name: 'common.ts' }], + }, + // ├── services/domains/* + domain: { + name: '{kebab-case}', + // child + children: [ + { name: '{folder-name}.api.ts' }, + { name: '{folder-name}.query-options.ts' }, + { name: '{folder-name}.schema.ts' }, + { name: 'index.ts' }, + ], + }, + // ├── services/navigation + + navigation: { + name: 'navigation', + // child + children: [{ name: 'paths.ts' }, { name: 'types.ts' }], + }, + // ├── services/i18n + i18n: { + name: 'i18n', + // child + children: [{ name: 'instance.ts' }, { name: 'i18next.d.ts' }], + }, + // ├── services/theme-generation + themeGen: { + name: 'theme-generation', + // child + children: [ + { + name: 'types', + // child + children: [ + { name: 'backgrounds.ts' }, + { name: 'borders.ts' }, + { name: 'colors.ts' }, + { name: 'common.ts' }, + { name: 'config.ts' }, + { name: 'fonts.ts' }, + { name: 'gutters.ts' }, + { name: 'theme.ts' }, + ], + }, + { name: 'generate-config.ts' }, + ], + }, + // ├── theme/ + theme: { + name: 'theme', + // child + children: [ + { + name: 'assets', + // child + children: [ + { + name: 'icons', + // child + children: [{ name: 'kebab-case.{svg}' }], + }, + { + name: 'images', + // child + children: [ + { + children: [{ name: 'kebab-case.{webp}' }], + name: 'dark', + }, + { name: 'kebab-case.{webp}' }, + ], + }, + { + name: 'context.d.ts', + }, + { + name: 'get-assets-context.ts', + }, + ], + }, + { name: '_config.ts' }, + { name: 'backgrounds.ts' }, + { name: 'borders.ts' }, + { name: 'components.ts' }, + { name: 'fonts.ts' }, + { name: 'gutters.ts' }, + { name: 'layout.ts' }, + ], + }, + // ├── translations/ + translations: { + name: 'translations', + // child + children: [{ name: '*.json' }], + }, + }, + structure: [ + { + name: 'src', + // folders + children: [ + { ruleId: 'tests' }, + { ruleId: 'components' }, + { ruleId: 'hooks' }, + { ruleId: 'navigators' }, + { ruleId: 'screens' }, + { ruleId: 'services' }, + { ruleId: 'theme' }, + { ruleId: 'translations' }, + { name: 'app.tsx' }, + { name: 'reactotron.config.ts' }, + ], + }, + { name: 'index.d.ts' }, + { name: 'index.js' }, + ], +}); diff --git a/template/eslint/independent-modules.mjs b/template/eslint/independent-modules.mjs new file mode 100644 index 000000000..1497b9feb --- /dev/null +++ b/template/eslint/independent-modules.mjs @@ -0,0 +1,311 @@ +// @ts-check + +import { createIndependentModules } from 'eslint-plugin-project-structure'; + +const reusableImportPatterns = { + // ├── components/ + // │ ├── atoms/ + atomsBarrel: ['src/components/atoms/index.ts'], + // │ ├── molecules/ + moleculesBarrel: ['src/components/molecules/index.ts'], + // │ ├── organisms/ + organismsBarrel: ['src/components/organisms/index.ts'], + // │ ├── templates/ + templatesBarrel: ['src/components/templates/index.ts'], + // │ └── providers/ + providersBarrel: ['src/components/providers/index.ts'], + // aggregate all barrels for easy import in rules@& + components: [ + '{atomsBarrel}', + '{moleculesBarrel}', + '{organismsBarrel}', + '{templatesBarrel}', + '{providersBarrel}', + ], + // ├── hooks/ + hooksBarrel: ['src/hooks/index.ts'], + // ├── navigators/ + navigatorsBarrel: ['src/navigators/index.ts'], + // ├── screens/ + screensBarrel: ['src/screens/index.ts'], + // ├── services/ + services: [ + 'src/services/*.ts', + 'src/services/domains/**/index.ts', + 'src/services/**/*.ts', + ], + themeService: [ + 'src/services/theme-generation/*.ts', + 'src/services/theme-generation/**/*.ts', + ], + // ├── theme/ + // │ ├── assets/ + // │ | ├── icons/ + getAssetsContext: ['src/theme/assets/get-assets-context.ts'], + themeIcons: [String.raw`src/theme/assets/icons/*.svg`], + // | | └── images/ + themeConfig: ['src/theme/*.ts'], + themeImages: [ + 'src/theme/assets/images/*.webp', + 'src/theme/assets/images/**/*.png', + ], + // | ├── styles/ + themeStyles: ['src/theme/styles/*.css'], + // ├── translations/ + translations: ['src/translations/*.json'], +}; + +export const independentModulesConfig = createIndependentModules({ + modules: [ + // src + // ├── __tests__/ + // │ ├── mocks/ + // | | └── *.ts + // │ ├── setup.ts + // │ └── test-wrappers.tsx + { + name: 'Tests files', + pattern: 'src/__tests__/**/*.(ts|tsx)', + // can import: + allowImportsFrom: [ + '{family}/**/*.(ts|tsx)', + '{hooksBarrel}', + '{services}', + '{providersBarrel}', + '{getAssetsContext}', + ], + }, + // ├── components/ + // │ ├── atoms/ + // │ | ├── component-folder/ + // | | | ├── component-folder.tsx + // | | | └── (component-folder.test.tsx) + // │ | ├── ... + // │ | └── index.ts + // TODO: improve this rule to allow imports from same family only but also from all barrels below + { + name: 'Tests component files', + pattern: 'src/components/**/*.(test|stories).tsx', + // can import: + allowImportsFrom: ['**/*'], + }, + { + name: 'Atoms Components', + pattern: 'src/components/atoms/**/*.tsx', + // can import: + allowImportsFrom: [ + '{providersBarrel}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{services}', + '{getAssetsContext}', + ], + }, + // │ ├── molecules/ // same structure as atoms/ + { + name: 'Molecules Components', + pattern: 'src/components/molecules/**/*.tsx', + // can import: + allowImportsFrom: [ + '{atomsBarrel}', + '{providersBarrel}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{services}', + '{getAssetsContext}', + ], + }, + // │ ├── organisms/ // same structure as atoms/ + { + name: 'Organisms Components', + pattern: 'src/components/organisms/**/*.tsx', + // can import: + allowImportsFrom: [ + '{atomsBarrel}', + '{moleculesBarrel}', + '{family_1}/**/*.tsx', // allow imports from same family so organisms can import other organisms + '{providersBarrel}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{services}', + '{getAssetsContext}', + ], + }, + // │ ├── templates/ // same structure as atoms/ + { + name: 'Templates Components', + pattern: 'src/components/templates/**/*.tsx', + // can import: + allowImportsFrom: [ + '{atomsBarrel}', + '{moleculesBarrel}', + '{organismsBarrel}', + '{providersBarrel}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{services}', + '{family_2}/**/*.tsx', // allow imports from same family so templates can import other templates + '{getAssetsContext}', + ], + }, + // │ └── providers/ // same structure as atoms/ + { + name: 'Theme Provider', + pattern: 'src/components/providers/theme-provider/theme-provider.tsx', + // can import: + allowImportsFrom: [ + '{hooksBarrel}', + '{services}', + '{getAssetsContext}', + '{themeConfig}', + ], + }, + { + name: 'Providers Components', + pattern: 'src/components/providers/**/*.tsx', + // can import: + allowImportsFrom: [ + '{hooksBarrel}', + '{themeIcons}', // why ? + '{themeImages}', // why ? + '{services}', + '{getAssetsContext}', + ], + }, + // ├── hooks/ + // │ ├── use-hook-name/ + // │ | ├── use-hook-name.ts + // │ | └── (use-hook-name.test.tsx) + // │ └── index.ts + { + name: 'Hooks', + pattern: 'src/hooks/**/*.tsx', + // can import: + allowImportsFrom: [ + '{family_1}/**/use-*.ts', // allow imports from same family so hooks can import other hooks + // '{themeIcons}', // why ? + // '{themeImages}', // why ? + '{services}', + ], + }, + // TODO: improve this rule to allow imports from same family only but also from all barrels above + { + name: 'Tests hooks files', + pattern: 'src/hooks/**/*.test.ts', + // can import: + allowImportsFrom: ['{family_2}/*.ts'], + }, + // ├── screens/ + { + name: 'Screens Test Components', + pattern: 'src/screens/**/*.test.tsx', + // can import: + allowImportsFrom: [ + '{family_1}/**/*.tsx', // allow imports from same family so screens test files can import other screens test files + '{components}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{services}', + ], + }, + { + name: 'Screens Components', + pattern: 'src/screens/**/*.tsx', + // can import: + allowImportsFrom: [ + '{components}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{services}', + ], + }, + // ├── navigators/ + { + name: 'Navigators Components', + pattern: 'src/navigators/**/*.tsx', + // can import: + allowImportsFrom: [ + '{screensBarrel}', + '{components}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{services}', + ], + }, + // ├── services/ + // │ ├── domains/ + // │ │ ├── domain-name/ + // │ │ │ ├── (api.ts) + // │ │ │ ├── (query-options.ts) + // │ │ │ ├── (schema.ts) + // │ │ │ └── index.ts + // │ ├── navigation/ + // │ │ ├── router.ts + // │ │ └── routeTree.gen.ts + // │ ├── i18n/ + // │ │ ├── instance.ts + // │ │ └── i18next.d.ts + // │ ├── api.ts + // │ ├── instance.ts + // │ └── *.ts + { + name: 'Services', + pattern: 'src/services/**/*.ts', + // can import: + allowImportsFrom: [ + '{family_1}/**/*.ts', + '{translations}', + '{themeImages}', + '{themeIcons}', + ], // allow imports from same family so services can import other services + }, + // ├── theme/ + // | ├── assets/ + // | | ├── icons/ + // | | | └── kebab-case.{svg} + // | | └── images/ + // | | | └── kebab-case.{webp} + // | ├── styles/ + // | | └── *.css + { + name: 'Theme', + pattern: 'src/theme/**/*.ts', + // can import nothing: + allowImportsFrom: [ + '{family_1}/**/*.ts', // allow imports from same family so theme files can import other theme files + '{themeService}', + //TODO: allow imports from themegen + ], + }, + // ├── translations/ + // | └── *.json + { + name: 'Translations', + pattern: 'src/translations/**/*.ts', + // can import nothing: + allowImportsFrom: [], + }, + // └── app.tsx + { + name: 'app.tsx', + pattern: 'src/app.tsx', + // can import: + allowImportsFrom: [ + '{components}', + '{hooksBarrel}', + '{themeIcons}', + '{themeImages}', + '{themeStyles}', + '{services}', + ], + }, + ], + reusableImportPatterns, +}); diff --git a/template/index.js b/template/index.js index a6bde65e8..7e046f256 100644 --- a/template/index.js +++ b/template/index.js @@ -1,7 +1,7 @@ import { AppRegistry } from 'react-native'; import { name as appName } from './app.json'; -import App from './src/App'; +import App from './src/app'; // if (__DEV__) { // void import('@/reactotron.config'); diff --git a/template/jest.setup.js b/template/jest.setup.js deleted file mode 100644 index a8fc81f76..000000000 --- a/template/jest.setup.js +++ /dev/null @@ -1,2 +0,0 @@ -import './src/tests/__mocks__/libs'; -import './src/tests/__mocks__/getAssetsContext'; diff --git a/template/projectStructure.cache.json b/template/projectStructure.cache.json new file mode 100644 index 000000000..4678cf190 --- /dev/null +++ b/template/projectStructure.cache.json @@ -0,0 +1,14 @@ +[ + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/screens/Example/Example.test.tsx", + "errorMessage": "🔥 Folder 'screens' is invalid. 🔥\n\nAllowed names = __tests__, components, hooks, routes, services, theme, translations\nError location = ./src/screens\n\n" + }, + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/components/atoms/Skeleton/Skeleton.test.tsx", + "errorMessage": "🔥 Folder 'Skeleton' is invalid. 🔥\n\nAllowed names = {kebab-case}\nError location = ./src/components/atoms/Skeleton\n\n" + }, + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/App.tsx", + "errorMessage": "🔥 File 'App.tsx' is invalid. 🔥\n\nAllowed names = main.tsx\nError location = ./src/App.tsx\n\n" + } +] \ No newline at end of file diff --git a/template/src/App.tsx b/template/src/App.tsx index b496acec1..c078f656b 100644 --- a/template/src/App.tsx +++ b/template/src/App.tsx @@ -1,25 +1,14 @@ -import 'react-native-gesture-handler'; +import '@/services/translation'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { createMMKV } from 'react-native-mmkv'; -import ApplicationNavigator from '@/navigation/Application'; -import { ThemeProvider } from '@/theme'; -import '@/translations'; +import { ThemeProvider } from '@/components/providers'; -export const queryClient = new QueryClient({ - defaultOptions: { - mutations: { - retry: false, - }, - queries: { - retry: false, - }, - }, -}); +import { ApplicationNavigator } from '@/navigators'; -export const storage = createMMKV(); +import { queryClient } from './services/http-client'; +import { storage } from './services/storage'; function App() { return ( diff --git a/template/src/tests/__mocks__/getAssetsContext.ts b/template/src/__tests__/mocks/get-assets-context.ts similarity index 89% rename from template/src/tests/__mocks__/getAssetsContext.ts rename to template/src/__tests__/mocks/get-assets-context.ts index 3dcdbdc87..58f79918b 100644 --- a/template/src/tests/__mocks__/getAssetsContext.ts +++ b/template/src/__tests__/mocks/get-assets-context.ts @@ -1,4 +1,4 @@ -import type { AssetType } from '@/theme/assets/getAssetsContext'; +import type { AssetType } from '@/theme/assets/get-assets-context'; jest.mock('@/theme/assets/getAssetsContext', () => jest.fn((type: AssetType) => { diff --git a/template/src/tests/__mocks__/libs/index.ts b/template/src/__tests__/mocks/libs/index.ts similarity index 100% rename from template/src/tests/__mocks__/libs/index.ts rename to template/src/__tests__/mocks/libs/index.ts diff --git a/template/src/tests/__mocks__/libs/react-native-reanimated.ts b/template/src/__tests__/mocks/libs/react-native-reanimated.ts similarity index 100% rename from template/src/tests/__mocks__/libs/react-native-reanimated.ts rename to template/src/__tests__/mocks/libs/react-native-reanimated.ts diff --git a/template/src/tests/__mocks__/libs/react-native-safe-area-context.ts b/template/src/__tests__/mocks/libs/react-native-safe-area-context.ts similarity index 100% rename from template/src/tests/__mocks__/libs/react-native-safe-area-context.ts rename to template/src/__tests__/mocks/libs/react-native-safe-area-context.ts diff --git a/template/src/__tests__/setup.ts b/template/src/__tests__/setup.ts new file mode 100644 index 000000000..e28413094 --- /dev/null +++ b/template/src/__tests__/setup.ts @@ -0,0 +1,2 @@ +import './mocks/libs'; +import './mocks/get-assets-context'; diff --git a/template/src/tests/TestAppWrapper.tsx b/template/src/__tests__/test-wrappers.tsx similarity index 86% rename from template/src/tests/TestAppWrapper.tsx rename to template/src/__tests__/test-wrappers.tsx index 6645d4aea..18b8fbf08 100644 --- a/template/src/tests/TestAppWrapper.tsx +++ b/template/src/__tests__/test-wrappers.tsx @@ -1,11 +1,12 @@ +import '@/services/translation'; + import { QueryClientProvider } from '@tanstack/react-query'; import { type PropsWithChildren } from 'react'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { ThemeProvider } from '@/theme'; -import '@/translations'; -import { queryClient, storage } from '../App'; +import { queryClient, storage } from '../app'; function TestAppWrapper({ children }: PropsWithChildren) { return ( diff --git a/template/src/components/atoms/IconByVariant/IconByVariant.tsx b/template/src/components/atoms/IconByVariant/IconByVariant.tsx deleted file mode 100644 index 9f2633ea9..000000000 --- a/template/src/components/atoms/IconByVariant/IconByVariant.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { FC } from 'react'; -import type { SvgProps } from 'react-native-svg'; - -import { useMemo } from 'react'; -import * as z from 'zod'; - -import { useTheme } from '@/theme'; -import getAssetsContext from '@/theme/assets/getAssetsContext'; - -type Properties = { - readonly path: string; -} & SvgProps; - -const icons = getAssetsContext('icons'); -const EXTENSION = 'svg'; -const SIZE = 24; - -// 1. Declare the fallback outside to guarantee a stable, single reference in memory. -const FallbackIcon: FC = () => undefined; - -// 2. Resolve the component without creating ANY new functions inline. -const resolveIconComponent = (path: string, variant: string): FC => { - const schema = z.object({ - default: z.custom>(), - }); - - const getModule = (p: string) => schema.parse(icons(p)).default; - - try { - if (variant !== 'default') { - try { - return getModule(`./${variant}/${path}.${EXTENSION}`); - } catch { - // Continue to default fallback below - } - } - return getModule(`./${path}.${EXTENSION}`); - } catch (error) { - console.warn(`Icon ${path} not found. Returning fallback.`, error); - // 3. Return the stable reference instead of an inline function - return FallbackIcon; - } -}; - -function IconByVariant({ - height = SIZE, - path, - width = SIZE, - ...props -}: Properties) { - const { variant } = useTheme(); - - const IconComponent = useMemo( - () => resolveIconComponent(path, variant), - [path, variant], - ); - - // eslint-disable-next-line react-hooks/static-components - return ; -} - -export default IconByVariant; diff --git a/template/src/components/atoms/Skeleton/Skeleton.test.tsx b/template/src/components/atoms/Skeleton/Skeleton.test.tsx index 95fab1e4a..a545880f1 100644 --- a/template/src/components/atoms/Skeleton/Skeleton.test.tsx +++ b/template/src/components/atoms/Skeleton/Skeleton.test.tsx @@ -1,9 +1,9 @@ import { render, screen } from '@testing-library/react-native'; import { Text } from 'react-native'; -import TestAppWrapper from '@/tests/TestAppWrapper'; +import TestAppWrapper from '@/__tests__/test-wrappers'; -import SkeletonLoader from './Skeleton'; +import SkeletonLoader from './skeleton'; const WAIT = 800; @@ -15,7 +15,7 @@ describe('SkeletonLoader', () => { it('renders children when not loading', () => { render( - Loaded Content + {'Loaded Content'} , { wrapper: TestAppWrapper, diff --git a/template/src/components/atoms/Skeleton/Skeleton.tsx b/template/src/components/atoms/Skeleton/Skeleton.tsx index 7631fcb40..588c3b0ef 100644 --- a/template/src/components/atoms/Skeleton/Skeleton.tsx +++ b/template/src/components/atoms/Skeleton/Skeleton.tsx @@ -1,7 +1,5 @@ -import type { DimensionValue, ViewProps } from 'react-native'; - import { useEffect } from 'react'; -import { View } from 'react-native'; +import { type DimensionValue, View, type ViewProps } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, @@ -9,7 +7,7 @@ import Animated, { withTiming, } from 'react-native-reanimated'; -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; type Properties = { readonly height?: DimensionValue; diff --git a/template/src/components/atoms/AssetByVariant/AssetByVariant.tsx b/template/src/components/atoms/asset-by-variant/asset-by-variant.tsx similarity index 85% rename from template/src/components/atoms/AssetByVariant/AssetByVariant.tsx rename to template/src/components/atoms/asset-by-variant/asset-by-variant.tsx index c0c1825fa..8813f1ac4 100644 --- a/template/src/components/atoms/AssetByVariant/AssetByVariant.tsx +++ b/template/src/components/atoms/asset-by-variant/asset-by-variant.tsx @@ -1,11 +1,10 @@ -import type { ImageProps, ImageSourcePropType } from 'react-native'; - import { useMemo } from 'react'; -import { Image } from 'react-native'; +import { Image, type ImageProps, type ImageSourcePropType } from 'react-native'; import * as z from 'zod'; -import { useTheme } from '@/theme'; -import getAssetsContext from '@/theme/assets/getAssetsContext'; +import { useTheme } from '@/hooks'; + +import getAssetsContext from '@/theme/assets/get-assets-context'; type Properties = { readonly extension?: string; diff --git a/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx b/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx new file mode 100644 index 000000000..384aaf1d6 --- /dev/null +++ b/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx @@ -0,0 +1,68 @@ +import type { SvgProps } from 'react-native-svg'; + +import { type ReactElement, useMemo } from 'react'; +import * as z from 'zod'; + +import { useTheme } from '@/hooks'; + +import getAssetsContext from '@/theme/assets/get-assets-context'; + +type Properties = { + readonly path: string; +} & SvgProps; + +const icons = getAssetsContext('icons'); +const EXTENSION = 'svg'; +const SIZE = 24; + +function IconByVariant({ + height = SIZE, + path, + width = SIZE, + ...props +}: Properties) { + const { variant } = useTheme(); + + const iconProperties = { ...props, height, width }; + const Icon = useMemo(() => { + try { + const getDefaultSource = () => + z + .object({ + default: z.custom>(() => + z.custom>(), + ), + }) + .parse(icons(`./${path}.${EXTENSION}`)).default; + + if (variant === 'default') { + return getDefaultSource(); + } + + try { + const fetchedModule = z + .object({ + default: z.custom>(() => + z.custom>(), + ), + }) + .parse(icons(`./${variant}/${path}.${EXTENSION}`)); + + return fetchedModule.default; + } catch (error) { + console.warn( + `Couldn't load the icon: ${path}.${EXTENSION} for the variant ${variant}, Fallback to default`, + error, + ); + return getDefaultSource(); + } + } catch (error) { + console.error(`Couldn't load the icon: ${path}.${EXTENSION}`, error); + throw error; + } + }, [variant, path]); + + return ; +} + +export default IconByVariant; diff --git a/template/src/components/atoms/index.ts b/template/src/components/atoms/index.ts index 689b9d474..7de310010 100644 --- a/template/src/components/atoms/index.ts +++ b/template/src/components/atoms/index.ts @@ -1,3 +1,3 @@ -export { default as AssetByVariant } from './AssetByVariant/AssetByVariant'; -export { default as IconByVariant } from './IconByVariant/IconByVariant'; -export { default as Skeleton } from './Skeleton/Skeleton'; +export { default as AssetByVariant } from './asset-by-variant/asset-by-variant'; +export { default as IconByVariant } from './icon-by-variant/icon-by-variant'; +export { default as Skeleton } from './skeleton/skeleton'; diff --git a/template/src/components/molecules/DefaultError/DefaultError.tsx b/template/src/components/molecules/default-error/default-error.tsx similarity index 91% rename from template/src/components/molecules/DefaultError/DefaultError.tsx rename to template/src/components/molecules/default-error/default-error.tsx index d9c1900de..664038acb 100644 --- a/template/src/components/molecules/DefaultError/DefaultError.tsx +++ b/template/src/components/molecules/default-error/default-error.tsx @@ -2,15 +2,15 @@ import { useErrorBoundary } from 'react-error-boundary'; import { useTranslation } from 'react-i18next'; import { Text, TouchableOpacity, View } from 'react-native'; -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; import { IconByVariant } from '@/components/atoms'; -type Properties = { +type Props = { readonly onReset?: () => void; }; -function DefaultErrorScreen({ onReset = undefined }: Properties) { +function DefaultErrorScreen({ onReset = undefined }: Props) { const { colors, fonts, gutters, layout } = useTheme(); const { t } = useTranslation(); const { resetBoundary } = useErrorBoundary(); diff --git a/template/src/components/molecules/index.ts b/template/src/components/molecules/index.ts index d6b1d0d87..922fc4bcf 100644 --- a/template/src/components/molecules/index.ts +++ b/template/src/components/molecules/index.ts @@ -1 +1 @@ -export { default as DefaultError } from './DefaultError/DefaultError'; +export { default as DefaultError } from './default-error/default-error'; diff --git a/template/src/components/organisms/ErrorBoundary/ErrorBoundary.tsx b/template/src/components/organisms/error-boundary/error-boundary.tsx similarity index 75% rename from template/src/components/organisms/ErrorBoundary/ErrorBoundary.tsx rename to template/src/components/organisms/error-boundary/error-boundary.tsx index 180785cd0..22d215419 100644 --- a/template/src/components/organisms/ErrorBoundary/ErrorBoundary.tsx +++ b/template/src/components/organisms/error-boundary/error-boundary.tsx @@ -1,7 +1,9 @@ import type { ErrorInfo } from 'react'; -import type { ErrorBoundaryPropsWithFallback } from 'react-error-boundary'; -import { ErrorBoundary as DefaultErrorBoundary } from 'react-error-boundary'; +import { + ErrorBoundary as DefaultErrorBoundary, + ErrorBoundaryPropsWithFallback, +} from 'react-error-boundary'; import { DefaultError } from '@/components/molecules'; @@ -17,7 +19,7 @@ function ErrorBoundary({ onReset = undefined, ...props }: Properties) { - const onErrorReport = (error: unknown, info: ErrorInfo) => { + const onErrorReport = (error: Error, info: ErrorInfo) => { // use any crash reporting tool here return onError?.(error, info); }; diff --git a/template/src/components/organisms/index.ts b/template/src/components/organisms/index.ts index 3cd68d3b8..0e0b4692b 100644 --- a/template/src/components/organisms/index.ts +++ b/template/src/components/organisms/index.ts @@ -1 +1 @@ -export { default as ErrorBoundary } from './ErrorBoundary/ErrorBoundary'; +export { default as ErrorBoundary } from './error-boundary/error-boundary'; diff --git a/template/src/components/providers/index.ts b/template/src/components/providers/index.ts new file mode 100644 index 000000000..c8a0e3641 --- /dev/null +++ b/template/src/components/providers/index.ts @@ -0,0 +1 @@ +export { default as ThemeProvider } from './theme-provider/theme-provider'; diff --git a/template/src/theme/ThemeProvider/ThemeProvider.test.tsx b/template/src/components/providers/theme-provider/theme-provider.test.tsx similarity index 95% rename from template/src/theme/ThemeProvider/ThemeProvider.test.tsx rename to template/src/components/providers/theme-provider/theme-provider.test.tsx index f42e43941..c57509346 100644 --- a/template/src/theme/ThemeProvider/ThemeProvider.test.tsx +++ b/template/src/components/providers/theme-provider/theme-provider.test.tsx @@ -2,7 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; import { Button, Text, View } from 'react-native'; import { createMMKV, MMKV } from 'react-native-mmkv'; -import { ThemeProvider, useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; + +import ThemeProvider from './theme-provider'; function TestChildComponent() { const { changeTheme, variant } = useTheme(); diff --git a/template/src/theme/ThemeProvider/ThemeProvider.tsx b/template/src/components/providers/theme-provider/theme-provider.tsx similarity index 75% rename from template/src/theme/ThemeProvider/ThemeProvider.tsx rename to template/src/components/providers/theme-provider/theme-provider.tsx index 07433a237..caa2fac49 100644 --- a/template/src/theme/ThemeProvider/ThemeProvider.tsx +++ b/template/src/components/providers/theme-provider/theme-provider.tsx @@ -1,9 +1,24 @@ -import type { PropsWithChildren } from 'react'; +import type { + FulfilledThemeConfiguration, + Variant, +} from '@/services/theme-generation/types/config'; +import type { + ComponentTheme, + Theme, +} from '@/services/theme-generation/types/theme'; import type { MMKV } from 'react-native-mmkv'; import { DarkTheme, DefaultTheme } from '@react-navigation/native'; -import { createContext, useCallback, useMemo, useState } from 'react'; - +import { + createContext, + type PropsWithChildren, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; + +import generateConfig from '@/services/theme-generation/generate-config'; import { generateBackgrounds, staticBackgroundStyles, @@ -22,12 +37,6 @@ import { } from '@/theme/fonts'; import { generateGutters, staticGutterStyles } from '@/theme/gutters'; import layout from '@/theme/layout'; -import generateConfig from '@/theme/ThemeProvider/generateConfig'; -import type { - FulfilledThemeConfiguration, - Variant, -} from '@/theme/types/config'; -import type { ComponentTheme, Theme } from '@/theme/types/theme'; type Context = { changeTheme: (variant: Variant) => void; @@ -41,16 +50,18 @@ type Properties = PropsWithChildren<{ function ThemeProvider({ children = false, storage }: Properties) { // Current theme variant - const [variant, setVariant] = useState(() => { - const storedTheme = storage.getString('theme'); + const [variant, setVariant] = useState( + (storage.getString('theme') ?? 'default') as Variant, + ); - if (storedTheme) { - return storedTheme as Variant; + // Initialize theme at default if not defined + useEffect(() => { + const appHasThemeDefined = storage.contains('theme'); + if (!appHasThemeDefined) { + storage.set('theme', 'default'); + setVariant('default'); } - - storage.set('theme', 'default'); - return 'default'; - }); + }, [storage]); const changeTheme = useCallback( (nextVariant: Variant) => { @@ -66,9 +77,10 @@ function ThemeProvider({ children = false, storage }: Properties) { }, [variant]); const fonts = useMemo(() => { + const fontColors = generateFontColors(fullConfig); return { ...generateFontSizes(), - ...generateFontColors(fullConfig), + ...(Array.isArray(fontColors) ? { fontColors } : fontColors), ...staticFontStyles, }; }, [fullConfig]); @@ -88,8 +100,9 @@ function ThemeProvider({ children = false, storage }: Properties) { }, [fullConfig]); const borders = useMemo(() => { + const borderColors = generateBorderColors(fullConfig); return { - ...generateBorderColors(fullConfig), + ...(Array.isArray(borderColors) ? { borderColors } : borderColors), ...generateBorderRadius(), ...generateBorderWidths(), ...staticBorderStyles, diff --git a/template/src/components/templates/index.ts b/template/src/components/templates/index.ts index 5826cf969..d767a4468 100644 --- a/template/src/components/templates/index.ts +++ b/template/src/components/templates/index.ts @@ -1 +1 @@ -export { default as SafeScreen } from './SafeScreen/SafeScreen'; +export { default as SafeScreen } from './safe-screen/safe-screen'; diff --git a/template/src/components/templates/SafeScreen/SafeScreen.tsx b/template/src/components/templates/safe-screen/safe-screen.tsx similarity index 82% rename from template/src/components/templates/SafeScreen/SafeScreen.tsx rename to template/src/components/templates/safe-screen/safe-screen.tsx index 40763d1ea..ad260da20 100644 --- a/template/src/components/templates/SafeScreen/SafeScreen.tsx +++ b/template/src/components/templates/safe-screen/safe-screen.tsx @@ -1,10 +1,12 @@ import type { PropsWithChildren } from 'react'; -import type { SafeAreaViewProps } from 'react-native-safe-area-context'; import { StatusBar } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { + SafeAreaView, + SafeAreaViewProps, +} from 'react-native-safe-area-context'; -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; import { DefaultError } from '@/components/molecules'; import { ErrorBoundary } from '@/components/organisms'; @@ -19,7 +21,7 @@ type Properties = PropsWithChildren< function SafeScreen({ children = undefined, isError = false, - onResetError = undefined, + onResetError, style, ...props }: Properties) { diff --git a/template/src/hooks/domain/index.ts b/template/src/hooks/domain/index.ts deleted file mode 100644 index 519304b69..000000000 --- a/template/src/hooks/domain/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useUser } from './user/useUser'; diff --git a/template/src/hooks/domain/user/schema.ts b/template/src/hooks/domain/user/schema.ts deleted file mode 100644 index 1496d3e9a..000000000 --- a/template/src/hooks/domain/user/schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import * as z from 'zod'; - -export const userSchema = z.object({ - id: z.number(), - name: z.string(), -}); - -export type User = z.infer; diff --git a/template/src/hooks/domain/user/useUser.ts b/template/src/hooks/domain/user/useUser.ts deleted file mode 100644 index 7c5d9f6f4..000000000 --- a/template/src/hooks/domain/user/useUser.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { User } from './schema'; - -import { useQuery, useQueryClient } from '@tanstack/react-query'; - -import { UserServices } from './userService'; - -const enum UserQueryKey { - fetchOne = 'fetchOneUser', -} - -const useFetchOneQuery = (currentId: User['id']) => - useQuery({ - enabled: currentId >= 0, - queryFn: () => UserServices.fetchOne(currentId), - queryKey: [UserQueryKey.fetchOne, currentId], - }); - -export const useUser = () => { - const client = useQueryClient(); - - const invalidateQuery = (queryKeys: UserQueryKey[]) => - client.invalidateQueries({ - queryKey: queryKeys, - }); - - return { - invalidateQuery, - useFetchOneQuery, - }; -}; diff --git a/template/src/hooks/domain/user/userService.ts b/template/src/hooks/domain/user/userService.ts deleted file mode 100644 index 16ddbb248..000000000 --- a/template/src/hooks/domain/user/userService.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { instance } from '@/services/instance'; - -import { userSchema } from './schema'; - -export const UserServices = { - fetchOne: async (id: number) => { - const response = await instance.get(`users/${id}`).json(); - return userSchema.parse(response); - }, -}; diff --git a/template/src/hooks/index.ts b/template/src/hooks/index.ts index 40e034a22..3b8b3ea34 100644 --- a/template/src/hooks/index.ts +++ b/template/src/hooks/index.ts @@ -1,2 +1 @@ -export * from './domain'; -export { useI18n } from './language/useI18n'; +export { useTheme } from './use-theme/use-theme'; diff --git a/template/src/hooks/language/schema.ts b/template/src/hooks/language/schema.ts deleted file mode 100644 index c69ce4156..000000000 --- a/template/src/hooks/language/schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as z from 'zod'; - -export const enum SupportedLanguages { - EN_EN = 'en-EN', - FR_FR = 'fr-FR', -} - -export const languageSchema = z.enum([ - SupportedLanguages.EN_EN, - SupportedLanguages.FR_FR, -]); - -export type Language = z.infer; diff --git a/template/src/hooks/language/useI18n.ts b/template/src/hooks/language/useI18n.ts deleted file mode 100644 index 1ae2ef315..000000000 --- a/template/src/hooks/language/useI18n.ts +++ /dev/null @@ -1,19 +0,0 @@ -import i18next from 'i18next'; - -import { SupportedLanguages } from './schema'; - -const changeLanguage = (lang: SupportedLanguages) => { - void i18next.changeLanguage(lang); -}; - -const toggleLanguage = () => { - void i18next.changeLanguage( - i18next.language === (SupportedLanguages.EN_EN as string) - ? SupportedLanguages.FR_FR - : SupportedLanguages.EN_EN, - ); -}; - -export const useI18n = () => { - return { changeLanguage, toggleLanguage }; -}; diff --git a/template/src/theme/hooks/useTheme.ts b/template/src/hooks/use-theme/use-theme.ts similarity index 64% rename from template/src/theme/hooks/useTheme.ts rename to template/src/hooks/use-theme/use-theme.ts index aab3198cc..317e06edd 100644 --- a/template/src/theme/hooks/useTheme.ts +++ b/template/src/hooks/use-theme/use-theme.ts @@ -1,8 +1,8 @@ import { useContext } from 'react'; -import { ThemeContext } from '../ThemeProvider/ThemeProvider'; +import { ThemeContext } from '@/components/providers/theme-provider/theme-provider'; -const useTheme = () => { +export const useTheme = () => { const context = useContext(ThemeContext); if (context === undefined) { @@ -11,5 +11,3 @@ const useTheme = () => { return context; }; - -export default useTheme; diff --git a/template/src/navigators/index.ts b/template/src/navigators/index.ts new file mode 100644 index 000000000..e5b77add9 --- /dev/null +++ b/template/src/navigators/index.ts @@ -0,0 +1 @@ +export { default as ApplicationNavigator } from './root'; diff --git a/template/src/navigation/Application.tsx b/template/src/navigators/root.tsx similarity index 83% rename from template/src/navigation/Application.tsx rename to template/src/navigators/root.tsx index 98e9fb9c5..8c73b26b0 100644 --- a/template/src/navigation/Application.tsx +++ b/template/src/navigators/root.tsx @@ -1,13 +1,15 @@ +import type { RootStackParamList } from '@/services/navigation/types'; + import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { Paths } from '@/navigation/paths'; -import type { RootStackParamList } from '@/navigation/types'; -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; import { Example, Startup } from '@/screens'; +import { Paths } from '@/services/navigation/paths'; + const Stack = createStackNavigator(); function ApplicationNavigator() { diff --git a/template/src/reactotron.config.ts b/template/src/reactotron.config.ts index b9da44522..26c7d0198 100644 --- a/template/src/reactotron.config.ts +++ b/template/src/reactotron.config.ts @@ -1,10 +1,10 @@ -import type { ReactotronReactNative } from 'reactotron-react-native'; - -import Reactotron from 'reactotron-react-native'; +import Reactotron, { + type ReactotronReactNative, +} from 'reactotron-react-native'; import mmkvPlugin from 'reactotron-react-native-mmkv'; import config from '../app.json'; -import { storage } from './App'; +import { storage } from './services/storage'; Reactotron.configure({ name: config.name, diff --git a/template/src/screens/Example/Example.test.tsx b/template/src/screens/Example/Example.test.tsx index 16a851ff1..47a23e7b5 100644 --- a/template/src/screens/Example/Example.test.tsx +++ b/template/src/screens/Example/Example.test.tsx @@ -4,11 +4,11 @@ import { I18nextProvider } from 'react-i18next'; import { createMMKV, MMKV } from 'react-native-mmkv'; import { SafeAreaProvider } from 'react-native-safe-area-context'; -import { SupportedLanguages } from '@/hooks/language/schema'; -import { ThemeProvider } from '@/theme'; -import i18n from '@/translations'; +import { ThemeProvider } from '@/components/providers'; -import Example from './Example'; +import i18n, { SupportedLanguages } from '@/services/i18n/instance'; + +import Example from './example'; describe('Example screen should render correctly', () => { let storage: MMKV; diff --git a/template/src/screens/Example/Example.tsx b/template/src/screens/Example/Example.tsx index 12fc01574..54871c8b8 100644 --- a/template/src/screens/Example/Example.tsx +++ b/template/src/screens/Example/Example.tsx @@ -1,19 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, ScrollView, Text, TouchableOpacity, View } from 'react-native'; -import { useI18n, useUser } from '@/hooks'; -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; import { AssetByVariant, IconByVariant, Skeleton } from '@/components/atoms'; import { SafeScreen } from '@/components/templates'; +import { fetchOneQueryOptions } from '@/services/domains/user/user.query-options'; +import { SupportedLanguages } from '@/services/i18n/instance'; + const MAX_RANDOM_ID = 9; function Example() { - const { t } = useTranslation(); - const { useFetchOneQuery } = useUser(); - const { toggleLanguage } = useI18n(); + const { i18n, t } = useTranslation(); const { backgrounds, @@ -28,15 +29,16 @@ function Example() { const [currentId, setCurrentId] = useState(-1); - const fetchOneUserQuery = useFetchOneQuery(currentId); + const fetchOneUserQuery = useQuery(fetchOneQueryOptions(currentId)); useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler if (fetchOneUserQuery.isSuccess) { Alert.alert( t('screen_example.hello_user', { name: fetchOneUserQuery.data.name }), ); } - }, [fetchOneUserQuery.isSuccess, fetchOneUserQuery.data, t]); + }, [fetchOneUserQuery.data?.name, fetchOneUserQuery.isSuccess, t]); const onChangeTheme = () => { changeTheme(variant === 'default' ? 'dark' : 'default'); @@ -120,7 +122,13 @@ function Example() { { + const newLanguage = + i18n.language === SupportedLanguages.FR_FR + ? SupportedLanguages.EN_EN + : SupportedLanguages.FR_FR; + void i18n.changeLanguage(newLanguage); + }} style={[components.buttonCircle, gutters.marginBottom_16]} testID="change-language-button" > diff --git a/template/src/screens/Startup/Startup.tsx b/template/src/screens/Startup/Startup.tsx index 11f9f3f68..d8907d0a3 100644 --- a/template/src/screens/Startup/Startup.tsx +++ b/template/src/screens/Startup/Startup.tsx @@ -1,16 +1,17 @@ -import type { RootScreenProps } from '@/Navigation/types'; +import type { RootScreenProps } from '@/services/navigation/types'; import { useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Text, View } from 'react-native'; -import { Paths } from '@/navigation/paths'; -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; import { AssetByVariant } from '@/components/atoms'; import { SafeScreen } from '@/components/templates'; +import { Paths } from '@/services/navigation/paths'; + function Startup({ navigation }: RootScreenProps) { const { fonts, gutters, layout } = useTheme(); const { t } = useTranslation(); diff --git a/template/src/screens/index.ts b/template/src/screens/index.ts index cba129359..e68804f49 100644 --- a/template/src/screens/index.ts +++ b/template/src/screens/index.ts @@ -1,2 +1,2 @@ -export { default as Example } from './Example/Example'; -export { default as Startup } from './Startup/Startup'; +export { default as Example } from './example/example'; +export { default as Startup } from './startup/startup'; diff --git a/template/src/services/domains/user/user.api.ts b/template/src/services/domains/user/user.api.ts new file mode 100644 index 000000000..5300803bc --- /dev/null +++ b/template/src/services/domains/user/user.api.ts @@ -0,0 +1,10 @@ +import { httpClient } from '@/services/http-client'; + +import { UserSchema } from './user.schema'; + +export const UserApis = { + fetchOne: async (id: number) => { + const response = await httpClient.get(`users/${id}`).json(); + return UserSchema.parse(response); + }, +}; diff --git a/template/src/services/domains/user/user.query-options.ts b/template/src/services/domains/user/user.query-options.ts new file mode 100644 index 000000000..deb880a18 --- /dev/null +++ b/template/src/services/domains/user/user.query-options.ts @@ -0,0 +1,16 @@ +import type { User } from './user.schema'; + +import { queryOptions } from '@tanstack/react-query'; + +import { UserApis } from './user.api'; + +export const UserQueryKeys = { + fetchOne: 'fetchOneUser', +}; + +export const fetchOneQueryOptions = (currentId: User['id']) => + queryOptions({ + enabled: currentId >= 0, + queryFn: () => UserApis.fetchOne(currentId), + queryKey: [UserQueryKeys.fetchOne, currentId], + }); diff --git a/template/src/services/domains/user/user.schema.ts b/template/src/services/domains/user/user.schema.ts new file mode 100644 index 000000000..780314b42 --- /dev/null +++ b/template/src/services/domains/user/user.schema.ts @@ -0,0 +1,8 @@ +import * as z from 'zod'; + +export const UserSchema = z.object({ + id: z.number(), + name: z.string(), +}); + +export type User = z.infer; diff --git a/template/src/services/http-client.ts b/template/src/services/http-client.ts new file mode 100644 index 000000000..91facbf5b --- /dev/null +++ b/template/src/services/http-client.ts @@ -0,0 +1,22 @@ +import { QueryClient } from '@tanstack/react-query'; +import ky from 'ky'; + +const prefixUrl = `${process.env.API_URL ?? ''}/`; + +export const httpClient = ky.extend({ + headers: { + Accept: 'application/json', + }, + prefixUrl, +}); + +export const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + queries: { + retry: false, + }, + }, +}); diff --git a/template/src/translations/i18next.d.ts b/template/src/services/i18n/i18next.d.ts similarity index 62% rename from template/src/translations/i18next.d.ts rename to template/src/services/i18n/i18next.d.ts index 62946a0d9..cd8c40b62 100644 --- a/template/src/translations/i18next.d.ts +++ b/template/src/services/i18n/i18next.d.ts @@ -1,5 +1,8 @@ -import type { SupportedLanguages } from '@/hooks/language/schema'; -import type { defaultNS, resources } from '@/translations'; +import type { + defaultNS, + resources, + SupportedLanguages, +} from '@/services/i18n/instance'; export type TranslationKeys = RecursiveKeys< defaultTranslations[typeof defaultNS] @@ -24,4 +27,12 @@ declare module 'i18next' { defaultNS: typeof defaultNS; resources: defaultTranslations; }; + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface i18n { + changeLanguage( + lng: SupportedLanguages, + callback?: Callback, + ): Promise; + language: SupportedLanguages; + } } diff --git a/template/src/translations/index.ts b/template/src/services/i18n/instance.ts similarity index 61% rename from template/src/translations/index.ts rename to template/src/services/i18n/instance.ts index 17ca2a8f3..49c912aaa 100644 --- a/template/src/translations/index.ts +++ b/template/src/services/i18n/instance.ts @@ -2,12 +2,22 @@ import 'intl-pluralrules'; import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; +import * as z from 'zod'; -import type { Language } from '@/hooks/language/schema'; +import en from '../../translations/en-EN.json'; +import fr from '../../translations/fr-FR.json'; -import en from './en-EN.json'; -import fr from './fr-FR.json'; +export const enum SupportedLanguages { + EN_EN = 'en-EN', + FR_FR = 'fr-FR', +} +export const languageSchema = z.enum([ + SupportedLanguages.EN_EN, + SupportedLanguages.FR_FR, +]); + +export type Language = z.infer; export const defaultNS = 'boilerplate'; export const resources = { diff --git a/template/src/services/instance.ts b/template/src/services/instance.ts deleted file mode 100644 index bcd4c0429..000000000 --- a/template/src/services/instance.ts +++ /dev/null @@ -1,10 +0,0 @@ -import ky from 'ky'; - -const prefixUrl = `${process.env.API_URL ?? ''}/`; - -export const instance = ky.extend({ - headers: { - Accept: 'application/json', - }, - prefixUrl, -}); diff --git a/template/src/navigation/paths.ts b/template/src/services/navigation/paths.ts similarity index 100% rename from template/src/navigation/paths.ts rename to template/src/services/navigation/paths.ts diff --git a/template/src/navigation/types.ts b/template/src/services/navigation/types.ts similarity index 83% rename from template/src/navigation/types.ts rename to template/src/services/navigation/types.ts index ab57e4a25..899487eba 100644 --- a/template/src/navigation/types.ts +++ b/template/src/services/navigation/types.ts @@ -1,7 +1,6 @@ +import type { Paths } from '@/services/navigation/paths'; import type { StackScreenProps } from '@react-navigation/stack'; -import type { Paths } from '@/navigation/paths'; - export type RootScreenProps< S extends keyof RootStackParamList = keyof RootStackParamList, > = StackScreenProps; diff --git a/template/src/services/storage.ts b/template/src/services/storage.ts new file mode 100644 index 000000000..1bdcf853f --- /dev/null +++ b/template/src/services/storage.ts @@ -0,0 +1,3 @@ +import { createMMKV } from 'react-native-mmkv'; + +export const storage = createMMKV(); diff --git a/template/src/theme/ThemeProvider/generateConfig.ts b/template/src/services/theme-generation/generate-config.ts similarity index 93% rename from template/src/theme/ThemeProvider/generateConfig.ts rename to template/src/services/theme-generation/generate-config.ts index d2e955223..4bf04c5a3 100644 --- a/template/src/theme/ThemeProvider/generateConfig.ts +++ b/template/src/services/theme-generation/generate-config.ts @@ -1,9 +1,7 @@ +import type { HasProperty } from './types/common'; +import type { FulfilledThemeConfiguration, Variant } from './types/config'; + import { config } from '@/theme/_config'; -import type { HasProperty } from '@/theme/types/common'; -import type { - FulfilledThemeConfiguration, - Variant, -} from '@/theme/types/config'; function hasProperty( configuration: Config, diff --git a/template/src/theme/types/backgrounds.ts b/template/src/services/theme-generation/types/backgrounds.ts similarity index 99% rename from template/src/theme/types/backgrounds.ts rename to template/src/services/theme-generation/types/backgrounds.ts index 96f92b26b..ef11e3fa8 100644 --- a/template/src/theme/types/backgrounds.ts +++ b/template/src/services/theme-generation/types/backgrounds.ts @@ -1,6 +1,5 @@ import type { RemoveBeforeSeparator } from './common'; import type { UnionConfiguration } from './config'; - import type { staticBackgroundStyles } from '@/theme/backgrounds'; export type Backgrounds = { diff --git a/template/src/theme/types/borders.ts b/template/src/services/theme-generation/types/borders.ts similarity index 99% rename from template/src/theme/types/borders.ts rename to template/src/services/theme-generation/types/borders.ts index ed2372242..5eaa2788f 100644 --- a/template/src/theme/types/borders.ts +++ b/template/src/services/theme-generation/types/borders.ts @@ -1,6 +1,5 @@ import type { ArrayValue, RemoveBeforeSeparator, ToNumber } from './common'; import type { UnionConfiguration } from './config'; - import type { config } from '@/theme/_config'; import type { staticBorderStyles } from '@/theme/borders'; diff --git a/template/src/theme/types/colors.ts b/template/src/services/theme-generation/types/colors.ts similarity index 100% rename from template/src/theme/types/colors.ts rename to template/src/services/theme-generation/types/colors.ts diff --git a/template/src/theme/types/common.ts b/template/src/services/theme-generation/types/common.ts similarity index 100% rename from template/src/theme/types/common.ts rename to template/src/services/theme-generation/types/common.ts diff --git a/template/src/theme/types/config.ts b/template/src/services/theme-generation/types/config.ts similarity index 94% rename from template/src/theme/types/config.ts rename to template/src/services/theme-generation/types/config.ts index 6f36b77e8..d7a88d4c5 100644 --- a/template/src/theme/types/config.ts +++ b/template/src/services/theme-generation/types/config.ts @@ -1,8 +1,7 @@ +import type generateConfig from '../generate-config'; import type { AllPartial } from './common'; -import type { Theme as NavigationTheme } from '@react-navigation/native'; - import type { config } from '@/theme/_config'; -import type generateConfig from '@/theme/ThemeProvider/generateConfig'; +import type { Theme as NavigationTheme } from '@react-navigation/native'; export type FulfilledThemeConfiguration = { readonly backgrounds: Record; diff --git a/template/src/theme/types/fonts.ts b/template/src/services/theme-generation/types/fonts.ts similarity index 99% rename from template/src/theme/types/fonts.ts rename to template/src/services/theme-generation/types/fonts.ts index 811797155..de30db8e2 100644 --- a/template/src/theme/types/fonts.ts +++ b/template/src/services/theme-generation/types/fonts.ts @@ -1,6 +1,5 @@ import type { ArrayValue, RemoveBeforeSeparator, ToNumber } from './common'; import type { UnionConfiguration } from './config'; - import type { config } from '@/theme/_config'; import type { staticFontStyles } from '@/theme/fonts'; diff --git a/template/src/theme/types/gutters.ts b/template/src/services/theme-generation/types/gutters.ts similarity index 99% rename from template/src/theme/types/gutters.ts rename to template/src/services/theme-generation/types/gutters.ts index dfcdf4c16..ae9ae44d7 100644 --- a/template/src/theme/types/gutters.ts +++ b/template/src/services/theme-generation/types/gutters.ts @@ -4,7 +4,6 @@ import type { RemoveBeforeSeparator, ToNumber, } from './common'; - import type { config } from '@/theme/_config'; import type { staticGutterStyles } from '@/theme/gutters'; diff --git a/template/src/theme/types/theme.ts b/template/src/services/theme-generation/types/theme.ts similarity index 93% rename from template/src/theme/types/theme.ts rename to template/src/services/theme-generation/types/theme.ts index dac5d47be..436c16155 100644 --- a/template/src/theme/types/theme.ts +++ b/template/src/services/theme-generation/types/theme.ts @@ -1,13 +1,12 @@ import type { Backgrounds } from './backgrounds'; import type { Borders } from './borders'; +import type { Colors } from './colors'; import type { Variant } from './config'; import type { Fonts } from './fonts'; import type { Gutters } from './gutters'; -import type { Theme as NavigationTheme } from '@react-navigation/native'; - import type componentGenerators from '@/theme/components'; import type layout from '@/theme/layout'; -import type { Colors } from '@/theme/types/colors'; +import type { Theme as NavigationTheme } from '@react-navigation/native'; export type ComponentTheme = Omit; diff --git a/template/src/theme/_config.ts b/template/src/theme/_config.ts index b4c9a8bb9..92cebc56b 100644 --- a/template/src/theme/_config.ts +++ b/template/src/theme/_config.ts @@ -1,3 +1,5 @@ +import type { ThemeConfiguration } from '@/services/theme-generation/types/config'; + import { DarkTheme, DefaultTheme } from '@react-navigation/native'; import type { ThemeConfiguration } from '@/theme/types/config'; diff --git a/template/src/theme/assets/getAssetsContext.ts b/template/src/theme/assets/get-assets-context.ts similarity index 100% rename from template/src/theme/assets/getAssetsContext.ts rename to template/src/theme/assets/get-assets-context.ts diff --git a/template/src/theme/backgrounds.ts b/template/src/theme/backgrounds.ts index c0cf17f7a..b0f908c39 100644 --- a/template/src/theme/backgrounds.ts +++ b/template/src/theme/backgrounds.ts @@ -1,8 +1,7 @@ +import type { Backgrounds } from '@/services/theme-generation/types/backgrounds'; +import type { UnionConfiguration } from '@/services/theme-generation/types/config'; import type { ViewStyle } from 'react-native'; -import type { Backgrounds } from '@/theme/types/backgrounds'; -import type { UnionConfiguration } from '@/theme/types/config'; - /** * Generates background styles from configuration * @param configuration diff --git a/template/src/theme/borders.ts b/template/src/theme/borders.ts index 0e0b2d4ed..2d2b7130d 100644 --- a/template/src/theme/borders.ts +++ b/template/src/theme/borders.ts @@ -7,8 +7,11 @@ import type { BorderRadius, BorderTopRadius, BorderWidths, -} from '@/theme/types/borders'; -import type { UnionConfiguration } from '@/theme/types/config'; +} from '@/services/theme-generation/types/borders'; +import type { UnionConfiguration } from '@/services/theme-generation/types/config'; +import type { ViewStyle } from 'react-native'; + +import { config } from './_config'; /** * Generates border color styles from configuration @@ -24,7 +27,7 @@ export const generateBorderColors = (configuration: UnionConfiguration) => { borderColor: value, }, }); - }, {} as BorderColors); + }, {}); }; /** diff --git a/template/src/theme/components.ts b/template/src/theme/components.ts index 5e68b2f80..9ec439081 100644 --- a/template/src/theme/components.ts +++ b/template/src/theme/components.ts @@ -1,3 +1,4 @@ +import type { ComponentTheme } from '@/services/theme-generation/types/theme'; import type { ImageStyle, TextStyle, ViewStyle } from 'react-native'; import type { ComponentTheme } from '@/theme/types/theme'; diff --git a/template/src/theme/fonts.ts b/template/src/theme/fonts.ts index bf9c15a11..85dd09a6c 100644 --- a/template/src/theme/fonts.ts +++ b/template/src/theme/fonts.ts @@ -1,8 +1,11 @@ +import type { UnionConfiguration } from '@/services/theme-generation/types/config'; +import type { + FontColors, + FontSizes, +} from '@/services/theme-generation/types/fonts'; import type { TextStyle } from 'react-native'; -import { config } from '@/theme/_config'; -import type { UnionConfiguration } from '@/theme/types/config'; -import type { FontColors, FontSizes } from '@/theme/types/fonts'; +import { config } from './_config'; export const generateFontColors = (configuration: UnionConfiguration) => { // eslint-disable-next-line unicorn/no-array-reduce @@ -14,7 +17,7 @@ export const generateFontColors = (configuration: UnionConfiguration) => { }, }); }, - {} as FontColors, + {}, ); }; diff --git a/template/src/theme/gutters.ts b/template/src/theme/gutters.ts index b02ae3955..fca3f7f69 100644 --- a/template/src/theme/gutters.ts +++ b/template/src/theme/gutters.ts @@ -1,7 +1,5 @@ -import { type ViewStyle } from 'react-native'; - -import type { UnionConfiguration } from '@/theme/types/config'; -import type { Gutters } from '@/theme/types/gutters'; +import type { UnionConfiguration } from '@/services/theme-generation/types/config'; +import type { Gutters } from '@/services/theme-generation/types/gutters'; export const generateGutters = (configuration: UnionConfiguration): Gutters => { // eslint-disable-next-line unicorn/no-array-reduce diff --git a/template/src/theme/index.ts b/template/src/theme/index.ts deleted file mode 100644 index 4c79cae6d..000000000 --- a/template/src/theme/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as useTheme } from './hooks/useTheme'; -export { default as ThemeProvider } from './ThemeProvider/ThemeProvider'; From d23eb04f3177c94fd457abd39445cd6d00366bf6 Mon Sep 17 00:00:00 2001 From: jeremydolle Date: Wed, 25 Feb 2026 18:06:37 +0100 Subject: [PATCH 02/10] save --- template/eslint.config.js | 46 +++++------ template/kebab.sh | 37 +++++++++ template/projectStructure.cache.json | 24 +++++- .../atoms/icon-by-variant/icon-by-variant.tsx | 80 +++++++++---------- template/src/components/atoms/index.ts | 2 +- .../theme-provider/theme-provider.tsx | 19 ++--- template/src/theme/_config.ts | 3 +- template/src/theme/borders.ts | 3 - template/src/theme/components.ts | 2 - template/src/theme/gutters.ts | 2 + 10 files changed, 129 insertions(+), 89 deletions(-) create mode 100755 template/kebab.sh diff --git a/template/eslint.config.js b/template/eslint.config.js index d4434aa8c..de94d0063 100644 --- a/template/eslint.config.js +++ b/template/eslint.config.js @@ -3,10 +3,10 @@ import eslint from '@eslint/js'; import eslintConfigPrettier from 'eslint-config-prettier'; import jest from 'eslint-plugin-jest'; import perfectionist from 'eslint-plugin-perfectionist'; -import { - projectStructureParser, - projectStructurePlugin, -} from 'eslint-plugin-project-structure'; +// import { +// projectStructureParser, +// projectStructurePlugin, +// } from 'eslint-plugin-project-structure'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; @@ -15,30 +15,30 @@ import unicorn from 'eslint-plugin-unicorn'; import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; -import { fileCompositionConfig } from './eslint/file-composition/index.mjs'; -import { folderStructureConfig } from './eslint/folder-structure.mjs'; -import { independentModulesConfig } from './eslint/independent-modules.mjs'; +// import { fileCompositionConfig } from './eslint/file-composition/index.mjs'; +// import { folderStructureConfig } from './eslint/folder-structure.mjs'; +// import { independentModulesConfig } from './eslint/independent-modules.mjs'; const ERROR = 2; const OFF = 0; export default defineConfig([ - { - files: ['**/*.{js,jsx,ts,tsx}'], - ignores: ['project-structure.cache.json'], - languageOptions: { parser: projectStructureParser }, - plugins: { - 'project-structure': projectStructurePlugin, - }, - rules: { - 'project-structure/file-composition': [ERROR, fileCompositionConfig], - 'project-structure/folder-structure': [ERROR, folderStructureConfig], - 'project-structure/independent-modules': [ - ERROR, - independentModulesConfig, - ], - }, - }, + // { + // files: ['**/*.{js,jsx,ts,tsx}'], + // ignores: ['project-structure.cache.json'], + // languageOptions: { parser: projectStructureParser }, + // plugins: { + // 'project-structure': projectStructurePlugin, + // }, + // rules: { + // 'project-structure/file-composition': [ERROR, fileCompositionConfig], + // 'project-structure/folder-structure': [ERROR, folderStructureConfig], + // 'project-structure/independent-modules': [ + // ERROR, + // independentModulesConfig, + // ], + // }, + // }, { ignores: ['coverage/**', 'dist/**'], }, diff --git a/template/kebab.sh b/template/kebab.sh new file mode 100755 index 000000000..5af4d6646 --- /dev/null +++ b/template/kebab.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Sécurité : s'assurer que le répertoire de travail est propre +if ! git diff-index --quiet HEAD --; then + echo "⚠️ Erreur : Tu as des changements non validés (uncommitted)." + echo "Merci de faire un commit ou un stash avant de lancer ce script." + exit 1 +fi + +# Vérification que le dossier src existe +if [ ! -d "src" ]; then + echo "⚠️ Erreur : Le dossier 'src' est introuvable." + exit 1 +fi + +echo "Début de la conversion en kebab-case pour le dossier 'src'..." + +# On demande à Git de lister UNIQUEMENT le contenu du dossier src +git ls-files "src" | while read -r file; do + + # Transformation du chemin en kebab-case + new_file=$(echo "$file" | sed -E 's/([a-z0-9])([A-Z])/\1-\2/g' | tr '[:upper:]' '[:lower:]' | tr ' _' '--' | sed -E 's/-+/-/g') + + # Si le nom doit être modifié + if [ "$file" != "$new_file" ]; then + echo "🔄 $file -> $new_file" + + # Créer le dossier parent cible si nécessaire + mkdir -p "$(dirname "$new_file")" 2>/dev/null + + # Le renommage en deux étapes pour macOS/Git + git mv "$file" "${file}.tmp-rename" + git mv "${file}.tmp-rename" "$new_file" + fi +done + +echo "✅ Terminé ! Vérifie le résultat avec 'git status'." \ No newline at end of file diff --git a/template/projectStructure.cache.json b/template/projectStructure.cache.json index 4678cf190..dd1a40584 100644 --- a/template/projectStructure.cache.json +++ b/template/projectStructure.cache.json @@ -1,4 +1,24 @@ [ + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/translations/index.ts", + "errorMessage": "🔥 File 'index.ts' is invalid. 🔥\n\nAllowed names = *.json\nError location = ./src/translations/index.ts\n\n" + }, + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/screens/Startup/Startup.tsx", + "errorMessage": "🔥 Folder 'Startup' is invalid. 🔥\n\nAllowed names = {kebab-case}\nError location = ./src/screens/Startup\n\n" + }, + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/screens/Example/Example.test.tsx", + "errorMessage": "🔥 Folder 'Example' is invalid. 🔥\n\nAllowed names = {kebab-case}\nError location = ./src/screens/Example\n\n" + }, + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/components/atoms/skeleton/Skeleton.test.tsx", + "errorMessage": "🔥 File 'Skeleton.test.tsx' is invalid. 🔥\n\nAllowed names = skeleton(-{kebab-case})?.tsx, skeleton(-{kebab-case})?.stories.tsx, skeleton(-{kebab-case})?.test.tsx\nError location = ./src/components/atoms/skeleton/Skeleton.test.tsx\n\n" + }, + { + "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/components/atoms/skeleton/Skeleton.tsx", + "errorMessage": "🔥 File 'Skeleton.tsx' is invalid. 🔥\n\nAllowed names = skeleton(-{kebab-case})?.tsx, skeleton(-{kebab-case})?.stories.tsx, skeleton(-{kebab-case})?.test.tsx\nError location = ./src/components/atoms/skeleton/Skeleton.tsx\n\n" + }, { "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/screens/Example/Example.test.tsx", "errorMessage": "🔥 Folder 'screens' is invalid. 🔥\n\nAllowed names = __tests__, components, hooks, routes, services, theme, translations\nError location = ./src/screens\n\n" @@ -6,9 +26,5 @@ { "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/components/atoms/Skeleton/Skeleton.test.tsx", "errorMessage": "🔥 Folder 'Skeleton' is invalid. 🔥\n\nAllowed names = {kebab-case}\nError location = ./src/components/atoms/Skeleton\n\n" - }, - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/App.tsx", - "errorMessage": "🔥 File 'App.tsx' is invalid. 🔥\n\nAllowed names = main.tsx\nError location = ./src/App.tsx\n\n" } ] \ No newline at end of file diff --git a/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx b/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx index 384aaf1d6..9f2633ea9 100644 --- a/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx +++ b/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx @@ -1,11 +1,11 @@ +import type { FC } from 'react'; import type { SvgProps } from 'react-native-svg'; -import { type ReactElement, useMemo } from 'react'; +import { useMemo } from 'react'; import * as z from 'zod'; -import { useTheme } from '@/hooks'; - -import getAssetsContext from '@/theme/assets/get-assets-context'; +import { useTheme } from '@/theme'; +import getAssetsContext from '@/theme/assets/getAssetsContext'; type Properties = { readonly path: string; @@ -15,6 +15,33 @@ const icons = getAssetsContext('icons'); const EXTENSION = 'svg'; const SIZE = 24; +// 1. Declare the fallback outside to guarantee a stable, single reference in memory. +const FallbackIcon: FC = () => undefined; + +// 2. Resolve the component without creating ANY new functions inline. +const resolveIconComponent = (path: string, variant: string): FC => { + const schema = z.object({ + default: z.custom>(), + }); + + const getModule = (p: string) => schema.parse(icons(p)).default; + + try { + if (variant !== 'default') { + try { + return getModule(`./${variant}/${path}.${EXTENSION}`); + } catch { + // Continue to default fallback below + } + } + return getModule(`./${path}.${EXTENSION}`); + } catch (error) { + console.warn(`Icon ${path} not found. Returning fallback.`, error); + // 3. Return the stable reference instead of an inline function + return FallbackIcon; + } +}; + function IconByVariant({ height = SIZE, path, @@ -23,46 +50,13 @@ function IconByVariant({ }: Properties) { const { variant } = useTheme(); - const iconProperties = { ...props, height, width }; - const Icon = useMemo(() => { - try { - const getDefaultSource = () => - z - .object({ - default: z.custom>(() => - z.custom>(), - ), - }) - .parse(icons(`./${path}.${EXTENSION}`)).default; - - if (variant === 'default') { - return getDefaultSource(); - } - - try { - const fetchedModule = z - .object({ - default: z.custom>(() => - z.custom>(), - ), - }) - .parse(icons(`./${variant}/${path}.${EXTENSION}`)); - - return fetchedModule.default; - } catch (error) { - console.warn( - `Couldn't load the icon: ${path}.${EXTENSION} for the variant ${variant}, Fallback to default`, - error, - ); - return getDefaultSource(); - } - } catch (error) { - console.error(`Couldn't load the icon: ${path}.${EXTENSION}`, error); - throw error; - } - }, [variant, path]); + const IconComponent = useMemo( + () => resolveIconComponent(path, variant), + [path, variant], + ); - return ; + // eslint-disable-next-line react-hooks/static-components + return ; } export default IconByVariant; diff --git a/template/src/components/atoms/index.ts b/template/src/components/atoms/index.ts index 7de310010..b8799f3e1 100644 --- a/template/src/components/atoms/index.ts +++ b/template/src/components/atoms/index.ts @@ -1,3 +1,3 @@ export { default as AssetByVariant } from './asset-by-variant/asset-by-variant'; export { default as IconByVariant } from './icon-by-variant/icon-by-variant'; -export { default as Skeleton } from './skeleton/skeleton'; +export { default as Skeleton } from './skeleton/Skeleton'; diff --git a/template/src/components/providers/theme-provider/theme-provider.tsx b/template/src/components/providers/theme-provider/theme-provider.tsx index caa2fac49..0530f143f 100644 --- a/template/src/components/providers/theme-provider/theme-provider.tsx +++ b/template/src/components/providers/theme-provider/theme-provider.tsx @@ -13,7 +13,6 @@ import { createContext, type PropsWithChildren, useCallback, - useEffect, useMemo, useState, } from 'react'; @@ -50,18 +49,16 @@ type Properties = PropsWithChildren<{ function ThemeProvider({ children = false, storage }: Properties) { // Current theme variant - const [variant, setVariant] = useState( - (storage.getString('theme') ?? 'default') as Variant, - ); + const [variant, setVariant] = useState(() => { + const storedTheme = storage.getString('theme'); - // Initialize theme at default if not defined - useEffect(() => { - const appHasThemeDefined = storage.contains('theme'); - if (!appHasThemeDefined) { - storage.set('theme', 'default'); - setVariant('default'); + if (storedTheme) { + return storedTheme as Variant; } - }, [storage]); + + storage.set('theme', 'default'); + return 'default'; + }); const changeTheme = useCallback( (nextVariant: Variant) => { diff --git a/template/src/theme/_config.ts b/template/src/theme/_config.ts index 92cebc56b..63ec83d14 100644 --- a/template/src/theme/_config.ts +++ b/template/src/theme/_config.ts @@ -1,9 +1,8 @@ import type { ThemeConfiguration } from '@/services/theme-generation/types/config'; +import type { ThemeConfiguration } from '@/theme/types/config'; import { DarkTheme, DefaultTheme } from '@react-navigation/native'; -import type { ThemeConfiguration } from '@/theme/types/config'; - export const enum Variant { DARK = 'dark', } diff --git a/template/src/theme/borders.ts b/template/src/theme/borders.ts index 2d2b7130d..7ba410339 100644 --- a/template/src/theme/borders.ts +++ b/template/src/theme/borders.ts @@ -1,6 +1,3 @@ -import type { ViewStyle } from 'react-native'; - -import { config } from '@/theme/_config'; import type { BorderBottomRadius, BorderColors, diff --git a/template/src/theme/components.ts b/template/src/theme/components.ts index 9ec439081..7e57397e9 100644 --- a/template/src/theme/components.ts +++ b/template/src/theme/components.ts @@ -1,8 +1,6 @@ import type { ComponentTheme } from '@/services/theme-generation/types/theme'; import type { ImageStyle, TextStyle, ViewStyle } from 'react-native'; -import type { ComponentTheme } from '@/theme/types/theme'; - type AllStyle = {} & Record; const generateComponentStyles = ({ diff --git a/template/src/theme/gutters.ts b/template/src/theme/gutters.ts index fca3f7f69..621fa17dc 100644 --- a/template/src/theme/gutters.ts +++ b/template/src/theme/gutters.ts @@ -1,6 +1,8 @@ import type { UnionConfiguration } from '@/services/theme-generation/types/config'; import type { Gutters } from '@/services/theme-generation/types/gutters'; +import { ViewStyle } from 'react-native'; + export const generateGutters = (configuration: UnionConfiguration): Gutters => { // eslint-disable-next-line unicorn/no-array-reduce return configuration.gutters.reduce((accumulator, current) => { From 98b2328ad9684cd42b81f6480747f60e33001ee5 Mon Sep 17 00:00:00 2001 From: jeremydolle Date: Wed, 25 Feb 2026 18:08:02 +0100 Subject: [PATCH 03/10] save --- template/kebab.sh | 37 ------------------- template/src/{App.tsx => app.tsx} | 0 .../skeleton.test.tsx} | 0 .../Skeleton.tsx => skeleton/skeleton.tsx} | 0 .../example.test.tsx} | 0 .../Example.tsx => example/example.tsx} | 0 .../Startup.tsx => startup/startup.tsx} | 0 .../translations/{en-EN.json => en-en.json} | 0 .../translations/{fr-FR.json => fr-fr.json} | 0 9 files changed, 37 deletions(-) delete mode 100755 template/kebab.sh rename template/src/{App.tsx => app.tsx} (100%) rename template/src/components/atoms/{Skeleton/Skeleton.test.tsx => skeleton/skeleton.test.tsx} (100%) rename template/src/components/atoms/{Skeleton/Skeleton.tsx => skeleton/skeleton.tsx} (100%) rename template/src/screens/{Example/Example.test.tsx => example/example.test.tsx} (100%) rename template/src/screens/{Example/Example.tsx => example/example.tsx} (100%) rename template/src/screens/{Startup/Startup.tsx => startup/startup.tsx} (100%) rename template/src/translations/{en-EN.json => en-en.json} (100%) rename template/src/translations/{fr-FR.json => fr-fr.json} (100%) diff --git a/template/kebab.sh b/template/kebab.sh deleted file mode 100755 index 5af4d6646..000000000 --- a/template/kebab.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash - -# Sécurité : s'assurer que le répertoire de travail est propre -if ! git diff-index --quiet HEAD --; then - echo "⚠️ Erreur : Tu as des changements non validés (uncommitted)." - echo "Merci de faire un commit ou un stash avant de lancer ce script." - exit 1 -fi - -# Vérification que le dossier src existe -if [ ! -d "src" ]; then - echo "⚠️ Erreur : Le dossier 'src' est introuvable." - exit 1 -fi - -echo "Début de la conversion en kebab-case pour le dossier 'src'..." - -# On demande à Git de lister UNIQUEMENT le contenu du dossier src -git ls-files "src" | while read -r file; do - - # Transformation du chemin en kebab-case - new_file=$(echo "$file" | sed -E 's/([a-z0-9])([A-Z])/\1-\2/g' | tr '[:upper:]' '[:lower:]' | tr ' _' '--' | sed -E 's/-+/-/g') - - # Si le nom doit être modifié - if [ "$file" != "$new_file" ]; then - echo "🔄 $file -> $new_file" - - # Créer le dossier parent cible si nécessaire - mkdir -p "$(dirname "$new_file")" 2>/dev/null - - # Le renommage en deux étapes pour macOS/Git - git mv "$file" "${file}.tmp-rename" - git mv "${file}.tmp-rename" "$new_file" - fi -done - -echo "✅ Terminé ! Vérifie le résultat avec 'git status'." \ No newline at end of file diff --git a/template/src/App.tsx b/template/src/app.tsx similarity index 100% rename from template/src/App.tsx rename to template/src/app.tsx diff --git a/template/src/components/atoms/Skeleton/Skeleton.test.tsx b/template/src/components/atoms/skeleton/skeleton.test.tsx similarity index 100% rename from template/src/components/atoms/Skeleton/Skeleton.test.tsx rename to template/src/components/atoms/skeleton/skeleton.test.tsx diff --git a/template/src/components/atoms/Skeleton/Skeleton.tsx b/template/src/components/atoms/skeleton/skeleton.tsx similarity index 100% rename from template/src/components/atoms/Skeleton/Skeleton.tsx rename to template/src/components/atoms/skeleton/skeleton.tsx diff --git a/template/src/screens/Example/Example.test.tsx b/template/src/screens/example/example.test.tsx similarity index 100% rename from template/src/screens/Example/Example.test.tsx rename to template/src/screens/example/example.test.tsx diff --git a/template/src/screens/Example/Example.tsx b/template/src/screens/example/example.tsx similarity index 100% rename from template/src/screens/Example/Example.tsx rename to template/src/screens/example/example.tsx diff --git a/template/src/screens/Startup/Startup.tsx b/template/src/screens/startup/startup.tsx similarity index 100% rename from template/src/screens/Startup/Startup.tsx rename to template/src/screens/startup/startup.tsx diff --git a/template/src/translations/en-EN.json b/template/src/translations/en-en.json similarity index 100% rename from template/src/translations/en-EN.json rename to template/src/translations/en-en.json diff --git a/template/src/translations/fr-FR.json b/template/src/translations/fr-fr.json similarity index 100% rename from template/src/translations/fr-FR.json rename to template/src/translations/fr-fr.json From c72912f05cbba6971ce833795f7e661372279383 Mon Sep 17 00:00:00 2001 From: jeremydolle Date: Wed, 25 Feb 2026 18:22:03 +0100 Subject: [PATCH 04/10] save --- template/src/__tests__/test-wrappers.tsx | 5 ++-- .../atoms/icon-by-variant/icon-by-variant.tsx | 8 ++--- .../error-boundary/error-boundary.tsx | 2 +- .../templates/safe-screen/safe-screen.tsx | 2 +- template/src/navigators/root.tsx | 19 ++++++++++-- template/src/screens/startup/startup.tsx | 29 ++++--------------- template/src/services/navigation/types.ts | 4 ++- template/src/theme/_config.ts | 1 - template/src/theme/borders.ts | 2 +- template/src/theme/fonts.ts | 2 +- template/tsconfig.json | 3 +- 11 files changed, 39 insertions(+), 38 deletions(-) diff --git a/template/src/__tests__/test-wrappers.tsx b/template/src/__tests__/test-wrappers.tsx index 18b8fbf08..2aea44459 100644 --- a/template/src/__tests__/test-wrappers.tsx +++ b/template/src/__tests__/test-wrappers.tsx @@ -4,9 +4,10 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { type PropsWithChildren } from 'react'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { ThemeProvider } from '@/theme'; +import { ThemeProvider } from '@/components/providers'; -import { queryClient, storage } from '../app'; +import { queryClient } from '@/services/http-client'; +import { storage } from '@/services/storage'; function TestAppWrapper({ children }: PropsWithChildren) { return ( diff --git a/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx b/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx index 9f2633ea9..4e1d3681d 100644 --- a/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx +++ b/template/src/components/atoms/icon-by-variant/icon-by-variant.tsx @@ -1,11 +1,11 @@ -import type { FC } from 'react'; import type { SvgProps } from 'react-native-svg'; -import { useMemo } from 'react'; +import { FC, useMemo } from 'react'; import * as z from 'zod'; -import { useTheme } from '@/theme'; -import getAssetsContext from '@/theme/assets/getAssetsContext'; +import { useTheme } from '@/hooks'; + +import getAssetsContext from '@/theme/assets/get-assets-context'; type Properties = { readonly path: string; diff --git a/template/src/components/organisms/error-boundary/error-boundary.tsx b/template/src/components/organisms/error-boundary/error-boundary.tsx index 22d215419..91286fd1d 100644 --- a/template/src/components/organisms/error-boundary/error-boundary.tsx +++ b/template/src/components/organisms/error-boundary/error-boundary.tsx @@ -19,7 +19,7 @@ function ErrorBoundary({ onReset = undefined, ...props }: Properties) { - const onErrorReport = (error: Error, info: ErrorInfo) => { + const onErrorReport = (error: unknown, info: ErrorInfo) => { // use any crash reporting tool here return onError?.(error, info); }; diff --git a/template/src/components/templates/safe-screen/safe-screen.tsx b/template/src/components/templates/safe-screen/safe-screen.tsx index ad260da20..fc8cdf58b 100644 --- a/template/src/components/templates/safe-screen/safe-screen.tsx +++ b/template/src/components/templates/safe-screen/safe-screen.tsx @@ -21,7 +21,7 @@ type Properties = PropsWithChildren< function SafeScreen({ children = undefined, isError = false, - onResetError, + onResetError = undefined, style, ...props }: Properties) { diff --git a/template/src/navigators/root.tsx b/template/src/navigators/root.tsx index 8c73b26b0..cf6cf789b 100644 --- a/template/src/navigators/root.tsx +++ b/template/src/navigators/root.tsx @@ -2,6 +2,7 @@ import type { RootStackParamList } from '@/services/navigation/types'; import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; +import { useQuery } from '@tanstack/react-query'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { useTheme } from '@/hooks'; @@ -15,12 +16,26 @@ const Stack = createStackNavigator(); function ApplicationNavigator() { const { navigationTheme, variant } = useTheme(); + const { isError, isSuccess } = useQuery({ + queryFn: () => { + return Promise.resolve(true); + }, + queryKey: ['startup'], + }); + return ( - - + {isSuccess ? ( + + ) : ( + + )} diff --git a/template/src/screens/startup/startup.tsx b/template/src/screens/startup/startup.tsx index d8907d0a3..a663b9c1f 100644 --- a/template/src/screens/startup/startup.tsx +++ b/template/src/screens/startup/startup.tsx @@ -1,7 +1,5 @@ import type { RootScreenProps } from '@/services/navigation/types'; -import { useQuery } from '@tanstack/react-query'; -import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Text, View } from 'react-native'; @@ -12,26 +10,12 @@ import { SafeScreen } from '@/components/templates'; import { Paths } from '@/services/navigation/paths'; -function Startup({ navigation }: RootScreenProps) { +function Startup({ + isError = false, +}: { isError?: boolean } & RootScreenProps) { const { fonts, gutters, layout } = useTheme(); const { t } = useTranslation(); - const { isError, isFetching, isSuccess } = useQuery({ - queryFn: () => { - return Promise.resolve(true); - }, - queryKey: ['startup'], - }); - - useEffect(() => { - if (isSuccess) { - navigation.reset({ - index: 0, - routes: [{ name: Paths.Example }], - }); - } - }, [isSuccess, navigation]); - return ( ) { resizeMode="contain" style={{ height: 300, width: 300 }} /> - {isFetching ? ( - - ) : undefined} {isError ? ( {t('common_error')} - ) : undefined} + ) : ( + + )} ); diff --git a/template/src/services/navigation/types.ts b/template/src/services/navigation/types.ts index 899487eba..1218be4ea 100644 --- a/template/src/services/navigation/types.ts +++ b/template/src/services/navigation/types.ts @@ -7,5 +7,7 @@ export type RootScreenProps< export type RootStackParamList = { [Paths.Example]: undefined; - [Paths.Startup]: undefined; + [Paths.Startup]: { + isError: boolean; + }; }; diff --git a/template/src/theme/_config.ts b/template/src/theme/_config.ts index 63ec83d14..c8c59317f 100644 --- a/template/src/theme/_config.ts +++ b/template/src/theme/_config.ts @@ -1,5 +1,4 @@ import type { ThemeConfiguration } from '@/services/theme-generation/types/config'; -import type { ThemeConfiguration } from '@/theme/types/config'; import { DarkTheme, DefaultTheme } from '@react-navigation/native'; diff --git a/template/src/theme/borders.ts b/template/src/theme/borders.ts index 7ba410339..47f9a3026 100644 --- a/template/src/theme/borders.ts +++ b/template/src/theme/borders.ts @@ -24,7 +24,7 @@ export const generateBorderColors = (configuration: UnionConfiguration) => { borderColor: value, }, }); - }, {}); + }, {} as BorderColors); }; /** diff --git a/template/src/theme/fonts.ts b/template/src/theme/fonts.ts index 85dd09a6c..5224734c5 100644 --- a/template/src/theme/fonts.ts +++ b/template/src/theme/fonts.ts @@ -17,7 +17,7 @@ export const generateFontColors = (configuration: UnionConfiguration) => { }, }); }, - {}, + {} as FontColors, ); }; diff --git a/template/tsconfig.json b/template/tsconfig.json index 78ec09aaa..0d471af0f 100644 --- a/template/tsconfig.json +++ b/template/tsconfig.json @@ -16,7 +16,8 @@ "*.js", ".*.js", "*.ts", - ".prettierrc.mjs" + ".prettierrc.mjs", + "eslint/**/*.mjs" ], "exclude": ["**/node_modules", "_", "android", "ios", "**/Pods"] } From dc2713d510b8d5490052396991789bd4416b79e3 Mon Sep 17 00:00:00 2001 From: jeremydolle Date: Wed, 25 Feb 2026 18:31:58 +0100 Subject: [PATCH 05/10] save --- .../components/providers/theme-provider/theme-provider.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/template/src/components/providers/theme-provider/theme-provider.tsx b/template/src/components/providers/theme-provider/theme-provider.tsx index 0530f143f..b6057147c 100644 --- a/template/src/components/providers/theme-provider/theme-provider.tsx +++ b/template/src/components/providers/theme-provider/theme-provider.tsx @@ -74,10 +74,9 @@ function ThemeProvider({ children = false, storage }: Properties) { }, [variant]); const fonts = useMemo(() => { - const fontColors = generateFontColors(fullConfig); return { + ...generateFontColors(fullConfig), ...generateFontSizes(), - ...(Array.isArray(fontColors) ? { fontColors } : fontColors), ...staticFontStyles, }; }, [fullConfig]); @@ -97,9 +96,8 @@ function ThemeProvider({ children = false, storage }: Properties) { }, [fullConfig]); const borders = useMemo(() => { - const borderColors = generateBorderColors(fullConfig); return { - ...(Array.isArray(borderColors) ? { borderColors } : borderColors), + ...generateBorderColors(fullConfig), ...generateBorderRadius(), ...generateBorderWidths(), ...staticBorderStyles, From 26c5826ae0ce48bc23da0e3c355ae3bdc0cb7a2f Mon Sep 17 00:00:00 2001 From: jeremydolle Date: Thu, 26 Feb 2026 11:59:04 +0100 Subject: [PATCH 06/10] refactor: update ESLint configuration and improve folder structure rules; rename ApplicationNavigator to RootNavigator --- template/eslint.config.js | 46 +++++----- template/eslint/folder-structure.mjs | 36 ++++---- template/eslint/independent-modules.mjs | 87 ++++++------------- template/jest.config.js | 7 +- template/projectStructure.cache.json | 30 ------- .../src/__tests__/mocks/get-assets-context.ts | 2 +- template/src/__tests__/mocks/libs/index.ts | 1 + .../__tests__/mocks/libs/react-native-mmkv.ts | 7 ++ .../mocks/libs/react-native-reanimated.ts | 8 +- template/src/__tests__/test-wrappers.tsx | 2 +- template/src/app.tsx | 6 +- .../atoms/skeleton/skeleton.test.tsx | 12 +-- template/src/navigators/index.ts | 2 +- template/src/navigators/root.tsx | 4 +- 14 files changed, 101 insertions(+), 149 deletions(-) delete mode 100644 template/projectStructure.cache.json create mode 100644 template/src/__tests__/mocks/libs/react-native-mmkv.ts diff --git a/template/eslint.config.js b/template/eslint.config.js index de94d0063..d4434aa8c 100644 --- a/template/eslint.config.js +++ b/template/eslint.config.js @@ -3,10 +3,10 @@ import eslint from '@eslint/js'; import eslintConfigPrettier from 'eslint-config-prettier'; import jest from 'eslint-plugin-jest'; import perfectionist from 'eslint-plugin-perfectionist'; -// import { -// projectStructureParser, -// projectStructurePlugin, -// } from 'eslint-plugin-project-structure'; +import { + projectStructureParser, + projectStructurePlugin, +} from 'eslint-plugin-project-structure'; import react from 'eslint-plugin-react'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; @@ -15,30 +15,30 @@ import unicorn from 'eslint-plugin-unicorn'; import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; -// import { fileCompositionConfig } from './eslint/file-composition/index.mjs'; -// import { folderStructureConfig } from './eslint/folder-structure.mjs'; -// import { independentModulesConfig } from './eslint/independent-modules.mjs'; +import { fileCompositionConfig } from './eslint/file-composition/index.mjs'; +import { folderStructureConfig } from './eslint/folder-structure.mjs'; +import { independentModulesConfig } from './eslint/independent-modules.mjs'; const ERROR = 2; const OFF = 0; export default defineConfig([ - // { - // files: ['**/*.{js,jsx,ts,tsx}'], - // ignores: ['project-structure.cache.json'], - // languageOptions: { parser: projectStructureParser }, - // plugins: { - // 'project-structure': projectStructurePlugin, - // }, - // rules: { - // 'project-structure/file-composition': [ERROR, fileCompositionConfig], - // 'project-structure/folder-structure': [ERROR, folderStructureConfig], - // 'project-structure/independent-modules': [ - // ERROR, - // independentModulesConfig, - // ], - // }, - // }, + { + files: ['**/*.{js,jsx,ts,tsx}'], + ignores: ['project-structure.cache.json'], + languageOptions: { parser: projectStructureParser }, + plugins: { + 'project-structure': projectStructurePlugin, + }, + rules: { + 'project-structure/file-composition': [ERROR, fileCompositionConfig], + 'project-structure/folder-structure': [ERROR, folderStructureConfig], + 'project-structure/independent-modules': [ + ERROR, + independentModulesConfig, + ], + }, + }, { ignores: ['coverage/**', 'dist/**'], }, diff --git a/template/eslint/folder-structure.mjs b/template/eslint/folder-structure.mjs index e79f48a2d..28053a093 100644 --- a/template/eslint/folder-structure.mjs +++ b/template/eslint/folder-structure.mjs @@ -9,7 +9,7 @@ import { createFolderStructure } from 'eslint-plugin-project-structure'; // | | | ├── lib-name.ts // | | └── *.ts // │ ├── setup.ts -// │ └── test-wrappers.tsx +// │ └── *.tsx // ├── components/ // │ ├── atoms/ // │ | ├── component-folder/ @@ -100,14 +100,15 @@ export const folderStructureConfig = createFolderStructure({ // child children: [ { + name: 'mocks', + // child children: [ - { children: [{ name: '*.ts' }], name: 'libs' }, - { name: '*.ts' }, + { children: [{ name: '{kebab-case}.ts' }], name: 'libs' }, + { name: '{kebab-case}.ts' }, ], - name: 'mocks', }, { name: 'setup.ts' }, - { name: 'test-wrappers.tsx' }, + { name: '{kebab-case}.tsx' }, ], }, // ├── components/ @@ -153,7 +154,6 @@ export const folderStructureConfig = createFolderStructure({ // child children: [ { name: '{folder-name}(-{kebab-case})?.tsx' }, - { name: '{folder-name}(-{kebab-case})?.stories.tsx' }, { name: '{folder-name}(-{kebab-case})?.test.tsx' }, ], }, @@ -164,9 +164,8 @@ export const folderStructureConfig = createFolderStructure({ { name: '{folder-name}(-{kebab-case})?.tsx', // force existence - enforceExistence: ['{node-name}.stories.tsx', '{node-name}.test.tsx'], + enforceExistence: ['{node-name}.test.tsx'], }, - { name: '{folder-name}(-{kebab-case})?.stories.tsx' }, { name: '{folder-name}(-{kebab-case})?.test.tsx' }, ], }, @@ -190,7 +189,7 @@ export const folderStructureConfig = createFolderStructure({ // child children: [ { name: 'index.ts' }, - { name: '*.tsx' }, + { name: '{kebab-case}.tsx' }, { children: [ { name: '{folder-name}.tsx' }, @@ -252,17 +251,24 @@ export const folderStructureConfig = createFolderStructure({ ], }, // ├── services/navigation - navigation: { name: 'navigation', // child - children: [{ name: 'paths.ts' }, { name: 'types.ts' }], + children: [ + { name: 'paths.ts' }, + { name: 'types.ts' }, + { name: '{kebab-case}.ts' }, + ], }, // ├── services/i18n i18n: { name: 'i18n', // child - children: [{ name: 'instance.ts' }, { name: 'i18next.d.ts' }], + children: [ + { name: 'instance.ts' }, + { name: 'i18next.d.ts' }, + { name: '{kebab-case}.ts' }, + ], }, // ├── services/theme-generation themeGen: { @@ -305,10 +311,10 @@ export const folderStructureConfig = createFolderStructure({ // child children: [ { - children: [{ name: 'kebab-case.{webp}' }], + children: [{ name: 'kebab-case.{webp,png}' }], name: 'dark', }, - { name: 'kebab-case.{webp}' }, + { name: 'kebab-case.{webp,png}' }, ], }, { @@ -332,7 +338,7 @@ export const folderStructureConfig = createFolderStructure({ translations: { name: 'translations', // child - children: [{ name: '*.json' }], + children: [{ name: '{kebab-case}.json' }], }, }, structure: [ diff --git a/template/eslint/independent-modules.mjs b/template/eslint/independent-modules.mjs index 1497b9feb..d304b7b63 100644 --- a/template/eslint/independent-modules.mjs +++ b/template/eslint/independent-modules.mjs @@ -3,6 +3,7 @@ import { createIndependentModules } from 'eslint-plugin-project-structure'; const reusableImportPatterns = { + testWrappers: ['src/__tests__/**/*.tsx'], // ├── components/ // │ ├── atoms/ atomsBarrel: ['src/components/atoms/index.ts'], @@ -39,18 +40,16 @@ const reusableImportPatterns = { 'src/services/theme-generation/**/*.ts', ], // ├── theme/ + themeConfig: ['src/theme/*.ts'], // │ ├── assets/ - // │ | ├── icons/ getAssetsContext: ['src/theme/assets/get-assets-context.ts'], + // │ | ├── icons/ themeIcons: [String.raw`src/theme/assets/icons/*.svg`], // | | └── images/ - themeConfig: ['src/theme/*.ts'], themeImages: [ 'src/theme/assets/images/*.webp', 'src/theme/assets/images/**/*.png', ], - // | ├── styles/ - themeStyles: ['src/theme/styles/*.css'], // ├── translations/ translations: ['src/translations/*.json'], }; @@ -59,16 +58,12 @@ export const independentModulesConfig = createIndependentModules({ modules: [ // src // ├── __tests__/ - // │ ├── mocks/ - // | | └── *.ts - // │ ├── setup.ts - // │ └── test-wrappers.tsx { name: 'Tests files', pattern: 'src/__tests__/**/*.(ts|tsx)', // can import: allowImportsFrom: [ - '{family}/**/*.(ts|tsx)', + '{dirname}/**/*.(ts|tsx)', '{hooksBarrel}', '{services}', '{providersBarrel}', @@ -76,24 +71,25 @@ export const independentModulesConfig = createIndependentModules({ ], }, // ├── components/ - // │ ├── atoms/ - // │ | ├── component-folder/ - // | | | ├── component-folder.tsx - // | | | └── (component-folder.test.tsx) - // │ | ├── ... - // │ | └── index.ts - // TODO: improve this rule to allow imports from same family only but also from all barrels below { - name: 'Tests component files', - pattern: 'src/components/**/*.(test|stories).tsx', + name: 'tests Components', + pattern: 'src/components/**/*.test.tsx', // can import: - allowImportsFrom: ['**/*'], + allowImportsFrom: [ + '{testWrappers}', + '{dirname}/*.tsx', + '{hooksBarrel}', + '{services}', + '{providersBarrel}', + ], }, + // │ ├── atoms/ { name: 'Atoms Components', pattern: 'src/components/atoms/**/*.tsx', // can import: allowImportsFrom: [ + '{dirname}/*.tsx', '{providersBarrel}', '{hooksBarrel}', '{themeIcons}', @@ -102,12 +98,13 @@ export const independentModulesConfig = createIndependentModules({ '{getAssetsContext}', ], }, - // │ ├── molecules/ // same structure as atoms/ + // │ ├── molecules/ { name: 'Molecules Components', pattern: 'src/components/molecules/**/*.tsx', // can import: allowImportsFrom: [ + '{dirname}/*.tsx', '{atomsBarrel}', '{providersBarrel}', '{hooksBarrel}', @@ -117,15 +114,15 @@ export const independentModulesConfig = createIndependentModules({ '{getAssetsContext}', ], }, - // │ ├── organisms/ // same structure as atoms/ + // │ ├── organisms/ { name: 'Organisms Components', pattern: 'src/components/organisms/**/*.tsx', // can import: allowImportsFrom: [ + '{dirname}/*.tsx', '{atomsBarrel}', '{moleculesBarrel}', - '{family_1}/**/*.tsx', // allow imports from same family so organisms can import other organisms '{providersBarrel}', '{hooksBarrel}', '{themeIcons}', @@ -134,12 +131,14 @@ export const independentModulesConfig = createIndependentModules({ '{getAssetsContext}', ], }, - // │ ├── templates/ // same structure as atoms/ + // │ ├── templates/ { name: 'Templates Components', pattern: 'src/components/templates/**/*.tsx', // can import: allowImportsFrom: [ + '{family_2}/**/*.tsx', // allow imports from same family so templates can import other templates + '{dirname}/*.tsx', '{atomsBarrel}', '{moleculesBarrel}', '{organismsBarrel}', @@ -148,11 +147,10 @@ export const independentModulesConfig = createIndependentModules({ '{themeIcons}', '{themeImages}', '{services}', - '{family_2}/**/*.tsx', // allow imports from same family so templates can import other templates '{getAssetsContext}', ], }, - // │ └── providers/ // same structure as atoms/ + // │ └── providers/ { name: 'Theme Provider', pattern: 'src/components/providers/theme-provider/theme-provider.tsx', @@ -168,27 +166,15 @@ export const independentModulesConfig = createIndependentModules({ name: 'Providers Components', pattern: 'src/components/providers/**/*.tsx', // can import: - allowImportsFrom: [ - '{hooksBarrel}', - '{themeIcons}', // why ? - '{themeImages}', // why ? - '{services}', - '{getAssetsContext}', - ], + allowImportsFrom: ['{hooksBarrel}', '{services}', '{getAssetsContext}'], }, // ├── hooks/ - // │ ├── use-hook-name/ - // │ | ├── use-hook-name.ts - // │ | └── (use-hook-name.test.tsx) - // │ └── index.ts { name: 'Hooks', pattern: 'src/hooks/**/*.tsx', // can import: allowImportsFrom: [ '{family_1}/**/use-*.ts', // allow imports from same family so hooks can import other hooks - // '{themeIcons}', // why ? - // '{themeImages}', // why ? '{services}', ], }, @@ -240,21 +226,6 @@ export const independentModulesConfig = createIndependentModules({ ], }, // ├── services/ - // │ ├── domains/ - // │ │ ├── domain-name/ - // │ │ │ ├── (api.ts) - // │ │ │ ├── (query-options.ts) - // │ │ │ ├── (schema.ts) - // │ │ │ └── index.ts - // │ ├── navigation/ - // │ │ ├── router.ts - // │ │ └── routeTree.gen.ts - // │ ├── i18n/ - // │ │ ├── instance.ts - // │ │ └── i18next.d.ts - // │ ├── api.ts - // │ ├── instance.ts - // │ └── *.ts { name: 'Services', pattern: 'src/services/**/*.ts', @@ -267,13 +238,6 @@ export const independentModulesConfig = createIndependentModules({ ], // allow imports from same family so services can import other services }, // ├── theme/ - // | ├── assets/ - // | | ├── icons/ - // | | | └── kebab-case.{svg} - // | | └── images/ - // | | | └── kebab-case.{webp} - // | ├── styles/ - // | | └── *.css { name: 'Theme', pattern: 'src/theme/**/*.ts', @@ -285,7 +249,6 @@ export const independentModulesConfig = createIndependentModules({ ], }, // ├── translations/ - // | └── *.json { name: 'Translations', pattern: 'src/translations/**/*.ts', @@ -302,8 +265,8 @@ export const independentModulesConfig = createIndependentModules({ '{hooksBarrel}', '{themeIcons}', '{themeImages}', - '{themeStyles}', '{services}', + '{navigatorsBarrel}', ], }, ], diff --git a/template/jest.config.js b/template/jest.config.js index 76236690d..06b56fd44 100644 --- a/template/jest.config.js +++ b/template/jest.config.js @@ -1,15 +1,16 @@ module.exports = { collectCoverageFrom: [ - '/src/Components/**/*.{jsx, tsx}', - '/src/App.{jsx, tsx}', + '/src/components/**/*.{jsx, tsx}', + '/src/app.{jsx, tsx}', ], coverageReporters: ['html', 'text', 'text-summary', 'cobertura'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], preset: 'react-native', setupFiles: ['./node_modules/react-native-gesture-handler/jestSetup.js'], - setupFilesAfterEnv: ['/jest.setup.js'], + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], testMatch: ['**/*.test.ts?(x)', '**/*.test.js?(x)'], transformIgnorePatterns: [ 'node_modules/(?!(jest-)?react-native|@react-native|@react-native-community|@react-navigation|ky)', ], + watchman: false, }; diff --git a/template/projectStructure.cache.json b/template/projectStructure.cache.json deleted file mode 100644 index dd1a40584..000000000 --- a/template/projectStructure.cache.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/translations/index.ts", - "errorMessage": "🔥 File 'index.ts' is invalid. 🔥\n\nAllowed names = *.json\nError location = ./src/translations/index.ts\n\n" - }, - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/screens/Startup/Startup.tsx", - "errorMessage": "🔥 Folder 'Startup' is invalid. 🔥\n\nAllowed names = {kebab-case}\nError location = ./src/screens/Startup\n\n" - }, - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/screens/Example/Example.test.tsx", - "errorMessage": "🔥 Folder 'Example' is invalid. 🔥\n\nAllowed names = {kebab-case}\nError location = ./src/screens/Example\n\n" - }, - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/components/atoms/skeleton/Skeleton.test.tsx", - "errorMessage": "🔥 File 'Skeleton.test.tsx' is invalid. 🔥\n\nAllowed names = skeleton(-{kebab-case})?.tsx, skeleton(-{kebab-case})?.stories.tsx, skeleton(-{kebab-case})?.test.tsx\nError location = ./src/components/atoms/skeleton/Skeleton.test.tsx\n\n" - }, - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/components/atoms/skeleton/Skeleton.tsx", - "errorMessage": "🔥 File 'Skeleton.tsx' is invalid. 🔥\n\nAllowed names = skeleton(-{kebab-case})?.tsx, skeleton(-{kebab-case})?.stories.tsx, skeleton(-{kebab-case})?.test.tsx\nError location = ./src/components/atoms/skeleton/Skeleton.tsx\n\n" - }, - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/screens/Example/Example.test.tsx", - "errorMessage": "🔥 Folder 'screens' is invalid. 🔥\n\nAllowed names = __tests__, components, hooks, routes, services, theme, translations\nError location = ./src/screens\n\n" - }, - { - "filename": "/Users/jeremydolle/Documents/Projets/react-native-boilerplate/template/src/components/atoms/Skeleton/Skeleton.test.tsx", - "errorMessage": "🔥 Folder 'Skeleton' is invalid. 🔥\n\nAllowed names = {kebab-case}\nError location = ./src/components/atoms/Skeleton\n\n" - } -] \ No newline at end of file diff --git a/template/src/__tests__/mocks/get-assets-context.ts b/template/src/__tests__/mocks/get-assets-context.ts index 58f79918b..615817038 100644 --- a/template/src/__tests__/mocks/get-assets-context.ts +++ b/template/src/__tests__/mocks/get-assets-context.ts @@ -1,6 +1,6 @@ import type { AssetType } from '@/theme/assets/get-assets-context'; -jest.mock('@/theme/assets/getAssetsContext', () => +jest.mock('@/theme/assets/get-assets-context', () => jest.fn((type: AssetType) => { const testIcon = 'mocked-icon-uri'; // Simulated URI for icons const testImage = 'test-image-uri'; // Simulated URI for images diff --git a/template/src/__tests__/mocks/libs/index.ts b/template/src/__tests__/mocks/libs/index.ts index aca5c9f13..04e6e8516 100644 --- a/template/src/__tests__/mocks/libs/index.ts +++ b/template/src/__tests__/mocks/libs/index.ts @@ -1,2 +1,3 @@ import './react-native-reanimated'; import './react-native-safe-area-context'; +import './react-native-mmkv'; diff --git a/template/src/__tests__/mocks/libs/react-native-mmkv.ts b/template/src/__tests__/mocks/libs/react-native-mmkv.ts new file mode 100644 index 000000000..5932cbae7 --- /dev/null +++ b/template/src/__tests__/mocks/libs/react-native-mmkv.ts @@ -0,0 +1,7 @@ +jest.mock('react-native-nitro-modules', () => { + return { + NitroModules: () => { + return {}; + }, + }; +}); diff --git a/template/src/__tests__/mocks/libs/react-native-reanimated.ts b/template/src/__tests__/mocks/libs/react-native-reanimated.ts index ee9e70fc5..e2e9927fd 100644 --- a/template/src/__tests__/mocks/libs/react-native-reanimated.ts +++ b/template/src/__tests__/mocks/libs/react-native-reanimated.ts @@ -1,3 +1,7 @@ -import { setUpTests } from 'react-native-reanimated'; +// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-member-access +require('react-native-reanimated').setUpTests(); -setUpTests(); +jest.mock('react-native-worklets', () => + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-return + require('react-native-worklets/src/mock'), +); diff --git a/template/src/__tests__/test-wrappers.tsx b/template/src/__tests__/test-wrappers.tsx index 2aea44459..c733779a0 100644 --- a/template/src/__tests__/test-wrappers.tsx +++ b/template/src/__tests__/test-wrappers.tsx @@ -1,4 +1,4 @@ -import '@/services/translation'; +import '@/services/i18n/instance'; import { QueryClientProvider } from '@tanstack/react-query'; import { type PropsWithChildren } from 'react'; diff --git a/template/src/app.tsx b/template/src/app.tsx index c078f656b..7500061a5 100644 --- a/template/src/app.tsx +++ b/template/src/app.tsx @@ -1,11 +1,11 @@ -import '@/services/translation'; +import '@/services/i18n/instance'; import { QueryClientProvider } from '@tanstack/react-query'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { ThemeProvider } from '@/components/providers'; -import { ApplicationNavigator } from '@/navigators'; +import { RootNavigator } from '@/navigators'; import { queryClient } from './services/http-client'; import { storage } from './services/storage'; @@ -15,7 +15,7 @@ function App() { - + diff --git a/template/src/components/atoms/skeleton/skeleton.test.tsx b/template/src/components/atoms/skeleton/skeleton.test.tsx index a545880f1..c00b8f677 100644 --- a/template/src/components/atoms/skeleton/skeleton.test.tsx +++ b/template/src/components/atoms/skeleton/skeleton.test.tsx @@ -45,9 +45,9 @@ describe('SkeletonLoader', () => { value: { opacity: number }; }; // TODO: use toHaveAnimatedStyle for better API but for now there is an issue with the library - // expect(skeleton).toHaveAnimatedStyle({ - // opacity: 0.2, - // }); + expect(skeleton).toHaveAnimatedStyle({ + opacity: 0.2, + }); expect(animatedStyle.value).toEqual({ opacity: 0.2, @@ -58,9 +58,9 @@ describe('SkeletonLoader', () => { opacity: 1, }); // TODO: use toHaveAnimatedStyle for better API but for now there is an issue with the library - // expect(skeleton).toHaveAnimatedStyle({ - // opacity: 1, - // }); + expect(skeleton).toHaveAnimatedStyle({ + opacity: 1, + }); expect(skeleton).toHaveStyle({ backgroundColor: '#A1A1A1', borderRadius: 4, diff --git a/template/src/navigators/index.ts b/template/src/navigators/index.ts index e5b77add9..9aa3bf36c 100644 --- a/template/src/navigators/index.ts +++ b/template/src/navigators/index.ts @@ -1 +1 @@ -export { default as ApplicationNavigator } from './root'; +export { default as RootNavigator } from './root'; diff --git a/template/src/navigators/root.tsx b/template/src/navigators/root.tsx index cf6cf789b..002f3cc1f 100644 --- a/template/src/navigators/root.tsx +++ b/template/src/navigators/root.tsx @@ -13,7 +13,7 @@ import { Paths } from '@/services/navigation/paths'; const Stack = createStackNavigator(); -function ApplicationNavigator() { +function RootNavigator() { const { navigationTheme, variant } = useTheme(); const { isError, isSuccess } = useQuery({ @@ -42,4 +42,4 @@ function ApplicationNavigator() { ); } -export default ApplicationNavigator; +export default RootNavigator; From 07923d8ed9d4c4451ca2fcdfce5467ee45ddf420 Mon Sep 17 00:00:00 2001 From: jeremydolle Date: Thu, 26 Feb 2026 12:25:50 +0100 Subject: [PATCH 07/10] docs: update navigation, data fetching, i18n, theming, debugging, testing, and eslint documentation - Corrected folder paths in navigation documentation. - Enhanced data fetching section with TanStack Query integration and domain-based architecture. - Improved i18n documentation with type-safe translations and supported languages. - Updated theming guide for better clarity on the useTheme hook and theme provider. - Added detailed debugging setup instructions with Reactotron. - Introduced comprehensive testing documentation covering Jest and React Native Testing Library. - Added ESLint configuration details, project structure enforcement rules, and best practices for code quality. --- ...26-02-26-React-Native-Boilerplate-5.0.0.md | 682 ++++++++++++++++++ documentation/docs/01-Getting Started.mdx | 6 +- documentation/docs/03-Project Structure.md | 70 +- documentation/docs/04-Guides/01-Navigate.md | 24 +- .../docs/04-Guides/02-Data Fetching.md | 193 +++-- documentation/docs/04-Guides/03-I18n.md | 71 +- .../docs/04-Guides/04-Theming/01-Using.md | 30 +- documentation/docs/04-Guides/07-Debugging.md | 9 +- .../08 - Components/06 - SafeScreen.md | 7 +- documentation/docs/04-Guides/08-Testing.md | 329 +++++++++ documentation/docs/04-Guides/09-ESLint.md | 534 ++++++++++++++ 11 files changed, 1853 insertions(+), 102 deletions(-) create mode 100644 documentation/blog/2026-02-26-React-Native-Boilerplate-5.0.0.md create mode 100644 documentation/docs/04-Guides/08-Testing.md create mode 100644 documentation/docs/04-Guides/09-ESLint.md diff --git a/documentation/blog/2026-02-26-React-Native-Boilerplate-5.0.0.md b/documentation/blog/2026-02-26-React-Native-Boilerplate-5.0.0.md new file mode 100644 index 000000000..32b8d1435 --- /dev/null +++ b/documentation/blog/2026-02-26-React-Native-Boilerplate-5.0.0.md @@ -0,0 +1,682 @@ +--- +title: React Native Boilerplate 5.0.0 +authors: jed +description: Major architecture refactoring and modern tooling +hide_table_of_contents: false +tags: [v5, boilerplate react, react-native, architecture, eslint, services] +--- + +The version 5.0.0 brings a major architecture refactoring focused on better code organization, +stricter project structure enforcement, and improved developer experience. +This update introduces a services layer, enforces consistent file naming conventions, +and provides powerful ESLint rules to maintain project structure integrity. + + + +## Migration Guide from 4.x to 5.0 + +### Breaking Changes Overview + +This version includes several breaking changes that require manual migration: + +1. **File naming convention** changed to kebab-case +2. **Services layer** introduced for business logic +3. **Navigator** renamed and relocated +4. **Test structure** reorganized +5. **Theme hooks** moved to dedicated location +6. **ESLint configuration** significantly enhanced + +### Step-by-Step Migration + +#### 1. File Naming Convention + +All component files now use kebab-case instead of PascalCase: + +**Before:** +``` +src/components/ + atoms/ + AssetByVariant/ + AssetByVariant.tsx + IconByVariant/ + IconByVariant.tsx + Skeleton/ + Skeleton.tsx +``` + +**After:** +``` +src/components/ + atoms/ + asset-by-variant/ + asset-by-variant.tsx + icon-by-variant/ + icon-by-variant.tsx + skeleton/ + skeleton.tsx +``` + +**Migration steps:** +- Rename all component folders to kebab-case +- Rename all component files to kebab-case +- Update all import statements accordingly + +The index files still use kebab-case and exports remain unchanged: + +```ts +// src/components/atoms/index.ts +export { default as AssetByVariant } from './asset-by-variant/asset-by-variant'; +export { default as IconByVariant } from './icon-by-variant/icon-by-variant'; +export { default as Skeleton } from './skeleton/skeleton'; +``` + +#### 2. App.tsx → app.tsx + +The main App file has been renamed to lowercase: + +**Before:** +```tsx +// src/App.tsx +import ApplicationNavigator from '@/navigation/Application'; + +function App() { + return ( + + + + + + + + ); +} +``` + +**After:** +```tsx +// src/app.tsx +import '@/services/i18n/instance'; +import { RootNavigator } from '@/navigators'; +import { queryClient } from './services/http-client'; +import { storage } from './services/storage'; + +function App() { + return ( + + + + + + + + ); +} +``` + +**Migration steps:** +- Rename `src/App.tsx` to `src/app.tsx` +- Update imports to use new services structure +- Update navigator import + +#### 3. Services Layer + +A new services layer has been introduced to centralize business logic, API calls, and configurations. + +##### HTTP Client + +**Before:** +```tsx +// src/App.tsx +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}); +``` + +**After:** +```ts +// src/services/http-client.ts +import { QueryClient } from '@tanstack/react-query'; +import ky from 'ky'; + +export const httpClient = ky.extend({ + headers: { Accept: 'application/json' }, + prefixUrl: `${process.env.API_URL ?? ''}/`, +}); + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}); +``` + +##### Storage + +**Before:** +```tsx +// src/App.tsx +import { createMMKV } from 'react-native-mmkv'; +export const storage = createMMKV(); +``` + +**After:** +```ts +// src/services/storage.ts +import { createMMKV } from 'react-native-mmkv'; +export const storage = createMMKV(); +``` + +##### Domain Services + +The new domain-based service structure organizes API calls, schemas, and query options: + +``` +src/services/ + domains/ + user/ + user.api.ts # API calls + user.schema.ts # Zod schemas and types + user.query-options.ts # TanStack Query options +``` + +**Example:** + +```ts +// src/services/domains/user/user.schema.ts +import * as z from 'zod'; + +export const UserSchema = z.object({ + id: z.number(), + name: z.string(), +}); + +export type User = z.infer; +``` + +```ts +// src/services/domains/user/user.api.ts +import { httpClient } from '@/services/http-client'; +import { UserSchema } from './user.schema'; + +export const UserApis = { + fetchOne: async (id: number) => { + const response = await httpClient.get(`users/${id}`).json(); + return UserSchema.parse(response); + }, +}; +``` + +```ts +// src/services/domains/user/user.query-options.ts +import { queryOptions } from '@tanstack/react-query'; +import { UserApis } from './user.api'; +import type { User } from './user.schema'; + +export const UserQueryKeys = { + fetchOne: 'fetchOneUser', +}; + +export const fetchOneQueryOptions = (currentId: User['id']) => + queryOptions({ + enabled: currentId >= 0, + queryFn: () => UserApis.fetchOne(currentId), + queryKey: [UserQueryKeys.fetchOne, currentId], + }); +``` + +**Migration steps:** +- Move existing API calls to `services/domains/{domain}/{domain}.api.ts` +- Create schemas in `services/domains/{domain}/{domain}.schema.ts` +- Create query options in `services/domains/{domain}/{domain}.query-options.ts` +- Update imports throughout your codebase + +#### 4. I18n Refactoring + +Internationalization has been moved to the services layer: + +**Before:** +``` +src/ + translations/ + index.ts + en-EN.json + fr-FR.json + hooks/ + language/ + useI18n.ts + schema.ts +``` + +**After:** +``` +src/ + translations/ + en-en.json # lowercase + fr-fr.json # lowercase + services/ + i18n/ + instance.ts + i18next.d.ts +``` + +**Migration steps:** +- Rename translation files to lowercase (en-EN.json → en-en.json) +- Move i18n configuration to `services/i18n/instance.ts` +- Import i18n at the top of `app.tsx`: `import '@/services/i18n/instance';` +- Remove old `translations/index.ts` and `hooks/language/` files + +#### 5. Navigator Refactoring + +The navigator has been renamed and relocated: + +**Before:** +```tsx +// src/navigation/Application.tsx +function ApplicationNavigator() { + const { navigationTheme, variant } = useTheme(); + + return ( + + + + + + + + + ); +} +``` + +**After:** +```tsx +// src/navigators/root.tsx +function RootNavigator() { + const { navigationTheme, variant } = useTheme(); + + const { isError, isSuccess } = useQuery({ + queryFn: () => Promise.resolve(true), + queryKey: ['startup'], + }); + + return ( + + + + {isSuccess ? ( + + ) : ( + + )} + + + + ); +} +``` + +**Migration steps:** +- Move `src/navigation/Application.tsx` to `src/navigators/root.tsx` +- Rename `ApplicationNavigator` to `RootNavigator` +- Move navigation types to `src/services/navigation/types.ts` +- Move paths to `src/services/navigation/paths.ts` +- Update imports in your application + +#### 6. Hooks Reorganization + +**Before:** +``` +src/ + theme/ + hooks/ + useTheme.ts + hooks/ + domain/ + user/ + useUser.ts + userService.ts + schema.ts + language/ + useI18n.ts + schema.ts +``` + +**After:** +``` +src/ + hooks/ + use-theme/ + use-theme.ts + services/ + domains/ + user/ + user.api.ts + user.query-options.ts + user.schema.ts +``` + +**Migration steps:** +- Move `useTheme` to `hooks/use-theme/use-theme.ts` +- Move domain hooks to services layer +- Update import paths: `import { useTheme } from '@/hooks';` +- Convert domain hooks to use new query-options pattern + +#### 7. Theme Provider + +The ThemeProvider has been moved to components/providers: + +**Before:** +```tsx +import { ThemeProvider } from '@/theme'; +``` + +**After:** +```tsx +import { ThemeProvider } from '@/components/providers'; +``` + +#### 8. Test Structure + +Tests have been reorganized with a cleaner structure: + +**Before:** +``` +src/ + __mocks__/ + libs/ + react-native-reanimated.ts + tests/ + TestAppWrapper.tsx + components/ + atoms/ + Skeleton/ + Skeleton.test.tsx +``` + +**After:** +``` +src/ + __tests__/ + setup.ts + test-wrappers.tsx + mocks/ + get-assets-context.ts + libs/ + index.ts + react-native-reanimated.ts + react-native-safe-area-context.ts + react-native-mmkv.ts + components/ + atoms/ + skeleton/ + skeleton.test.tsx +``` + +**Migration steps:** +- Move `tests/TestAppWrapper.tsx` to `__tests__/test-wrappers.tsx` +- Create `__tests__/setup.ts` for test configuration +- Organize mocks in `__tests__/mocks/` +- Update jest.config.js: + +```js +module.exports = { + preset: 'react-native', + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], + // ... rest of config +}; +``` + +- Update test imports: + +```tsx +// Before +import TestAppWrapper from '@/tests/TestAppWrapper'; + +// After +import TestAppWrapper from '@/__tests__/test-wrappers'; +``` + +#### 9. Screen Files + +Screens follow the same kebab-case convention: + +**Before:** +``` +src/screens/ + Example/ + Example.tsx + Example.test.tsx + Startup/ + Startup.tsx +``` + +**After:** +``` +src/screens/ + example/ + example.tsx + example.test.tsx + startup/ + startup.tsx +``` + +#### 10. Theme Assets + +Asset helper functions have been renamed: + +**Before:** +```tsx +import getAssetsContext from '@/theme/assets/getAssetsContext'; +``` + +**After:** +```tsx +import getAssetsContext from '@/theme/assets/get-assets-context'; +``` + +### Enhanced ESLint Configuration + +Version 5.0 introduces a powerful ESLint configuration that enforces project structure: + +#### New ESLint Plugins + +- **eslint-plugin-project-structure**: Enforces folder and file composition rules +- **eslint-plugin-perfectionist**: Ensures consistent sorting (imports, types, etc.) +- **eslint-plugin-unicorn**: Best practices and code quality +- **eslint-plugin-react-you-might-not-need-an-effect**: Prevents unnecessary effects + +#### Configuration Structure + +**Before:** +```js +// eslint.config.mjs +export default tseslint.config(/*...*/); +``` + +**After:** +```js +// eslint.config.js +import { fileCompositionConfig } from './eslint/file-composition/index.mjs'; +import { folderStructureConfig } from './eslint/folder-structure.mjs'; +import { independentModulesConfig } from './eslint/independent-modules.mjs'; + +export default defineConfig([ + { + files: ['**/*.{js,jsx,ts,tsx}'], + plugins: { 'project-structure': projectStructurePlugin }, + rules: { + 'project-structure/file-composition': [ERROR, fileCompositionConfig], + 'project-structure/folder-structure': [ERROR, folderStructureConfig], + 'project-structure/independent-modules': [ERROR, independentModulesConfig], + }, + }, + // ... other configs +]); +``` + +#### Project Structure Rules + +The new ESLint configuration enforces: + +1. **Folder Structure**: Ensures correct file locations +2. **File Composition**: Enforces file content patterns (hooks, components, etc.) +3. **Independent Modules**: Prevents circular dependencies and enforces module boundaries + +**Example folder structure rules:** + +```js +// eslint/folder-structure.mjs +export const folderStructureConfig = { + structure: { + children: [ + { + name: 'src', + children: [ + { name: 'components', children: [/* atoms, molecules, organisms, templates */] }, + { name: 'hooks' }, + { name: 'navigators' }, + { name: 'screens' }, + { name: 'services', children: [/* domains, i18n, navigation, storage */] }, + { name: 'theme' }, + { name: 'translations' }, + ], + }, + ], + }, +}; +``` + +**File composition example:** + +```js +// eslint/file-composition/hook.mjs +export const hookConfig = { + name: 'Hook', + rules: { + filePattern: '**/hooks/**/*.{ts,tsx}', + allowOnlySpecifiedSelectors: true, + selectors: ['function', 'exportNamedDeclaration'], + }, +}; +``` + +This ensures that hook files only contain function declarations and exports, preventing accidental complexity. + +### TypeScript Configuration Updates + +Minor improvements to tsconfig.json: + +```json +{ + "compilerOptions": { + "types": ["jest", "@testing-library/jest-native"] + } +} +``` + +### What Stays the Same + +- React Query for data fetching +- MMKV for storage +- Theme configuration approach +- Component architecture (atoms, molecules, organisms, templates) +- Testing with Jest and React Native Testing Library + +## New Features + +### Startup Logic with TanStack Query + +The RootNavigator now includes startup logic using TanStack Query: + +```tsx +const { isError, isSuccess } = useQuery({ + queryFn: () => Promise.resolve(true), + queryKey: ['startup'], +}); +``` + +This makes it easier to handle initialization logic and conditional rendering based on app state. + +### Ky HTTP Client + +The boilerplate now uses Ky as the HTTP client, providing a modern, lightweight alternative to axios: + +```ts +import ky from 'ky'; + +export const httpClient = ky.extend({ + headers: { Accept: 'application/json' }, + prefixUrl: `${process.env.API_URL ?? ''}/`, +}); +``` + +Ky provides: +- Better TypeScript support +- Simpler API +- Smaller bundle size +- Native Fetch API under the hood + +### Domain-Driven Architecture + +The new services layer encourages domain-driven design: + +``` +services/ + domains/ + user/ # User domain + product/ # Product domain + auth/ # Auth domain +``` + +Each domain contains: +- **schema**: Type definitions and validation +- **api**: API calls +- **query-options**: TanStack Query configuration + +This makes the codebase more maintainable and testable. + +## Migration Checklist + +Use this checklist to ensure a complete migration: + +- [ ] Rename all component files and folders to kebab-case +- [ ] Rename App.tsx to app.tsx +- [ ] Create services layer structure +- [ ] Move queryClient to services/http-client.ts +- [ ] Move storage to services/storage.ts +- [ ] Move i18n to services/i18n/ +- [ ] Rename translation files to lowercase +- [ ] Move navigator to navigators/root.tsx +- [ ] Rename ApplicationNavigator to RootNavigator +- [ ] Move navigation types to services/navigation/types.ts +- [ ] Move navigation paths to services/navigation/paths.ts +- [ ] Move useTheme to hooks/use-theme/use-theme.ts +- [ ] Update ThemeProvider import +- [ ] Reorganize tests to __tests__ structure +- [ ] Update jest.config.js setupFilesAfterEnv +- [ ] Migrate domain hooks to services structure +- [ ] Update all import paths throughout the codebase +- [ ] Update eslint.config.js (or use the new configuration) +- [ ] Run ESLint and fix any issues: `npm run lint` +- [ ] Run tests: `npm test` +- [ ] Rename all screen files to kebab-case +- [ ] Update getAssetsContext imports + +## Conclusion + +Version 5.0 brings significant architectural improvements that will make your codebase more maintainable, +testable, and scalable. While the migration requires some effort, the benefits of better structure, +stricter linting, and clearer separation of concerns will pay dividends in the long run. + +The new services layer, domain-driven architecture, and powerful ESLint rules ensure that your project +stays organized as it grows. + +Happy coding! 🚀 diff --git a/documentation/docs/01-Getting Started.mdx b/documentation/docs/01-Getting Started.mdx index 01a3c23b6..4853632a8 100644 --- a/documentation/docs/01-Getting Started.mdx +++ b/documentation/docs/01-Getting Started.mdx @@ -29,11 +29,13 @@ If you find value in this boilerplate, consider giving us a star. It would brigh | ---------------------------------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [`Javascript or TypeScript`](/docs/installation#using-the-boilerplate) | Every project, developer, team, and experience is unique. That's why you have the freedom to select either a JavaScript or TypeScript codebase. [The choice is yours!](/docs/installation#using-the-boilerplate) | | [`Navigation`](/docs/navigate) | With [React Navigation](https://reactnavigation.org/), we offer a swift start to your navigation structure through a robust dependency. Check out the details in our [navigation structure documentation](/docs/navigate#navigation-structure). | -| [`Data fetching`](/docs/data-fetching)️ | Leveraging [TanStackQuery](https://react-query.tanstack.com/), data fetching has never been this effortless. | +| [`Data fetching`](/docs/data-fetching)️ | Leveraging [TanStack Query](https://tanstack.com/query/latest), data fetching has never been this effortless, with a clean domain-based architecture. | | [`Internationalization`](/docs/internationalization) | Our application is fully prepared for multilingual support, all thanks to [React i18next](https://react.i18next.com/). | | [`Multi theming`](/docs/theming/how-to-use) | Without any extra dependencies, we offer an easy-to-use and maintainable theme configuration | -| [`Safe synchrone storage`](/docs/storage) | With [React Native MMKV](https://github.com/mrousavy/react-native-mmkv), storing data becomes a breeze, and it can be done securely. | +| [`Safe synchronous storage`](/docs/storage) | With [React Native MMKV](https://github.com/mrousavy/react-native-mmkv), storing data becomes a breeze, and it can be done securely. | | [`Environment`](/docs/environment) | The app comes pre-installed with all the necessary tools for handling simple environment variables | +| [`Project Structure Enforcement`](/docs/eslint-project-structure) | Powerful ESLint rules ensure consistent architecture, preventing common mistakes and enforcing best practices automatically. | +| [`Testing`](/docs/testing) | Pre-configured [Jest](https://jestjs.io/) and [React Native Testing Library](https://callstack.github.io/react-native-testing-library/) setup with organized mocks and test utilities. | ## Dependencies diff --git a/documentation/docs/03-Project Structure.md b/documentation/docs/03-Project Structure.md index bf38f3755..57d9869bf 100644 --- a/documentation/docs/03-Project Structure.md +++ b/documentation/docs/03-Project Structure.md @@ -16,11 +16,12 @@ To achieve this, the project structure is thoughtfully organized into distinct s |--------------------|-------------------------------------------------------------------------------------------------------------------| | `src/components` | Home to application components, following the atomic design methodology for organizing presentational components. | | `src/hooks` | Custom hooks used throughout the application. | -| `src/navigation` | Navigator components responsible for handling navigation. | +| `src/navigators` | Navigator components responsible for handling navigation. | | `src/screens` | Screen components representing various app screens. | -| `src/services` ️ | Houses data fetching and related services. | +| `src/services` | Houses data fetching, HTTP client, i18n, navigation types, and business logic organized by domain. | | `src/theme` | Holds theme configuration for the application. | -| `src/translations` | Configuration related to language support. | +| `src/translations` | Translation files for language support (e.g., `en-en.json`, `fr-fr.json`). | +| `src/__tests__` | Test configuration, mocks, and test utilities. | ## Specific Top-Level Boilerplate Files @@ -28,12 +29,71 @@ To achieve this, the project structure is thoughtfully organized into distinct s |--------------------|-----------------------------------------------------| | `.env` | Environment variables configuration. | | `jest.config.js` | Configuration file for Jest testing. | -| `jest.setup.js` | Jest mocking configuration. | | `tsconfig.json` | TypeScript configuration (for TypeScript projects). | -| `src/App.{js.tsx}` | Main component of the application. | +| `src/app.tsx` | Main component of the application. | +| `eslint.config.js` | ESLint configuration with project structure rules. | ## Atomic Design The `components` folder follows the [atomic design](https://bradfrost.com/blog/post/atomic-web-design/) methodology. This approach emphasizes modularity and reusability by breaking down elements into atomic components. By doing so, development teams can create more consistent, scalable, and maintainable projects. + +## Services Layer + +The `services` folder is organized to separate business logic, data fetching, and application configuration: + +### Domain-Based Organization + +``` +src/services/ + domains/ + user/ + user.api.ts # API calls + user.schema.ts # Zod schemas and types + user.query-options.ts # TanStack Query options + product/ + product.api.ts + product.schema.ts + product.query-options.ts +``` + +Each domain contains: +- **`*.api.ts`**: API calls using the HTTP client +- **`*.schema.ts`**: Zod schemas for validation and TypeScript types +- **`*.query-options.ts`**: TanStack Query configuration (query keys, options) + +### Core Services + +| File | Description | +|---------------------------|------------------------------------------------------------------| +| `http-client.ts` | Ky HTTP client instance and TanStack Query client configuration. | +| `storage.ts` | MMKV storage instance for persistent data. | +| `i18n/instance.ts` | i18next configuration and language management. | +| `i18n/i18next.d.ts` | TypeScript type definitions for translations. | +| `navigation/types.ts` | Navigation types and parameter lists. | +| `navigation/paths.ts` | Navigation path constants. | + +This structure promotes: +- **Separation of concerns**: Each file has a single responsibility +- **Type safety**: Schemas provide runtime validation and compile-time types +- **Testability**: Easy to mock and test individual services +- **Scalability**: New domains can be added without affecting existing code + +## File Naming Convention + +All files and folders use **kebab-case** naming convention: + +``` +✅ Correct: + - asset-by-variant.tsx + - use-theme.ts + - user.api.ts + +❌ Incorrect: + - AssetByVariant.tsx + - useTheme.ts + - UserAPI.ts +``` + +This ensures consistency across the codebase and is enforced by ESLint rules. diff --git a/documentation/docs/04-Guides/01-Navigate.md b/documentation/docs/04-Guides/01-Navigate.md index 870d65bdd..18cd7cd9e 100644 --- a/documentation/docs/04-Guides/01-Navigate.md +++ b/documentation/docs/04-Guides/01-Navigate.md @@ -13,16 +13,24 @@ any new features and improvements. ## Navigation structure -All navigation-related configurations and navigators are neatly organized within the `src/navigation` folder. Here's a brief overview: +All navigation-related configurations and navigators are neatly organized within the `src/navigators` folder. Here's a brief overview: -### Root file (`Application.{js, tsx}`) +### Root file (`root.tsx`) This serves as the root navigator, which is responsible for handling the initial navigation of the application. -It's a simple stack navigator that includes the [`Startup`](/docs/data-fetching#fetching-data-at-startup) screen and an Example screen. +It's a simple stack navigator that includes a `Startup` screen and an `Example` screen with conditional rendering based on startup state. -The workflow is designed so that when the application launches, the user is initially presented with the `Startup` screen. -This screen takes on the responsibility of loading essential application data, such as user profiles and settings. -Once this data is loaded, the `Startup` screen facilitates navigation to the `Example` screen. +The workflow is designed so that when the application launches, the user is initially presented with the `Startup` screen or the `Example` screen depending on the result of the startup query. +The navigator uses TanStack Query to handle initialization logic: + +```tsx +const { isError, isSuccess } = useQuery({ + queryFn: () => Promise.resolve(true), + queryKey: ['startup'], +}); +``` + +Once the startup query succeeds, users are navigated to the `Example` screen. This pattern makes it easy to handle loading states, errors, and conditional navigation. As your application evolves, you have the flexibility to extend this file by adding more screens and navigators. @@ -34,7 +42,9 @@ You can either add your own navigators or, if you prefer, replace the existing s ## Using typescript It's crucial not to overlook the creation of types for your navigation parameters. This practice helps prevent errors and enhances autocompletion. -You can define these types in the `@/navigation/types.ts` file. +You can define these types in the `src/services/navigation/types.ts` file. + +Navigation paths are defined in `src/services/navigation/paths.ts` for centralized path management. For more in-depth information on this topic, please refer to the [React Navigation documentation](https://reactnavigation.org/docs/typescript/). diff --git a/documentation/docs/04-Guides/02-Data Fetching.md b/documentation/docs/04-Guides/02-Data Fetching.md index e2c4602ab..6e9fbf675 100644 --- a/documentation/docs/04-Guides/02-Data Fetching.md +++ b/documentation/docs/04-Guides/02-Data Fetching.md @@ -14,87 +14,152 @@ greatly enhance the efficiency and reliability of data management in your applic ## Fetching Data at Startup This boilerplate offers a convenient method for fetching data before presenting the application content to the user. -This capability is made possible through the [navigation structure](/docs/navigate#navigation-structure) of the initial -setup and the inclusion of the `Startup` screen. +This capability is made possible through the [navigation structure](/docs/navigate#navigation-structure) where the +`RootNavigator` conditionally renders screens based on startup state. -The `Startup` screen takes on the role of being the very first screen shown to the user upon launching the application. -It serves as the entry point where essential data can be fetched and prepared before the user interacts with the app's content. -This ensures a smoother and more responsive user experience. +The `RootNavigator` uses TanStack Query to handle initialization logic: -```tsx title="src/screens/Startup/Startup.tsx" - // highlight-next-line +```tsx title="src/navigators/root.tsx" import { useQuery } from '@tanstack/react-query'; -const Startup = ({ navigation }: ApplicationScreenProps) => { - const { layout, gutters, fonts } = useTheme(); - const { t } = useTranslation(); - - // highlight-start - const { isSuccess, isFetching, isError } = useQuery({ - queryKey: ['startup'], - queryFn: () => { - // Fetch data here - return Promise.resolve(true); - }, - }); - // highlight-end - - useEffect(() => { - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); - }, [isSuccess]); - - return ( - //... - ); +function RootNavigator() { + const { navigationTheme, variant } = useTheme(); + + // highlight-start + const { isError, isSuccess } = useQuery({ + queryFn: () => { + // Fetch startup data here + return Promise.resolve(true); + }, + queryKey: ['startup'], + }); + // highlight-end + + return ( + + + + {/* highlight-start */} + {isSuccess ? ( + + ) : ( + + )} + {/* highlight-end */} + + + + ); +} +``` + +This approach ensures a cleaner separation of concerns: the navigator handles navigation logic while screens focus on UI and user interaction. + +## Domain-Based Data Fetching + +The boilerplate uses a domain-based architecture for organizing data fetching logic. Each domain contains: + +### 1. Schema Definition + +Define your data types and validation using Zod: + +```ts title="src/services/domains/user/user.schema.ts" +import * as z from 'zod'; + +export const UserSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), +}); + +export type User = z.infer; +``` + +### 2. API Calls + +Create API functions using the HTTP client: + +```ts title="src/services/domains/user/user.api.ts" +import { httpClient } from '@/services/http-client'; +import { UserSchema } from './user.schema'; + +export const UserApis = { + fetchOne: async (id: number) => { + const response = await httpClient.get(`users/${id}`).json(); + return UserSchema.parse(response); + }, + + fetchAll: async () => { + const response = await httpClient.get('users').json(); + return z.array(UserSchema).parse(response); + }, }; ``` -The `useQuery` hook is employed for data fetching. Now, let's explore how to formulate the request. +The `httpClient` is a pre-configured [Ky](https://github.com/sindresorhus/ky) instance located in `src/services/http-client.ts`. + +### 3. Query Options -Consider a scenario where we wish to retrieve application settings from an API before the user accesses the application's content. -To achieve this, we will create a service responsible for fetching this data. +Define TanStack Query configuration: -```ts -import { instance } from '@/services/instance'; +```ts title="src/services/domains/user/user.query-options.ts" +import { queryOptions } from '@tanstack/react-query'; +import { UserApis } from './user.api'; +import type { User } from './user.schema'; -export default async () => instance.get(`/settings`); +export const UserQueryKeys = { + fetchOne: 'fetchOneUser', + fetchAll: 'fetchAllUsers', +}; + +export const fetchOneUserQueryOptions = (userId: User['id']) => + queryOptions({ + enabled: userId >= 0, + queryFn: () => UserApis.fetchOne(userId), + queryKey: [UserQueryKeys.fetchOne, userId], + }); + +export const fetchAllUsersQueryOptions = () => + queryOptions({ + queryFn: () => UserApis.fetchAll(), + queryKey: [UserQueryKeys.fetchAll], + }); ``` -The `instance` is an http client instance that comes pre-configured in the boilerplate. +### 4. Using in Components -Next, we will use the `fetchSettings` service within the `Startup` screen. +Import and use the query options in your components: -```tsx title="src/screens/Startup/Startup.tsx" +```tsx title="src/screens/example/example.tsx" import { useQuery } from '@tanstack/react-query'; -// highlight-next-line -import fetchSettings from '@/folder/fetchSettings'; - -const Startup = ({ navigation }: ApplicationScreenProps) => { - const { layout, gutters, fonts } = useTheme(); - const { t } = useTranslation(['startup']); - - const { isSuccess, isFetching, isError } = useQuery({ - queryKey: ['startup'], - // highlight-next-line - queryFn: fetchSettings, - }); - - useEffect(() => { - navigation.reset({ - index: 0, - routes: [{ name: 'Main' }], - }); - }, [isSuccess]); - - return ( - //... - ); -}; +import { fetchOneUserQueryOptions } from '@/services/domains/user/user.query-options'; + +function ExampleScreen() { + const userId = 1; + + const { data: user, isLoading, isError } = useQuery( + fetchOneUserQueryOptions(userId) + ); + + if (isLoading) return Loading...; + if (isError) return Error loading user; + + return {user.name}; +} ``` +## Benefits of This Architecture + +- **Type Safety**: Zod schemas provide runtime validation and compile-time types +- **Centralized Logic**: All domain logic is in one place +- **Reusability**: Query options can be reused across components +- **Testability**: Each layer can be tested independently +- **Consistency**: Enforced by ESLint rules for project structure + ## Advanced usage Since we've utilized no additional or custom configuration, for further information, diff --git a/documentation/docs/04-Guides/03-I18n.md b/documentation/docs/04-Guides/03-I18n.md index 275d808f0..72ebc3174 100644 --- a/documentation/docs/04-Guides/03-I18n.md +++ b/documentation/docs/04-Guides/03-I18n.md @@ -18,9 +18,76 @@ within the application's interface. ## Translation files structure All the translations are situated in the `src/translations` folder. -Within this folder, you'll find one file for each language. +Within this folder, you'll find one file for each language, following the kebab-case naming convention: + +``` +src/translations/ + en-en.json + fr-fr.json +``` + +These translation files are loaded into the i18n instance, which is managed in the `src/services/i18n/` folder: + +``` +src/services/i18n/ + instance.ts # i18next configuration and initialization + i18next.d.ts # TypeScript type definitions for translations +``` + +The i18n instance is initialized when importing the application: + +```tsx title="src/app.tsx" +import '@/services/i18n/instance'; +``` + +## Type-Safe Translations + +The boilerplate provides full TypeScript support for translations. The `i18next.d.ts` file extends i18next types +to provide autocomplete and type checking for translation keys: + +```ts title="src/services/i18n/i18next.d.ts" +import type { defaultNS, resources, SupportedLanguages } from '@/services/i18n/instance'; + +declare module 'i18next' { + type CustomTypeOptions = { + defaultNS: typeof defaultNS; + resources: defaultTranslations; + }; + + interface i18n { + changeLanguage(lng: SupportedLanguages, callback?: Callback): Promise; + language: SupportedLanguages; + } +} +``` + +This ensures that: +- Translation keys are validated at compile-time +- You get autocomplete when using `t()` function +- Language changes are type-safe + +## Supported Languages + +Languages are defined as an enum in the i18n instance: + +```ts title="src/services/i18n/instance.ts" +export const enum SupportedLanguages { + EN_EN = 'en-en', + FR_FR = 'fr-fr', +} + +export const languageSchema = z.enum([ + SupportedLanguages.EN_EN, + SupportedLanguages.FR_FR, +]); +``` + +To add a new language: +1. Add a new translation file (e.g., `es-es.json`) +2. Import it in `instance.ts` +3. Add it to the `SupportedLanguages` enum +4. Add it to the `languageSchema` -These translation files are loaded into the i18n instance, which is located in the `src/translations/index.ts` file. This setup centralizes and manages the translation resources for your application, making it easier to maintain and switch between different languages as needed. diff --git a/documentation/docs/04-Guides/04-Theming/01-Using.md b/documentation/docs/04-Guides/04-Theming/01-Using.md index 515590843..a9c21efd6 100644 --- a/documentation/docs/04-Guides/04-Theming/01-Using.md +++ b/documentation/docs/04-Guides/04-Theming/01-Using.md @@ -6,9 +6,9 @@ id: theming-how-to-use keywords: [theme, theming, useTheme, hooks, themeProvider] --- -The boilerplate provides a pre-configured theme ready for use. -To make use of it, simply follow this section's instructions. -If you'd like to gain a deeper understanding of the theme configuration, please refer to +The boilerplate provides a pre-configured theme ready for use. +To make use of it, simply follow this section's instructions. +If you'd like to gain a deeper understanding of the theme configuration, please refer to the [configuration](/docs/theming/configuration) section for more details. ## `useTheme` hook @@ -16,7 +16,7 @@ the [configuration](/docs/theming/configuration) section for more details. If you need to access the style classes, you can use the `useTheme` hook in the following manner: ```tsx -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; const Example = () => { const { @@ -32,7 +32,7 @@ const Example = () => { components, //highlight-end } = useTheme(); - + return ( { {isError && ( @@ -61,18 +61,18 @@ const Example = () => { ## Change theme -As mentioned in the [configuration](/docs/theming/configuration) section, you have the flexibility to add new themes +As mentioned in the [configuration](/docs/theming/configuration) section, you have the flexibility to add new themes (variants) and switch between them directly within the app. For example, if you have a `default` theme configured with a `dark` variant, you can switch the current theme to `dark` at any point. To achieve this, you can employ the `useTheme` hook as follows: ```tsx -import { useTheme } from '@/theme'; +import { useTheme } from '@/hooks'; const Example = () => { const { changeTheme } = useTheme(); - + return (